From c1b5540332ac2fd65215366f9ea10940b86d5fe7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:31:27 +0000 Subject: [PATCH 01/12] Replace custom template substitution with yt-dlp's evaluate_outtmpl Replace the hand-rolled _outtmpl_substitute_field() / _compile_outtmpl_pattern() with a new _resolve_outtmpl_fields() that delegates to yt-dlp's YoutubeDL.evaluate_outtmpl(). This gives playlist/channel output templates access to yt-dlp's full template syntax: defaults (%(field|fallback)s), conditional formatting (%(field&prefix {})s), math (%(field+N)d), datetime formatting (%(field>%Y-%m-%d)s), and more. Only field references whose root name matches the targeted prefix (e.g. "playlist" or "channel") are resolved; all other references remain as template placeholders for yt-dlp to fill during the actual download. Agent-Logs-Url: https://github.com/alexta69/metube/sessions/0ae5ff34-540f-4fc8-a81c-358fb92b7c15 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- app/tests/test_ytdl_utils.py | 44 +++++++++++++++++---- app/ytdl.py | 77 +++++++++++++++++------------------- 2 files changed, 73 insertions(+), 48 deletions(-) diff --git a/app/tests/test_ytdl_utils.py b/app/tests/test_ytdl_utils.py index 54c9400..06d6d50 100644 --- a/app/tests/test_ytdl_utils.py +++ b/app/tests/test_ytdl_utils.py @@ -24,7 +24,7 @@ class _ImpersonateTarget: fake_impersonate.ImpersonateTarget = _ImpersonateTarget fake_networking.impersonate = fake_impersonate -fake_utils.STR_FORMAT_RE_TMPL = r"(?P)%\((?P{})\)(?P[-0-9.]*{})" +fake_utils.STR_FORMAT_RE_TMPL = r"(?P)%\((?P(?P{}))\)(?P[-0-9.]*{})" fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa" fake_yt_dlp.networking = fake_networking fake_yt_dlp.utils = fake_utils @@ -37,11 +37,15 @@ from ytdl import ( DownloadInfo, _compact_persisted_entry, _convert_srt_to_txt_file, - _outtmpl_substitute_field, + _resolve_outtmpl_fields, _sanitize_entry_for_pickle, _sanitize_path_component, ) +# Detect whether the real yt-dlp is loaded (as opposed to the minimal fake +# shim above). _resolve_outtmpl_fields needs YoutubeDL at runtime. +_has_real_ytdlp = hasattr(sys.modules.get("yt_dlp"), "YoutubeDL") + class SanitizePathComponentTests(unittest.TestCase): def test_replaces_windows_invalid_chars(self): @@ -52,15 +56,39 @@ class SanitizePathComponentTests(unittest.TestCase): self.assertEqual(_sanitize_path_component(42), 42) -class OuttmplSubstituteFieldTests(unittest.TestCase): - def test_simple_substitution(self): - self.assertEqual(_outtmpl_substitute_field("%(title)s", "title", "Hello"), "Hello") +@unittest.skipUnless(_has_real_ytdlp, "requires real yt-dlp") +class ResolveOuttmplFieldsTests(unittest.TestCase): + """Tests for _resolve_outtmpl_fields (delegates to yt-dlp's template engine).""" + + def test_simple_playlist_substitution(self): + info = {"playlist_title": "My PL", "playlist_index": "03"} + result = _resolve_outtmpl_fields("%(playlist_title)s/%(title)s.%(ext)s", info, ("playlist",)) + self.assertEqual(result, "My PL/%(title)s.%(ext)s") def test_format_spec_int(self): - self.assertEqual(_outtmpl_substitute_field("%(idx)02d", "idx", 3), "03") + info = {"playlist_index": "3"} + result = _resolve_outtmpl_fields("%(playlist_index)02d-%(title)s", info, ("playlist",)) + self.assertEqual(result, "03-%(title)s") - def test_missing_field_unchanged(self): - self.assertEqual(_outtmpl_substitute_field("%(other)s", "title", "x"), "%(other)s") + def test_non_targeted_fields_unchanged(self): + info = {"playlist_title": "PL"} + result = _resolve_outtmpl_fields("%(title)s/%(ext)s", info, ("playlist",)) + self.assertEqual(result, "%(title)s/%(ext)s") + + def test_default_value(self): + info = {"playlist_index": "1"} + result = _resolve_outtmpl_fields("%(playlist_title|Unknown)s/%(playlist_index)s", info, ("playlist",)) + self.assertEqual(result, "Unknown/1") + + def test_channel_prefix(self): + info = {"channel": "MyChan", "channel_index": "05"} + result = _resolve_outtmpl_fields("%(channel)s/%(channel_index)02d-%(title)s", info, ("channel",)) + self.assertEqual(result, "MyChan/05-%(title)s") + + def test_math_operation(self): + info = {"playlist_index": "3"} + result = _resolve_outtmpl_fields("%(playlist_index+100)d", info, ("playlist",)) + self.assertEqual(result, "103") class SanitizeEntryForPickleTests(unittest.TestCase): diff --git a/app/ytdl.py b/app/ytdl.py index 6aac00d..15f42f1 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -13,7 +13,6 @@ import logging import re import types from typing import Any, Optional -from functools import lru_cache import yt_dlp.networking.impersonate from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES @@ -24,13 +23,6 @@ from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf log = logging.getLogger('ytdl') -@lru_cache(maxsize=None) -def _compile_outtmpl_pattern(field: str) -> re.Pattern: - """Compile a regex pattern to match a specific field in an output template, including optional format specifiers.""" - conversion_types = f"[{re.escape(STR_FORMAT_TYPES)}]" - return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types)) - - # Characters that are invalid in Windows/NTFS path components. These are pre- # sanitised when substituting playlist/channel titles into output templates so # that downloads do not fail on NTFS-mounted volumes or Windows Docker hosts. @@ -41,44 +33,51 @@ def _sanitize_path_component(value: Any) -> Any: """Replace characters that are invalid in Windows path components with '_'. Non-string values (int, float, None, …) are passed through unchanged so - that ``_outtmpl_substitute_field`` can still coerce them with format specs - (e.g. ``%(playlist_index)02d``). Only string values are sanitised because - Windows-invalid characters are only a concern for human-readable strings - (titles, channel names, etc.) that may end up as directory names. + that numeric format specs (e.g. ``%(playlist_index)02d``) still work. + Only string values are sanitised because Windows-invalid characters are + only a concern for human-readable strings (titles, channel names, etc.) + that may end up as directory names. """ if not isinstance(value, str): return value return _WINDOWS_INVALID_PATH_CHARS.sub('_', value) -def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str: - """Substitute a single field in an output template, applying any format specifiers to the value.""" - pattern = _compile_outtmpl_pattern(field) +# Regex matching yt-dlp output-template field references, e.g. ``%(title)s`` +# or ``%(playlist_index)03d``. Built from yt-dlp's own ``STR_FORMAT_RE_TMPL`` +# so that it stays in sync with upstream changes to the template syntax. +_OUTTMPL_FIELD_RE = re.compile( + STR_FORMAT_RE_TMPL.format('[^)]+', f'[{STR_FORMAT_TYPES}ljhqBUDS]') +) - def replacement(match: re.Match) -> str: - if match.group("has_key") is None: - return match.group(0) - prefix = match.group("prefix") or "" - format_spec = match.group("format") +def _resolve_outtmpl_fields(template: str, info_dict: dict, prefixes: tuple[str, ...]) -> str: + """Resolve specific fields in an output template using yt-dlp's template engine. - if not format_spec: - return f"{prefix}{value}" + Only field references whose root name starts with one of *prefixes* are + evaluated. All other references are left untouched so that yt-dlp can + resolve them later during the actual download. - conversion_type = format_spec[-1] - try: - if conversion_type in "diouxX": - coerced_value = int(value) - elif conversion_type in "eEfFgG": - coerced_value = float(value) - else: - coerced_value = value + This delegates to ``YoutubeDL.evaluate_outtmpl`` for each targeted field + reference, giving access to the full yt-dlp template syntax (defaults, + conditional formatting, math operations, datetime formatting, etc.). + """ + matches = list(_OUTTMPL_FIELD_RE.finditer(template)) + if not matches: + return template - return f"{prefix}{('%' + format_spec) % coerced_value}" - except (ValueError, TypeError): - return f"{prefix}{value}" + with yt_dlp.YoutubeDL({'quiet': True}) as ydl: + for match in reversed(matches): + key = match.group('key') + if key is None: + continue + root = re.match(r'\w+', key) + if root is None or not root.group(0).startswith(prefixes): + continue + resolved = ydl.evaluate_outtmpl(match.group(0), info_dict) + template = template[:match.start()] + resolved + template[match.end():] - return pattern.sub(replacement, template) + return template _MAX_ENTRY_SANITIZE_DEPTH = 64 @@ -818,15 +817,13 @@ class DownloadQueue: if entry is not None and entry.get('playlist_index') is not None: if len(self.config.OUTPUT_TEMPLATE_PLAYLIST): output = self.config.OUTPUT_TEMPLATE_PLAYLIST - for property, value in entry.items(): - if property.startswith("playlist"): - output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value)) + sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()} + output = _resolve_outtmpl_fields(output, sanitized, ('playlist',)) if entry is not None and entry.get('channel_index') is not None: if len(self.config.OUTPUT_TEMPLATE_CHANNEL): output = self.config.OUTPUT_TEMPLATE_CHANNEL - for property, value in entry.items(): - if property.startswith("channel"): - output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value)) + 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) playlist_item_limit = getattr(dl, 'playlist_item_limit', 0) if playlist_item_limit > 0: From b17e1e5668f677e821dc541da44412d736c23f0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:34:09 +0000 Subject: [PATCH 02/12] Add explanatory comment for fake STR_FORMAT_RE_TMPL key group in tests Agent-Logs-Url: https://github.com/alexta69/metube/sessions/0ae5ff34-540f-4fc8-a81c-358fb92b7c15 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- app/tests/test_ytdl_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/tests/test_ytdl_utils.py b/app/tests/test_ytdl_utils.py index 06d6d50..a577339 100644 --- a/app/tests/test_ytdl_utils.py +++ b/app/tests/test_ytdl_utils.py @@ -24,6 +24,9 @@ class _ImpersonateTarget: fake_impersonate.ImpersonateTarget = _ImpersonateTarget fake_networking.impersonate = fake_impersonate +# The inner ``key`` group mirrors the real ``STR_FORMAT_RE_TMPL`` so that +# ``_OUTTMPL_FIELD_RE`` (compiled at import time) has the named group that +# ``_resolve_outtmpl_fields`` reads via ``match.group('key')``. fake_utils.STR_FORMAT_RE_TMPL = r"(?P)%\((?P(?P{}))\)(?P[-0-9.]*{})" fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa" fake_yt_dlp.networking = fake_networking From 981e6c10035f5bd270b39a70d51dbb6802c0f548 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:59:32 +0000 Subject: [PATCH 03/12] Propagate missing playlist context fields (playlist_count, playlist_autonumber, n_entries, __last_playlist_index) The playlist/channel processing loop now sets playlist_count, playlist_autonumber, n_entries, and __last_playlist_index on each video entry so that templates like %(playlist_autonumber)s, %(playlist_count)s, and %(playlist_index&{} - |)s resolve correctly instead of showing NA. Also updates _compact_persisted_entry to preserve n_entries and __last_playlist_index across restarts. Fixes #692 Agent-Logs-Url: https://github.com/alexta69/metube/sessions/b5aeb55a-3197-4a14-b8b4-96c9a67796e8 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- app/tests/test_ytdl_utils.py | 37 ++++++++++++++++++++++++++++++++++++ app/ytdl.py | 14 +++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/tests/test_ytdl_utils.py b/app/tests/test_ytdl_utils.py index a577339..0f2e66d 100644 --- a/app/tests/test_ytdl_utils.py +++ b/app/tests/test_ytdl_utils.py @@ -93,6 +93,35 @@ class ResolveOuttmplFieldsTests(unittest.TestCase): result = _resolve_outtmpl_fields("%(playlist_index+100)d", info, ("playlist",)) self.assertEqual(result, "103") + def test_playlist_count_and_autonumber(self): + info = { + "playlist_title": "My PL", + "playlist_index": "03", + "playlist_count": 10, + "playlist_autonumber": 3, + "n_entries": 10, + "__last_playlist_index": 10, + } + result = _resolve_outtmpl_fields( + "%(playlist_title)s/%(playlist_autonumber)s of %(playlist_count)s - %(title)s.%(ext)s", + info, + ("playlist",), + ) + # playlist_autonumber is auto-padded by yt-dlp using __last_playlist_index + self.assertEqual(result, "My PL/03 of 10 - %(title)s.%(ext)s") + + def test_conditional_playlist_index(self): + info = { + "playlist_index": "5", + "playlist_count": 10, + } + result = _resolve_outtmpl_fields( + "%(playlist_index&{} - |)s%(title)s.%(ext)s", + info, + ("playlist",), + ) + self.assertEqual(result, "5 - %(title)s.%(ext)s") + class SanitizeEntryForPickleTests(unittest.TestCase): def test_nested(self): @@ -250,8 +279,12 @@ class CompactPersistedEntryTests(unittest.TestCase): entry = { "playlist_index": "01", "playlist_title": "Playlist", + "playlist_count": 10, + "playlist_autonumber": 1, "channel_index": "02", "channel_title": "Channel", + "n_entries": 10, + "__last_playlist_index": 10, "formats": [{"id": "huge"}], "description": "big blob", } @@ -263,8 +296,12 @@ class CompactPersistedEntryTests(unittest.TestCase): { "playlist_index": "01", "playlist_title": "Playlist", + "playlist_count": 10, + "playlist_autonumber": 1, "channel_index": "02", "channel_title": "Channel", + "n_entries": 10, + "__last_playlist_index": 10, }, ) diff --git a/app/ytdl.py b/app/ytdl.py index 15f42f1..65b5ed4 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -295,13 +295,16 @@ _PERSISTED_DOWNLOAD_FIELDS = ( ) +_COMPACT_ENTRY_EXTRA_KEYS = frozenset(("n_entries", "__last_playlist_index")) + + def _compact_persisted_entry(entry: Any) -> Optional[dict[str, Any]]: if not isinstance(entry, dict): return None compact = { key: value for key, value in entry.items() - if key.startswith("playlist") or key.startswith("channel") + if key.startswith("playlist") or key.startswith("channel") or key in _COMPACT_ENTRY_EXTRA_KEYS } return compact or None @@ -893,8 +896,9 @@ class DownloadQueue: # Convert generator to list if needed (for len() and slicing operations) if isinstance(entries, types.GeneratorType): entries = list(entries) - log.info(f'{etype} detected with {len(entries)} entries') - index_digits = len(str(len(entries))) + total_entries = len(entries) + log.info(f'{etype} detected with {total_entries} entries') + index_digits = len(str(total_entries)) results = [] if playlist_item_limit > 0: log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries') @@ -906,6 +910,10 @@ class DownloadQueue: etr["_type"] = "video" etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel") etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index) + etr[f"{etype}_count"] = total_entries + etr[f"{etype}_autonumber"] = index + etr["n_entries"] = total_entries + etr["__last_playlist_index"] = total_entries for property in ("id", "title", "uploader", "uploader_id"): if property in entry: etr[f"{etype}_{property}"] = entry[property] From 1cc27d3f55fdc32327b89c85b845dc13d20b19b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:23:14 +0000 Subject: [PATCH 04/12] Initial plan From 771ba52d5344a4983f17acfbabe8bfd53d58fc84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:25:14 +0000 Subject: [PATCH 05/12] Use PORT env variable in Dockerfile HEALTHCHECK instead of hardcoded 8081 Agent-Logs-Url: https://github.com/alexta69/metube/sessions/899e7074-fd3d-4538-8bad-8ee6804d5052 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8d17cd5..f9896bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,9 +63,10 @@ ENV UMASK=022 ENV DOWNLOAD_DIR=/downloads ENV STATE_DIR=/downloads/.metube ENV TEMP_DIR=/downloads +ENV PORT=8081 VOLUME /downloads EXPOSE 8081 -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:8081/" || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:${PORT}/" || exit 1 # Add build-time argument for version ARG VERSION=dev From d7eaaaa94baeb557b49dde9fb7a4b29b8a4e2d2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:02:25 +0000 Subject: [PATCH 06/12] Add clarifying comments for n_entries and __last_playlist_index fields (closes #692) Agent-Logs-Url: https://github.com/alexta69/metube/sessions/b5aeb55a-3197-4a14-b8b4-96c9a67796e8 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- app/ytdl.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/ytdl.py b/app/ytdl.py index 65b5ed4..889c0fb 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -912,6 +912,8 @@ class DownloadQueue: etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index) etr[f"{etype}_count"] = total_entries etr[f"{etype}_autonumber"] = index + # n_entries: standard yt-dlp field for total count (used by template engine) + # __last_playlist_index: yt-dlp internal field for auto-padding autonumber etr["n_entries"] = total_entries etr["__last_playlist_index"] = total_entries for property in ("id", "title", "uploader", "uploader_id"): From 0cba61c9a4a80a1f2e5f8e091e26d2094736e830 Mon Sep 17 00:00:00 2001 From: Alex Shnitman Date: Thu, 2 Apr 2026 10:52:56 +0300 Subject: [PATCH 07/12] update README --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ad47702..e588b5e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,12 @@ ![Build Status](https://github.com/alexta69/metube/actions/workflows/main.yml/badge.svg) ![Docker Pulls](https://img.shields.io/docker/pulls/alexta69/metube.svg) -Web GUI for youtube-dl (using the [yt-dlp](https://github.com/yt-dlp/yt-dlp) fork) with playlist support. Allows you to download videos from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md). +MeTube is a self-hosted web UI for `yt-dlp`, for downloading media from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md). + +Key capabilities: +* Download videos, audio, captions, and thumbnails from a browser UI. +* Download playlists and channels, with configurable output and download options. +* Subscribe to channels and playlists, periodically check for new items, and queue new uploads automatically. ![screenshot1](https://github.com/alexta69/metube/raw/master/screenshot.gif) From 565a715037eb41df0c10ef179d690bce6a88da51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 06:16:12 +0000 Subject: [PATCH 08/12] feat: add per-download yt-dlp presets and overrides Agent-Logs-Url: https://github.com/alexta69/metube/sessions/8a3119fc-63d1-4508-a196-8c50ff248812 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- README.md | 2 + app/main.py | 107 ++++++++++++++++++ app/subscriptions.py | 16 +++ app/tests/test_api.py | 49 ++++++++ app/tests/test_config.py | 25 ++++ app/tests/test_download_queue.py | 40 +++++++ app/tests/test_main_helpers.py | 44 +++++++ app/ytdl.py | 32 +++++- ui/src/app/app.html | 29 +++++ ui/src/app/app.ts | 57 ++++++++++ ui/src/app/interfaces/download.ts | 2 + ui/src/app/services/downloads.service.spec.ts | 13 +++ ui/src/app/services/downloads.service.ts | 10 ++ ui/src/app/services/subscriptions.service.ts | 2 + 14 files changed, 427 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e588b5e..bfa709a 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Certain values can be set via environment variables, using the `-e` parameter on * __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. +* __YTDL_OPTIONS_PRESETS__: A JSON object mapping preset names to yt-dlp option objects. These preset names are exposed in the web UI's Advanced Options panel so users can pick per-download overrides without changing the global `YTDL_OPTIONS`. +* __YTDL_OPTIONS_PRESETS_FILE__: A path to a JSON file containing `YTDL_OPTIONS_PRESETS`. If both are specified, values from `YTDL_OPTIONS_PRESETS_FILE` are merged into `YTDL_OPTIONS_PRESETS`. ### 🌐 Web Server & URLs diff --git a/app/main.py b/app/main.py index 284198d..4ffe2cb 100644 --- a/app/main.py +++ b/app/main.py @@ -57,6 +57,8 @@ class Config: 'CLEAR_COMPLETED_AFTER': '0', 'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS_FILE': '', + 'YTDL_OPTIONS_PRESETS': '{}', + 'YTDL_OPTIONS_PRESETS_FILE': '', 'ROBOTS_TXT': '', 'HOST': '0.0.0.0', 'PORT': '8081', @@ -91,12 +93,17 @@ class Config: # 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()) + if self.YTDL_OPTIONS_PRESETS_FILE and self.YTDL_OPTIONS_PRESETS_FILE.startswith('.'): + self.YTDL_OPTIONS_PRESETS_FILE = str(Path(self.YTDL_OPTIONS_PRESETS_FILE).resolve()) self._runtime_overrides = {} success,_ = self.load_ytdl_options() if not success: sys.exit(1) + success,_ = self.load_ytdl_option_presets() + if not success: + sys.exit(1) def set_runtime_override(self, key, value): self._runtime_overrides[key] = value @@ -160,6 +167,37 @@ class Config: self._apply_runtime_overrides() return (True, '') + def load_ytdl_option_presets(self) -> tuple[bool, str]: + try: + self.YTDL_OPTIONS_PRESETS = json.loads(os.environ.get('YTDL_OPTIONS_PRESETS', '{}')) + assert isinstance(self.YTDL_OPTIONS_PRESETS, dict) + assert all(isinstance(name, str) and isinstance(options, dict) for name, options in self.YTDL_OPTIONS_PRESETS.items()) + except (json.decoder.JSONDecodeError, AssertionError): + msg = 'Environment variable YTDL_OPTIONS_PRESETS is invalid' + log.error(msg) + return (False, msg) + + if not self.YTDL_OPTIONS_PRESETS_FILE: + return (True, '') + + log.info(f'Loading yt-dlp option presets from "{self.YTDL_OPTIONS_PRESETS_FILE}"') + if not os.path.exists(self.YTDL_OPTIONS_PRESETS_FILE): + msg = f'File "{self.YTDL_OPTIONS_PRESETS_FILE}" not found' + log.error(msg) + return (False, msg) + try: + with open(self.YTDL_OPTIONS_PRESETS_FILE) as json_data: + opts = json.load(json_data) + assert isinstance(opts, dict) + assert all(isinstance(name, str) and isinstance(options, dict) for name, options in opts.items()) + except (json.decoder.JSONDecodeError, AssertionError): + msg = 'YTDL_OPTIONS_PRESETS_FILE contents is invalid' + log.error(msg) + return (False, msg) + + self.YTDL_OPTIONS_PRESETS.update(opts) + return (True, '') + config = Config() # Align root logger level with Config (keeps a single source of truth). # This re-applies the log level after Config loads, in case LOGLEVEL was @@ -194,6 +232,53 @@ VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'} VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'} VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'} VALID_THUMBNAIL_FORMATS = {'jpg'} +BLOCKED_YTDL_OVERRIDE_KEYS = frozenset({ + 'exec', + 'exec_before_dl', + 'exec_cmd', + 'external_downloader', + 'external_downloader_args', + 'format', + 'ignore_no_formats_error', + 'no_color', + 'outtmpl', + 'paths', + 'postprocessor_hooks', + 'progress_hooks', + 'quiet', + 'socket_timeout', + 'verbose', +}) + + +def _iter_nested_keys(value): + if isinstance(value, dict): + for key, nested in value.items(): + yield str(key) + yield from _iter_nested_keys(nested) + elif isinstance(value, list): + for item in value: + yield from _iter_nested_keys(item) + + +def _parse_ytdl_options_overrides(value) -> dict: + if value is None or value == '': + return {} + + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError as exc: + raise web.HTTPBadRequest(reason='ytdl_options_overrides must be valid JSON') from exc + + if not isinstance(value, dict): + raise web.HTTPBadRequest(reason='ytdl_options_overrides must be a JSON object') + + blocked_keys = sorted({key for key in _iter_nested_keys(value) if key in BLOCKED_YTDL_OVERRIDE_KEYS}) + if blocked_keys: + raise web.HTTPBadRequest(reason=f'ytdl_options_overrides contains disallowed keys: {blocked_keys}') + + return value def _migrate_legacy_request(post: dict) -> dict: @@ -384,6 +469,8 @@ def parse_download_options(post: dict) -> dict: chapter_template = post.get('chapter_template') subtitle_language = post.get('subtitle_language') subtitle_mode = post.get('subtitle_mode') + ytdl_options_preset = post.get('ytdl_options_preset') + ytdl_options_overrides = post.get('ytdl_options_overrides') if custom_name_prefix is None: custom_name_prefix = '' @@ -401,12 +488,16 @@ def parse_download_options(post: dict) -> dict: subtitle_language = 'en' if subtitle_mode is None: subtitle_mode = 'prefer_manual' + if ytdl_options_preset is None: + ytdl_options_preset = '' download_type = str(download_type).strip().lower() codec = str(codec or 'auto').strip().lower() format = str(format or '').strip().lower() quality = str(quality).strip().lower() subtitle_language = str(subtitle_language).strip() subtitle_mode = str(subtitle_mode).strip() + ytdl_options_preset = str(ytdl_options_preset).strip() + ytdl_options_overrides = _parse_ytdl_options_overrides(ytdl_options_overrides) if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') @@ -414,6 +505,8 @@ def parse_download_options(post: dict) -> dict: raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters') if subtitle_mode not in VALID_SUBTITLE_MODES: raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}') + if ytdl_options_preset and ytdl_options_preset not in config.YTDL_OPTIONS_PRESETS: + raise web.HTTPBadRequest(reason='ytdl_options_preset must match a configured preset') if download_type not in VALID_DOWNLOAD_TYPES: raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}') @@ -466,6 +559,8 @@ def parse_download_options(post: dict) -> dict: 'chapter_template': chapter_template, 'subtitle_language': subtitle_language, 'subtitle_mode': subtitle_mode, + 'ytdl_options_preset': ytdl_options_preset, + 'ytdl_options_overrides': ytdl_options_overrides, } @@ -500,9 +595,19 @@ async def add(request): o['chapter_template'], o['subtitle_language'], o['subtitle_mode'], + o['ytdl_options_preset'], + o['ytdl_options_overrides'], ) return web.Response(text=serializer.encode(status)) + +@routes.get(config.URL_PREFIX + 'presets') +async def presets(request): + return web.Response( + text=serializer.encode({'presets': sorted(config.YTDL_OPTIONS_PRESETS.keys())}), + content_type='application/json', + ) + @routes.post(config.URL_PREFIX + 'cancel-add') async def cancel_add(request): dqueue.cancel_add() @@ -541,6 +646,8 @@ async def subscribe(request): chapter_template=o['chapter_template'], subtitle_language=o['subtitle_language'], subtitle_mode=o['subtitle_mode'], + ytdl_options_preset=o['ytdl_options_preset'], + ytdl_options_overrides=o['ytdl_options_overrides'], ) return web.Response(text=serializer.encode(result)) diff --git a/app/subscriptions.py b/app/subscriptions.py index 901ff59..94a1d3b 100644 --- a/app/subscriptions.py +++ b/app/subscriptions.py @@ -145,6 +145,8 @@ class SubscriptionInfo: chapter_template: str = "" subtitle_language: str = "en" subtitle_mode: str = "prefer_manual" + ytdl_options_preset: str = "" + ytdl_options_overrides: dict[str, Any] = field(default_factory=dict) last_checked: Optional[float] = None seen_ids: list[str] = field(default_factory=list) error: Optional[str] = None @@ -190,6 +192,8 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]: "chapter_template": sub.chapter_template, "subtitle_language": sub.subtitle_language, "subtitle_mode": sub.subtitle_mode, + "ytdl_options_preset": sub.ytdl_options_preset, + "ytdl_options_overrides": sub.ytdl_options_overrides, "last_checked": sub.last_checked, "seen_ids": list(sub.seen_ids), "error": sub.error, @@ -311,6 +315,8 @@ class SubscriptionManager: chapter_template: str, subtitle_language: str, subtitle_mode: str, + ytdl_options_preset: str = "", + ytdl_options_overrides: Optional[dict[str, Any]] = None, ) -> tuple[list[str], list[str]]: queued_ids: list[str] = [] queue_errors: list[str] = [] @@ -336,6 +342,8 @@ class SubscriptionManager: chapter_template or None, subtitle_language, subtitle_mode, + ytdl_options_preset, + ytdl_options_overrides, ) if isinstance(result, dict) and result.get("status") == "error": msg = str(result.get("msg") or f"Queueing failed for {vurl}") @@ -403,6 +411,8 @@ class SubscriptionManager: chapter_template: str, subtitle_language: str, subtitle_mode: str, + ytdl_options_preset: str = "", + ytdl_options_overrides: Optional[dict[str, Any]] = None, ) -> dict: url = self._normalize_url(url) if not url: @@ -460,6 +470,8 @@ class SubscriptionManager: chapter_template=chapter_template or "", subtitle_language=subtitle_language, subtitle_mode=subtitle_mode, + ytdl_options_preset=ytdl_options_preset, + ytdl_options_overrides=dict(ytdl_options_overrides or {}), last_checked=time.time(), seen_ids=list(dict.fromkeys(all_ids)), error=None, @@ -608,6 +620,8 @@ class SubscriptionManager: dl_chapter = cur.chapter_template dl_sublang = cur.subtitle_language dl_submode = cur.subtitle_mode + dl_ytdl_preset = cur.ytdl_options_preset + dl_ytdl_overrides = dict(cur.ytdl_options_overrides) new_entries: list[dict] = [] new_ids: list[str] = [] @@ -632,6 +646,8 @@ class SubscriptionManager: chapter_template=dl_chapter or "", subtitle_language=dl_sublang, subtitle_mode=dl_submode, + ytdl_options_preset=dl_ytdl_preset, + ytdl_options_overrides=dl_ytdl_overrides, ) log.info( "Subscription check finished for %s: %d new, %d queued, %d failed", diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 4aa18e8..5af4e25 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -37,6 +37,8 @@ def _valid_video_add_body(**kwargs): "codec": "auto", "format": "any", "quality": "best", + "ytdl_options_preset": "", + "ytdl_options_overrides": "", } base.update(kwargs) return base @@ -59,6 +61,23 @@ async def test_add_ok(mock_dqueue): mock_dqueue.add.assert_awaited_once() +@pytest.mark.asyncio +async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}}) + req = _json_request( + _valid_video_add_body( + ytdl_options_preset="Preset A", + ytdl_options_overrides='{"writesubtitles": true}', + ) + ) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call is not None + assert call.args[13] == "Preset A" + assert call.args[14] == {"writesubtitles": True} + + @pytest.mark.asyncio async def test_add_missing_url_returns_400(mock_dqueue): req = _json_request({"download_type": "video", "quality": "best", "format": "any"}) @@ -124,6 +143,27 @@ async def test_add_invalid_json_body(mock_dqueue): await main.add(req) +@pytest.mark.asyncio +async def test_add_invalid_ytdl_options_override_json(mock_dqueue): + req = _json_request(_valid_video_add_body(ytdl_options_overrides="{bad json}")) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_blocked_ytdl_options_override_key(mock_dqueue): + req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "rm -rf /"}')) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + +@pytest.mark.asyncio +async def test_add_unknown_ytdl_preset(mock_dqueue): + req = _json_request(_valid_video_add_body(ytdl_options_preset="Missing")) + with pytest.raises(web.HTTPBadRequest): + await main.add(req) + + @pytest.mark.asyncio async def test_delete_missing_ids(mock_dqueue): req = _json_request({"where": "queue"}) @@ -168,6 +208,15 @@ async def test_version_json(mock_dqueue): assert "yt-dlp" in body and "version" in body +@pytest.mark.asyncio +async def test_presets_endpoint_returns_names(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset B": {}, "Preset A": {}}) + req = MagicMock(spec=web.Request) + resp = await main.presets(req) + assert resp.status == 200 + assert json.loads(resp.text) == {"presets": ["Preset A", "Preset B"]} + + @pytest.mark.asyncio async def test_cookie_status(mock_dqueue): req = MagicMock(spec=web.Request) diff --git a/app/tests/test_config.py b/app/tests/test_config.py index 0461ba1..558f998 100644 --- a/app/tests/test_config.py +++ b/app/tests/test_config.py @@ -33,6 +33,16 @@ class ConfigTests(unittest.TestCase): c = Config() self.assertEqual(c.YTDL_OPTIONS["quiet"], True) + def test_ytdl_option_presets_json_loaded(self): + presets = {"Audio extras": {"embed_thumbnail": True}} + with patch.dict( + os.environ, + _base_env(YTDL_OPTIONS_PRESETS=json.dumps(presets)), + clear=False, + ): + c = Config() + self.assertEqual(c.YTDL_OPTIONS_PRESETS["Audio extras"]["embed_thumbnail"], True) + def test_invalid_ytdl_options_exits(self): with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False): with self.assertRaises(SystemExit): @@ -73,6 +83,21 @@ class ConfigTests(unittest.TestCase): finally: os.unlink(path) + def test_ytdl_option_presets_file_merges(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump({"With subtitles": {"writesubtitles": True}}, f) + path = f.name + try: + with patch.dict( + os.environ, + _base_env(YTDL_OPTIONS_PRESETS="{}", YTDL_OPTIONS_PRESETS_FILE=path), + clear=False, + ): + c = Config() + self.assertIn("With subtitles", c.YTDL_OPTIONS_PRESETS) + finally: + os.unlink(path) + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py index dfa21c7..5397c15 100644 --- a/app/tests/test_download_queue.py +++ b/app/tests/test_download_queue.py @@ -25,6 +25,7 @@ def dq_env(): cfg.TEMP_DIR = dl cfg.MAX_CONCURRENT_DOWNLOADS = "3" cfg.YTDL_OPTIONS = {} + cfg.YTDL_OPTIONS_PRESETS = {} cfg.CUSTOM_DIRS = True cfg.CREATE_CUSTOM_DIRS = True cfg.CLEAR_COMPLETED_AFTER = "0" @@ -175,3 +176,42 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env): assert result["status"] == "ok" assert dq.pending.exists("https://example.com/watch?v=1") + + +@pytest.mark.asyncio +async def test_add_merges_global_preset_and_override_options(dq_env): + notifier = AsyncMock() + dq_env.YTDL_OPTIONS = {"writesubtitles": False, "cookiefile": "/tmp/global.txt"} + dq_env.YTDL_OPTIONS_PRESETS = {"Preset A": {"writesubtitles": True, "proxy": "http://preset"}} + + def fake_extract(self, url): + return { + "_type": "video", + "id": "vid2", + "title": "Preset Video", + "url": url, + "webpage_url": url, + } + + dq = DownloadQueue(dq_env, notifier) + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract): + result = await dq.add( + "https://example.com/preset", + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ytdl_options_preset="Preset A", + ytdl_options_overrides={"proxy": "http://override", "embed_thumbnail": True}, + ) + + assert result["status"] == "ok" + queued = dq.pending.get("https://example.com/preset") + assert queued.ytdl_opts["cookiefile"] == "/tmp/global.txt" + assert queued.ytdl_opts["writesubtitles"] is True + assert queued.ytdl_opts["proxy"] == "http://override" + assert queued.ytdl_opts["embed_thumbnail"] is True diff --git a/app/tests/test_main_helpers.py b/app/tests/test_main_helpers.py index 4258b79..e6cfcfd 100644 --- a/app/tests/test_main_helpers.py +++ b/app/tests/test_main_helpers.py @@ -101,5 +101,49 @@ class FrontendSafeTests(unittest.TestCase): self.assertNotIn("DOWNLOAD_DIR", safe) +class ParseYtdlOverridesTests(unittest.TestCase): + def test_empty_override_string_returns_empty_dict(self): + self.assertEqual(main._parse_ytdl_options_overrides(""), {}) + + def test_rejects_non_object_json(self): + with self.assertRaises(main.web.HTTPBadRequest): + main._parse_ytdl_options_overrides('["bad"]') + + def test_rejects_blocked_keys(self): + with self.assertRaises(main.web.HTTPBadRequest): + main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}') + + +class ParseDownloadOptionsTests(unittest.TestCase): + def test_accepts_known_preset_and_overrides(self): + previous = dict(main.config.YTDL_OPTIONS_PRESETS) + main.config.YTDL_OPTIONS_PRESETS = {"With subtitles": {"writesubtitles": True}} + try: + parsed = main.parse_download_options({ + "url": "https://example.com/v", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_preset": "With subtitles", + "ytdl_options_overrides": '{"writesubtitles": true}', + }) + finally: + main.config.YTDL_OPTIONS_PRESETS = previous + self.assertEqual(parsed["ytdl_options_preset"], "With subtitles") + self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True}) + + def test_rejects_unknown_preset(self): + with self.assertRaises(main.web.HTTPBadRequest): + main.parse_download_options({ + "url": "https://example.com/v", + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "ytdl_options_preset": "Missing preset", + }) + + if __name__ == "__main__": unittest.main() diff --git a/app/ytdl.py b/app/ytdl.py index 889c0fb..61d514e 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -188,6 +188,8 @@ class DownloadInfo: chapter_template, subtitle_language="en", subtitle_mode="prefer_manual", + ytdl_options_preset="", + ytdl_options_overrides=None, ): self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' @@ -210,6 +212,8 @@ class DownloadInfo: self.chapter_template = chapter_template self.subtitle_language = subtitle_language self.subtitle_mode = subtitle_mode + self.ytdl_options_preset = ytdl_options_preset + self.ytdl_options_overrides = dict(ytdl_options_overrides or {}) self.subtitle_files = [] def __setstate__(self, state): @@ -262,6 +266,10 @@ class DownloadInfo: self.subtitle_language = "en" if not hasattr(self, "subtitle_mode"): self.subtitle_mode = "prefer_manual" + if not hasattr(self, "ytdl_options_preset"): + self.ytdl_options_preset = "" + if not hasattr(self, "ytdl_options_overrides"): + self.ytdl_options_overrides = {} if not hasattr(self, "entry"): self.entry = None if not hasattr(self, "subtitle_files"): @@ -285,6 +293,8 @@ _PERSISTED_DOWNLOAD_FIELDS = ( "chapter_template", "subtitle_language", "subtitle_mode", + "ytdl_options_preset", + "ytdl_options_overrides", "status", "timestamp", "error", @@ -828,6 +838,10 @@ class DownloadQueue: 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) + preset_name = getattr(dl, 'ytdl_options_preset', '') + if preset_name: + ytdl_options.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {})) + ytdl_options.update(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') @@ -855,6 +869,8 @@ class DownloadQueue: chapter_template, subtitle_language, subtitle_mode, + ytdl_options_preset, + ytdl_options_overrides, already, _add_gen=None, ): @@ -887,6 +903,8 @@ class DownloadQueue: chapter_template, subtitle_language, subtitle_mode, + ytdl_options_preset, + ytdl_options_overrides, already, _add_gen, ) @@ -934,6 +952,8 @@ class DownloadQueue: chapter_template, subtitle_language, subtitle_mode, + ytdl_options_preset, + ytdl_options_overrides, already, _add_gen, ) @@ -965,6 +985,8 @@ class DownloadQueue: chapter_template=chapter_template, subtitle_language=subtitle_language, subtitle_mode=subtitle_mode, + ytdl_options_preset=ytdl_options_preset, + ytdl_options_overrides=ytdl_options_overrides, ) await self.__add_download(dl, auto_start) return {'status': 'ok'} @@ -985,13 +1007,15 @@ class DownloadQueue: chapter_template=None, subtitle_language="en", subtitle_mode="prefer_manual", + ytdl_options_preset="", + ytdl_options_overrides=None, already=None, _add_gen=None, ): log.info( f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} ' f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' - f'{subtitle_language=} {subtitle_mode=}' + f'{subtitle_language=} {subtitle_mode=} {ytdl_options_preset=}' ) if already is None: _add_gen = self._add_generation @@ -1020,6 +1044,8 @@ class DownloadQueue: chapter_template, subtitle_language, subtitle_mode, + ytdl_options_preset, + ytdl_options_overrides, already, _add_gen, ) @@ -1039,6 +1065,8 @@ class DownloadQueue: chapter_template=None, subtitle_language="en", subtitle_mode="prefer_manual", + ytdl_options_preset="", + ytdl_options_overrides=None, ): normalized_entry = copy.deepcopy(entry) if isinstance(entry, dict) else entry already = set() @@ -1056,6 +1084,8 @@ class DownloadQueue: chapter_template, subtitle_language, subtitle_mode, + ytdl_options_preset, + ytdl_options_overrides, already, None, ) diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 303bdd1..73d4f77 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -447,6 +447,35 @@ ngbTooltip="How often to poll subscriptions for new videos"> +
+
+ Option Preset + +
+
+
+
+ Custom yt-dlp Options + +
+
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index e0c9a67..4786b7e 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -83,6 +83,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy { chapterTemplate: string; subtitleLanguage: string; subtitleMode: string; + ytdlOptionsPreset: string; + ytdlOptionsOverrides: string; + ytdlOptionPresetNames: string[] = []; addInProgress = false; cancelRequested = false; subscribeInProgress = false; @@ -231,6 +234,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy { this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; + this.ytdlOptionsPreset = this.cookieService.get('metube_ytdl_options_preset') || ''; + this.ytdlOptionsOverrides = this.cookieService.get('metube_ytdl_options_overrides') || ''; const allowedDownloadTypes = new Set(this.downloadTypes.map(t => t.id)); const allowedVideoCodecs = new Set(this.videoCodecs.map(c => c.id)); if (!allowedDownloadTypes.has(this.downloadType)) { @@ -287,6 +292,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy { }); this.getConfiguration(); this.getYtdlOptionsUpdateTime(); + this.getYtdlOptionPresets(); this.customDirs$ = this.getMatchingCustomDir(); this.setTheme(this.activeTheme!); @@ -415,6 +421,39 @@ export class App implements AfterViewInit, OnInit, OnDestroy { }); } + getYtdlOptionPresets() { + this.downloads.getPresets().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (data) => { + this.ytdlOptionPresetNames = Array.isArray(data?.presets) + ? data.presets.filter((preset): preset is string => typeof preset === 'string') + : []; + if (this.ytdlOptionsPreset && !this.ytdlOptionPresetNames.includes(this.ytdlOptionsPreset)) { + this.ytdlOptionsPreset = ''; + this.ytdlOptionsPresetChanged(); + } + this.cdr.markForCheck(); + }, + }); + } + + private validateYtdlOptionsOverrides(value: string): boolean { + const trimmed = value?.trim() || ''; + if (!trimmed) { + return true; + } + try { + const parsed = JSON.parse(trimmed); + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + alert('Custom yt-dlp options must be a JSON object'); + return false; + } + } catch { + alert('Custom yt-dlp options must be valid JSON'); + return false; + } + return true; + } + private rebuildCachedSubs() { this.cachedSubs = Array.from(this.subscriptionsSvc.subscriptions.entries()); const validIds = new Set(this.cachedSubs.map(([id]) => id)); @@ -491,6 +530,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy { alert('Chapter template must include %(section_number)'); return; } + if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) { + return; + } this.subscribeInProgress = true; this.subscriptionsSvc .subscribe({ @@ -695,6 +737,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy { this.saveSelection(this.downloadType); } + ytdlOptionsPresetChanged() { + this.cookieService.set('metube_ytdl_options_preset', this.ytdlOptionsPreset, { expires: this.settingsCookieExpiryDays }); + } + + ytdlOptionsOverridesChanged() { + this.cookieService.set('metube_ytdl_options_overrides', this.ytdlOptionsOverrides, { expires: this.settingsCookieExpiryDays }); + } + isVideoType() { return this.downloadType === 'video'; } @@ -894,6 +944,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy { chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate, subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage, subtitleMode: overrides.subtitleMode ?? this.subtitleMode, + ytdlOptionsPreset: overrides.ytdlOptionsPreset ?? this.ytdlOptionsPreset, + ytdlOptionsOverrides: overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides, }; } @@ -905,6 +957,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy { alert('Chapter template must include %(section_number)'); return; } + if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) { + return; + } console.debug('Downloading:', payload); this.addInProgress = true; @@ -960,6 +1015,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy { chapterTemplate: download.chapter_template, subtitleLanguage: download.subtitle_language, subtitleMode: download.subtitle_mode, + ytdlOptionsPreset: download.ytdl_options_preset || '', + ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '', }); this.downloads.delById('done', [key]).subscribe(); } diff --git a/ui/src/app/interfaces/download.ts b/ui/src/app/interfaces/download.ts index c988f71..d06af7f 100644 --- a/ui/src/app/interfaces/download.ts +++ b/ui/src/app/interfaces/download.ts @@ -14,6 +14,8 @@ export interface Download { chapter_template?: string; subtitle_language?: string; subtitle_mode?: string; + ytdl_options_preset?: string; + ytdl_options_overrides?: Record; status: string; msg: string; percent: number; diff --git a/ui/src/app/services/downloads.service.spec.ts b/ui/src/app/services/downloads.service.spec.ts index 29e4406..5d4723c 100644 --- a/ui/src/app/services/downloads.service.spec.ts +++ b/ui/src/app/services/downloads.service.spec.ts @@ -39,6 +39,8 @@ function basePayload(): AddDownloadPayload { chapterTemplate: '', subtitleLanguage: 'en', subtitleMode: 'prefer_manual', + ytdlOptionsPreset: '', + ytdlOptionsOverrides: '', }; } @@ -79,11 +81,22 @@ describe('DownloadsService', () => { chapter_template: '', subtitle_language: 'en', subtitle_mode: 'prefer_manual', + ytdl_options_preset: '', + ytdl_options_overrides: '', }), ); req.flush({ status: 'ok' }); }); + it('getPresets() fetches configured preset names', () => { + service.getPresets().subscribe((result) => { + expect(result).toEqual({ presets: ['Preset A'] }); + }); + const req = httpMock.expectOne('presets'); + expect(req.request.method).toBe('GET'); + req.flush({ presets: ['Preset A'] }); + }); + it('cancelAdd posts to cancel-add', () => { service.cancelAdd().subscribe(); const req = httpMock.expectOne('cancel-add'); diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index 0a2b6f4..66792f7 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -20,6 +20,8 @@ export interface AddDownloadPayload { chapterTemplate: string; subtitleLanguage: string; subtitleMode: string; + ytdlOptionsPreset: string; + ytdlOptionsOverrides: string; } @Injectable({ providedIn: 'root' @@ -141,11 +143,19 @@ export class DownloadsService { chapter_template: payload.chapterTemplate, subtitle_language: payload.subtitleLanguage, subtitle_mode: payload.subtitleMode, + ytdl_options_preset: payload.ytdlOptionsPreset, + ytdl_options_overrides: payload.ytdlOptionsOverrides, }).pipe( catchError(this.handleHTTPError) ); } + public getPresets() { + return this.http.get<{ presets: string[] }>('presets').pipe( + catchError(() => of({ presets: [] })) + ); + } + public startById(ids: string[]) { return this.http.post('start', {ids: ids}); } diff --git a/ui/src/app/services/subscriptions.service.ts b/ui/src/app/services/subscriptions.service.ts index ae35f3c..57fbcf6 100644 --- a/ui/src/app/services/subscriptions.service.ts +++ b/ui/src/app/services/subscriptions.service.ts @@ -94,6 +94,8 @@ export class SubscriptionsService { chapter_template: payload.chapterTemplate, subtitle_language: payload.subtitleLanguage, subtitle_mode: payload.subtitleMode, + ytdl_options_preset: payload.ytdlOptionsPreset, + ytdl_options_overrides: payload.ytdlOptionsOverrides, check_interval_minutes: payload.checkIntervalMinutes, }) .pipe(catchError((err) => this.handleHTTPError(err))); From 6e9b2dd7b3281719480d65ec22f2b3fb983b15d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:05:19 +0000 Subject: [PATCH 09/12] Gate manual yt-dlp overrides behind flag Agent-Logs-Url: https://github.com/alexta69/metube/sessions/31b4274d-cf48-4260-b73b-633cbcd2bb09 Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com> --- README.md | 1 + app/main.py | 45 +++-------- app/tests/test_api.py | 14 +++- app/tests/test_config.py | 6 ++ app/tests/test_main_helpers.py | 18 ++++- ui/src/app/app.html | 4 +- ui/src/app/app.spec.ts | 143 +++++++++++++++++++++++++++++++++ ui/src/app/app.ts | 12 ++- 8 files changed, 201 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index bfa709a..bb1845a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. * __YTDL_OPTIONS_PRESETS__: A JSON object mapping preset names to yt-dlp option objects. These preset names are exposed in the web UI's Advanced Options panel so users can pick per-download overrides without changing the global `YTDL_OPTIONS`. * __YTDL_OPTIONS_PRESETS_FILE__: A path to a JSON file containing `YTDL_OPTIONS_PRESETS`. If both are specified, values from `YTDL_OPTIONS_PRESETS_FILE` are merged into `YTDL_OPTIONS_PRESETS`. +* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show the web UI field for manual per-download `ytdl_options_overrides`. Defaults to `false`. Enabling this allows arbitrary yt-dlp API options to be supplied by UI users, which may enable arbitrary command execution inside the container depending on the options used. Enable only if you understand and accept that risk. ### 🌐 Web Server & URLs diff --git a/app/main.py b/app/main.py index 4ffe2cb..2a01d77 100644 --- a/app/main.py +++ b/app/main.py @@ -59,6 +59,7 @@ class Config: 'YTDL_OPTIONS_FILE': '', 'YTDL_OPTIONS_PRESETS': '{}', 'YTDL_OPTIONS_PRESETS_FILE': '', + 'ALLOW_YTDL_OPTIONS_OVERRIDES': 'false', 'ROBOTS_TXT': '', 'HOST': '0.0.0.0', 'PORT': '8081', @@ -72,7 +73,7 @@ class Config: 'ENABLE_ACCESSLOG': 'false', } - _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG') + _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES') def __init__(self): for k, v in self._DEFAULTS.items(): @@ -126,6 +127,7 @@ class Config: 'PUBLIC_HOST_AUDIO_URL', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', + 'ALLOW_YTDL_OPTIONS_OVERRIDES', ) def frontend_safe(self) -> dict: @@ -232,36 +234,7 @@ VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'} VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'} VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'} VALID_THUMBNAIL_FORMATS = {'jpg'} -BLOCKED_YTDL_OVERRIDE_KEYS = frozenset({ - 'exec', - 'exec_before_dl', - 'exec_cmd', - 'external_downloader', - 'external_downloader_args', - 'format', - 'ignore_no_formats_error', - 'no_color', - 'outtmpl', - 'paths', - 'postprocessor_hooks', - 'progress_hooks', - 'quiet', - 'socket_timeout', - 'verbose', -}) - - -def _iter_nested_keys(value): - if isinstance(value, dict): - for key, nested in value.items(): - yield str(key) - yield from _iter_nested_keys(nested) - elif isinstance(value, list): - for item in value: - yield from _iter_nested_keys(item) - - -def _parse_ytdl_options_overrides(value) -> dict: +def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict: if value is None or value == '': return {} @@ -274,9 +247,8 @@ def _parse_ytdl_options_overrides(value) -> dict: if not isinstance(value, dict): raise web.HTTPBadRequest(reason='ytdl_options_overrides must be a JSON object') - blocked_keys = sorted({key for key in _iter_nested_keys(value) if key in BLOCKED_YTDL_OVERRIDE_KEYS}) - if blocked_keys: - raise web.HTTPBadRequest(reason=f'ytdl_options_overrides contains disallowed keys: {blocked_keys}') + if value and not enabled: + raise web.HTTPBadRequest(reason='ytdl_options_overrides are disabled') return value @@ -497,7 +469,10 @@ def parse_download_options(post: dict) -> dict: subtitle_language = str(subtitle_language).strip() subtitle_mode = str(subtitle_mode).strip() ytdl_options_preset = str(ytdl_options_preset).strip() - ytdl_options_overrides = _parse_ytdl_options_overrides(ytdl_options_overrides) + ytdl_options_overrides = _parse_ytdl_options_overrides( + ytdl_options_overrides, + enabled=config.ALLOW_YTDL_OPTIONS_OVERRIDES, + ) if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 5af4e25..b8d1f2d 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -64,6 +64,7 @@ async def test_add_ok(mock_dqueue): @pytest.mark.asyncio async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch): monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}}) + monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True) req = _json_request( _valid_video_add_body( ytdl_options_preset="Preset A", @@ -151,12 +152,23 @@ async def test_add_invalid_ytdl_options_override_json(mock_dqueue): @pytest.mark.asyncio -async def test_add_blocked_ytdl_options_override_key(mock_dqueue): +async def test_add_rejects_ytdl_options_overrides_when_disabled(mock_dqueue): req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "rm -rf /"}')) with pytest.raises(web.HTTPBadRequest): await main.add(req) +@pytest.mark.asyncio +async def test_add_allows_any_ytdl_options_override_key_when_enabled(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True) + req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "echo hi"}')) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call is not None + assert call.args[14] == {"exec": "echo hi"} + + @pytest.mark.asyncio async def test_add_unknown_ytdl_preset(mock_dqueue): req = _json_request(_valid_video_add_body(ytdl_options_preset="Missing")) diff --git a/app/tests/test_config.py b/app/tests/test_config.py index 558f998..d97c1a9 100644 --- a/app/tests/test_config.py +++ b/app/tests/test_config.py @@ -59,6 +59,12 @@ class ConfigTests(unittest.TestCase): safe = c.frontend_safe() self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("HOST", safe) + self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False) + + def test_allow_ytdl_options_overrides_boolean_loaded(self): + with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False): + c = Config() + self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES) def test_runtime_override_roundtrip(self): with patch.dict(os.environ, _base_env(), clear=False): diff --git a/app/tests/test_main_helpers.py b/app/tests/test_main_helpers.py index e6cfcfd..37dd6fd 100644 --- a/app/tests/test_main_helpers.py +++ b/app/tests/test_main_helpers.py @@ -99,25 +99,34 @@ class FrontendSafeTests(unittest.TestCase): self.assertIn(key, safe) self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("DOWNLOAD_DIR", safe) + self.assertIn("ALLOW_YTDL_OPTIONS_OVERRIDES", safe) class ParseYtdlOverridesTests(unittest.TestCase): def test_empty_override_string_returns_empty_dict(self): - self.assertEqual(main._parse_ytdl_options_overrides(""), {}) + self.assertEqual(main._parse_ytdl_options_overrides("", enabled=False), {}) def test_rejects_non_object_json(self): with self.assertRaises(main.web.HTTPBadRequest): - main._parse_ytdl_options_overrides('["bad"]') + main._parse_ytdl_options_overrides('["bad"]', enabled=True) - def test_rejects_blocked_keys(self): + def test_rejects_non_empty_overrides_when_disabled(self): with self.assertRaises(main.web.HTTPBadRequest): - main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}') + main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=False) + + def test_allows_any_keys_when_enabled(self): + self.assertEqual( + main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=True), + {"exec": "rm -rf /"}, + ) class ParseDownloadOptionsTests(unittest.TestCase): def test_accepts_known_preset_and_overrides(self): previous = dict(main.config.YTDL_OPTIONS_PRESETS) + previous_allow = main.config.ALLOW_YTDL_OPTIONS_OVERRIDES main.config.YTDL_OPTIONS_PRESETS = {"With subtitles": {"writesubtitles": True}} + main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = True try: parsed = main.parse_download_options({ "url": "https://example.com/v", @@ -130,6 +139,7 @@ class ParseDownloadOptionsTests(unittest.TestCase): }) finally: main.config.YTDL_OPTIONS_PRESETS = previous + main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = previous_allow self.assertEqual(parsed["ytdl_options_preset"], "With subtitles") self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True}) diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 73d4f77..453d4d9 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -447,7 +447,7 @@ ngbTooltip="How often to poll subscriptions for new videos">
-
+
Option Preset - - - -
-
+
+ + +
Download Folder @@ -405,7 +392,6 @@ ngbTooltip="Choose where to save downloads. Type to create a new folder." /> }
-
@@ -419,6 +405,48 @@ ngbTooltip="Add a prefix to downloaded filenames">
+
+
+
+
+ + +
+
+ @if (splitByChapters) { +
+
+ Template + +
+
+ } +
+
+
+ + + +
+
+
+ Auto Start + +
+
Items Limit @@ -448,7 +476,10 @@
-
+ + + +
Option Preset @@ -481,101 +512,72 @@
}
-
-
-
-
-
- - -
-
- @if (splitByChapters) { -
-
- Template - -
-
+ + + +
+
+
Cookies
+ +
+ + @if (hasCookies) { + + } +
+
-
- - -
-
-
-
-
-
Cookies
- -
- - @if (hasCookies) { - - } -
- +
+
Bulk Actions
+
+
+
-
-
Bulk Actions
-
-
- -
-
- -
-
- -
-
+
+ +
+
+
diff --git a/ui/src/app/app.sass b/ui/src/app/app.sass index 9368c48..7232aaf 100644 --- a/ui/src/app/app.sass +++ b/ui/src/app/app.sass @@ -182,6 +182,18 @@ main opacity: 0.65 pointer-events: none +.settings-section-label + font-size: 0.8rem + text-transform: uppercase + letter-spacing: 0.1em + font-weight: 600 + color: var(--bs-body-color) + margin-top: 1.75rem + margin-bottom: 0.75rem + + &:first-child + margin-top: 0 + .action-group-label font-size: 0.7rem text-transform: uppercase diff --git a/uv.lock b/uv.lock index d439471..44a77db 100644 --- a/uv.lock +++ b/uv.lock @@ -235,59 +235,59 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.6" +version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -324,15 +324,15 @@ wheels = [ [[package]] name = "deno" -version = "2.7.10" +version = "2.7.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/01/c03ed7db9adbd02a45de56037e2b685adc730775e8878229881ed907458d/deno-2.7.10.tar.gz", hash = "sha256:ea30a61f98c9a57b80f80a525a1d4687e36b7fcca133f813439c8431489e703b", size = 8165, upload-time = "2026-03-31T15:12:00.299Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/4b/a28d8c7ff5d797f52098dcbce91b3ff8394bdbd0dd07cb4c87b032ead539/deno-2.7.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4e361633c1ce6ec439d312911ec230e4e060c4e5ca957e8f58823129af511b13", size = 47849281, upload-time = "2026-03-31T15:11:43.879Z" }, - { url = "https://files.pythonhosted.org/packages/b5/da/d572cf9f195aaf317a0e222af10f8adf3f3acfd114f80fec78c110fb66e6/deno-2.7.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6c4c03e583c4c4d5647ec97690038a4b7c00a7ad076949b5ce26203857b1c85a", size = 44608218, upload-time = "2026-03-31T15:11:47.664Z" }, - { url = "https://files.pythonhosted.org/packages/e6/da/f77fd4852d84063728d618b9f4c088b31d27a999b6dfc3dd1f9623dd56a9/deno-2.7.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b415a36b63e3c5c478180a5cc0e9f517095005086144dbe52b34268b397c404b", size = 48384632, upload-time = "2026-03-31T15:11:50.816Z" }, - { url = "https://files.pythonhosted.org/packages/dc/13/a41c3aba09103cd31ddbe2fc8fc98df5118df7b23d4cee248926367c6469/deno-2.7.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1f4aab0be642692205df91e39eff1db774d6b4c5d8dfcff0669d014fd0c80cba", size = 50420236, upload-time = "2026-03-31T15:11:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a9/423f671846107bed51c405bbd1e32782f0b39edd5d075e4f806b8eea77f5/deno-2.7.10-py3-none-win_amd64.whl", hash = "sha256:3c2ee1773cf48b0fe9e74d23da3b6f9b685240e90db81ce6f5c8c0922c08b992", size = 49403842, upload-time = "2026-03-31T15:11:57.728Z" }, + { 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" }, ] [[package]]