Compare commits

...

18 Commits

Author SHA1 Message Date
Alex Shnitman d41bdf61e2 finalize custom options (closes #563, #482, #261, #681) 2026-04-03 13:20:37 +03:00
copilot-swe-agent[bot] a02abf5853 Keep override controls on dedicated row
Agent-Logs-Url: https://github.com/alexta69/metube/sessions/aef158da-f919-4a3d-a5ee-b71df51c124d

Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-04-03 09:21:44 +00:00
copilot-swe-agent[bot] b16e597125 Fix frontend test typing for override 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>
2026-04-03 09:07:34 +00:00
copilot-swe-agent[bot] 6e9b2dd7b3 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>
2026-04-03 09:05:19 +00:00
copilot-swe-agent[bot] 565a715037 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>
2026-04-03 06:16:12 +00:00
Alex b4d497f53d Merge pull request #937 from alexta69/copilot/check-issue-692
Propagate missing playlist context fields (playlist_count, playlist_autonumber, etc.)
2026-04-02 10:55:00 +03:00
Alex Shnitman 0cba61c9a4 update README 2026-04-02 10:52:56 +03:00
Alex Shnitman 9858157581 Merge branch 'copilot/fix-healthcheck-failure-ipvlan' of https://github.com/alexta69/metube into copilot/check-issue-692 (closes #936) 2026-04-02 10:52:11 +03:00
copilot-swe-agent[bot] d7eaaaa94b 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>
2026-04-02 10:51:03 +03:00
copilot-swe-agent[bot] 771ba52d53 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>
2026-04-02 07:25:14 +00:00
copilot-swe-agent[bot] 1cc27d3f55 Initial plan 2026-04-02 07:23:14 +00:00
copilot-swe-agent[bot] 981e6c1003 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>
2026-04-01 19:59:32 +00:00
copilot-swe-agent[bot] b17e1e5668 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>
2026-04-01 19:34:09 +00:00
copilot-swe-agent[bot] c1b5540332 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>
2026-04-01 19:31:27 +00:00
Alex Shnitman 483575d24a add subscriptions; change persistence file format to JSON (closes #901, #76, #113, #170, #242, #444, #503, #555, #566) 2026-04-01 14:33:24 +03:00
Alex Shnitman 84c6418f91 fix pickle (closes #814) 2026-03-21 12:42:17 +02:00
Alex Shnitman a1f2fe3e73 implement tests 2026-03-20 13:12:31 +02:00
AutoUpdater 0bf508dbc6 upgrade yt-dlp from 2026.3.13 to 2026.3.17 2026-03-18 00:14:51 +00:00
38 changed files with 5755 additions and 1172 deletions
+10 -5
View File
@@ -26,14 +26,19 @@ jobs:
- name: Build frontend
working-directory: ui
run: pnpm run build
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Run frontend tests
working-directory: ui
run: pnpm exec ng test --watch=false
env:
CI: true
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install Python dependencies
run: uv sync --frozen --group dev
- name: Run backend smoke checks
run: python -m compileall app
- name: Run backend tests
run: python -m unittest discover -s app/tests -p "test_*.py"
run: uv run pytest app/tests/
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@0.35.0
with:
+2
View File
@@ -13,12 +13,14 @@
"env": {
"DOWNLOAD_DIR": "${env:USERPROFILE}/Downloads",
"STATE_DIR": "${env:TEMP}",
"ALLOW_YTDL_OPTIONS_OVERRIDES": "true",
}
},
"osx": {
"env": {
"DOWNLOAD_DIR": "${env:HOME}/Downloads",
"STATE_DIR": "${env:TMPDIR}",
"ALLOW_YTDL_OPTIONS_OVERRIDES": "true",
}
},
"console": "integratedTerminal"
+2 -4
View File
@@ -26,9 +26,6 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
gosu \
curl \
tini \
file \
gdbmtool \
sqlite3 \
build-essential && \
curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && \
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
@@ -66,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
+13 -2
View File
@@ -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)
@@ -36,6 +41,9 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
* __SUBSCRIPTION_DEFAULT_CHECK_INTERVAL__: Default minutes between automatic checks for each subscription. Defaults to `60`.
* __SUBSCRIPTION_SCAN_PLAYLIST_END__: Maximum playlist/channel entries to fetch per subscription check (newest-first). Defaults to `50`.
* __SUBSCRIPTION_MAX_SEEN_IDS__: Cap on stored video IDs per subscription to limit state file growth. Defaults to `50000`.
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
### 📁 Storage & Directories
@@ -46,7 +54,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
* __STATE_DIR__: Path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
@@ -60,6 +68,9 @@ 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`.
* __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
+255 -34
View File
@@ -17,6 +17,7 @@ import re
from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo
from yt_dlp.version import __version__ as yt_dlp_version
log = logging.getLogger('main')
@@ -50,9 +51,15 @@ class Config:
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL': '60',
'SUBSCRIPTION_SCAN_PLAYLIST_END': '50',
'SUBSCRIPTION_MAX_SEEN_IDS': '50000',
'CLEAR_COMPLETED_AFTER': '0',
'YTDL_OPTIONS': '{}',
'YTDL_OPTIONS_FILE': '',
'YTDL_OPTIONS_PRESETS': '{}',
'YTDL_OPTIONS_PRESETS_FILE': '',
'ALLOW_YTDL_OPTIONS_OVERRIDES': 'false',
'ROBOTS_TXT': '',
'HOST': '0.0.0.0',
'PORT': '8081',
@@ -66,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():
@@ -87,12 +94,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
@@ -114,6 +126,8 @@ class Config:
'PUBLIC_HOST_URL',
'PUBLIC_HOST_AUDIO_URL',
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
'ALLOW_YTDL_OPTIONS_OVERRIDES',
)
def frontend_safe(self) -> dict:
@@ -155,6 +169,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
@@ -189,6 +234,23 @@ 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'}
def _parse_ytdl_options_overrides(value, *, enabled: bool) -> 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')
if value and not enabled:
raise web.HTTPBadRequest(reason='ytdl_options_overrides are disabled')
return value
def _migrate_legacy_request(post: dict) -> dict:
@@ -272,6 +334,34 @@ dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize())
app.on_cleanup.append(lambda app: Download.shutdown_manager())
class MetubeSubscriptionNotifier(SubscriptionNotifier):
async def subscription_added(self, sub: SubscriptionInfo):
log.info("Subscription added: %s", sub.name)
await sio.emit('subscription_added', serializer.encode(sub.to_public_dict()))
async def subscription_updated(self, sub: SubscriptionInfo):
await sio.emit('subscription_updated', serializer.encode(sub.to_public_dict()))
async def subscription_removed(self, sub_id: str):
log.info("Subscription removed: %s", sub_id)
await sio.emit('subscription_removed', serializer.encode(sub_id))
async def subscriptions_all(self, subs: list[SubscriptionInfo]):
await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in subs]))
submgr = SubscriptionManager(config, dqueue, MetubeSubscriptionNotifier())
app.on_cleanup.append(lambda app: submgr.close())
async def _subscription_loop_startup(app):
"""aiohttp on_startup requires awaitable receivers; start_background_loop is sync."""
submgr.start_background_loop()
app.on_startup.append(_subscription_loop_startup)
class FileOpsFilter(DefaultFilter):
def __call__(self, change_type: int, path: str) -> bool:
# Check if this path matches our YTDL_OPTIONS_FILE
@@ -332,27 +422,17 @@ async def _read_json_request(request: web.Request) -> dict:
return post
@routes.post(config.URL_PREFIX + 'add')
async def add(request):
log.info("Received request to add download")
post = await _read_json_request(request)
post = _migrate_legacy_request(post)
log.info(
"Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s",
post.get('download_type'),
post.get('quality'),
post.get('format'),
bool(post.get('folder')),
post.get('auto_start'),
)
def parse_download_options(post: dict) -> dict:
"""Validate add/subscribe body; raise HTTPBadRequest on invalid input."""
post = _migrate_legacy_request(dict(post))
url = post.get('url')
download_type = post.get('download_type')
codec = post.get('codec')
format = post.get('format')
quality = post.get('quality')
if not url or not quality or not download_type:
log.error("Bad request: missing 'url', 'download_type', or 'quality'")
raise web.HTTPBadRequest()
raise web.HTTPBadRequest(reason="missing 'url', 'download_type', or 'quality'")
url = str(url).strip()
folder = post.get('folder')
custom_name_prefix = post.get('custom_name_prefix')
playlist_item_limit = post.get('playlist_item_limit')
@@ -361,6 +441,8 @@ async def add(request):
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 = ''
@@ -378,12 +460,19 @@ async def add(request):
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,
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')
@@ -391,6 +480,8 @@ async def add(request):
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)}')
@@ -429,28 +520,152 @@ async def add(request):
except (TypeError, ValueError) as exc:
raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from exc
return {
'url': url,
'download_type': download_type,
'codec': codec,
'format': format,
'quality': quality,
'folder': folder,
'custom_name_prefix': custom_name_prefix,
'playlist_item_limit': playlist_item_limit,
'auto_start': auto_start,
'split_by_chapters': split_by_chapters,
'chapter_template': chapter_template,
'subtitle_language': subtitle_language,
'subtitle_mode': subtitle_mode,
'ytdl_options_preset': ytdl_options_preset,
'ytdl_options_overrides': ytdl_options_overrides,
}
@routes.post(config.URL_PREFIX + 'add')
async def add(request):
log.info("Received request to add download")
post = await _read_json_request(request)
try:
o = parse_download_options(post)
except web.HTTPBadRequest as e:
log.error("Bad request: %s", e.reason)
raise
log.info(
"Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s",
o['download_type'],
o['quality'],
o['format'],
bool(o.get('folder')),
o['auto_start'],
)
status = await dqueue.add(
url,
download_type,
codec,
format,
quality,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
chapter_template,
subtitle_language,
subtitle_mode,
o['url'],
o['download_type'],
o['codec'],
o['format'],
o['quality'],
o['folder'],
o['custom_name_prefix'],
o['playlist_item_limit'],
o['auto_start'],
o['split_by_chapters'],
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()
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
@routes.post(config.URL_PREFIX + 'subscribe')
async def subscribe(request):
post = await _read_json_request(request)
try:
o = parse_download_options(post)
except web.HTTPBadRequest:
raise
cic = post.get('check_interval_minutes')
if cic is None:
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
try:
cic = int(cic)
except (TypeError, ValueError) as exc:
raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
if cic < 1:
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1')
result = await submgr.add_subscription(
o['url'],
check_interval_minutes=cic,
download_type=o['download_type'],
codec=o['codec'],
format=o['format'],
quality=o['quality'],
folder=o['folder'] or '',
custom_name_prefix=o['custom_name_prefix'],
auto_start=o['auto_start'],
playlist_item_limit=o['playlist_item_limit'],
split_by_chapters=o['split_by_chapters'],
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))
@routes.get(config.URL_PREFIX + 'subscriptions')
async def subscriptions_list(request):
return web.Response(text=serializer.encode([s.to_public_dict() for s in submgr.list_all()]))
@routes.post(config.URL_PREFIX + 'subscriptions/update')
async def subscriptions_update(request):
post = await _read_json_request(request)
sub_id = post.get('id')
if not sub_id:
raise web.HTTPBadRequest(reason='missing subscription id')
changes = {k: v for k, v in post.items() if k != 'id' and k in ('enabled', 'check_interval_minutes', 'name')}
if not changes:
raise web.HTTPBadRequest(reason='no valid fields to update')
log.info("Subscription update requested for %s: %s", sub_id, sorted(changes.keys()))
result = await submgr.update_subscription(str(sub_id), changes)
return web.Response(text=serializer.encode(result))
@routes.post(config.URL_PREFIX + 'subscriptions/delete')
async def subscriptions_delete(request):
post = await _read_json_request(request)
ids = post.get('ids')
if not ids or not isinstance(ids, list):
raise web.HTTPBadRequest(reason='missing ids list')
result = await submgr.delete_subscriptions([str(i) for i in ids])
return web.Response(text=serializer.encode(result))
@routes.post(config.URL_PREFIX + 'subscriptions/check')
async def subscriptions_check(request):
post = await _read_json_request(request)
ids = post.get('ids')
if ids is not None and not isinstance(ids, list):
raise web.HTTPBadRequest(reason='ids must be a list')
log.info("Subscription check-now requested for ids=%s", ids if ids else "all-enabled")
result = await submgr.check_now([str(i) for i in ids] if ids else None)
return web.Response(text=serializer.encode(result))
@routes.post(config.URL_PREFIX + 'delete')
async def delete(request):
post = await _read_json_request(request)
@@ -554,6 +769,7 @@ async def history(request):
async def connect(sid, environ):
log.info(f"Client connected: {sid}")
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid)
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
if config.CUSTOM_DIRS:
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
@@ -623,14 +839,14 @@ def get_custom_dirs():
return result
@routes.get(config.URL_PREFIX)
def index(request):
async def index(request):
response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html'))
if 'metube_theme' not in request.cookies:
response.set_cookie('metube_theme', config.DEFAULT_THEME)
return response
@routes.get(config.URL_PREFIX + 'robots.txt')
def robots(request):
async def robots(request):
if config.ROBOTS_TXT:
response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT))
else:
@@ -640,7 +856,7 @@ def robots(request):
return response
@routes.get(config.URL_PREFIX + 'version')
def version(request):
async def version(request):
return web.json_response({
"yt-dlp": yt_dlp_version,
"version": os.getenv("METUBE_VERSION", "dev")
@@ -648,11 +864,11 @@ def version(request):
if config.URL_PREFIX != '/':
@routes.get('/')
def index_redirect_root(request):
async def index_redirect_root(request):
return web.HTTPFound(config.URL_PREFIX)
@routes.get(config.URL_PREFIX[:-1])
def index_redirect_dir(request):
async def index_redirect_dir(request):
return web.HTTPFound(config.URL_PREFIX)
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
@@ -672,6 +888,11 @@ async def add_cors(request):
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import base64
import collections.abc
import json
import logging
import os
import shelve
import tempfile
import time
from datetime import datetime
from typing import Any, Optional
log = logging.getLogger("state_store")
STATE_SCHEMA_VERSION = 2
_BYTES_MARKER = "__metube_bytes__"
_DATETIME_MARKER = "__metube_datetime__"
def to_json_compatible(value: Any) -> Any:
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, bytes):
return {_BYTES_MARKER: base64.b64encode(value).decode("ascii")}
if isinstance(value, datetime):
return {_DATETIME_MARKER: value.isoformat()}
if isinstance(value, collections.abc.Mapping):
return {str(k): to_json_compatible(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [to_json_compatible(v) for v in value]
if isinstance(value, collections.abc.Iterable):
return [to_json_compatible(v) for v in value]
raise TypeError(f"Value of type {type(value).__name__} is not JSON serializable")
def from_json_compatible(value: Any) -> Any:
if isinstance(value, list):
return [from_json_compatible(v) for v in value]
if isinstance(value, dict):
if set(value.keys()) == {_BYTES_MARKER}:
return base64.b64decode(value[_BYTES_MARKER].encode("ascii"))
if set(value.keys()) == {_DATETIME_MARKER}:
return datetime.fromisoformat(value[_DATETIME_MARKER])
return {k: from_json_compatible(v) for k, v in value.items()}
return value
def read_legacy_shelf(path: str) -> Optional[list[tuple[Any, Any]]]:
if not os.path.exists(path):
return None
try:
with shelve.open(path, "r") as shelf:
return list(shelf.items())
except Exception as exc:
log.warning("Could not read legacy shelf at %s: %s", path, exc)
return None
class AtomicJsonStore:
def __init__(self, path: str, *, kind: str, schema_version: int = STATE_SCHEMA_VERSION):
self.path = path
self.kind = kind
self.schema_version = schema_version
def _ensure_parent(self) -> None:
parent = os.path.dirname(self.path)
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
def _build_payload(self, data: dict[str, Any]) -> dict[str, Any]:
payload = {
"schema_version": self.schema_version,
"kind": self.kind,
}
payload.update(data)
return payload
def load(self) -> Optional[dict[str, Any]]:
if not os.path.exists(self.path):
return None
try:
with open(self.path, encoding="utf-8") as f:
payload = json.load(f)
if not isinstance(payload, dict):
raise ValueError("State file must contain a JSON object")
if payload.get("kind") != self.kind:
raise ValueError(
f"State file kind mismatch: expected {self.kind}, got {payload.get('kind')}"
)
return payload
except Exception as exc:
self.quarantine_invalid_file(exc)
return None
def save(self, data: dict[str, Any]) -> None:
self._ensure_parent()
payload = self._build_payload(data)
parent = os.path.dirname(self.path) or "."
fd, tmp_path = tempfile.mkstemp(
prefix=f".{os.path.basename(self.path)}.",
suffix=".tmp",
dir=parent,
text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, self.path)
self._fsync_directory(parent)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
def quarantine_invalid_file(self, exc: Exception) -> None:
if not os.path.exists(self.path):
return
ts = time.strftime("%Y%m%d%H%M%S")
backup_path = f"{self.path}.invalid.{ts}"
try:
os.replace(self.path, backup_path)
log.warning(
"State file at %s was invalid (%s); moved it to %s",
self.path,
exc,
backup_path,
)
except OSError as move_exc:
log.warning(
"State file at %s was invalid (%s) and could not be moved aside: %s",
self.path,
exc,
move_exc,
)
@staticmethod
def _fsync_directory(path: str) -> None:
try:
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
fd = os.open(path, flags)
except OSError:
return
try:
os.fsync(fd)
except OSError:
pass
finally:
os.close(fd)
+682
View File
@@ -0,0 +1,682 @@
"""Channel/playlist subscriptions: periodic yt-dlp flat extract + queue new videos."""
from __future__ import annotations
import asyncio
import copy
import logging
import os
import time
import types
import uuid
from dataclasses import dataclass, field, fields
from typing import Any, Optional
import yt_dlp
import yt_dlp.networking.impersonate
from state_store import AtomicJsonStore, read_legacy_shelf
log = logging.getLogger("subscriptions")
VIDEO_ONLY_MSG = (
"This URL points to a single video, not a channel or playlist. Use Download instead."
)
_MEDIA_HINT_FIELDS = (
"duration",
"timestamp",
"release_timestamp",
"upload_date",
"view_count",
"live_status",
"availability",
)
def _impersonate_opt(ytdl_options: dict) -> dict:
opts = dict(ytdl_options)
if "impersonate" in opts:
opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(
opts["impersonate"]
)
return opts
def _build_ydl_params(config, *, playlistend: Optional[int] = None) -> dict:
params: dict[str, Any] = {
"quiet": not logging.getLogger().isEnabledFor(logging.DEBUG),
"verbose": logging.getLogger().isEnabledFor(logging.DEBUG),
"no_color": True,
"extract_flat": True,
"ignore_no_formats_error": True,
"lazy_playlist": True,
"paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR},
**config.YTDL_OPTIONS,
}
params = _impersonate_opt(params)
if playlistend is not None and playlistend > 0:
params["playlistend"] = playlistend
return params
def _is_media_entry(entry: Any) -> bool:
if not isinstance(entry, dict):
return False
etype = str(entry.get("_type") or "")
if etype in ("playlist", "multi_video", "channel"):
return False
if entry.get("entries"):
return False
url = _entry_video_url(entry)
if not url:
return False
ie_key = str(entry.get("ie_key") or entry.get("extractor_key") or "").lower()
if any(token in ie_key for token in ("playlist", "channel", "tab")):
return any(entry.get(field) is not None for field in _MEDIA_HINT_FIELDS)
return True
def extract_flat_playlist(config, url: str, playlistend: int, *, _depth: int = 0):
"""Return (info_dict, entries_list) for playlist/channel URLs."""
params = _build_ydl_params(config, playlistend=playlistend)
with yt_dlp.YoutubeDL(params=params) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
return None, []
etype = info.get("_type") or "video"
if etype == "video":
return info, []
if etype in ("playlist", "channel"):
entries = info.get("entries") or []
if isinstance(entries, types.GeneratorType):
entries = list(entries)
# Drop None placeholders from incomplete flat playlists
entries = [e for e in entries if e]
media_entries = [e for e in entries if _is_media_entry(e)]
if media_entries:
return info, media_entries
if _depth < 1:
for ent in entries[:5]:
nested_url = _entry_video_url(ent)
if not nested_url:
continue
nested_info, nested_entries = extract_flat_playlist(
config,
nested_url,
playlistend,
_depth=_depth + 1,
)
if nested_entries:
return nested_info, nested_entries
return info, entries
if etype.startswith("url") and info.get("url"):
# Single nested URL without playlist wrapper — treat as non-subscribable
return info, []
return info, []
def _entry_video_url(entry: dict) -> Optional[str]:
return entry.get("webpage_url") or entry.get("url")
def _entry_id(entry: dict) -> Optional[str]:
eid = entry.get("id")
if eid is not None:
return str(eid)
url = _entry_video_url(entry)
return url
@dataclass
class SubscriptionInfo:
id: str
name: str
url: str
enabled: bool = True
check_interval_minutes: int = 60
download_type: str = "video"
codec: str = "auto"
format: str = "any"
quality: str = "best"
folder: str = ""
custom_name_prefix: str = ""
auto_start: bool = True
playlist_item_limit: int = 0
split_by_chapters: bool = False
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
timestamp: float = field(default_factory=time.time)
def seen_set(self) -> set[str]:
return set(self.seen_ids)
def to_public_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"url": self.url,
"enabled": self.enabled,
"check_interval_minutes": self.check_interval_minutes,
"download_type": self.download_type,
"codec": self.codec,
"format": self.format,
"quality": self.quality,
"folder": self.folder,
"last_checked": self.last_checked,
"seen_count": len(self.seen_ids),
"error": self.error,
}
def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
return {
"id": sub.id,
"name": sub.name,
"url": sub.url,
"enabled": sub.enabled,
"check_interval_minutes": sub.check_interval_minutes,
"download_type": sub.download_type,
"codec": sub.codec,
"format": sub.format,
"quality": sub.quality,
"folder": sub.folder,
"custom_name_prefix": sub.custom_name_prefix,
"auto_start": sub.auto_start,
"playlist_item_limit": sub.playlist_item_limit,
"split_by_chapters": sub.split_by_chapters,
"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,
}
def _subscription_from_record(record: Any) -> Optional[SubscriptionInfo]:
field_names = {f.name for f in fields(SubscriptionInfo)}
if isinstance(record, SubscriptionInfo):
return record
if isinstance(record, dict):
try:
return SubscriptionInfo(**{k: v for k, v in record.items() if k in field_names})
except TypeError:
return None
return None
class SubscriptionNotifier:
"""Hook for Socket.IO / UI updates."""
async def subscription_added(self, sub: SubscriptionInfo) -> None:
raise NotImplementedError
async def subscription_updated(self, sub: SubscriptionInfo) -> None:
raise NotImplementedError
async def subscription_removed(self, sub_id: str) -> None:
raise NotImplementedError
async def subscriptions_all(self, subs: list[SubscriptionInfo]) -> None:
raise NotImplementedError
class SubscriptionManager:
def __init__(self, config, download_queue, notifier: SubscriptionNotifier):
self.config = config
self.dqueue = download_queue
self.notifier = notifier
pdir = config.STATE_DIR
if not os.path.isdir(pdir):
os.makedirs(pdir, exist_ok=True)
self._legacy_path = os.path.join(pdir, "subscriptions")
self._path = os.path.join(pdir, "subscriptions.json")
self._store = AtomicJsonStore(self._path, kind="subscriptions")
self._subs: dict[str, SubscriptionInfo] = {}
self._url_index: dict[str, str] = {} # normalized url -> id
self._pending_urls: set[str] = set()
self._lock = asyncio.Lock()
self._loop_task: Optional[asyncio.Task] = None
self._load_all()
def close(self) -> None:
# No persistent shelf handle to close.
return
def _normalize_url(self, url: str) -> str:
return (url or "").strip()
def _normalize_seen_ids(self, seen_ids: list[str]) -> list[str]:
max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000))
normalized = [str(sid) for sid in dict.fromkeys(seen_ids)]
if len(normalized) > max_seen:
normalized = normalized[:max_seen]
return normalized
def _load_all(self) -> None:
payload = self._store.load()
loaded_from_legacy = False
if payload is not None:
records = payload.get("items") or []
else:
legacy_items = read_legacy_shelf(self._legacy_path)
records = [raw for _key, raw in legacy_items] if legacy_items else []
if records:
loaded_from_legacy = True
loaded_subs = self._iter_valid_subs(records)
compact_records = []
for sub in loaded_subs:
sub.seen_ids = self._normalize_seen_ids(sub.seen_ids)
self._subs[sub.id] = sub
self._url_index[self._normalize_url(sub.url)] = sub.id
compact_records.append(_subscription_to_record(sub))
if loaded_from_legacy or (
payload is not None
and (
payload.get("schema_version") != self._store.schema_version
or compact_records != records
)
):
self._store.save({"items": compact_records})
def _iter_valid_subs(self, records: list[Any]) -> list[SubscriptionInfo]:
subs: list[SubscriptionInfo] = []
for record in records:
sub = _subscription_from_record(record)
if sub is not None:
subs.append(sub)
return subs
def _save_locked(self) -> None:
self._store.save({"items": [_subscription_to_record(sub) for sub in self._subs.values()]})
async def _queue_subscription_entries(
self,
entries: list[dict],
*,
download_type: str,
codec: str,
format: str,
quality: str,
folder: str,
custom_name_prefix: str,
playlist_item_limit: int,
auto_start: bool,
split_by_chapters: bool,
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] = []
for ent in entries:
eid = _entry_id(ent)
vurl = _entry_video_url(ent)
if not eid or not vurl:
continue
queue_entry = dict(ent)
queue_entry["_type"] = "video"
queue_entry["webpage_url"] = vurl
result = await self.dqueue.add_entry(
queue_entry,
download_type,
codec,
format,
quality,
folder or None,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
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}")
queue_errors.append(msg)
log.warning("Subscription queueing failed for %s: %s", vurl, msg)
continue
queued_ids.append(eid)
return queued_ids, queue_errors
def list_all(self) -> list[SubscriptionInfo]:
return list(self._subs.values())
def get(self, sub_id: str) -> Optional[SubscriptionInfo]:
return self._subs.get(sub_id)
def start_background_loop(self) -> None:
if self._loop_task is not None and not self._loop_task.done():
return
self._loop_task = asyncio.create_task(self._periodic_loop())
self._loop_task.add_done_callback(
lambda t: log.error("Subscription loop failed: %s", t.exception())
if not t.cancelled() and t.exception()
else None
)
async def _periodic_loop(self) -> None:
while True:
await asyncio.sleep(60)
try:
await self.run_due_checks()
except Exception as e:
log.exception("Subscription periodic check error: %s", e)
async def run_due_checks(self) -> None:
now = time.time()
due: list[SubscriptionInfo] = []
async with self._lock:
for sub in list(self._subs.values()):
if not sub.enabled:
continue
interval_sec = max(60, int(sub.check_interval_minutes) * 60)
if sub.last_checked is None:
due.append(sub)
continue
if now - sub.last_checked < interval_sec:
continue
due.append(sub)
for sub in due:
await self._check_one_unlocked(sub)
async def add_subscription(
self,
url: str,
*,
check_interval_minutes: int,
download_type: str,
codec: str,
format: str,
quality: str,
folder: str,
custom_name_prefix: str,
auto_start: bool,
playlist_item_limit: int,
split_by_chapters: bool,
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:
return {"status": "error", "msg": "Missing URL"}
async with self._lock:
if url in self._url_index or url in self._pending_urls:
return {"status": "error", "msg": "This URL is already subscribed"}
self._pending_urls.add(url)
try:
scan_first = max(int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)), 1)
try:
info, entries = extract_flat_playlist(self.config, url, scan_first)
except yt_dlp.utils.YoutubeDLError as exc:
return {"status": "error", "msg": str(exc)}
if not info:
return {"status": "error", "msg": "Could not resolve URL"}
etype = info.get("_type") or "video"
if etype not in ("playlist", "channel"):
return {"status": "error", "msg": VIDEO_ONLY_MSG}
name = (
info.get("title")
or info.get("channel")
or info.get("playlist_title")
or info.get("uploader")
or url
)
seen_entries = [ent for ent in entries if _is_media_entry(ent)]
all_ids: list[str] = []
for ent in seen_entries:
eid = _entry_id(ent)
if eid:
all_ids.append(eid)
sub = SubscriptionInfo(
id=str(uuid.uuid4()),
name=str(name),
url=url,
enabled=True,
check_interval_minutes=max(1, int(check_interval_minutes)),
download_type=download_type,
codec=codec,
format=format,
quality=quality,
folder=folder or "",
custom_name_prefix=custom_name_prefix or "",
auto_start=bool(auto_start),
playlist_item_limit=int(playlist_item_limit),
split_by_chapters=bool(split_by_chapters),
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,
)
async with self._lock:
if url in self._url_index:
return {"status": "error", "msg": "This URL is already subscribed"}
self._subs[sub.id] = sub
self._url_index[url] = sub.id
try:
self._save_locked()
except Exception:
self._subs.pop(sub.id, None)
self._url_index.pop(url, None)
raise
await self.notifier.subscription_added(sub)
return {"status": "ok", "subscription": sub.to_public_dict()}
finally:
async with self._lock:
self._pending_urls.discard(url)
async def delete_subscriptions(self, ids: list[str]) -> dict:
removed: list[str] = []
async with self._lock:
previous_subs = self._subs.copy()
previous_index = self._url_index.copy()
for sid in ids:
sub = self._subs.pop(sid, None)
if sub:
normalized_url = self._normalize_url(sub.url)
self._url_index.pop(normalized_url, None)
removed.append(sid)
if removed:
try:
self._save_locked()
except Exception:
self._subs = previous_subs
self._url_index = previous_index
raise
for sid in removed:
await self.notifier.subscription_removed(sid)
return {"status": "ok"}
async def update_subscription(self, sub_id: str, changes: dict) -> dict:
async with self._lock:
sub = self._subs.get(sub_id)
if not sub:
return {"status": "error", "msg": "Subscription not found"}
previous = copy.deepcopy(sub)
old_enabled = sub.enabled
if "enabled" in changes:
sub.enabled = bool(changes["enabled"])
if "check_interval_minutes" in changes:
sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"]))
if "name" in changes and changes["name"]:
sub.name = str(changes["name"])
try:
self._save_locked()
except Exception:
self._subs[sub_id] = previous
raise
updated = sub
if "enabled" in changes and updated.enabled != old_enabled:
log.info(
"Subscription %s %s",
updated.name,
"resumed" if updated.enabled else "paused",
)
await self.notifier.subscription_updated(updated)
return {"status": "ok", "subscription": updated.to_public_dict()}
async def check_now(self, ids: Optional[list[str]] = None) -> dict:
async with self._lock:
targets = (
[self._subs[i] for i in ids if i in self._subs]
if ids
else [s for s in self._subs.values() if s.enabled]
)
log.info(
"Manual subscription check requested for %d subscription(s)",
len(targets),
)
for sub in targets:
await self._check_one_unlocked(sub)
return {"status": "ok"}
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
sid = sub.id
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
log.info("Checking subscription: %s", sub.name)
try:
info, entries = extract_flat_playlist(self.config, sub.url, scan)
except yt_dlp.utils.YoutubeDLError as exc:
async with self._lock:
cur = self._subs.get(sid)
if cur:
previous = copy.deepcopy(cur)
cur.error = str(exc)
try:
self._save_locked()
except Exception:
self._subs[sid] = previous
raise
sub = cur
log.warning("Subscription check failed for %s: %s", sub.name, exc)
await self.notifier.subscription_updated(sub)
return
entries = [ent for ent in entries if _is_media_entry(ent)]
etype = (info or {}).get("_type") or "video"
if etype == "video" or not entries:
async with self._lock:
cur = self._subs.get(sid)
if cur:
previous = copy.deepcopy(cur)
cur.error = VIDEO_ONLY_MSG
try:
self._save_locked()
except Exception:
self._subs[sid] = previous
raise
sub = cur
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
await self.notifier.subscription_updated(sub)
return
async with self._lock:
cur = self._subs.get(sid)
if not cur:
return
seen = cur.seen_set()
seen_ids_snapshot = list(cur.seen_ids)
dl_type = cur.download_type
dl_codec = cur.codec
dl_format = cur.format
dl_quality = cur.quality
dl_folder = cur.folder
dl_prefix = cur.custom_name_prefix
dl_plimit = cur.playlist_item_limit
dl_autostart = cur.auto_start
dl_split = cur.split_by_chapters
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] = []
for ent in entries:
eid = _entry_id(ent)
if not eid or eid in seen:
continue
new_entries.append(ent)
new_ids.append(eid)
queued_ids, queue_errors = await self._queue_subscription_entries(
new_entries,
download_type=dl_type,
codec=dl_codec,
format=dl_format,
quality=dl_quality,
folder=dl_folder,
custom_name_prefix=dl_prefix,
playlist_item_limit=dl_plimit,
auto_start=dl_autostart,
split_by_chapters=dl_split,
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",
sub.name,
len(new_entries),
len(queued_ids),
len(queue_errors),
)
merged = list(dict.fromkeys(queued_ids + seen_ids_snapshot))
max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000))
if len(merged) > max_seen:
merged = merged[:max_seen]
async with self._lock:
cur = self._subs.get(sid)
if not cur:
return
previous = copy.deepcopy(cur)
cur.seen_ids = merged
cur.last_checked = time.time()
cur.error = "; ".join(queue_errors[:3]) if queue_errors else None
try:
self._save_locked()
except Exception:
self._subs[sid] = previous
raise
sub = cur
await self.notifier.subscription_updated(sub)
async def emit_all(self) -> None:
await self.notifier.subscriptions_all(self.list_all())
+32
View File
@@ -0,0 +1,32 @@
"""Pytest configuration: set env and filesystem layout before importing ``main``."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
def _ensure_test_env() -> None:
if os.environ.get("METUBE_TEST_ENV_READY"):
return
tmp = tempfile.mkdtemp(prefix="metube-pytest-")
base = Path(tmp)
browser = base / "ui" / "dist" / "metube" / "browser"
browser.mkdir(parents=True)
(browser / "index.html").write_text("<html><body></body></html>", encoding="utf-8")
dl = base / "downloads"
st = base / "state"
dl.mkdir(parents=True)
st.mkdir(parents=True)
os.environ["DOWNLOAD_DIR"] = str(dl)
os.environ["STATE_DIR"] = str(st)
os.environ["TEMP_DIR"] = str(dl)
os.environ["YTDL_OPTIONS"] = "{}"
os.environ["YTDL_OPTIONS_FILE"] = ""
os.environ["BASE_DIR"] = str(base)
os.environ["LOGLEVEL"] = "INFO"
os.environ["METUBE_TEST_ENV_READY"] = "1"
_ensure_test_env()
+268
View File
@@ -0,0 +1,268 @@
"""HTTP handler tests for ``main`` using mocked ``web.Request`` (no TestServer)."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from aiohttp import web
import main
@pytest.fixture
def mock_dqueue(monkeypatch):
d = MagicMock()
d.initialize = AsyncMock(return_value=None)
d.add = AsyncMock(return_value={"status": "ok"})
d.cancel = AsyncMock(return_value={"status": "ok"})
d.start_pending = AsyncMock(return_value={"status": "ok"})
d.cancel_add = MagicMock()
d.queue = MagicMock()
d.done = MagicMock()
d.pending = MagicMock()
d.queue.saved_items = MagicMock(return_value=[])
d.done.saved_items = MagicMock(return_value=[])
d.pending.saved_items = MagicMock(return_value=[])
d.get = MagicMock(return_value=([], []))
monkeypatch.setattr(main, "dqueue", d)
return d
def _valid_video_add_body(**kwargs):
base = {
"url": "https://example.com/watch?v=1",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "",
"ytdl_options_overrides": "",
}
base.update(kwargs)
return base
def _json_request(body: dict | None):
req = MagicMock(spec=web.Request)
req.json = AsyncMock(return_value=body)
return req
@pytest.mark.asyncio
async def test_add_ok(mock_dqueue):
req = _json_request(_valid_video_add_body())
resp = await main.add(req)
assert resp.status == 200
text = resp.text
data = json.loads(text)
assert data["status"] == "ok"
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}})
monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", 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"})
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
mock_dqueue.add.assert_not_called()
@pytest.mark.asyncio
async def test_add_invalid_download_type(mock_dqueue):
req = _json_request(_valid_video_add_body(download_type="invalid"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_video_quality(mock_dqueue):
req = _json_request(_valid_video_add_body(quality="9999"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_subtitle_language(mock_dqueue):
req = _json_request(
{
"url": "https://example.com/v",
"download_type": "captions",
"codec": "auto",
"format": "srt",
"quality": "best",
"subtitle_language": "bad language!",
}
)
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_custom_name_prefix_path_traversal(mock_dqueue):
req = _json_request(_valid_video_add_body(custom_name_prefix="../evil"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_chapter_template_path_traversal(mock_dqueue):
req = _json_request(
_valid_video_add_body(
split_by_chapters=True,
chapter_template="/etc/passwd%(title)s",
)
)
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_json_body(mock_dqueue):
req = MagicMock(spec=web.Request)
req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0))
with pytest.raises(web.HTTPBadRequest):
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_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"))
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"})
with pytest.raises(web.HTTPBadRequest):
await main.delete(req)
@pytest.mark.asyncio
async def test_delete_queue_calls_cancel(mock_dqueue):
req = _json_request({"where": "queue", "ids": ["http://x"]})
resp = await main.delete(req)
assert resp.status == 200
mock_dqueue.cancel.assert_awaited_once_with(["http://x"])
@pytest.mark.asyncio
async def test_start_pending(mock_dqueue):
req = _json_request({"ids": ["a"]})
resp = await main.start(req)
assert resp.status == 200
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
@pytest.mark.asyncio
async def test_history_shape(mock_dqueue):
mock_dqueue.queue.saved_items.return_value = []
mock_dqueue.done.saved_items.return_value = []
mock_dqueue.pending.saved_items.return_value = []
req = MagicMock(spec=web.Request)
resp = await main.history(req)
assert resp.status == 200
data = json.loads(resp.text)
assert set(data.keys()) == {"done", "queue", "pending"}
@pytest.mark.asyncio
async def test_version_json(mock_dqueue):
req = MagicMock(spec=web.Request)
resp = await main.version(req)
assert resp.status == 200
body = json.loads(resp.text)
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)
resp = await main.cookie_status(req)
assert resp.status == 200
data = json.loads(resp.text)
assert data.get("status") == "ok"
assert "has_cookies" in data
@pytest.mark.asyncio
async def test_options_add_cors(mock_dqueue):
req = MagicMock(spec=web.Request)
resp = await main.add_cors(req)
assert resp.status == 200
@pytest.mark.asyncio
async def test_upload_cookies_missing_field(mock_dqueue):
req = MagicMock(spec=web.Request)
reader = MagicMock()
field = MagicMock()
field.name = "wrongname"
reader.next = AsyncMock(side_effect=[field, None])
req.multipart = AsyncMock(return_value=reader)
resp = await main.upload_cookies(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_add_legacy_format_migrated(mock_dqueue):
req = _json_request({"url": "https://example.com/v", "format": "m4a", "quality": "best"})
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call is not None
assert call.args[1] == "audio"
+109
View File
@@ -0,0 +1,109 @@
"""Tests for ``Config`` (env parsing, yt-dlp options, frontend_safe)."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from unittest.mock import patch
from main import Config
def _base_env(**overrides: str) -> dict[str, str]:
env = {k: str(v) for k, v in Config._DEFAULTS.items()}
env.update(overrides)
return env
class ConfigTests(unittest.TestCase):
def test_url_prefix_gets_trailing_slash(self):
with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=False):
c = Config()
self.assertEqual(c.URL_PREFIX, "foo/")
def test_ytdl_options_json_loaded(self):
opts = {"quiet": True, "no_warnings": True}
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS=json.dumps(opts)),
clear=False,
):
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):
Config()
def test_invalid_boolean_env_exits(self):
with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_frontend_safe_excludes_secrets(self):
with patch.dict(os.environ, _base_env(), clear=False):
c = Config()
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):
c = Config()
c.set_runtime_override("cookiefile", "/tmp/c.txt")
self.assertEqual(c.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt")
c.remove_runtime_override("cookiefile")
self.assertIsNone(c.YTDL_OPTIONS.get("cookiefile"))
def test_ytdl_options_file_merges(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, f)
path = f.name
try:
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path),
clear=False,
):
c = Config()
self.assertIn("extractor_args", c.YTDL_OPTIONS)
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()
+119 -1
View File
@@ -1,6 +1,16 @@
"""Tests for ``app.dl_formats`` format selectors and yt-dlp option mapping."""
from __future__ import annotations
import copy
import unittest
from app.dl_formats import get_format, get_opts
from app.dl_formats import (
_normalize_caption_mode,
_normalize_subtitle_language,
get_format,
get_opts,
)
class DlFormatsTests(unittest.TestCase):
@@ -16,6 +26,114 @@ class DlFormatsTests(unittest.TestCase):
opts = get_opts("audio", "auto", "mp3", "best", {})
self.assertTrue(opts.get("writethumbnail"))
def test_custom_format_passthrough(self):
self.assertEqual(get_format("video", "auto", "custom:bestvideo+bestaudio", "best"), "bestvideo+bestaudio")
def test_thumbnail_and_captions_format_strings(self):
self.assertEqual(get_format("thumbnail", "auto", "jpg", "best"), "bestaudio/best")
self.assertEqual(get_format("captions", "auto", "srt", "best"), "bestaudio/best")
def test_audio_formats(self):
for fmt in ("m4a", "mp3", "opus", "wav", "flac"):
with self.subTest(fmt=fmt):
self.assertIn(f"ext={fmt}", get_format("audio", "auto", fmt, "best"))
def test_video_unknown_format_raises(self):
with self.assertRaises(ValueError):
get_format("video", "auto", "mkv", "best")
def test_unknown_download_type_raises(self):
with self.assertRaises(ValueError):
get_format("unknown", "auto", "any", "best")
def test_video_any_mp4_ios_with_height_quality(self):
self.assertIn("height<=1080", get_format("video", "auto", "any", "1080"))
self.assertNotIn("height<=", get_format("video", "auto", "any", "best"))
self.assertNotIn("height<=", get_format("video", "auto", "any", "worst"))
def test_video_codec_filters(self):
self.assertIn("h264", get_format("video", "h264", "any", "best"))
self.assertIn("hevc", get_format("video", "h265", "any", "best"))
self.assertIn("av0?1", get_format("video", "av1", "any", "best"))
self.assertIn("vp0?9", get_format("video", "vp9", "any", "best"))
def test_video_mp4_includes_m4a_audio(self):
s = get_format("video", "auto", "mp4", "720")
self.assertIn("[ext=m4a]", s)
def test_video_ios_selector_contains_avc_pattern(self):
s = get_format("video", "auto", "ios", "best")
self.assertIn("h26[45]", s)
def test_get_opts_deepcopy_does_not_mutate_input(self):
base = {"postprocessors": [{"key": "Existing"}]}
orig = copy.deepcopy(base)
get_opts("audio", "auto", "mp3", "best", base)
self.assertEqual(base, orig)
def test_get_opts_audio_m4a_postprocessors(self):
opts = get_opts("audio", "auto", "m4a", "best", {})
keys = [p["key"] for p in opts["postprocessors"]]
self.assertIn("FFmpegExtractAudio", keys)
def test_get_opts_audio_mp3_quality_not_best(self):
opts = get_opts("audio", "auto", "mp3", "192", {})
ext = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegExtractAudio")
self.assertEqual(ext["preferredquality"], "192")
def test_get_opts_thumbnail_skip_download(self):
opts = get_opts("thumbnail", "auto", "jpg", "best", {})
self.assertTrue(opts.get("skip_download"))
self.assertTrue(opts.get("writethumbnail"))
def test_get_opts_captions_manual_only(self):
opts = get_opts(
"captions", "auto", "vtt", "best", {}, subtitle_language="fr", subtitle_mode="manual_only"
)
self.assertTrue(opts.get("writesubtitles"))
self.assertFalse(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["fr"])
def test_get_opts_captions_auto_only(self):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="de", subtitle_mode="auto_only"
)
self.assertFalse(opts.get("writesubtitles"))
self.assertTrue(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["de-orig", "de"])
def test_get_opts_captions_prefer_auto(self):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="es", subtitle_mode="prefer_auto"
)
self.assertTrue(opts.get("writesubtitles"))
self.assertTrue(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["es-orig", "es"])
def test_get_opts_captions_prefer_manual_default_branch(self):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="it", subtitle_mode="prefer_manual"
)
self.assertEqual(opts["subtitleslangs"], ["it", "it-orig"])
def test_get_opts_captions_txt_maps_to_srt_format(self):
opts = get_opts("captions", "auto", "txt", "best", {})
self.assertEqual(opts["subtitlesformat"], "srt")
def test_get_opts_merges_existing_postprocessors(self):
opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]})
keys = [p["key"] for p in opts["postprocessors"]]
self.assertIn("SponsorBlock", keys)
self.assertIn("FFmpegExtractAudio", keys)
def test_normalize_caption_mode_invalid_defaults(self):
self.assertEqual(_normalize_caption_mode(""), "prefer_manual")
self.assertEqual(_normalize_caption_mode("not_a_mode"), "prefer_manual")
def test_normalize_subtitle_language_empty_defaults_en(self):
self.assertEqual(_normalize_subtitle_language(""), "en")
self.assertEqual(_normalize_subtitle_language(" "), "en")
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
"""Tests for ``DownloadQueue`` with mocked yt-dlp extraction."""
from __future__ import annotations
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from ytdl import DownloadQueue
@pytest.fixture
def dq_env():
with tempfile.TemporaryDirectory() as tmp:
dl = os.path.join(tmp, "downloads")
st = os.path.join(tmp, "state")
os.makedirs(dl, exist_ok=True)
os.makedirs(st, exist_ok=True)
cfg = MagicMock()
cfg.STATE_DIR = st
cfg.DOWNLOAD_DIR = dl
cfg.AUDIO_DOWNLOAD_DIR = dl
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"
cfg.DELETE_FILE_ON_TRASHCAN = False
cfg.OUTPUT_TEMPLATE = "%(title)s.%(ext)s"
cfg.OUTPUT_TEMPLATE_CHAPTER = "%(title)s.%(ext)s"
cfg.OUTPUT_TEMPLATE_PLAYLIST = ""
cfg.OUTPUT_TEMPLATE_CHANNEL = ""
yield cfg
def test_cancel_add_increments_generation(dq_env):
notifier = MagicMock()
dq = DownloadQueue(dq_env, notifier)
before = dq._add_generation
dq.cancel_add()
assert dq._add_generation == before + 1
def test_get_returns_tuple_of_lists(dq_env):
notifier = MagicMock()
dq = DownloadQueue(dq_env, notifier)
q, done = dq.get()
assert q == [] and done == []
@pytest.mark.asyncio
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
notifier = AsyncMock()
def fake_extract(self, url):
return {
"_type": "video",
"id": "vid1",
"title": "Test 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/watch?v=1",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
)
assert result["status"] == "ok"
assert dq.pending.exists("https://example.com/watch?v=1")
@pytest.mark.asyncio
async def test_cancel_removes_from_pending(dq_env):
notifier = AsyncMock()
def fake_extract(self, url):
return {
"_type": "video",
"id": "vid1",
"title": "Test Video",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
await dq.add(
"https://example.com/pending",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
)
url = "https://example.com/pending"
await dq.cancel([url])
assert not dq.pending.exists(url)
notifier.canceled.assert_awaited()
@pytest.mark.asyncio
async def test_start_pending_moves_to_queue(dq_env):
notifier = AsyncMock()
def fake_extract(self, url):
return {
"_type": "video",
"id": "vid1",
"title": "Test Video",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
await dq.add(
"https://example.com/startme",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
)
url = "https://example.com/startme"
# Starting will spawn real download — cancel immediately before worker runs much
with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
await dq.start_pending([url])
assert not dq.pending.exists(url)
@pytest.mark.asyncio
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
entry = {
"_type": "video",
"id": "vid1",
"title": "Test Video",
"url": "https://example.com/watch?v=1",
"webpage_url": "https://example.com/watch?v=1",
"playlist_index": "01",
"playlist_title": "Playlist",
}
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")):
result = await dq.add_entry(
entry,
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
)
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
+159
View File
@@ -0,0 +1,159 @@
"""Tests for pure helpers in ``main`` (legacy API migration, logging, JSON serializer)."""
from __future__ import annotations
import json
import logging
import unittest
import main
class MigrateLegacyRequestTests(unittest.TestCase):
def test_already_new_schema_unchanged(self):
post = {"download_type": "video", "codec": "h264", "format": "mp4", "quality": "1080"}
before = post.copy()
self.assertIs(main._migrate_legacy_request(post), post)
self.assertEqual(post, before)
def test_legacy_audio_m4a(self):
post = {"format": "m4a", "quality": "best"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "audio")
self.assertEqual(post["codec"], "auto")
self.assertEqual(post["format"], "m4a")
def test_legacy_thumbnail(self):
post = {"format": "thumbnail", "quality": "best"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "thumbnail")
self.assertEqual(post["format"], "jpg")
self.assertEqual(post["quality"], "best")
def test_legacy_captions_with_subtitle_format(self):
post = {"format": "captions", "subtitle_format": "vtt", "quality": "best"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "captions")
self.assertEqual(post["format"], "vtt")
def test_legacy_video_best_ios(self):
post = {"format": "any", "quality": "best_ios", "video_codec": "auto"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "video")
self.assertEqual(post["format"], "ios")
self.assertEqual(post["quality"], "best")
def test_legacy_video_quality_audio_maps_to_m4a(self):
post = {"format": "mp4", "quality": "audio", "video_codec": "h264"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "audio")
self.assertEqual(post["format"], "m4a")
self.assertEqual(post["quality"], "best")
def test_legacy_video_default(self):
post = {"format": "mp4", "quality": "1080", "video_codec": "h265"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "video")
self.assertEqual(post["codec"], "h265")
self.assertEqual(post["format"], "mp4")
self.assertEqual(post["quality"], "1080")
class ParseLogLevelTests(unittest.TestCase):
def test_valid_levels(self):
self.assertEqual(main.parseLogLevel("INFO"), logging.INFO)
self.assertEqual(main.parseLogLevel("debug"), logging.DEBUG)
def test_invalid_returns_none(self):
self.assertIsNone(main.parseLogLevel("not_a_level"))
self.assertIsNone(main.parseLogLevel(123))
class ObjectSerializerTests(unittest.TestCase):
def test_dict_like_object(self):
class Obj:
def __init__(self):
self.a = 1
ser = main.ObjectSerializer()
self.assertEqual(json.loads(ser.encode(Obj())), {"a": 1})
def test_generator_becomes_list(self):
ser = main.ObjectSerializer()
def gen():
yield 1
yield 2
self.assertEqual(json.loads(ser.encode(gen())), [1, 2])
def test_string_not_split_to_chars(self):
ser = main.ObjectSerializer()
self.assertEqual(json.loads(ser.encode("hello")), "hello")
class FrontendSafeTests(unittest.TestCase):
def test_only_expected_keys(self):
safe = main.config.frontend_safe()
for key in main.Config._FRONTEND_KEYS:
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("", enabled=False), {})
def test_rejects_non_object_json(self):
with self.assertRaises(main.web.HTTPBadRequest):
main._parse_ytdl_options_overrides('["bad"]', enabled=True)
def test_rejects_non_empty_overrides_when_disabled(self):
with self.assertRaises(main.web.HTTPBadRequest):
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",
"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
main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = previous_allow
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()
+291
View File
@@ -0,0 +1,291 @@
"""Integration tests for ``PersistentQueue`` using the JSON state store."""
from __future__ import annotations
import json
import os
import shelve
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
fake_yt_dlp = types.ModuleType("yt_dlp")
fake_networking = types.ModuleType("yt_dlp.networking")
fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate")
fake_utils = types.ModuleType("yt_dlp.utils")
class _ImpersonateTarget:
@staticmethod
def from_str(value):
return value
fake_impersonate.ImpersonateTarget = _ImpersonateTarget
fake_networking.impersonate = fake_impersonate
fake_utils.STR_FORMAT_RE_TMPL = r"(?P<prefix>)%\((?P<has_key>{})\)(?P<format>[-0-9.]*{})"
fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa"
fake_yt_dlp.networking = fake_networking
fake_yt_dlp.utils = fake_utils
sys.modules.setdefault("yt_dlp", fake_yt_dlp)
sys.modules.setdefault("yt_dlp.networking", fake_networking)
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
sys.modules.setdefault("yt_dlp.utils", fake_utils)
from ytdl import DownloadInfo, PersistentQueue
class _FakeDownload:
__slots__ = ("info",)
def __init__(self, info: DownloadInfo):
self.info = info
def _make_info(url: str = "https://example.com/v") -> DownloadInfo:
return DownloadInfo(
id="id1",
title="Title",
url=url,
quality="best",
download_type="video",
codec="auto",
format="any",
folder="",
custom_name_prefix="",
error=None,
entry=None,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
)
def _create_legacy_shelf(path: str, *infos: DownloadInfo) -> None:
with shelve.open(path, "c") as shelf:
for info in infos:
shelf[info.url] = info
class PersistentQueueTests(unittest.TestCase):
def test_put_get_delete_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
dl = _FakeDownload(_make_info("http://a.example"))
pq.put(dl)
self.assertTrue(os.path.exists(path + ".json"))
self.assertTrue(pq.exists("http://a.example"))
self.assertFalse(pq.empty())
got = pq.get("http://a.example")
self.assertEqual(got.info.url, "http://a.example")
pq.delete("http://a.example")
self.assertFalse(pq.exists("http://a.example"))
def test_saved_items_sorted_by_timestamp(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
a = _FakeDownload(_make_info("http://first.example"))
b = _FakeDownload(_make_info("http://second.example"))
a.info.timestamp = 100
b.info.timestamp = 200
pq.put(a)
pq.put(b)
keys = [k for k, _ in pq.saved_items()]
self.assertEqual(keys, ["http://first.example", "http://second.example"])
def test_load_restores_from_json(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq1 = PersistentQueue("queue", path)
pq1.put(_FakeDownload(_make_info("http://load.example")))
pq2 = PersistentQueue("queue", path)
pq2.load()
self.assertTrue(pq2.exists("http://load.example"))
def test_load_imports_legacy_shelve(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
_create_legacy_shelf(path, _make_info("http://legacy.example"))
pq = PersistentQueue("queue", path)
pq.load()
self.assertTrue(pq.exists("http://legacy.example"))
self.assertTrue(os.path.exists(path + ".json"))
def test_queue_persists_only_compact_entry_subset(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
info = _make_info("http://entry.example")
info.entry = {
"playlist_index": "01",
"playlist_title": "Playlist",
"channel_index": "02",
"channel_title": "Channel",
"formats": [{"id": "huge"}],
"description": "very large payload",
}
pq.put(_FakeDownload(info))
with open(path + ".json", encoding="utf-8") as f:
payload = json.load(f)
record = payload["items"][0]["info"]
self.assertEqual(
record["entry"],
{
"playlist_index": "01",
"playlist_title": "Playlist",
"channel_index": "02",
"channel_title": "Channel",
},
)
self.assertNotIn("formats", record["entry"])
self.assertNotIn("description", record["entry"])
def test_completed_queue_does_not_persist_entry_or_transient_progress(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "completed")
pq = PersistentQueue("completed", path)
info = _make_info("http://done.example")
info.status = "finished"
info.percent = 88
info.speed = 123
info.eta = 9
info.entry = {
"playlist_index": "01",
"playlist_title": "Playlist",
"formats": [{"id": "huge"}],
}
info.filename = "done.mp4"
pq.put(_FakeDownload(info))
with open(path + ".json", encoding="utf-8") as f:
payload = json.load(f)
record = payload["items"][0]["info"]
self.assertNotIn("entry", record)
self.assertNotIn("percent", record)
self.assertNotIn("speed", record)
self.assertNotIn("eta", record)
self.assertEqual(record["filename"], "done.mp4")
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
_create_legacy_shelf(path, _make_info("http://legacy.example"))
with open(path + ".json", "w", encoding="utf-8") as f:
f.write("{not valid json")
pq = PersistentQueue("queue", path)
pq.load()
self.assertTrue(pq.exists("http://legacy.example"))
self.assertTrue(
any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp))
)
def test_loading_old_json_rewrites_to_compact_format(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
with open(path + ".json", "w", encoding="utf-8") as f:
json.dump(
{
"schema_version": 1,
"kind": "persistent_queue:queue",
"items": [
{
"key": "http://legacy-json.example",
"info": {
"id": "id1",
"title": "Title",
"url": "http://legacy-json.example",
"quality": "best",
"download_type": "video",
"codec": "auto",
"format": "any",
"folder": "",
"custom_name_prefix": "",
"playlist_item_limit": 0,
"split_by_chapters": False,
"chapter_template": "",
"subtitle_language": "en",
"subtitle_mode": "prefer_manual",
"status": "pending",
"timestamp": 1,
"entry": {
"playlist_index": "01",
"playlist_title": "Playlist",
"formats": [{"id": "huge"}],
},
"percent": 15,
"speed": 20,
"eta": 30,
},
}
],
},
f,
)
pq = PersistentQueue("queue", path)
pq.load()
with open(path + ".json", encoding="utf-8") as f:
payload = json.load(f)
record = payload["items"][0]["info"]
self.assertEqual(payload["schema_version"], 2)
self.assertEqual(record["entry"], {"playlist_index": "01", "playlist_title": "Playlist"})
self.assertNotIn("percent", record)
self.assertNotIn("speed", record)
self.assertNotIn("eta", record)
def test_put_rollbacks_in_memory_queue_when_state_write_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
dl = _FakeDownload(_make_info("http://rollback.example"))
self.assertFalse(pq.exists("http://rollback.example"))
orig_save = __import__("state_store").AtomicJsonStore.save
def bad_save(store, data):
if store.path == path + ".json":
raise OSError("simulated shelf failure")
return orig_save(store, data)
with patch("ytdl.AtomicJsonStore.save", bad_save):
with self.assertRaises(OSError):
pq.put(dl)
self.assertFalse(pq.exists("http://rollback.example"))
def test_put_rollbacks_to_previous_download_when_replace_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
first = _FakeDownload(_make_info("http://same.example"))
second = _FakeDownload(_make_info("http://same.example"))
second.info.title = "Replaced title"
pq.put(first)
orig_save = __import__("state_store").AtomicJsonStore.save
def bad_save(store, data):
if store.path == path + ".json":
raise OSError("simulated shelf failure")
return orig_save(store, data)
with patch("ytdl.AtomicJsonStore.save", bad_save):
with self.assertRaises(OSError):
pq.put(second)
self.assertEqual(pq.get("http://same.example").info.title, "Title")
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
class StateStoreTests(unittest.TestCase):
def test_save_and_load_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
store.save({"items": [{"key": "a", "info": {"title": "hello"}}]})
payload = store.load()
self.assertEqual(payload["kind"], "persistent_queue:queue")
self.assertEqual(payload["schema_version"], 2)
self.assertEqual(payload["items"][0]["info"]["title"], "hello")
def test_invalid_file_is_quarantined(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
with open(path, "w", encoding="utf-8") as f:
f.write("{broken")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
payload = store.load()
self.assertIsNone(payload)
self.assertTrue(
any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp))
)
def test_json_compat_helpers_roundtrip_bytes_and_datetime(self):
raw = {
"payload": b"abc",
"timestamp": datetime(2024, 1, 2, 3, 4, 5),
"items": (1, 2, 3),
}
restored = from_json_compatible(to_json_compatible(raw))
self.assertEqual(restored["payload"], b"abc")
self.assertEqual(restored["timestamp"], datetime(2024, 1, 2, 3, 4, 5))
self.assertEqual(restored["items"], [1, 2, 3])
if __name__ == "__main__":
unittest.main()
+443
View File
@@ -0,0 +1,443 @@
from __future__ import annotations
import json
import os
import shelve
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
fake_yt_dlp = types.ModuleType("yt_dlp")
fake_networking = types.ModuleType("yt_dlp.networking")
fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate")
class _ImpersonateTarget:
@staticmethod
def from_str(value):
return value
fake_impersonate.ImpersonateTarget = _ImpersonateTarget
fake_networking.impersonate = fake_impersonate
fake_yt_dlp.networking = fake_networking
fake_yt_dlp.utils = types.SimpleNamespace(YoutubeDLError=Exception)
sys.modules.setdefault("yt_dlp", fake_yt_dlp)
sys.modules.setdefault("yt_dlp.networking", fake_networking)
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
from subscriptions import SubscriptionManager, extract_flat_playlist
class _Config:
def __init__(self, state_dir: str):
self.STATE_DIR = state_dir
self.SUBSCRIPTION_SCAN_PLAYLIST_END = 50
self.SUBSCRIPTION_MAX_SEEN_IDS = 50000
self.DOWNLOAD_DIR = state_dir
self.TEMP_DIR = state_dir
self.YTDL_OPTIONS = {}
class _Queue:
def __init__(self):
self.entries = []
self.fail = False
async def add(self, *args, **kwargs):
return None
async def add_entry(self, entry, *args, **kwargs):
if self.fail:
return {"status": "error", "msg": "queue failed"}
self.entries.append((entry, args, kwargs))
return {"status": "ok"}
class _Notifier:
async def subscription_added(self, sub):
return None
async def subscription_updated(self, sub):
return None
async def subscription_removed(self, sub_id):
return None
async def subscriptions_all(self, subs):
return None
def _create_legacy_shelf(path: str, record) -> None:
with shelve.open(path, "c") as shelf:
shelf["sub-1"] = record
class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
def test_load_imports_legacy_subscription_shelf(self):
with tempfile.TemporaryDirectory() as tmp:
legacy_path = os.path.join(tmp, "subscriptions")
json_path = os.path.join(tmp, "subscriptions.json")
_create_legacy_shelf(
legacy_path,
{
"id": "sub-1",
"name": "Channel",
"url": "https://example.com/channel",
"timestamp": 1.0,
},
)
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
self.assertEqual(len(mgr.list_all()), 1)
self.assertTrue(os.path.exists(json_path))
with open(json_path, encoding="utf-8") as f:
payload = json.load(f)
self.assertEqual(payload["schema_version"], 2)
self.assertNotIn("timestamp", payload["items"][0])
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
with tempfile.TemporaryDirectory() as tmp:
legacy_path = os.path.join(tmp, "subscriptions")
json_path = os.path.join(tmp, "subscriptions.json")
_create_legacy_shelf(
legacy_path,
{
"id": "sub-1",
"name": "Channel",
"url": "https://example.com/channel",
"timestamp": 1.0,
},
)
with open(json_path, "w", encoding="utf-8") as f:
f.write("{not valid json")
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
self.assertEqual(len(mgr.list_all()), 1)
self.assertTrue(
any(name.startswith("subscriptions.json.invalid.") for name in os.listdir(tmp))
)
def test_load_rewrites_old_json_and_trims_seen_ids(self):
with tempfile.TemporaryDirectory() as tmp:
json_path = os.path.join(tmp, "subscriptions.json")
cfg = _Config(tmp)
cfg.SUBSCRIPTION_MAX_SEEN_IDS = 2
with open(json_path, "w", encoding="utf-8") as f:
json.dump(
{
"schema_version": 1,
"kind": "subscriptions",
"items": [
{
"id": "sub-1",
"name": "Channel",
"url": "https://example.com/channel",
"enabled": True,
"check_interval_minutes": 60,
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"folder": "",
"custom_name_prefix": "",
"auto_start": True,
"playlist_item_limit": 0,
"split_by_chapters": False,
"chapter_template": "",
"subtitle_language": "en",
"subtitle_mode": "prefer_manual",
"last_checked": None,
"seen_ids": ["a", "b", "a", "c"],
"error": None,
"timestamp": 123,
}
],
},
f,
)
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
self.assertEqual(mgr.list_all()[0].seen_ids, ["a", "b"])
with open(json_path, encoding="utf-8") as f:
payload = json.load(f)
self.assertEqual(payload["schema_version"], 2)
self.assertEqual(payload["items"][0]["seen_ids"], ["a", "b"])
self.assertNotIn("timestamp", payload["items"][0])
async def test_add_subscription_rolls_back_when_state_write_fails(self):
with tempfile.TemporaryDirectory() as tmp:
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
orig_save = __import__("state_store").AtomicJsonStore.save
def bad_save(store, data):
if store.path == mgr._path:
raise OSError("simulated shelf failure")
return orig_save(store, data)
with patch(
"subscriptions.extract_flat_playlist",
return_value=(
{"_type": "channel", "title": "Channel"},
[{"id": "v1", "webpage_url": "https://example.com/v1"}],
),
):
with patch("subscriptions.AtomicJsonStore.save", bad_save):
with self.assertRaises(OSError):
await mgr.add_subscription(
"https://example.com/channel",
check_interval_minutes=60,
download_type="video",
codec="auto",
format="any",
quality="best",
folder="",
custom_name_prefix="",
auto_start=True,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
subtitle_language="en",
subtitle_mode="prefer_manual",
)
self.assertEqual(mgr.list_all(), [])
self.assertNotIn("https://example.com/channel", mgr._url_index)
async def test_add_subscription_marks_existing_videos_seen_without_queueing(self):
with tempfile.TemporaryDirectory() as tmp:
queue = _Queue()
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
with patch(
"subscriptions.extract_flat_playlist",
return_value=(
{"_type": "channel", "title": "Channel"},
[
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
{"id": "v3", "title": "Three", "webpage_url": "https://example.com/v3"},
],
),
):
result = await mgr.add_subscription(
"https://example.com/channel",
check_interval_minutes=60,
download_type="video",
codec="auto",
format="any",
quality="best",
folder="",
custom_name_prefix="",
auto_start=True,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
subtitle_language="en",
subtitle_mode="prefer_manual",
)
assert result["status"] == "ok"
sub = mgr.list_all()[0]
self.assertEqual(sub.seen_ids, ["v1", "v2", "v3"])
self.assertIsNone(sub.error)
self.assertEqual(queue.entries, [])
async def test_add_subscription_skips_collection_tab_entries(self):
with tempfile.TemporaryDirectory() as tmp:
queue = _Queue()
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
with patch(
"subscriptions.extract_flat_playlist",
return_value=(
{"_type": "channel", "title": "Channel"},
[
{
"_type": "url",
"ie_key": "YoutubeTab",
"title": "Channel - Live",
"url": "https://example.com/live",
"webpage_url": "https://example.com/live",
},
{
"_type": "url",
"ie_key": "Youtube",
"id": "v1",
"title": "One",
"duration": 10,
"webpage_url": "https://example.com/v1",
},
],
),
):
result = await mgr.add_subscription(
"https://example.com/channel",
check_interval_minutes=60,
download_type="video",
codec="auto",
format="any",
quality="best",
folder="",
custom_name_prefix="",
auto_start=True,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
subtitle_language="en",
subtitle_mode="prefer_manual",
)
self.assertEqual(result["status"], "ok")
sub = mgr.list_all()[0]
self.assertEqual(sub.seen_ids, ["v1"])
self.assertEqual(queue.entries, [])
async def test_check_now_keeps_failed_queue_items_unseen_and_sets_error(self):
with tempfile.TemporaryDirectory() as tmp:
queue = _Queue()
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
with patch(
"subscriptions.extract_flat_playlist",
side_effect=[
(
{"_type": "channel", "title": "Channel"},
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
),
(
{"_type": "channel", "title": "Channel"},
[{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}],
),
],
):
result = await mgr.add_subscription(
"https://example.com/channel",
check_interval_minutes=60,
download_type="video",
codec="auto",
format="any",
quality="best",
folder="",
custom_name_prefix="",
auto_start=True,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
subtitle_language="en",
subtitle_mode="prefer_manual",
)
queue.fail = True
await mgr.check_now([result["subscription"]["id"]])
sub = mgr.list_all()[0]
self.assertEqual(sub.error, "queue failed")
self.assertEqual(sub.seen_ids, ["v1"])
async def test_check_now_queues_new_video_and_updates_seen_ids(self):
with tempfile.TemporaryDirectory() as tmp:
queue = _Queue()
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
with patch(
"subscriptions.extract_flat_playlist",
side_effect=[
(
{"_type": "channel", "title": "Channel"},
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
),
(
{"_type": "channel", "title": "Channel"},
[
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
],
),
],
):
result = await mgr.add_subscription(
"https://example.com/channel",
check_interval_minutes=60,
download_type="video",
codec="auto",
format="any",
quality="best",
folder="",
custom_name_prefix="",
auto_start=True,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
subtitle_language="en",
subtitle_mode="prefer_manual",
)
await mgr.check_now([result["subscription"]["id"]])
sub = mgr.list_all()[0]
self.assertIsNotNone(sub.last_checked)
self.assertIsNone(sub.error)
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
class ExtractFlatPlaylistTests(unittest.TestCase):
def test_descends_one_level_when_root_entries_are_nested_collections(self):
responses = iter(
[
{
"_type": "channel",
"entries": [
{
"_type": "url",
"ie_key": "YoutubeTab",
"title": "Channel - Videos",
"url": "https://example.com/videos",
"webpage_url": "https://example.com/videos",
}
],
},
{
"_type": "playlist",
"entries": [
{
"_type": "url",
"ie_key": "Youtube",
"id": "v1",
"title": "One",
"duration": 10,
"webpage_url": "https://example.com/v1",
}
],
},
]
)
class _FakeYDL:
def __init__(self, params):
self.params = params
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def extract_info(self, url, download=False):
return next(responses)
cfg = _Config(tempfile.mkdtemp())
with patch("subscriptions.yt_dlp.YoutubeDL", _FakeYDL, create=True):
info, entries = extract_flat_playlist(cfg, "https://example.com/channel", 50)
self.assertEqual(info.get("_type"), "playlist")
self.assertEqual([entry["webpage_url"] for entry in entries], ["https://example.com/v1"])
if __name__ == "__main__":
unittest.main()
+313
View File
@@ -0,0 +1,313 @@
"""Tests for pure helpers and migration logic in ``ytdl``."""
from __future__ import annotations
import pickle
import sys
import tempfile
import threading
import types
import unittest
from pathlib import Path
fake_yt_dlp = types.ModuleType("yt_dlp")
fake_networking = types.ModuleType("yt_dlp.networking")
fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate")
fake_utils = types.ModuleType("yt_dlp.utils")
class _ImpersonateTarget:
@staticmethod
def from_str(value):
return value
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<prefix>)%\((?P<has_key>(?P<key>{}))\)(?P<format>[-0-9.]*{})"
fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa"
fake_yt_dlp.networking = fake_networking
fake_yt_dlp.utils = fake_utils
sys.modules.setdefault("yt_dlp", fake_yt_dlp)
sys.modules.setdefault("yt_dlp.networking", fake_networking)
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
sys.modules.setdefault("yt_dlp.utils", fake_utils)
from ytdl import (
DownloadInfo,
_compact_persisted_entry,
_convert_srt_to_txt_file,
_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):
self.assertEqual(_sanitize_path_component('a:b*c?d"e<f>g|h'), "a_b_c_d_e_f_g_h")
def test_non_string_passthrough(self):
self.assertIs(_sanitize_path_component(None), None)
self.assertEqual(_sanitize_path_component(42), 42)
@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):
info = {"playlist_index": "3"}
result = _resolve_outtmpl_fields("%(playlist_index)02d-%(title)s", info, ("playlist",))
self.assertEqual(result, "03-%(title)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")
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):
def g():
yield 1
obj = {"a": g(), "b": [g()]}
out = _sanitize_entry_for_pickle(obj)
self.assertEqual(out, {"a": [1], "b": [[1]]})
pickle.dumps(out)
def test_plain(self):
self.assertEqual(_sanitize_entry_for_pickle(5), 5)
def test_set_converted_to_list(self):
obj = {"s": {1, 2}}
out = _sanitize_entry_for_pickle(obj)
self.assertEqual(sorted(out["s"]), [1, 2])
pickle.dumps(out)
def test_map_iterator(self):
out = _sanitize_entry_for_pickle({"m": map(int, ["1", "2"])})
self.assertEqual(out, {"m": [1, 2]})
def test_lock_replaced_with_none(self):
lock = threading.Lock()
out = _sanitize_entry_for_pickle({"k": lock})
self.assertIsNone(out["k"])
pickle.dumps(out)
def test_ordered_dict(self):
from collections import OrderedDict
od = OrderedDict([("z", 1), ("a", 2)])
out = _sanitize_entry_for_pickle(od)
self.assertEqual(out, {"z": 1, "a": 2})
class ConvertSrtToTxtTests(unittest.TestCase):
def test_basic_conversion(self):
srt = """1
00:00:01,000 --> 00:00:02,000
Hello <b>world</b>
2
00:00:03,000 --> 00:00:04,000
Second line
"""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sub.srt"
path.write_text(srt, encoding="utf-8")
txt_path = _convert_srt_to_txt_file(str(path))
self.assertIsNotNone(txt_path)
self.assertTrue(txt_path.endswith(".txt"))
content = Path(txt_path).read_text(encoding="utf-8")
self.assertIn("Hello world", content)
self.assertIn("Second line", content)
class DownloadInfoSetstateTests(unittest.TestCase):
def _base_state(self, **kwargs):
base = {
"id": "id1",
"title": "t",
"url": "http://example.com/v",
"folder": "",
"custom_name_prefix": "",
"error": None,
"entry": None,
"playlist_item_limit": 0,
"split_by_chapters": False,
"chapter_template": "",
"msg": None,
"percent": None,
"speed": None,
"eta": None,
"status": "pending",
"size": None,
"timestamp": 0,
}
base.update(kwargs)
return base
def test_migrates_old_audio_format(self):
state = self._base_state(format="m4a", quality="best")
di = DownloadInfo.__new__(DownloadInfo)
di.__setstate__(state)
self.assertEqual(di.download_type, "audio")
self.assertEqual(di.codec, "auto")
def test_migrates_thumbnail(self):
state = self._base_state(format="thumbnail", quality="best")
di = DownloadInfo.__new__(DownloadInfo)
di.__setstate__(state)
self.assertEqual(di.download_type, "thumbnail")
self.assertEqual(di.format, "jpg")
def test_migrates_captions(self):
state = self._base_state(format="captions", subtitle_format="vtt", quality="best")
di = DownloadInfo.__new__(DownloadInfo)
di.__setstate__(state)
self.assertEqual(di.download_type, "captions")
self.assertEqual(di.format, "vtt")
def test_migrates_best_ios(self):
state = self._base_state(
format="any", quality="best_ios", video_codec="auto"
)
di = DownloadInfo.__new__(DownloadInfo)
di.__setstate__(state)
self.assertEqual(di.format, "ios")
self.assertEqual(di.quality, "best")
def test_migrates_quality_audio(self):
state = self._base_state(format="mp4", quality="audio", video_codec="h264")
di = DownloadInfo.__new__(DownloadInfo)
di.__setstate__(state)
self.assertEqual(di.download_type, "audio")
self.assertEqual(di.format, "m4a")
def test_new_state_has_subtitle_files(self):
state = self._base_state(
download_type="video",
codec="auto",
format="any",
quality="best",
)
di = DownloadInfo.__new__(DownloadInfo)
di.__setstate__(state)
self.assertEqual(di.subtitle_files, [])
def test_missing_optional_fields_are_defaulted(self):
state = self._base_state(
download_type="video",
codec="auto",
format="any",
quality="best",
)
state.pop("folder")
state.pop("custom_name_prefix")
state.pop("playlist_item_limit")
state.pop("split_by_chapters")
state.pop("chapter_template")
di = DownloadInfo.__new__(DownloadInfo)
di.__setstate__(state)
self.assertEqual(di.folder, "")
self.assertEqual(di.custom_name_prefix, "")
self.assertEqual(di.playlist_item_limit, 0)
self.assertFalse(di.split_by_chapters)
self.assertEqual(di.chapter_template, "")
class CompactPersistedEntryTests(unittest.TestCase):
def test_keeps_only_playlist_and_channel_keys(self):
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",
}
compact = _compact_persisted_entry(entry)
self.assertEqual(
compact,
{
"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,
},
)
def test_returns_none_when_no_restart_relevant_keys_exist(self):
self.assertIsNone(_compact_persisted_entry({"id": "x", "title": "y"}))
if __name__ == "__main__":
unittest.main()
+329 -153
View File
@@ -1,34 +1,28 @@
import os
import shutil
import yt_dlp
import collections
import collections.abc
import copy
import pickle
from collections import OrderedDict
import shelve
import time
import asyncio
import multiprocessing
import logging
import re
import types
import dbm
import subprocess
from typing import Any
from functools import lru_cache
from typing import Any, Optional
import yt_dlp.networking.impersonate
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
from dl_formats import get_format, get_opts, AUDIO_FORMATS
from datetime import datetime
from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf, to_json_compatible
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.
@@ -39,55 +33,86 @@ 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
def _convert_generators_to_lists(obj):
"""Recursively convert generators to lists in a dictionary to make it pickleable."""
if isinstance(obj, types.GeneratorType):
return list(obj)
elif isinstance(obj, dict):
return {k: _convert_generators_to_lists(v) for k, v in obj.items()}
elif isinstance(obj, (list, tuple)):
return type(obj)(_convert_generators_to_lists(item) for item in obj)
else:
_MAX_ENTRY_SANITIZE_DEPTH = 64
def _sanitize_entry_for_pickle(obj, _depth=0):
"""Recursively normalize yt-dlp ``info_dict`` data so it can be stored in shelve/pickle.
Live streams and newer yt-dlp versions may nest generators, iterators, sets, or
non-serializable objects (e.g. locks) inside the extracted metadata. The previous
helper only walked plain dict/list/tuple and only expanded ``types.GeneratorType``.
"""
if _depth > _MAX_ENTRY_SANITIZE_DEPTH:
return None
if obj is None or isinstance(obj, (bool, int, float, str, bytes)):
return obj
if isinstance(obj, types.GeneratorType):
return _sanitize_entry_for_pickle(list(obj), _depth + 1)
if isinstance(obj, collections.abc.Mapping):
return {k: _sanitize_entry_for_pickle(v, _depth + 1) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return type(obj)(_sanitize_entry_for_pickle(x, _depth + 1) for x in obj)
if isinstance(obj, (set, frozenset)):
return [_sanitize_entry_for_pickle(x, _depth + 1) for x in obj]
if isinstance(obj, collections.deque):
return [_sanitize_entry_for_pickle(x, _depth + 1) for x in obj]
if isinstance(obj, collections.abc.Iterator):
try:
return _sanitize_entry_for_pickle(list(obj), _depth + 1)
except Exception:
return None
try:
pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
return obj
except Exception:
return None
def _convert_srt_to_txt_file(subtitle_path: str):
@@ -163,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}'
@@ -178,13 +205,15 @@ class DownloadInfo:
self.size = None
self.timestamp = time.time_ns()
self.error = error
# Convert generators to lists to make entry pickleable
self.entry = _convert_generators_to_lists(entry) if entry is not None else None
# Strip non-pickleable values (generators, iterators, locks, etc.) for shelve
self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
self.playlist_item_limit = playlist_item_limit
self.split_by_chapters = split_by_chapters
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):
@@ -223,8 +252,109 @@ class DownloadInfo:
if not getattr(self, "codec", None):
self.codec = "auto"
if not hasattr(self, "folder"):
self.folder = ""
if not hasattr(self, "custom_name_prefix"):
self.custom_name_prefix = ""
if not hasattr(self, "playlist_item_limit"):
self.playlist_item_limit = 0
if not hasattr(self, "split_by_chapters"):
self.split_by_chapters = False
if not hasattr(self, "chapter_template"):
self.chapter_template = ""
if not hasattr(self, "subtitle_language"):
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"):
self.subtitle_files = []
if not hasattr(self, "chapter_files"):
self.chapter_files = []
_PERSISTED_DOWNLOAD_FIELDS = (
"id",
"title",
"url",
"quality",
"download_type",
"codec",
"format",
"folder",
"custom_name_prefix",
"playlist_item_limit",
"split_by_chapters",
"chapter_template",
"subtitle_language",
"subtitle_mode",
"ytdl_options_preset",
"ytdl_options_overrides",
"status",
"timestamp",
"error",
"msg",
"filename",
"size",
"chapter_files",
)
_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") or key in _COMPACT_ENTRY_EXTRA_KEYS
}
return compact or None
def _download_info_to_record(
info: DownloadInfo,
*,
include_entry: bool,
) -> dict[str, Any]:
record: dict[str, Any] = {}
for key in _PERSISTED_DOWNLOAD_FIELDS:
if hasattr(info, key):
value = getattr(info, key)
if value is not None:
record[key] = to_json_compatible(value)
if include_entry:
compact_entry = _compact_persisted_entry(getattr(info, "entry", None))
if compact_entry is not None:
record["entry"] = to_json_compatible(compact_entry)
return record
def _download_info_from_record(record: dict[str, Any]) -> DownloadInfo:
info = DownloadInfo.__new__(DownloadInfo)
info.__setstate__({key: from_json_compatible(value) for key, value in record.items()})
if not hasattr(info, "msg"):
info.msg = None
if not hasattr(info, "percent"):
info.percent = None
if not hasattr(info, "speed"):
info.speed = None
if not hasattr(info, "eta"):
info.eta = None
if not hasattr(info, "status"):
info.status = "pending"
if not hasattr(info, "size"):
info.size = None
if not hasattr(info, "error"):
info.error = None
return info
class Download:
manager = None
@@ -475,11 +605,9 @@ class PersistentQueue:
pdir = os.path.dirname(path)
if not os.path.isdir(pdir):
os.mkdir(pdir)
with shelve.open(path, 'c'):
pass
self.path = path
self.repair()
self.legacy_path = path
self.path = f"{path}.json"
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
self.dict = OrderedDict()
def load(self):
@@ -496,20 +624,91 @@ class PersistentQueue:
return self.dict.items()
def saved_items(self):
with shelve.open(self.path, 'r') as shelf:
return sorted(shelf.items(), key=lambda item: item[1].timestamp)
items = [
(item["key"], _download_info_from_record(item["info"]))
for item in self._load_state_items()
]
return sorted(items, key=lambda item: item[1].timestamp)
def _should_persist_entry(self) -> bool:
return self.identifier != "completed"
def _serialize_items(self):
return [
{
"key": key,
"info": _download_info_to_record(
download.info,
include_entry=self._should_persist_entry(),
),
}
for key, download in self.dict.items()
]
def _save_dict(self):
self.store.save({"items": self._serialize_items()})
def _load_state_items(self):
payload = self.store.load()
if payload is not None:
items = payload.get("items")
if isinstance(items, list):
compact_items = [
{
"key": item["key"],
"info": _download_info_to_record(
_download_info_from_record(item["info"]),
include_entry=self._should_persist_entry(),
),
}
for item in items
if isinstance(item, dict) and "key" in item and "info" in item
]
if payload.get("schema_version") != self.store.schema_version or compact_items != items:
self.store.save({"items": compact_items})
return compact_items
log.warning("PersistentQueue:%s state file did not contain an items list", self.identifier)
return []
legacy_items = read_legacy_shelf(self.legacy_path)
if legacy_items is None:
return []
items = [
{
"key": key,
"info": _download_info_to_record(
value,
include_entry=self._should_persist_entry(),
),
}
for key, value in sorted(legacy_items, key=lambda item: item[1].timestamp)
]
self.store.save({"items": items})
return items
def put(self, value):
key = value.info.url
old = self.dict.get(key)
self.dict[key] = value
with shelve.open(self.path, 'w') as shelf:
shelf[key] = value.info
try:
self._save_dict()
except Exception:
if old is None:
del self.dict[key]
else:
self.dict[key] = old
raise
def delete(self, key):
if key in self.dict:
old = self.dict[key]
del self.dict[key]
with shelve.open(self.path, 'w') as shelf:
shelf.pop(key, None)
try:
self._save_dict()
except Exception:
self.dict[key] = old
raise
def next(self):
k, v = next(iter(self.dict.items()))
@@ -518,90 +717,6 @@ class PersistentQueue:
def empty(self):
return not bool(self.dict)
def repair(self):
# check DB format
type_check = subprocess.run(
["file", self.path],
capture_output=True,
text=True
)
db_type = type_check.stdout.lower()
# create backup (<queue>.old)
try:
shutil.copy2(self.path, f"{self.path}.old")
except Exception as e:
# if we cannot backup then its not safe to attempt a repair
# since it could be due to a filesystem error
log.debug(f"PersistentQueue:{self.identifier} backup failed, skipping repair")
return
if "gnu dbm" in db_type:
# perform gdbm repair
log_prefix = f"PersistentQueue:{self.identifier} repair (dbm/file)"
log.debug(f"{log_prefix} started")
try:
result = subprocess.run(
["gdbmtool", self.path],
input="recover verbose summary\n",
text=True,
capture_output=True,
timeout=60
)
log.debug(f"{log_prefix} {result.stdout}")
if result.stderr:
log.debug(f"{log_prefix} failed: {result.stderr}")
except FileNotFoundError:
log.debug(f"{log_prefix} failed: 'gdbmtool' was not found")
# perform null key cleanup
log_prefix = f"PersistentQueue:{self.identifier} repair (null keys)"
log.debug(f"{log_prefix} started")
deleted = 0
try:
with dbm.open(self.path, "w") as db:
for key in list(db.keys()):
if len(key) > 0 and all(b == 0x00 for b in key):
log.debug(f"{log_prefix} deleting key of length {len(key)} (all NUL bytes)")
del db[key]
deleted += 1
log.debug(f"{log_prefix} done - deleted {deleted} key(s)")
except dbm.error:
log.debug(f"{log_prefix} failed: db type is dbm.gnu, but the module is not available (dbm.error; module support may be missing or the file may be corrupted)")
elif "sqlite" in db_type:
# perform sqlite3 recovery
log_prefix = f"PersistentQueue:{self.identifier} repair (sqlite3/file)"
log.debug(f"{log_prefix} started")
try:
recover_proc = subprocess.Popen(
["sqlite3", self.path, ".recover"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
run_result = subprocess.run(
["sqlite3", f"{self.path}.tmp"],
stdin=recover_proc.stdout,
capture_output=True,
text=True,
timeout=60,
)
if recover_proc.stdout is not None:
recover_proc.stdout.close()
recover_stderr = recover_proc.stderr.read() if recover_proc.stderr is not None else ""
recover_proc.wait(timeout=60)
if run_result.stderr or recover_stderr:
error_text = " ".join(part for part in [recover_stderr.strip(), run_result.stderr.strip()] if part)
log.debug(f"{log_prefix} failed: {error_text}")
else:
shutil.move(f"{self.path}.tmp", self.path)
log.debug(f"{log_prefix}{run_result.stdout or ' was successful, no output'}")
except FileNotFoundError:
log.debug(f"{log_prefix} failed: 'sqlite3' was not found")
except subprocess.TimeoutExpired:
log.debug(f"{log_prefix} failed: sqlite recovery timed out")
class DownloadQueue:
def __init__(self, config, notifier):
self.config = config
@@ -715,16 +830,18 @@ 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)
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')
@@ -752,6 +869,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen=None,
):
@@ -784,6 +903,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen,
)
@@ -793,8 +914,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')
@@ -806,6 +928,12 @@ 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
# 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"):
if property in entry:
etr[f"{etype}_{property}"] = entry[property]
@@ -824,6 +952,8 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen,
)
@@ -855,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'}
@@ -875,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
@@ -910,10 +1044,52 @@ class DownloadQueue:
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
_add_gen,
)
async def add_entry(
self,
entry,
download_type,
codec,
format,
quality,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start=True,
split_by_chapters=False,
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()
return await self.__add_entry(
normalized_entry,
download_type,
codec,
format,
quality,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
chapter_template,
subtitle_language,
subtitle_mode,
ytdl_options_preset,
ytdl_options_overrides,
already,
None,
)
async def start_pending(self, ids):
for id in ids:
if not self.pending.exists(id):
+9
View File
@@ -15,4 +15,13 @@ dependencies = [
[dependency-groups]
dev = [
"pylint",
"pytest>=8.0",
"pytest-aiohttp>=1.0",
"pytest-asyncio>=0.24",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["app/tests"]
pythonpath = [".", "app"]
addopts = "-v"
+14 -14
View File
@@ -23,21 +23,21 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^21.2.4",
"@angular/common": "^21.2.4",
"@angular/compiler": "^21.2.4",
"@angular/core": "^21.2.4",
"@angular/forms": "^21.2.4",
"@angular/platform-browser": "^21.2.4",
"@angular/platform-browser-dynamic": "^21.2.4",
"@angular/service-worker": "^21.2.4",
"@angular/animations": "^21.2.7",
"@angular/common": "^21.2.7",
"@angular/compiler": "^21.2.7",
"@angular/core": "^21.2.7",
"@angular/forms": "^21.2.7",
"@angular/platform-browser": "^21.2.7",
"@angular/platform-browser-dynamic": "^21.2.7",
"@angular/service-worker": "^21.2.7",
"@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.5.2",
"@ng-select/ng-select": "^21.7.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"ngx-cookie-service": "^21.3.1",
@@ -48,16 +48,16 @@
},
"devDependencies": {
"@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.2.2",
"@angular/cli": "^21.2.2",
"@angular/compiler-cli": "^21.2.4",
"@angular/localize": "^21.2.4",
"@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.7",
"@angular/localize": "^21.2.7",
"@eslint/js": "^9.39.4",
"angular-eslint": "21.1.0",
"eslint": "^9.39.4",
"jsdom": "^27.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "8.47.0",
"vitest": "^4.1.0"
"vitest": "^4.1.2"
}
}
+658 -671
View File
File diff suppressed because it is too large Load Diff
+341 -135
View File
@@ -89,15 +89,7 @@
<!-- Main URL Input with Download Button -->
<div class="row mb-4">
<div class="col">
<div class="input-group input-group-lg shadow-sm">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
name="addUrl"
[(ngModel)]="addUrl"
[disabled]="addInProgress || downloads.loading">
<ng-template #urlBarActions>
@if (addInProgress && cancelRequested) {
<button class="btn btn-warning btn-lg px-3" type="button" disabled>
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
@@ -115,13 +107,54 @@
title="Cancel adding URL">
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
</button>
} @else if (subscribeInProgress) {
<button class="btn btn-primary btn-lg px-4" type="button" disabled>
Download
</button>
<button class="btn btn-outline-secondary btn-lg px-3" type="button" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Subscribing...
</button>
} @else {
<button class="btn btn-primary btn-lg px-4" type="submit"
(click)="addDownload()"
[disabled]="downloads.loading">
Download
</button>
<button class="btn btn-outline-secondary btn-lg px-3" type="button"
(click)="addSubscription()"
[disabled]="downloads.loading">
Subscribe
</button>
}
</ng-template>
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) -->
<div class="vstack gap-2 d-md-none">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<div class="btn-group w-100" role="group" aria-label="Download or subscribe">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
<!-- md and up: standard input-group so Bootstrap handles fused borders -->
<div class="input-group input-group-lg shadow-sm d-none d-md-flex">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
</div>
@@ -136,7 +169,7 @@
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
@@ -150,7 +183,7 @@
name="codec"
[(ngModel)]="codec"
(change)="codecChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (vc of videoCodecs; track vc.id) {
<option [ngValue]="vc.id">{{ vc.text }}</option>
}
@@ -164,7 +197,7 @@
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
@@ -178,7 +211,7 @@
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading || !showQualitySelector()">
[disabled]="addInProgress || subscribeInProgress || downloads.loading || !showQualitySelector()">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
@@ -193,7 +226,7 @@
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
@@ -207,7 +240,7 @@
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
@@ -221,7 +254,7 @@
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
@@ -229,28 +262,29 @@
</div>
</div>
} @else if (downloadType === 'captions') {
<div class="col-md-3">
<!-- 4× col-md-3 is too tight at ~768px (long addons wrap the 4th field); 2×2 mdlg, one row lg+ -->
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Subtitle output format for captions mode">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
@@ -258,7 +292,7 @@
</select>
</div>
</div>
<div class="col-md-3">
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text">Language</span>
<input class="form-control"
@@ -267,7 +301,7 @@
name="subtitleLanguage"
[(ngModel)]="subtitleLanguage"
(change)="subtitleLanguageChanged()"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
placeholder="e.g. en, es, zh-Hans"
ngbTooltip="Subtitle language (you can type any language code)">
<datalist id="subtitleLanguageOptions">
@@ -277,14 +311,14 @@
</datalist>
</div>
</div>
<div class="col-md-3">
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text">Subtitle Source</span>
<select class="form-select"
name="subtitleMode"
[(ngModel)]="subtitleMode"
(change)="subtitleModeChanged()"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
@for (mode of subtitleModes; track mode.id) {
<option [ngValue]="mode.id">{{ mode.text }}</option>
@@ -300,7 +334,7 @@
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
@@ -335,23 +369,10 @@
<div class="row">
<div class="col-12">
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
<div class="py-2">
<!-- Advanced Settings -->
<div class="row g-3 mb-2">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Start</span>
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="pt-1 pb-2">
<!-- Output -->
<div class="settings-section-label">Output</div>
<div class="row g-3">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Download Folder</span>
@@ -362,7 +383,7 @@
addTagText="Create directory"
bindLabel="folder"
[(ngModel)]="folder"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
[virtualScroll]="true"
[clearable]="true"
[loading]="downloads.loading"
@@ -371,7 +392,6 @@
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
}
</div>
</div>
<div class="col-md-6">
<div class="input-group">
@@ -381,10 +401,52 @@
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Add a prefix to downloaded filenames">
</div>
</div>
<div class="col-12">
<div class="row g-2 align-items-center">
<div class="col-auto">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Split video into separate files by chapters">
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
</div>
</div>
@if (splitByChapters) {
<div class="col">
<div class="input-group">
<span class="input-group-text">Template</span>
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Output template for chapter files">
</div>
</div>
}
</div>
</div>
</div>
<!-- Behavior -->
<div class="settings-section-label">Behavior</div>
<div class="row g-3">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Start</span>
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Items Limit</span>
@@ -395,104 +457,127 @@
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
</div>
</div>
<div class="col-12">
<div class="row g-2 align-items-center">
<div class="col-auto">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Split video into separate files by chapters">
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
</div>
</div>
@if (splitByChapters) {
<div class="col">
<div class="input-group">
<span class="input-group-text">Template</span>
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
(change)="chapterTemplateChanged()" [disabled]="addInProgress || downloads.loading"
ngbTooltip="Output template for chapter files">
</div>
</div>
}
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Subscription Check (min)</span>
<input type="number"
min="1"
class="form-control"
name="checkIntervalMinutes"
(keydown)="isNumber($event)"
[(ngModel)]="checkIntervalMinutes"
(ngModelChange)="checkIntervalChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="How often to poll subscriptions for new videos">
</div>
</div>
</div>
<!-- Advanced Actions -->
<div class="row">
<div class="col-12">
<hr class="my-3">
<div class="row g-3">
<div class="col-md-4">
<div class="action-group-label">Cookies</div>
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
(change)="onCookieFileSelect($event)"
[disabled]="cookieUploadInProgress || addInProgress">
<div class="btn-group w-100" role="group">
<label class="btn mb-0"
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
[class.disabled]="cookieUploadInProgress || addInProgress"
for="cookie-upload"
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
@if (cookieUploadInProgress) {
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
} @else {
<fa-icon [icon]="faUpload" class="me-2" />
}
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
</label>
@if (hasCookies) {
<button type="button" class="btn btn-outline-danger"
(click)="deleteCookies()"
[disabled]="cookieUploadInProgress || addInProgress"
ngbTooltip="Remove uploaded cookies">
<fa-icon [icon]="faTrashAlt" />
</button>
}
</div>
<div class="cookie-status" [class.active]="hasCookies">
@if (hasCookies) {
<fa-icon [icon]="faCheckCircle" class="me-1" />
Cookies active
} @else {
No cookies configured
}
</div>
<!-- yt-dlp -->
<div class="settings-section-label">yt-dlp</div>
<div class="row g-3">
<div class="col-12" [class.col-md-6]="allowYtdlOptionsOverrides()">
<div class="input-group">
<span class="input-group-text">Option Preset</span>
<select class="form-select"
name="ytdlOptionsPreset"
[(ngModel)]="ytdlOptionsPreset"
(change)="ytdlOptionsPresetChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Choose a named yt-dlp option preset configured on the server">
<option value="">Default</option>
@for (preset of ytdlOptionPresetNames; track preset) {
<option [value]="preset">{{ preset }}</option>
}
</select>
</div>
</div>
@if (allowYtdlOptionsOverrides()) {
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Custom yt-dlp Options</span>
<input type="text"
class="form-control"
placeholder='e.g. {"writesubtitles": true}'
name="ytdlOptionsOverrides"
[(ngModel)]="ytdlOptionsOverrides"
(change)="ytdlOptionsOverridesChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Optional per-download yt-dlp overrides as a JSON object">
</div>
</div>
}
</div>
<!-- Tools -->
<div class="settings-section-label">Tools</div>
<div class="row g-3">
<div class="col-md-4">
<div class="action-group-label">Cookies</div>
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
(change)="onCookieFileSelect($event)"
[disabled]="cookieUploadInProgress || addInProgress">
<div class="btn-group w-100" role="group">
<label class="btn mb-0"
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
[class.disabled]="cookieUploadInProgress || addInProgress"
for="cookie-upload"
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
@if (cookieUploadInProgress) {
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
} @else {
<fa-icon [icon]="faUpload" class="me-2" />
}
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
</label>
@if (hasCookies) {
<button type="button" class="btn btn-outline-danger"
(click)="deleteCookies()"
[disabled]="cookieUploadInProgress || addInProgress"
ngbTooltip="Remove uploaded cookies">
<fa-icon [icon]="faTrashAlt" />
</button>
}
</div>
<div class="cookie-status" [class.active]="hasCookies">
@if (hasCookies) {
<fa-icon [icon]="faCheckCircle" class="me-1" />
Cookies active
} @else {
No cookies configured
}
</div>
</div>
<div class="col-md-8">
<div class="action-group-label">Bulk Actions</div>
<div class="row g-2">
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2" />
Import URLs
</button>
</div>
<div class="col-md-8">
<div class="action-group-label">Bulk Actions</div>
<div class="row g-2">
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2" />
Import URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2" />
Export URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2" />
Copy URLs
</button>
</div>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2" />
Export URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2" />
Copy URLs
</button>
</div>
</div>
</div>
@@ -745,6 +830,127 @@
</tbody>
</table>
</div>
<div class="metube-section-header">Subscriptions</div>
<div class="px-2 py-3 border-bottom">
@if (checkingAllSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkAllSubscriptions()"
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check all now
</button>
}
@if (checkingSelectedSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check selected
</button>
}
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="deleteSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
<fa-icon [icon]="faTrashAlt" />&nbsp; Delete selected
</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<input type="checkbox" class="form-check-input"
[checked]="allSubsSelected()"
(change)="toggleSubMaster($event)"
[disabled]="downloads.loading || cachedSubs.length === 0"
aria-label="Select all subscriptions" />
</th>
<th scope="col">Name</th>
<th scope="col">URL</th>
<th scope="col" class="text-nowrap">Interval (min)</th>
<th scope="col" class="text-nowrap">Last checked</th>
<th scope="col">Status</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@for (entry of cachedSubs; track entry[0]) {
<tr>
<td>
<input type="checkbox" class="form-check-input"
[checked]="isSubSelected(entry[0])"
(change)="toggleSubSelected(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Select subscription ' + entry[1].name" />
</td>
<td>{{ entry[1].name }}</td>
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
<td>{{ entry[1].check_interval_minutes }}</td>
<td class="text-nowrap">
@if (entry[1].last_checked !== null) {
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
} @else {
<span class="text-muted"></span>
}
</td>
<td>
@if (entry[1].error) {
<span class="text-danger small">{{ entry[1].error }}</span>
} @else if (entry[1].enabled) {
<span class="text-success">Active</span>
} @else {
<span class="text-secondary">Paused</span>
}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
@if (isSubscriptionChecking(entry[0])) {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
disabled
[attr.aria-label]="'Checking ' + entry[1].name"
ngbTooltip="Checking now">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
} @else {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="checkSubscriptionNow(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Check now ' + entry[1].name"
ngbTooltip="Check now">
<fa-icon [icon]="faRedoAlt" />
</button>
}
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="toggleSubscriptionEnabled(entry[1])"
[disabled]="downloads.loading"
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
@if (entry[1].enabled) {
<fa-icon [icon]="faPause" />
} @else {
<fa-icon [icon]="faPlay" />
}
</button>
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
(click)="deleteSubscription(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Delete subscription ' + entry[1].name">
<fa-icon [icon]="faTrashAlt" />
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5">
+12
View File
@@ -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
+159 -14
View File
@@ -1,26 +1,128 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { Subject, of } from 'rxjs';
import { App } from './app';
import { DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { CookieService } from 'ngx-cookie-service';
vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
class DownloadsServiceStub {
loading = false;
queue = new Map();
done = new Map();
configuration: Record<string, unknown> = { CUSTOM_DIRS: true, CREATE_CUSTOM_DIRS: true, ALLOW_YTDL_OPTIONS_OVERRIDES: false };
customDirs = { download_dir: [], audio_download_dir: [] };
queueChanged = new Subject<void>();
doneChanged = new Subject<void>();
configurationChanged = new Subject<Record<string, unknown>>();
customDirsChanged = new Subject<Record<string, string[]>>();
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
updated = new Subject<void>();
getCookieStatus() {
return of({ status: 'ok', has_cookies: false });
}
getPresets() {
return of({ presets: ['Preset A'] });
}
add() {
return of({ status: 'ok' as const });
}
cancelAdd() {
return of({ status: 'ok' as const });
}
startById() {
return of({});
}
delById() {
return of({});
}
delByFilter() {
return of({});
}
startByFilter() {
return of({});
}
uploadCookies() {
return of({ status: 'ok' });
}
deleteCookies() {
return of({ status: 'ok' });
}
}
class SubscriptionsServiceStub {
subscriptions = new Map();
subscriptionsChanged = new Subject<void>();
subscribe() {
return of({ status: 'ok' as const });
}
delete() {
return of({});
}
refreshList() {
return of([]);
}
}
class CookieServiceStub {
private cookies = new Map<string, string>();
get(name: string) {
return this.cookies.get(name) ?? '';
}
set(name: string, value: string) {
this.cookies.set(name, value);
}
check(name: string) {
return this.cookies.has(name);
}
}
describe('App', () => {
let downloads: DownloadsServiceStub;
beforeEach(async () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
downloads = new DownloadsServiceStub();
await TestBed.configureTestingModule({
imports: [App],
providers: [
{ provide: DownloadsService, useValue: downloads },
{ provide: SubscriptionsService, useClass: SubscriptionsServiceStub },
{ provide: CookieService, useClass: CookieServiceStub },
{
provide: HttpClient,
useValue: {
get: vi.fn().mockReturnValue(of({ 'yt-dlp': 'test', version: 'test' })),
},
},
],
}).compileComponents();
});
@@ -30,4 +132,47 @@ describe('App', () => {
expect(app).toBeTruthy();
});
it('hides manual override input when disabled', () => {
const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true;
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).toBeNull();
const presetWrapper = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.col-12');
expect(presetWrapper?.classList.contains('col-md-6')).toBe(false);
const presetRow = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.row');
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
});
it('shows manual override input when enabled', () => {
downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] = true;
const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true;
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
const presetWrapper = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.col-12');
expect(presetWrapper?.classList.contains('col-md-6')).toBe(true);
const presetRow = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.row');
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
expect(presetRow?.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
});
it('does not submit manual overrides when disabled', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.ytdlOptionsOverrides = '{"exec":"echo hi"}';
const payload = app['buildAddPayload']();
expect(payload.ytdlOptionsOverrides).toBe('');
});
});
+341 -10
View File
@@ -1,16 +1,18 @@
import { AsyncPipe, DatePipe, KeyValuePipe } from '@angular/common';
import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
import { Observable, map, distinctUntilChanged } from 'rxjs';
import { Observable, Subscription, map, distinctUntilChanged, finalize } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { SubscriptionRow } from './interfaces/subscription';
import { Themes } from './theme';
import {
Download,
@@ -36,6 +38,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
NgTemplateOutlet,
KeyValuePipe,
AsyncPipe,
DatePipe,
@@ -53,6 +56,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
})
export class App implements AfterViewInit, OnInit, OnDestroy {
downloads = inject(DownloadsService);
subscriptionsSvc = inject(SubscriptionsService);
private cookieService = inject(CookieService);
private http = inject(HttpClient);
private cdr = inject(ChangeDetectorRef);
@@ -79,8 +83,18 @@ 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;
checkIntervalMinutes = 60;
cachedSubs: [string, SubscriptionRow][] = [];
selectedSubscriptionIds = new Set<string>();
checkingSubscriptionIds = new Set<string>();
checkingAllSubscriptions = false;
checkingSelectedSubscriptions = false;
hasCookies = false;
cookieUploadInProgress = false;
themes: Theme[] = Themes;
@@ -101,6 +115,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
cachedSortedDone: [string, Download][] = [];
lastCopiedErrorId: string | null = null;
private previousDownloadType = 'video';
private addRequestSub?: Subscription;
private selectionsByType: Record<string, {
codec: string;
format: string;
@@ -155,6 +170,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
faChevronRight = faChevronRight;
faChevronDown = faChevronDown;
faUpload = faUpload;
faPause = faPause;
faPlay = faPlay;
subtitleLanguages = [
{ id: 'en', text: 'English' },
{ id: 'ar', text: 'Arabic' },
@@ -217,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)) {
@@ -238,6 +257,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.saveSelection(this.downloadType);
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10);
if (!Number.isNaN(ci) && ci >= 1) {
this.checkIntervalMinutes = ci;
}
this.activeTheme = this.getPreferredTheme(this.cookieService);
// Subscribe to download updates
@@ -255,6 +278,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.updateMetrics();
this.cdr.markForCheck();
});
this.subscriptionsSvc.subscriptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.rebuildCachedSubs();
this.cdr.markForCheck();
});
}
ngOnInit() {
@@ -264,6 +292,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
});
this.getConfiguration();
this.getYtdlOptionsUpdateTime();
this.getYtdlOptionPresets();
this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme!);
@@ -286,6 +315,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
ngOnDestroy() {
this.addRequestSub?.unsubscribe();
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
}
@@ -326,6 +356,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return this.downloads.configuration['CUSTOM_DIRS'];
}
allowYtdlOptionsOverrides() {
return this.downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] === true;
}
allowCustomDir(tag: string) {
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
return tag;
@@ -380,11 +414,278 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
if (!this.chapterTemplate) {
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
}
if (!this.cookieService.check('metube_check_interval')) {
const dci = parseInt(String(config['SUBSCRIPTION_DEFAULT_CHECK_INTERVAL'] ?? 60), 10);
if (!Number.isNaN(dci) && dci >= 1) {
this.checkIntervalMinutes = dci;
}
}
this.cdr.markForCheck();
}
});
}
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 {
if (!this.allowYtdlOptionsOverrides()) {
return true;
}
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));
for (const id of [...this.selectedSubscriptionIds]) {
if (!validIds.has(id)) {
this.selectedSubscriptionIds.delete(id);
}
}
}
checkIntervalChanged() {
this.cookieService.set('metube_check_interval', String(this.checkIntervalMinutes), {
expires: this.settingsCookieExpiryDays,
});
}
private getStatusError(res: unknown): string | null {
const status = res as { status?: string; msg?: string };
return status?.status === 'error' ? status.msg || null : null;
}
private refreshSubscriptionsWithAlert() {
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
const error = this.getStatusError(refreshRes);
if (error) {
alert(error || 'Refresh subscriptions failed');
return;
}
this.cdr.markForCheck();
});
}
isSubSelected(id: string): boolean {
return this.selectedSubscriptionIds.has(id);
}
toggleSubSelected(id: string) {
if (this.selectedSubscriptionIds.has(id)) {
this.selectedSubscriptionIds.delete(id);
} else {
this.selectedSubscriptionIds.add(id);
}
this.cdr.markForCheck();
}
toggleSubMaster(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.selectedSubscriptionIds.clear();
if (checked) {
for (const [id] of this.cachedSubs) {
this.selectedSubscriptionIds.add(id);
}
}
this.cdr.markForCheck();
}
allSubsSelected(): boolean {
if (this.cachedSubs.length === 0) {
return false;
}
return this.cachedSubs.every(([id]) => this.selectedSubscriptionIds.has(id));
}
addSubscription() {
if (this.subscribeInProgress) {
return;
}
const payload = this.buildAddPayload();
if (!payload.url?.trim()) {
alert('Please enter a URL');
return;
}
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)');
return;
}
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return;
}
this.subscribeInProgress = true;
this.subscriptionsSvc
.subscribe({
...payload,
checkIntervalMinutes: this.checkIntervalMinutes,
})
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.subscribeInProgress = false;
this.cdr.markForCheck();
}),
)
.subscribe({
next: (res) => {
const r = res as { status?: string; msg?: string };
if (r.status === 'error') {
alert(r.msg || 'Subscribe failed');
} else {
this.addUrl = '';
}
},
});
}
deleteSubscription(id: string) {
this.subscriptionsSvc.delete([id]).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Delete subscription failed');
return;
}
this.selectedSubscriptionIds.delete(id);
this.cdr.markForCheck();
});
}
deleteSelectedSubscriptions() {
const ids = Array.from(this.selectedSubscriptionIds);
if (!ids.length) {
return;
}
this.subscriptionsSvc.delete(ids).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Delete subscriptions failed');
return;
}
this.selectedSubscriptionIds.clear();
this.cdr.markForCheck();
});
}
checkSubscriptionNow(id: string) {
if (this.checkingSubscriptionIds.has(id)) {
return;
}
this.checkingSubscriptionIds.add(id);
this.cdr.markForCheck();
this.subscriptionsSvc
.checkNow([id])
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.checkingSubscriptionIds.delete(id);
this.cdr.markForCheck();
}),
)
.subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Subscription check failed');
return;
}
this.refreshSubscriptionsWithAlert();
});
}
isSubscriptionChecking(id: string): boolean {
return this.checkingSubscriptionIds.has(id);
}
private runBulkSubscriptionCheck(ids: string[] | undefined, mode: 'all' | 'selected') {
const targetIds = ids ?? this.cachedSubs.filter(([, row]) => row.enabled).map(([id]) => id);
if (!targetIds.length) {
return;
}
const checkedIds = new Set(targetIds);
for (const id of checkedIds) {
this.checkingSubscriptionIds.add(id);
}
if (mode === 'all') {
this.checkingAllSubscriptions = true;
} else {
this.checkingSelectedSubscriptions = true;
}
this.cdr.markForCheck();
this.subscriptionsSvc
.checkNow(ids)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
for (const id of checkedIds) {
this.checkingSubscriptionIds.delete(id);
}
if (mode === 'all') {
this.checkingAllSubscriptions = false;
} else {
this.checkingSelectedSubscriptions = false;
}
this.cdr.markForCheck();
}),
)
.subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Subscription check failed');
return;
}
this.refreshSubscriptionsWithAlert();
});
}
checkSelectedSubscriptions() {
const ids = Array.from(this.selectedSubscriptionIds);
if (!ids.length) {
return;
}
this.runBulkSubscriptionCheck(ids, 'selected');
}
checkAllSubscriptions() {
this.runBulkSubscriptionCheck(undefined, 'all');
}
toggleSubscriptionEnabled(row: SubscriptionRow) {
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Update subscription failed');
}
});
}
getPreferredTheme(cookieService: CookieService) {
let theme = 'auto';
if (cookieService.check('metube_theme')) {
@@ -443,6 +744,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';
}
@@ -474,13 +783,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
queueSelectionChanged(checked: number) {
this.queueDelSelected().nativeElement.disabled = checked == 0;
this.queueDownloadSelected().nativeElement.disabled = checked == 0;
this.queueDelSelected().nativeElement.disabled = checked === 0;
this.queueDownloadSelected().nativeElement.disabled = checked === 0;
}
doneSelectionChanged(checked: number) {
this.doneDelSelected().nativeElement.disabled = checked == 0;
this.doneDownloadSelected().nativeElement.disabled = checked == 0;
this.doneDelSelected().nativeElement.disabled = checked === 0;
this.doneDownloadSelected().nativeElement.disabled = checked === 0;
}
private updateDoneActionButtons() {
@@ -628,6 +937,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
private buildAddPayload(overrides: Partial<AddDownloadPayload> = {}): AddDownloadPayload {
const allowYtdlOptionsOverrides = this.allowYtdlOptionsOverrides();
return {
url: overrides.url ?? this.addUrl,
downloadType: overrides.downloadType ?? this.downloadType,
@@ -642,6 +952,10 @@ 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: allowYtdlOptionsOverrides
? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides)
: '',
};
}
@@ -653,30 +967,45 @@ 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;
this.cancelRequested = false;
this.downloads.add(payload).subscribe((status: Status) => {
this.addRequestSub?.unsubscribe();
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
if (status.status === 'error' && !this.cancelRequested) {
alert(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') {
this.addUrl = '';
}
this.addInProgress = false;
this.cancelRequested = false;
this.resetAddState();
});
}
cancelAdding() {
this.cancelRequested = true;
this.downloads.cancelAdd().subscribe({
next: () => {
this.addRequestSub?.unsubscribe();
this.resetAddState();
},
error: (err) => {
this.cancelRequested = false;
console.error('Failed to cancel adding:', err?.message || err);
}
});
}
private resetAddState() {
this.addRequestSub = undefined;
this.addInProgress = false;
this.cancelRequested = false;
this.cdr.markForCheck();
}
downloadItemByKey(id: string) {
this.downloads.startById([id]).subscribe();
}
@@ -696,6 +1025,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();
}
@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { SelectAllCheckboxComponent } from './master-checkbox.component';
import { Checkable } from '../interfaces';
describe('SelectAllCheckboxComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SelectAllCheckboxComponent],
}).compileComponents();
});
it('clicked sets checked on all list items', () => {
const fixture = TestBed.createComponent(SelectAllCheckboxComponent);
const list = new Map<string, Checkable>();
list.set('u1', { checked: false });
fixture.componentRef.setInput('id', 'queue');
fixture.componentRef.setInput('list', list);
fixture.componentInstance.selected = true;
fixture.detectChanges();
fixture.componentInstance.clicked();
expect(list.get('u1')?.checked).toBe(true);
});
});
@@ -33,7 +33,7 @@ export class SelectAllCheckboxComponent {
return;
let checked = 0;
this.list().forEach(item => { if(item.checked) checked++ });
this.selected = checked > 0 && checked == this.list().size;
this.selected = checked > 0 && checked === this.list().size;
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
this.changed.emit(checked);
}
@@ -0,0 +1,25 @@
import { TestBed } from '@angular/core/testing';
import { SelectAllCheckboxComponent } from './master-checkbox.component';
import { ItemCheckboxComponent } from './slave-checkbox.component';
describe('ItemCheckboxComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ItemCheckboxComponent, SelectAllCheckboxComponent],
}).compileComponents();
});
it('creates with master and checkable inputs', () => {
const masterFixture = TestBed.createComponent(SelectAllCheckboxComponent);
masterFixture.componentRef.setInput('id', 'q');
masterFixture.componentRef.setInput('list', new Map());
masterFixture.detectChanges();
const itemFixture = TestBed.createComponent(ItemCheckboxComponent);
itemFixture.componentRef.setInput('id', 'row1');
itemFixture.componentRef.setInput('master', masterFixture.componentInstance);
itemFixture.componentRef.setInput('checkable', { checked: false });
itemFixture.detectChanges();
expect(itemFixture.componentInstance).toBeTruthy();
});
});
+2
View File
@@ -14,6 +14,8 @@ export interface Download {
chapter_template?: string;
subtitle_language?: string;
subtitle_mode?: string;
ytdl_options_preset?: string;
ytdl_options_overrides?: Record<string, unknown>;
status: string;
msg: string;
percent: number;
+1 -1
View File
@@ -6,4 +6,4 @@ export * from './download';
export * from './checkable';
export * from './format';
export * from './formats';
export * from './subscription';
+15
View File
@@ -0,0 +1,15 @@
export interface SubscriptionRow {
id: string;
name: string;
url: string;
enabled: boolean;
check_interval_minutes: number;
download_type: string;
codec: string;
format: string;
quality: string;
folder: string;
last_checked: number | null;
seen_count: number;
error: string | null;
}
+26
View File
@@ -0,0 +1,26 @@
import { EtaPipe } from './eta.pipe';
describe('EtaPipe', () => {
it('returns null for null input', () => {
const pipe = new EtaPipe();
expect(pipe.transform(null as unknown as number)).toBeNull();
});
it('formats seconds under one minute', () => {
const pipe = new EtaPipe();
expect(pipe.transform(0)).toBe('0s');
expect(pipe.transform(59)).toBe('59s');
});
it('formats minutes and seconds', () => {
const pipe = new EtaPipe();
expect(pipe.transform(60)).toBe('1m 0s');
expect(pipe.transform(90)).toBe('1m 30s');
});
it('formats hours', () => {
const pipe = new EtaPipe();
expect(pipe.transform(3600)).toBe('1h 0m 0s');
expect(pipe.transform(3661)).toBe('1h 1m 1s');
});
});
+24
View File
@@ -0,0 +1,24 @@
import { FileSizePipe } from './file-size.pipe';
describe('FileSizePipe', () => {
it('returns 0 Bytes for zero or NaN', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(0)).toBe('0 Bytes');
expect(pipe.transform(Number.NaN)).toBe('0 Bytes');
});
it('formats bytes and larger units', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(500)).toContain('Bytes');
expect(pipe.transform(1000)).toContain('KB');
expect(pipe.transform(1000 * 1000)).toContain('MB');
expect(pipe.transform(1000 ** 3)).toContain('GB');
});
it('handles boundaries between units', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(999)).toContain('Bytes');
expect(pipe.transform(1000)).toContain('KB');
expect(pipe.transform(1001)).toContain('KB');
});
});
+6
View File
@@ -12,4 +12,10 @@ describe('SpeedPipe', () => {
expect(pipe.transform(1024)).toBe('1 KB/s');
expect(pipe.transform(1536)).toBe('1.5 KB/s');
});
it('formats MB/s and GB/s', () => {
const pipe = new SpeedPipe();
expect(pipe.transform(1024 * 1024)).toBe('1 MB/s');
expect(pipe.transform(1024 * 1024 * 1024)).toBe('1 GB/s');
});
});
@@ -0,0 +1,292 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient, HttpErrorResponse } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Subject } from 'rxjs';
import { DownloadsService, AddDownloadPayload } from './downloads.service';
import { MeTubeSocket } from './metube-socket.service';
import { Download } from '../interfaces';
class MeTubeSocketStub {
private subjects: Record<string, Subject<string>> = {};
fromEvent(event: string) {
if (!this.subjects[event]) {
this.subjects[event] = new Subject<string>();
}
return this.subjects[event].asObservable();
}
emit(event: string, data: string) {
if (!this.subjects[event]) {
this.subjects[event] = new Subject<string>();
}
this.subjects[event].next(data);
}
}
function basePayload(): AddDownloadPayload {
return {
url: 'https://example.com/v',
downloadType: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
folder: '',
customNamePrefix: '',
playlistItemLimit: 0,
autoStart: true,
splitByChapters: false,
chapterTemplate: '',
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
ytdlOptionsPreset: '',
ytdlOptionsOverrides: '',
};
}
describe('DownloadsService', () => {
let socket: MeTubeSocketStub;
let httpMock: HttpTestingController;
let service: DownloadsService;
beforeEach(async () => {
socket = new MeTubeSocketStub();
await TestBed.configureTestingModule({
providers: [
DownloadsService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MeTubeSocket, useValue: socket },
],
}).compileComponents();
service = TestBed.inject(DownloadsService);
httpMock = TestBed.inject(HttpTestingController);
});
it('add() posts snake_case fields matching backend', () => {
service.add(basePayload()).subscribe();
const req = httpMock.expectOne('add');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(
expect.objectContaining({
url: 'https://example.com/v',
download_type: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
playlist_item_limit: 0,
auto_start: true,
split_by_chapters: false,
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');
expect(req.request.method).toBe('POST');
req.flush({ status: 'ok' });
});
it('startById posts ids', () => {
service.startById(['a', 'b']).subscribe();
const req = httpMock.expectOne('start');
expect(req.request.body).toEqual({ ids: ['a', 'b'] });
req.flush({});
});
it('delById marks items deleting and posts delete', () => {
const dl: Download = {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
deleting: false,
};
service.queue.set('u1', dl);
service.delById('queue', ['u1']).subscribe();
expect(dl.deleting).toBe(true);
const req = httpMock.expectOne('delete');
expect(req.request.body).toEqual({ where: 'queue', ids: ['u1'] });
req.flush({});
});
it('handleHTTPError extracts msg from object body', async () => {
const err = new HttpErrorResponse({
error: { msg: 'bad' },
status: 400,
});
const res = await new Promise((resolve) => {
service.handleHTTPError(err).subscribe(resolve);
});
expect((res as { status: string }).status).toBe('error');
expect((res as { msg?: string }).msg).toBe('bad');
});
it('socket all updates queue and done', () => {
const row: Download = {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
};
const q: [string, Download][] = [['u1', row]];
const d: [string, Download][] = [];
socket.emit('all', JSON.stringify([q, d]));
expect(service.loading).toBe(false);
expect(service.queue.has('u1')).toBe(true);
});
it('socket updated preserves checked and deleting', () => {
service.queue.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: true,
deleting: true,
});
socket.emit(
'updated',
JSON.stringify({ url: 'u1', title: 't', status: 'downloading' }),
);
const updated = service.queue.get('u1');
expect(updated?.checked).toBe(true);
expect(updated?.deleting).toBe(true);
});
it('socket completed moves entry to done', () => {
service.queue.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
expect(service.queue.has('u1')).toBe(false);
expect(service.done.has('u1')).toBe(true);
});
it('socket canceled removes from queue', () => {
service.queue.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
socket.emit('canceled', JSON.stringify('u1'));
expect(service.queue.has('u1')).toBe(false);
});
it('socket cleared removes from done', () => {
service.done.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
socket.emit('cleared', JSON.stringify('u1'));
expect(service.done.has('u1')).toBe(false);
});
it('socket configuration updates configuration', () => {
socket.emit('configuration', JSON.stringify({ CUSTOM_DIRS: true }));
expect(service.configuration['CUSTOM_DIRS']).toBe(true);
});
it('socket custom_dirs updates customDirs', () => {
socket.emit('custom_dirs', JSON.stringify({ download_dir: [''] }));
expect(service.customDirs['download_dir']).toEqual(['']);
});
afterEach(() => {
httpMock.verify();
});
});
+10
View File
@@ -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});
}
@@ -0,0 +1,130 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { of, Subject } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MeTubeSocket } from './metube-socket.service';
import { SubscriptionRow } from '../interfaces/subscription';
import { Status } from '../interfaces';
import { AddDownloadPayload } from './downloads.service';
export interface SubscribePayload extends AddDownloadPayload {
checkIntervalMinutes: number;
}
@Injectable({
providedIn: 'root',
})
export class SubscriptionsService {
private http = inject(HttpClient);
private socket = inject(MeTubeSocket);
private destroyRef = inject(DestroyRef);
subscriptions = new Map<string, SubscriptionRow>();
subscriptionsChanged = new Subject<void>();
private publishList(rows: SubscriptionRow[]) {
this.subscriptions.clear();
for (const row of rows) {
this.subscriptions.set(row.id, row);
}
this.subscriptionsChanged.next();
}
constructor() {
this.socket
.fromEvent('subscriptions_all')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const data: SubscriptionRow[] = JSON.parse(strdata);
this.publishList(data);
});
this.socket
.fromEvent('subscription_added')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const row: SubscriptionRow = JSON.parse(strdata);
this.subscriptions.set(row.id, row);
this.subscriptionsChanged.next();
});
this.socket
.fromEvent('subscription_updated')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const row: SubscriptionRow = JSON.parse(strdata);
this.subscriptions.set(row.id, row);
this.subscriptionsChanged.next();
});
this.socket
.fromEvent('subscription_removed')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const id: string = JSON.parse(strdata);
this.subscriptions.delete(id);
this.subscriptionsChanged.next();
});
}
handleHTTPError(error: HttpErrorResponse) {
const msg =
error.error instanceof ErrorEvent
? error.error.message
: typeof error.error === 'string'
? error.error
: error.error?.msg || error.message || 'Request failed';
return of({ status: 'error' as const, msg });
}
subscribe(payload: SubscribePayload) {
return this.http
.post<Status>('subscribe', {
url: payload.url,
download_type: payload.downloadType,
codec: payload.codec,
quality: payload.quality,
format: payload.format,
folder: payload.folder,
custom_name_prefix: payload.customNamePrefix,
playlist_item_limit: payload.playlistItemLimit,
auto_start: payload.autoStart,
split_by_chapters: payload.splitByChapters,
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)));
}
delete(ids: string[]) {
return this.http.post('subscriptions/delete', { ids }).pipe(catchError((err) => this.handleHTTPError(err)));
}
update(id: string, changes: Partial<Pick<SubscriptionRow, 'enabled' | 'check_interval_minutes' | 'name'>>) {
return this.http
.post('subscriptions/update', { id, ...changes })
.pipe(catchError((err) => this.handleHTTPError(err)));
}
checkNow(ids?: string[]) {
return this.http
.post('subscriptions/check', ids?.length ? { ids } : {})
.pipe(catchError((err) => this.handleHTTPError(err)));
}
fetchList() {
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(catchError(() => of([])));
}
refreshList() {
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(
tap((rows) => this.publishList(rows)),
catchError((err) => this.handleHTTPError(err)),
);
}
}
Generated
+213 -127
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -24,59 +24,59 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
]
[[package]]
@@ -93,14 +93,14 @@ wheels = [
[[package]]
name = "anyio"
version = "4.12.1"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
@@ -114,11 +114,11 @@ wheels = [
[[package]]
name = "attrs"
version = "25.4.0"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
@@ -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.5"
version = "2.7.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/31/8bbaf3fb6a41929ae161be0b2a79b2747b5e5490811573ef60af7e3aeac3/deno-2.7.5.tar.gz", hash = "sha256:50635e0462697fa6e79d90bcacbe98e19f785e604c0e5061754de89b3668af83", size = 8166, upload-time = "2026-03-11T12:48:44.286Z" }
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/68/15/47c4b8da4e1b312ab14a2517e3f484c4d67a879cb5099cb6c33b8ce00c8c/deno-2.7.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29cb89cdaea5f36133841fb4da058b1c6cb70d117ebfc7a24c717747b58e8503", size = 46641593, upload-time = "2026-03-11T12:48:16.589Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3a/c3f8842b7499ff3faeb7508711a82b736d3a4c6e0ffb359191386bcf539d/deno-2.7.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6456980341e97e4eb88e0c560fa57cd1b5f732e0eaadccc6c47d5ada73a71ff3", size = 43537874, upload-time = "2026-03-11T12:48:21.958Z" },
{ url = "https://files.pythonhosted.org/packages/71/a2/53a013ba3509648582748678d5c6980210a45e0913934f91bfe1ec237e07/deno-2.7.5-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:fdc1e647a06ef792643237c030f45295692b0abc05d5bc9894fb11fd70876953", size = 47265090, upload-time = "2026-03-11T12:48:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/3e/85/88c76daa72575f7229bb94191f15f4771f0614227bf8467bfe06e051f4ab/deno-2.7.5-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:c15e6b8ccf5f0808cd5ba243ea4eea7d8d78f6fdff228f5c6c85b96ba286bd3c", size = 49262188, upload-time = "2026-03-11T12:48:32.125Z" },
{ url = "https://files.pythonhosted.org/packages/42/5e/501a92ef93d6d46ed8a1a8c03cff8bcbccbc06c1f59b163113ff09cd23cf/deno-2.7.5-py3-none-win_amd64.whl", hash = "sha256:3e3d06006ee39901dd23068c4a501a4a524fb71c323e22503b1b2ddf236da463", size = 48481169, upload-time = "2026-03-11T12:48:38.684Z" },
{ 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]]
@@ -435,6 +435,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "8.0.1"
@@ -469,6 +478,9 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pylint" },
{ name = "pytest" },
{ name = "pytest-aiohttp" },
{ name = "pytest-asyncio" },
]
[package.metadata]
@@ -482,7 +494,12 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [{ name = "pylint" }]
dev = [
{ name = "pylint" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-aiohttp", specifier = ">=1.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
]
[[package]]
name = "multidict"
@@ -574,6 +591,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.4"
@@ -583,6 +609,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
@@ -691,6 +726,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pylint"
version = "4.0.5"
@@ -709,6 +753,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-aiohttp"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-engineio"
version = "4.13.1"
@@ -736,7 +822,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -744,9 +830,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
@@ -972,11 +1058,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2026.3.13"
version = "2026.3.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/69/59253e5627f583939e742a592f56dc7d7f30d164473e58f055e1fccdc02b/yt_dlp-2026.3.13.tar.gz", hash = "sha256:fb43659db684a3db6ff2f5c92e0f1641262f6ecc71dbb64fefe84177aaba9e36", size = 3117911, upload-time = "2026-03-13T09:02:22.711Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/ef/52ed7ed10d2e1a22badf74b520b617c48b0a725a981620393245ac842bf9/yt_dlp-2026.3.13-py3-none-any.whl", hash = "sha256:e22e7716f94c08e76b29c0172a3fe0c01d8cabab9bce7f528ad440d70a0d213c", size = 3315062, upload-time = "2026-03-13T09:02:20.357Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" },
]
[package.optional-dependencies]
@@ -1000,9 +1086,9 @@ deno = [
[[package]]
name = "yt-dlp-ejs"
version = "0.7.0"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/39/57bc2dedbcd4c921fa740fc99f83ada045a219a0e9bb3283b9ab2102e840/yt_dlp_ejs-0.7.0.tar.gz", hash = "sha256:ecac13eb9ff948da84b39f1030fa03422abaf32dc58a0edd78f5dbcc03843556", size = 95961, upload-time = "2026-03-13T07:34:43.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/e6/cceb9530e8f4e5940f6f7822d90e9d94f1b85343329a16baaf47bbbb3de1/yt_dlp_ejs-0.8.0.tar.gz", hash = "sha256:d5fa1639f63b5c4af8d932495f60689d5370f1a095782c944f7f62a303eb104e", size = 96571, upload-time = "2026-03-17T22:49:19.299Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/f6/54fe93b9db02b7727043fb48816504f09066ca6d7f7d6145cd9d713a1047/yt_dlp_ejs-0.7.0-py3-none-any.whl", hash = "sha256:967e9cbe114ddfd046ff4668af18b1827b4597e2e47a83deea668a355828c798", size = 53444, upload-time = "2026-03-13T07:34:42.195Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/520769863744b669440a924271a6159ddd82ad5ae26b4ac4d4b69e9f8d44/yt_dlp_ejs-0.8.0-py3-none-any.whl", hash = "sha256:79300e5fca7f937a1eeede11f0456862c1b41107ce1d726871e0207424f4bdb4", size = 53443, upload-time = "2026-03-17T22:49:17.736Z" },
]