Compare commits

..

6 Commits

Author SHA1 Message Date
Alex Shnitman abb9492d21 upgrade dependencies 2026-04-21 16:20:39 +03:00
Alex Shnitman 23de9824f0 cr fixes 2026-04-21 16:13:58 +03:00
rdiaz738 0ea934c08f Updated import and fixed race condition 2026-04-20 17:24:16 -07:00
Alex Shnitman e9f979b349 fix yt-dlp options overrides (closes #958) 2026-04-18 08:46:29 +03:00
Alex Shnitman ab42325db5 upgrade dependencies 2026-04-16 22:30:42 +03:00
Alex Shnitman 1a32eba474 fix PUBLIC_HOST_URL without a trailing slash (closes #959) 2026-04-16 22:08:08 +03:00
10 changed files with 856 additions and 602 deletions
+4
View File
@@ -104,6 +104,8 @@ MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves
When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options.
In JSON presets and overrides, setting an option to **`null`** clears that option for that download (for example, `"download_archive": null` overrides a global archive path so the archive is not used). This follows yt-dlps usual meaning of `None` for that option.
### Option format
yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
@@ -204,6 +206,8 @@ When a download starts, the final set of yt-dlp options is built in this order:
2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys).
3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys).
MeTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
**Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written.
### Configuration cookbooks
+5
View File
@@ -92,6 +92,11 @@ class Config:
if not self.URL_PREFIX.endswith('/'):
self.URL_PREFIX += '/'
for attr in ('PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL'):
val = getattr(self, attr)
if val and not val.endswith('/'):
setattr(self, attr, val + '/')
# Convert relative addresses to absolute addresses to prevent the failure of file address comparison
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
+41
View File
@@ -23,6 +23,47 @@ class ConfigTests(unittest.TestCase):
c = Config()
self.assertEqual(c.URL_PREFIX, "foo/")
def test_public_host_url_gets_trailing_slash(self):
with patch.dict(
os.environ,
_base_env(PUBLIC_HOST_URL="https://ytdl.example.com"),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
def test_public_host_audio_url_gets_trailing_slash(self):
with patch.dict(
os.environ,
_base_env(PUBLIC_HOST_AUDIO_URL="https://audio.example.com"),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "https://audio.example.com/")
def test_public_host_url_empty_stays_empty(self):
with patch.dict(
os.environ,
_base_env(PUBLIC_HOST_URL="", PUBLIC_HOST_AUDIO_URL=""),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_URL, "")
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
def test_public_host_url_already_slashed_unchanged(self):
with patch.dict(
os.environ,
_base_env(
PUBLIC_HOST_URL="https://ytdl.example.com/",
PUBLIC_HOST_AUDIO_URL="https://audio.example.com/",
),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "https://audio.example.com/")
def test_ytdl_options_json_loaded(self):
opts = {"quiet": True, "no_warnings": True}
with patch.dict(
+136 -4
View File
@@ -56,7 +56,7 @@ def test_get_returns_tuple_of_lists(dq_env):
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
notifier = AsyncMock()
def fake_extract(self, url):
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid1",
@@ -86,7 +86,7 @@ async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
async def test_cancel_removes_from_pending(dq_env):
notifier = AsyncMock()
def fake_extract(self, url):
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid1",
@@ -114,11 +114,54 @@ async def test_cancel_removes_from_pending(dq_env):
notifier.canceled.assert_awaited()
@pytest.mark.asyncio
async def test_cancel_before_start_marks_download_canceled(dq_env):
"""Regression test for the race condition where cancel() arrives after the
download has been placed in the queue and ``__start_download`` has been
scheduled via ``asyncio.create_task`` but has not yet executed. Without the
fix, the pending task would run ``download.start()`` despite the user
cancelling, because its ``download.canceled`` guard was never flipped."""
notifier = AsyncMock()
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid1",
"title": "Test Video",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/race"
start_mock = AsyncMock()
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
await dq.add(
url,
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=True,
)
assert dq.queue.exists(url)
download = dq.queue.get(url)
assert download.canceled is False
await dq.cancel([url])
assert not dq.queue.exists(url)
assert download.canceled is True
notifier.canceled.assert_awaited_with(url)
@pytest.mark.asyncio
async def test_start_pending_moves_to_queue(dq_env):
notifier = AsyncMock()
def fake_extract(self, url):
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid1",
@@ -187,7 +230,7 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
"Preset B": {"writesubtitles": False, "ratelimit": 1000},
}
def fake_extract(self, url):
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid2",
@@ -219,3 +262,92 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
assert queued.ytdl_opts["ratelimit"] == 1000
assert queued.ytdl_opts["proxy"] == "http://override"
assert queued.ytdl_opts["embed_thumbnail"] is True
@pytest.mark.asyncio
async def test_extract_info_preset_null_download_archive_overrides_global(dq_env):
"""Preset download_archive:null must apply during extract_info (global archive otherwise wins first)."""
dq_env.YTDL_OPTIONS = {"download_archive": "/tmp/archive.txt"}
dq_env.YTDL_OPTIONS_PRESETS = {"NoArchive": {"download_archive": None}}
captured_params: list = []
class FakeYoutubeDL:
def __init__(self, params=None):
captured_params.append(params)
def extract_info(self, url, download=False):
return {
"_type": "video",
"id": "vid-archive",
"title": "Archive Test",
"url": url,
"webpage_url": url,
}
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
result = await dq.add(
"https://example.com/archive-test",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
ytdl_options_presets=["NoArchive"],
)
assert result["status"] == "ok"
assert len(captured_params) == 1
extract_params = captured_params[0]
assert extract_params.get("download_archive") is None
assert extract_params["extract_flat"] is True
assert extract_params["noplaylist"] is True
@pytest.mark.asyncio
async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
"""MeTube's flat-extract settings must not be overridden by presets."""
dq_env.YTDL_OPTIONS = {}
dq_env.YTDL_OPTIONS_PRESETS = {
"TryOverride": {"extract_flat": False, "noplaylist": False},
}
captured_params: list = []
class FakeYoutubeDL:
def __init__(self, params=None):
captured_params.append(params)
def extract_info(self, url, download=False):
return {
"_type": "video",
"id": "vid-flat",
"title": "Flat Test",
"url": url,
"webpage_url": url,
}
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
result = await dq.add(
"https://example.com/flat-test",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
ytdl_options_presets=["TryOverride"],
)
assert result["status"] == "ok"
assert captured_params[0]["extract_flat"] is True
assert captured_params[0]["noplaylist"] is True
+30 -12
View File
@@ -9,6 +9,7 @@ from collections import OrderedDict
import time
import asyncio
import multiprocessing
from functools import partial
import logging
import re
import types
@@ -796,9 +797,19 @@ class DownloadQueue:
log.debug(f'Auto-clearing completed download: {url}')
await self.clear([url])
def __extract_info(self, url):
def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
"""Merge global options, presets (in order), and per-download overrides."""
opts = dict(self.config.YTDL_OPTIONS)
for preset_name in ytdl_options_presets or []:
opts.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
opts.update(ytdl_options_overrides or {})
return opts
def __extract_info(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
return yt_dlp.YoutubeDL(params={
user_opts = self._build_ytdl_options(ytdl_options_presets, ytdl_options_overrides)
params = {
**user_opts,
'quiet': not debug_logging,
'verbose': debug_logging,
'no_color': True,
@@ -806,9 +817,11 @@ class DownloadQueue:
'ignore_no_formats_error': True,
'noplaylist': True,
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
**self.config.YTDL_OPTIONS,
**({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}),
}).extract_info(url, download=False)
}
imp = user_opts.get('impersonate')
if imp is not None:
params['impersonate'] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(imp)
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
def __calc_download_path(self, download_type, folder):
base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR
@@ -844,10 +857,10 @@ class DownloadQueue:
output = self.config.OUTPUT_TEMPLATE_CHANNEL
sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()}
output = _resolve_outtmpl_fields(output, sanitized, ('channel',))
ytdl_options = dict(self.config.YTDL_OPTIONS)
for preset_name in getattr(dl, 'ytdl_options_presets', None) or []:
ytdl_options.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
ytdl_options.update(getattr(dl, 'ytdl_options_overrides', {}) or {})
ytdl_options = self._build_ytdl_options(
getattr(dl, 'ytdl_options_presets', None),
getattr(dl, 'ytdl_options_overrides', {}) or {},
)
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
if playlist_item_limit > 0:
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
@@ -1037,7 +1050,10 @@ class DownloadQueue:
else:
already.add(url)
try:
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
entry = await asyncio.get_running_loop().run_in_executor(
None,
partial(self.__extract_info, url, ytdl_options_presets, ytdl_options_overrides),
)
except yt_dlp.utils.YoutubeDLError as exc:
return {'status': 'error', 'msg': str(exc)}
return await self.__add_entry(
@@ -1124,9 +1140,11 @@ class DownloadQueue:
if not self.queue.exists(id):
log.warning(f'requested cancel for non-existent download {id}')
continue
if self.queue.get(id).started():
self.queue.get(id).cancel()
dl = self.queue.get(id)
if dl.started():
dl.cancel()
else:
dl.canceled = True
self.queue.delete(id)
await self.notifier.canceled(id)
return {'status': 'ok'}
+11 -11
View File
@@ -23,14 +23,14 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^21.2.8",
"@angular/common": "^21.2.8",
"@angular/compiler": "^21.2.8",
"@angular/core": "^21.2.8",
"@angular/forms": "^21.2.8",
"@angular/platform-browser": "^21.2.8",
"@angular/platform-browser-dynamic": "^21.2.8",
"@angular/service-worker": "^21.2.8",
"@angular/animations": "^21.2.9",
"@angular/common": "^21.2.9",
"@angular/compiler": "^21.2.9",
"@angular/core": "^21.2.9",
"@angular/forms": "^21.2.9",
"@angular/platform-browser": "^21.2.9",
"@angular/platform-browser-dynamic": "^21.2.9",
"@angular/service-worker": "^21.2.9",
"@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
@@ -50,14 +50,14 @@
"@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.2.7",
"@angular/cli": "^21.2.7",
"@angular/compiler-cli": "^21.2.8",
"@angular/localize": "^21.2.8",
"@angular/compiler-cli": "^21.2.9",
"@angular/localize": "^21.2.9",
"@eslint/js": "^9.39.4",
"angular-eslint": "21.1.0",
"eslint": "^9.39.4",
"jsdom": "^27.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "8.47.0",
"vitest": "^4.1.4"
"vitest": "^4.1.5"
}
}
+543 -526
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -608,6 +608,19 @@
@if (batchImportStatus) {
<small>{{ batchImportStatus }}</small>
}
@if (batchImportTotal > 0) {
<div class="progress mt-2" style="height: 20px;">
<div class="progress-bar"
[class.bg-danger]="batchImportFailures > 0"
role="progressbar"
[style.width.%]="(batchImportCount / batchImportTotal) * 100"
[attr.aria-valuenow]="batchImportCount"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="batchImportTotal">
{{ batchImportCount }} / {{ batchImportTotal }}
</div>
</div>
}
</div>
</div>
<div class="modal-footer">
+63 -39
View File
@@ -1,7 +1,7 @@
import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
import { Observable, Subscription, map, distinctUntilChanged, finalize } from 'rxjs';
import { Observable, Subject, Subscription, from, map, distinctUntilChanged, finalize, mergeMap, takeUntil, tap } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
@@ -104,8 +104,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
batchImportModalOpen = false;
batchImportText = '';
batchImportStatus = '';
batchImportCount = 0;
batchImportTotal = 0;
batchImportFailures = 0;
importInProgress = false;
cancelImportFlag = false;
private batchImportCancel$ = new Subject<void>();
// Maximum number of /add requests to have in-flight at once during a batch
// import. Keeps the server from being hit with hundreds of simultaneous
// yt-dlp metadata extractions when a user pastes a huge URL list.
private static readonly BATCH_IMPORT_CONCURRENCY = 4;
ytDlpOptionsUpdateTime: string | null = null;
ytDlpVersion: string | null = null;
metubeVersion: string | null = null;
@@ -1173,8 +1180,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.batchImportModalOpen = true;
this.batchImportText = '';
this.batchImportStatus = '';
this.batchImportCount = 0;
this.batchImportTotal = 0;
this.batchImportFailures = 0;
this.importInProgress = false;
this.cancelImportFlag = false;
setTimeout(() => {
const textarea = document.getElementById('batch-import-textarea');
if (textarea instanceof HTMLTextAreaElement) {
@@ -1200,48 +1209,63 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return;
}
this.importInProgress = true;
this.cancelImportFlag = false;
this.batchImportStatus = `Starting to import ${urls.length} URLs...`;
let index = 0;
const delayBetween = 1000;
const processNext = () => {
if (this.cancelImportFlag) {
this.batchImportStatus = `Import cancelled after ${index} of ${urls.length} URLs.`;
this.importInProgress = false;
return;
}
if (index >= urls.length) {
this.batchImportStatus = `Finished importing ${urls.length} URLs.`;
this.importInProgress = false;
return;
}
const url = urls[index];
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
// Pass current selection options to backend
this.downloads.add(this.buildAddPayload({ url }))
.subscribe({
next: (status: Status) => {
this.batchImportCount = 0;
this.batchImportFailures = 0;
this.batchImportTotal = urls.length;
this.updateBatchImportStatus();
from(urls).pipe(
mergeMap(
url => this.downloads.add(this.buildAddPayload({ url })).pipe(
// downloads.add() already catches HTTP errors and emits a single
// Status value, so `tap` (not `finalize`) is the right place to
// count. This avoids incrementing the counter when an in-flight
// request is aborted by cancellation.
tap((status: Status) => {
if (status.status === 'error') {
alert(`Error adding URL ${url}: ${status.msg}`);
this.batchImportFailures++;
console.error(`Error adding URL ${url}: ${status.msg}`);
}
index++;
setTimeout(processNext, delayBetween);
},
error: (err) => {
console.error(`Error importing URL ${url}:`, err);
index++;
setTimeout(processNext, delayBetween);
}
});
};
processNext();
this.batchImportCount++;
this.updateBatchImportStatus();
this.cdr.markForCheck();
}),
),
App.BATCH_IMPORT_CONCURRENCY,
),
takeUntil(this.batchImportCancel$),
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.importInProgress = false;
this.updateBatchImportStatus(true);
this.cdr.markForCheck();
}),
).subscribe();
}
// Cancel the batch import process
private updateBatchImportStatus(done = false): void {
const parts: string[] = [];
if (done) {
const processed = this.batchImportCount;
if (processed < this.batchImportTotal) {
parts.push(`Import cancelled after ${processed} of ${this.batchImportTotal} URLs.`);
} else {
parts.push(`Finished importing ${this.batchImportTotal} URLs.`);
}
} else {
parts.push(`Importing ${this.batchImportCount} of ${this.batchImportTotal} URLs...`);
}
if (this.batchImportFailures > 0) {
parts.push(`${this.batchImportFailures} failed.`);
}
this.batchImportStatus = parts.join(' ');
}
// Cancel the batch import process: aborts in-flight and pending requests
// immediately via the cancellation Subject wired into the pipeline.
cancelBatchImport(): void {
if (this.importInProgress) {
this.cancelImportFlag = true;
this.batchImportStatus += ' Cancelling...';
this.batchImportCancel$.next();
}
}
Generated
+10 -10
View File
@@ -324,15 +324,15 @@ wheels = [
[[package]]
name = "deno"
version = "2.7.11"
version = "2.7.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/3f/a0477c72b847c0082ceb8885261eb14fb4addde22e8fb0146c011636979b/deno-2.7.11.tar.gz", hash = "sha256:342a656fca446fadc261ed22af35693b6c34e79129fa2bd387a1e5d39f496a99", size = 8167, upload-time = "2026-04-01T12:48:16.673Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/ac/39b45b51e7aaf9cad1de36f91bd58677818a7d7a867fa0721faff43b67c4/deno-2.7.12.tar.gz", hash = "sha256:7ef413693b4a9d86837a6f4991738429908f6f42f6f3ed85254ab0e679438049", size = 8167, upload-time = "2026-04-09T20:39:50.505Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/64/c8cb5a9c50135ada59b412b7511852d551d2618e169f48a8e7b8e90a382a/deno-2.7.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:76aa633656b07f64cfda3aa94a1e16bc123034ba2fe676d23b179f8326728534", size = 47857669, upload-time = "2026-04-01T12:48:01.947Z" },
{ url = "https://files.pythonhosted.org/packages/27/f3/250216e71e21cfc291ff6eb6503a479410ff927c9f97ebb644463c620692/deno-2.7.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a1dd1ceb0c2109b54334a621503e5297d473f5c3e6702992dfa68d9b7cdf1177", size = 44607869, upload-time = "2026-04-01T12:48:05.23Z" },
{ url = "https://files.pythonhosted.org/packages/01/51/b8fcb7d6882d659abd678f48fc5bc89aa0aa10f5a399fca3823680f277ca/deno-2.7.11-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:56578808c891d1daeadc279ac73445a24a83c2c721b37016fd92b1aa9224979b", size = 48394760, upload-time = "2026-04-01T12:48:08.049Z" },
{ url = "https://files.pythonhosted.org/packages/a0/18/91d1ceb2e15b4446fcecdf4be17ed59b02f5b35d8c98c94e7eaadf10591e/deno-2.7.11-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:58eea29d18fe8f80f2a4354d96225a31961ed3fa68336a7fcdd6add331df6acd", size = 50418090, upload-time = "2026-04-01T12:48:10.84Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8d/7a801d85a0a8233d00ea1a381fd32e76c81be9db78ada162e211e7619221/deno-2.7.11-py3-none-win_amd64.whl", hash = "sha256:e2b69676e52543153b82e926f558983f9251db2251474f3225301cf7aca05a2b", size = 49417475, upload-time = "2026-04-01T12:48:14.158Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/0be717182294e9b61e58cd89b88d1b711c7e9c134e53890474b8d1e95704/deno-2.7.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:74677d1be23426b1f16ce33c1a96868bc7554e1d41a12fb4d446017bb1955d1d", size = 47683364, upload-time = "2026-04-09T20:39:35.086Z" },
{ url = "https://files.pythonhosted.org/packages/8d/44/ca1de5368b193c9819c57d505ff8ceb86dc38e2e87856b23c23b58abd234/deno-2.7.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acf94fa815ac84a948b196f53a7e088ed126ec2e390a25d92cdfdc8c5d8a851e", size = 44460697, upload-time = "2026-04-09T20:39:38.5Z" },
{ url = "https://files.pythonhosted.org/packages/ce/68/ce111604f5877220e53fb10c5180ffaa484f829f20fcbc7f7115a90afadd/deno-2.7.12-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:ab7c12ee0e10cd232dbb8f7cf5802bfdbcc3462c0b1453726b158d8a27d4185f", size = 48237362, upload-time = "2026-04-09T20:39:41.555Z" },
{ url = "https://files.pythonhosted.org/packages/d7/07/d8ba3284e098128076cd9f84e85d2e62d12fa1e5b8e5c47681983017af1a/deno-2.7.12-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b396675fef296530c386387990d26d118398452bac47541e739b353b9ff8809f", size = 50247653, upload-time = "2026-04-09T20:39:44.956Z" },
{ url = "https://files.pythonhosted.org/packages/07/b0/c6c215eca2aa54b700a95867901832245518b2f217edfc78e733401bf8b3/deno-2.7.12-py3-none-win_amd64.whl", hash = "sha256:ddeeb03427d7ce0979a395ae2d1aa5ebcddeb97ab0fd9e7dfa56a08e0e5b817c", size = 49200604, upload-time = "2026-04-09T20:39:48.218Z" },
]
[[package]]
@@ -593,11 +593,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.0"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]