mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Compare commits
28 Commits
2026.07.21
...
2026.08.15
| Author | SHA1 | Date | |
|---|---|---|---|
| c8fb5bbc27 | |||
| 56194f0bf3 | |||
| de57484fc9 | |||
| 7082858237 | |||
| 97d1cc865c | |||
| 482381d6b9 | |||
| 0445f5858b | |||
| 6551f7ad58 | |||
| 06c63ec6e5 | |||
| d66b04ccf5 | |||
| 2744f36b44 | |||
| ff1b73a576 | |||
| 1a09dbd686 | |||
| 08dccd98fb | |||
| 1f20aaee94 | |||
| 8a29f3a084 | |||
| 1839e5484d | |||
| fceac97033 | |||
| a13762aa61 | |||
| 50250f8374 | |||
| 926d392926 | |||
| 7f13784445 | |||
| 4aa20890d4 | |||
| 4e27600329 | |||
| 1c7261ab59 | |||
| 4cf2b1b0d0 | |||
| f3d670e288 | |||
| edf101faa0 |
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Enable pnpm
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
token: ${{ secrets.AUTOUPDATE_PAT }}
|
||||
-
|
||||
name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
-
|
||||
|
||||
@@ -96,7 +96,23 @@ release the same day. **Master is continuously released** — a PR must be
|
||||
release-ready exactly as merged; there is no stabilization window for follow-up
|
||||
fixes.
|
||||
|
||||
## Code style
|
||||
## Commit messages
|
||||
|
||||
A commit that resolves an issue must close it, with a GitHub closing keyword in
|
||||
parentheses at the end of the subject line:
|
||||
|
||||
```
|
||||
fix: stop metadata probes from writing playlist sidecar files (closes #1040)
|
||||
```
|
||||
|
||||
Because master is the default branch and is released on every push, the issue
|
||||
closes at the moment the fix ships, and keeps a permanent link to the commit that
|
||||
fixed it. A bare `(#1040)` is only a reference — and reads as a pull-request
|
||||
number — so it does not count; the keyword is what closes the issue.
|
||||
|
||||
Auto-closing leaves only a commit stub on the issue, which is not an answer to
|
||||
whoever reported it. Post an explanatory comment as well: what the cause was, what
|
||||
changed, and anything the reporter needs to do differently.
|
||||
|
||||
Follow `.editorconfig`:
|
||||
- Python: 4-space indent
|
||||
|
||||
@@ -10,7 +10,7 @@ Key capabilities:
|
||||
* Download playlists and channels, with configurable output and download options.
|
||||
* [Subscribe](https://github.com/alexta69/metube/wiki/Subscriptions) to channels and playlists, periodically check for new items, and queue new uploads automatically.
|
||||
|
||||

|
||||

|
||||
|
||||
## 🐳 Run using Docker
|
||||
|
||||
@@ -80,9 +80,11 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
* __YTDL_OPTIONS_PRESETS__: Named bundles of yt-dlp options, selectable per download in the UI. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for format and examples.
|
||||
* __YTDL_OPTIONS_PRESETS_FILE__: Path to a JSON file containing presets. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options).
|
||||
* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show a free-text field in the UI for per-download yt-dlp option overrides. Defaults to `false`. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details and security considerations.
|
||||
* __ALLOW_PRIVATE_ADDRESSES__: Whether to allow downloads from private, loopback, link-local and other non-global addresses. Defaults to `false`, which protects against SSRF by refusing URLs that resolve to internal hosts. Set to `true` only in trusted environments — for example when routing traffic through a proxy/VPN client in Fake-IP mode (sing-box, Clash, Mihomo), which resolves hosts to the `198.18.0.0/15` range. Enabling this disables the SSRF protection entirely, so only use it when you control the network.
|
||||
* __ALLOW_PRIVATE_ADDRESSES__: Whether to allow downloads from private, loopback, link-local and other non-global addresses. Defaults to `false`, which protects against SSRF by refusing URLs that resolve to internal hosts. Set to `true` only in trusted environments — for example when routing traffic through a proxy/VPN client in Fake-IP mode (sing-box, Clash, Mihomo), which resolves hosts to the `198.18.0.0/15` range. Enabling this disables the SSRF protection entirely, so only use it when you control the network. You do **not** need this to use a proxy on an internal address: a proxy configured through the `proxy` option in `YTDL_OPTIONS` (or the `*_proxy` environment variables) is always reachable at its own host and port, wherever it lives.
|
||||
* __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled).
|
||||
|
||||
Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a feed-level `.info.json` and thumbnail when you add a playlist or channel. These reuse the template of the items they belong to — `OUTPUT_TEMPLATE_CHANNEL` or `OUTPUT_TEMPLATE_PLAYLIST` — evaluated against the feed itself, so with the defaults they land in the same folder as the videos, named after the feed. Set `allow_playlist_files` to `false` in `YTDL_OPTIONS` to skip them.
|
||||
|
||||
### 🌐 Web Server & URLs
|
||||
|
||||
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
|
||||
|
||||
+19
@@ -893,6 +893,17 @@ async def cancel_add(request):
|
||||
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'retry')
|
||||
async def retry(request):
|
||||
# Singular by design, unlike the 'ids' batch endpoints: a retry re-extracts
|
||||
# the URL, so it can fail per item, and the caller removes that item's done
|
||||
# record only once it is confirmed re-queued. A batch form would have to
|
||||
# report per-id results for the caller to know which ones to remove.
|
||||
post = await _read_json_request(request)
|
||||
status = await dqueue.retry(_require_id(post))
|
||||
return web.Response(text=serializer.encode(status), content_type='application/json')
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||
async def subscribe(request):
|
||||
post = await _read_json_request(request)
|
||||
@@ -985,6 +996,13 @@ async def subscriptions_check(request):
|
||||
result = await submgr.check_now([str(i) for i in ids] if ids else None)
|
||||
return web.Response(text=serializer.encode(result))
|
||||
|
||||
def _require_id(post: dict) -> str:
|
||||
id = post.get('id')
|
||||
if not isinstance(id, str) or not id:
|
||||
raise web.HTTPBadRequest(reason="'id' must be a non-empty string")
|
||||
return id
|
||||
|
||||
|
||||
def _require_id_list(post: dict) -> list:
|
||||
ids = post.get('ids')
|
||||
if not isinstance(ids, list) or not ids or not all(isinstance(i, str) for i in ids):
|
||||
@@ -1227,6 +1245,7 @@ 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 + 'retry', 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)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Conservative music metadata enrichment for audio downloads.
|
||||
|
||||
This module only consumes fields already supplied by yt-dlp or retained on
|
||||
MeTube's queued playlist entry. It intentionally performs no external lookup
|
||||
or site-specific album detection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from yt_dlp.postprocessor.common import PostProcessor
|
||||
|
||||
|
||||
def _has_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
if isinstance(value, (list, tuple)):
|
||||
return any(_has_value(item) for item in value)
|
||||
return value is not None
|
||||
|
||||
|
||||
def _positive_int(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
def _track_position(value: Any) -> tuple[Optional[int], Optional[int]]:
|
||||
"""Return a track number and optional total from a scalar or ``n/total``."""
|
||||
if isinstance(value, str) and '/' in value:
|
||||
number, total = value.split('/', 1)
|
||||
return _positive_int(number.strip()), _positive_int(total.strip())
|
||||
return _positive_int(value), None
|
||||
|
||||
|
||||
def _first_positive_int(*values: Any) -> Optional[int]:
|
||||
return next((number for value in values if (number := _positive_int(value))), None)
|
||||
|
||||
|
||||
def _has_album_signal(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
|
||||
"""Use only extractor-owned fields to identify album-level metadata."""
|
||||
return any(
|
||||
_has_value(entry.get(key))
|
||||
for entry in (info, source_entry)
|
||||
for key in ('album', 'track_number')
|
||||
)
|
||||
|
||||
|
||||
def _is_music_audio(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
|
||||
return _has_album_signal(info, source_entry) or any(
|
||||
_has_value(entry.get(key))
|
||||
for entry in (info, source_entry)
|
||||
for key in ('track', 'artists')
|
||||
)
|
||||
|
||||
|
||||
def prefer_square_thumbnail(info: dict[str, Any]) -> None:
|
||||
"""Move the largest known square thumbnail to yt-dlp's preferred slot."""
|
||||
thumbnails = info.get('thumbnails')
|
||||
if not isinstance(thumbnails, list) or len(thumbnails) < 2:
|
||||
return
|
||||
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for index, thumbnail in enumerate(thumbnails):
|
||||
if not isinstance(thumbnail, dict):
|
||||
continue
|
||||
width = _positive_int(thumbnail.get('width'))
|
||||
height = _positive_int(thumbnail.get('height'))
|
||||
if width is not None and width == height:
|
||||
candidates.append((width * height, index))
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
_, selected_index = max(candidates)
|
||||
selected = thumbnails.pop(selected_index)
|
||||
thumbnails.append(selected)
|
||||
if selected.get('url'):
|
||||
info['thumbnail'] = selected['url']
|
||||
|
||||
|
||||
class MusicMetadataPreProcessor(PostProcessor):
|
||||
"""Enrich extracted audio metadata using extractor-owned album signals."""
|
||||
|
||||
def __init__(self, downloader=None, *, source_entry=None):
|
||||
super().__init__(downloader)
|
||||
self._source_entry = source_entry if isinstance(source_entry, dict) else {}
|
||||
|
||||
def run(self, info):
|
||||
if _has_album_signal(info, self._source_entry):
|
||||
number, inline_total = _track_position(info.get('track_number'))
|
||||
if number is None:
|
||||
number, source_inline_total = _track_position(
|
||||
self._source_entry.get('track_number')
|
||||
)
|
||||
inline_total = inline_total or source_inline_total
|
||||
if number is None:
|
||||
number = _positive_int(self._source_entry.get('playlist_index'))
|
||||
|
||||
total = inline_total or _first_positive_int(
|
||||
info.get('track_count'),
|
||||
info.get('track_total'),
|
||||
self._source_entry.get('track_count'),
|
||||
self._source_entry.get('track_total'),
|
||||
self._source_entry.get('playlist_count'),
|
||||
self._source_entry.get('n_entries'),
|
||||
)
|
||||
if number is not None:
|
||||
info['track_number'] = f'{number}/{total}' if total is not None else number
|
||||
|
||||
if not _has_value(info.get('album')):
|
||||
album = self._source_entry.get('album') or self._source_entry.get(
|
||||
'playlist_title'
|
||||
)
|
||||
if isinstance(album, str) and album.strip():
|
||||
info['album'] = album.strip()
|
||||
|
||||
if _is_music_audio(info, self._source_entry):
|
||||
prefer_square_thumbnail(info)
|
||||
return [], info
|
||||
+33
-2
@@ -64,6 +64,12 @@ def _build_ydl_params(
|
||||
"paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR},
|
||||
**config.YTDL_OPTIONS,
|
||||
**(extra_opts or {}),
|
||||
# A scan is a poll, not an add: it runs on a timer and queues items
|
||||
# through the download queue, which writes the feed metadata itself.
|
||||
# yt-dlp emits the playlist-level infojson/description/thumbnail
|
||||
# regardless of `download`, so without this a writeinfojson user would
|
||||
# get those files rewritten on every check interval. See issue #1040.
|
||||
"allow_playlist_files": False,
|
||||
}
|
||||
params = _impersonate_opt(params)
|
||||
if playlistend is not None and playlistend > 0:
|
||||
@@ -287,6 +293,24 @@ def validate_title_regex(value: Any) -> str:
|
||||
return s
|
||||
|
||||
|
||||
# The name is a display label the user picks; it is persisted and broadcast to
|
||||
# every connected client, so keep it a bounded single-line string.
|
||||
SUBSCRIPTION_NAME_MAX_LENGTH = 200
|
||||
|
||||
|
||||
def validate_subscription_name(value: Any) -> str:
|
||||
"""Return a stored subscription name, or raise ValueError if unusable."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("name must be a string")
|
||||
# Collapse newlines/tabs so a pasted title can't break the table layout.
|
||||
name = " ".join(value.split())
|
||||
if not name:
|
||||
raise ValueError("name must not be empty")
|
||||
if len(name) > SUBSCRIPTION_NAME_MAX_LENGTH:
|
||||
raise ValueError(f"name must be at most {SUBSCRIPTION_NAME_MAX_LENGTH} characters")
|
||||
return name
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> bool:
|
||||
"""Accept JSON booleans and common string forms used by API clients."""
|
||||
if isinstance(value, bool):
|
||||
@@ -674,6 +698,13 @@ class SubscriptionManager:
|
||||
return {"status": "ok"}
|
||||
|
||||
async def update_subscription(self, sub_id: str, changes: dict) -> dict:
|
||||
validated_name: Optional[str] = None
|
||||
if "name" in changes:
|
||||
try:
|
||||
validated_name = validate_subscription_name(changes["name"])
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "msg": str(exc)}
|
||||
|
||||
validated_tr: Optional[str] = None
|
||||
if "title_regex" in changes:
|
||||
try:
|
||||
@@ -722,8 +753,8 @@ class SubscriptionManager:
|
||||
sub.enabled = validated_enabled
|
||||
if interval_set:
|
||||
sub.check_interval_minutes = validated_interval
|
||||
if "name" in changes and changes["name"]:
|
||||
sub.name = str(changes["name"])
|
||||
if validated_name is not None:
|
||||
sub.name = validated_name
|
||||
if validated_tr is not None:
|
||||
sub.title_regex = validated_tr
|
||||
if skip_so_set:
|
||||
|
||||
@@ -20,6 +20,7 @@ def mock_dqueue(monkeypatch):
|
||||
d = MagicMock()
|
||||
d.initialize = AsyncMock(return_value=None)
|
||||
d.add = AsyncMock(return_value={"status": "ok"})
|
||||
d.retry = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||
d.clear = AsyncMock(return_value={"status": "ok"})
|
||||
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||
@@ -69,6 +70,22 @@ async def test_add_ok(mock_dqueue):
|
||||
mock_dqueue.add.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_passes_failed_download_id(mock_dqueue):
|
||||
req = _json_request({"id": "https://example.com/watch?v=1"})
|
||||
resp = await main.retry(req)
|
||||
assert resp.status == 200
|
||||
mock_dqueue.retry.assert_awaited_once_with("https://example.com/watch?v=1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("body", [{}, {"id": ""}, {"id": ["a"]}, {"ids": ["a"]}])
|
||||
async def test_retry_rejects_missing_or_non_string_id(mock_dqueue, body):
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.retry(_json_request(body))
|
||||
mock_dqueue.retry.assert_not_awaited()
|
||||
|
||||
|
||||
@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}})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
@@ -89,7 +90,7 @@ def test_get_returns_tuple_of_lists(dq_env):
|
||||
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -115,11 +116,59 @@ async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_unsupported_url_recorded_as_failed_entry(dq_env):
|
||||
"""An unsupported/unextractable URL must show up as a red-cross entry in the
|
||||
done list, not just a transient toast and a server log line."""
|
||||
import ytdl
|
||||
|
||||
notifier = AsyncMock()
|
||||
url = "https://example.com/not-a-video"
|
||||
|
||||
def boom(self, url, *_args, **_kwargs):
|
||||
raise ytdl.yt_dlp.utils.YoutubeDLError(f'Unsupported URL: {url}')
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||
result = await dq.add(
|
||||
url, "video", "auto", "any", "best", "", "", 0, auto_start=True,
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert dq.done.exists(url)
|
||||
failed = dq.done.get(url)
|
||||
assert failed.info.status == "error"
|
||||
assert failed.info.error == result["msg"]
|
||||
assert failed.info.url == url
|
||||
# The full URL stays in .url/.error for the detail panel; the display
|
||||
# title is shortened to the hostname so the Completed row stays readable.
|
||||
assert failed.info.title == "example.com"
|
||||
notifier.completed.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ssrf_rejected_url_recorded_as_failed_entry(dq_env):
|
||||
"""A URL rejected by the SSRF guard (before yt-dlp ever runs) must also
|
||||
surface as a failed entry, not just an error status returned to the caller."""
|
||||
notifier = AsyncMock()
|
||||
url = "file:///etc/passwd"
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
result = await dq.add(
|
||||
url, "video", "auto", "any", "best", "", "", 0, auto_start=True,
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert dq.done.exists(url)
|
||||
failed = dq.done.get(url)
|
||||
assert failed.info.status == "error"
|
||||
assert failed.info.error == result["msg"]
|
||||
notifier.completed.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_removes_from_pending(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -156,7 +205,7 @@ async def test_cancel_before_start_marks_download_canceled(dq_env):
|
||||
cancelling, because its ``download.canceled`` guard was never flipped."""
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -194,7 +243,7 @@ async def test_cancel_before_start_marks_download_canceled(dq_env):
|
||||
async def test_start_pending_moves_to_queue(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -254,6 +303,179 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_restores_playlist_output_context(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/watch?v=1"
|
||||
failed_info = DownloadInfo(
|
||||
id="vid1",
|
||||
title="Test Video",
|
||||
url=url,
|
||||
quality="best",
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
error="temporary failure",
|
||||
entry={
|
||||
"playlist_index": "01",
|
||||
"playlist_title": "My Playlist",
|
||||
"playlist_count": 10,
|
||||
},
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
)
|
||||
failed_info.status = "error"
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, failed_info))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": extracted_url,
|
||||
"webpage_url": extracted_url,
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
result = await dq.retry(url)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
queued = dq.queue.get(url)
|
||||
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
||||
assert queued.info.entry["playlist_index"] == "01"
|
||||
assert queued.info.entry["playlist_title"] == "My Playlist"
|
||||
|
||||
|
||||
def _failed_playlist_item(url, **overrides):
|
||||
"""A done-list entry for a playlist item that failed mid-download."""
|
||||
info = DownloadInfo(
|
||||
id="vid1",
|
||||
title="Test Video",
|
||||
url=url,
|
||||
quality="best",
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
error="temporary failure",
|
||||
entry={
|
||||
"playlist_index": "01",
|
||||
"playlist_title": "My Playlist",
|
||||
"playlist_count": 10,
|
||||
},
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
**overrides,
|
||||
)
|
||||
info.status = "error"
|
||||
return info
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_keeps_playlist_context_through_url_indirection(dq_env):
|
||||
# extract_flat=True makes yt-dlp hand back url/url_transparent results
|
||||
# unprocessed, so __add_entry recurses into add() a second time. The retry
|
||||
# context has to survive that hop or the item lands in the root directory.
|
||||
notifier = AsyncMock()
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/watch?v=1"
|
||||
resolved = "https://example.com/resolved?v=1"
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url)))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
if extracted_url == url:
|
||||
return {"_type": "url", "url": resolved, "id": "vid1"}
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": extracted_url,
|
||||
"webpage_url": extracted_url,
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
result = await dq.retry(url)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
queued = dq.queue.get(resolved)
|
||||
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
||||
assert queued.info.entry["playlist_title"] == "My Playlist"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_reapplies_current_options_gates(dq_env):
|
||||
# The stored options passed parse_download_options when first submitted, but
|
||||
# the configuration can have changed since; retry must not resurrect
|
||||
# overrides or presets the current configuration no longer allows.
|
||||
notifier = AsyncMock()
|
||||
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = False
|
||||
dq_env.YTDL_OPTIONS_PRESETS = {"Still There": {"writesubtitles": True}}
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/watch?v=1"
|
||||
info = _failed_playlist_item(
|
||||
url,
|
||||
ytdl_options_presets=["Still There", "Removed Preset"],
|
||||
ytdl_options_overrides={"paths": {"home": "/etc"}},
|
||||
)
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": extracted_url,
|
||||
"webpage_url": extracted_url,
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
result = await dq.retry(url)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
queued = dq.queue.get(url)
|
||||
assert queued.info.ytdl_options_overrides == {}
|
||||
assert queued.info.ytdl_options_presets == ["Still There"]
|
||||
assert queued.ytdl_opts.get("paths", {}).get("home") != "/etc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_keeps_overrides_while_still_allowed(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = True
|
||||
dq_env.YTDL_OPTIONS_PRESETS = {}
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/watch?v=1"
|
||||
info = _failed_playlist_item(url, ytdl_options_overrides={"writesubtitles": True})
|
||||
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||
|
||||
def fake_extract(self, extracted_url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": extracted_url,
|
||||
"webpage_url": extracted_url,
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
result = await dq.retry(url)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
|
||||
notifier = AsyncMock()
|
||||
@@ -316,7 +538,7 @@ async def test_channel_download_uses_output_template_when_channel_template_empty
|
||||
|
||||
channel_id = "UCabcd123"
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "playlist",
|
||||
"id": channel_id,
|
||||
@@ -365,7 +587,7 @@ async def test_playlist_download_not_treated_as_channel(dq_env):
|
||||
dq_env.OUTPUT_TEMPLATE_CHANNEL = ""
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "playlist",
|
||||
"id": "PLxyz789",
|
||||
@@ -412,7 +634,7 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
|
||||
"Preset B": {"writesubtitles": False, "ratelimit": 1000},
|
||||
}
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid2",
|
||||
@@ -535,11 +757,191 @@ async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
|
||||
assert captured_params[0]["noplaylist"] is True
|
||||
|
||||
|
||||
def _feed_extract(feed):
|
||||
"""Patch for __extract_info that returns a playlist/channel feed dict."""
|
||||
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return copy.deepcopy(feed)
|
||||
|
||||
return fake_extract
|
||||
|
||||
|
||||
_CHANNEL_FEED = {
|
||||
"_type": "playlist",
|
||||
"id": "UC123",
|
||||
"title": "Vanessa - Videos",
|
||||
"channel": "Vanessa",
|
||||
"channel_id": "UC123",
|
||||
"uploader": "Vanessa",
|
||||
"extractor": "youtube:tab",
|
||||
"extractor_key": "YoutubeTab",
|
||||
"webpage_url": "https://example.com/@vanessa/videos",
|
||||
"entries": [
|
||||
{"id": "v1", "title": "One", "url": "https://example.com/v1",
|
||||
"webpage_url": "https://example.com/v1", "_type": "url"},
|
||||
],
|
||||
}
|
||||
|
||||
_PLAYLIST_FEED = {
|
||||
"_type": "playlist",
|
||||
"id": "PL123",
|
||||
"title": "My Playlist",
|
||||
"extractor": "generic",
|
||||
"extractor_key": "Generic",
|
||||
"webpage_url": "https://example.com/playlist?list=PL123",
|
||||
"entries": [
|
||||
{"id": "v1", "title": "One", "url": "https://example.com/v1",
|
||||
"webpage_url": "https://example.com/v1", "_type": "url"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _written_files(root):
|
||||
found = []
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for f in files:
|
||||
found.append(os.path.relpath(os.path.join(dirpath, f), root))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_feed_metadata_lands_beside_its_items(dq_env):
|
||||
"""Issues #660/#1040: the feed-level .info.json follows the same template
|
||||
the items use, so it sits in the channel's own folder rather than in
|
||||
DOWNLOAD_DIR under yt-dlp's pl_* default name."""
|
||||
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||
dq_env.OUTPUT_TEMPLATE_CHANNEL = "%(channel)s/%(title)s.%(ext)s"
|
||||
|
||||
dq = DownloadQueue(dq_env, AsyncMock())
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_CHANNEL_FEED)), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
result = await dq.add(
|
||||
"https://example.com/@vanessa/videos", "video", "auto", "any", "best",
|
||||
"", "", 0, auto_start=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
||||
os.path.join("Vanessa", "Vanessa - Videos.info.json")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playlist_feed_metadata_uses_the_playlist_template(dq_env):
|
||||
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||
|
||||
dq = DownloadQueue(dq_env, AsyncMock())
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
await dq.add(
|
||||
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||
"", "", 0, auto_start=False,
|
||||
)
|
||||
|
||||
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
||||
os.path.join("My Playlist", "My Playlist.info.json")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feed_metadata_honours_custom_folder(dq_env):
|
||||
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||
|
||||
dq = DownloadQueue(dq_env, AsyncMock())
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
await dq.add(
|
||||
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||
"Music", "", 0, auto_start=False,
|
||||
)
|
||||
|
||||
assert _written_files(dq_env.DOWNLOAD_DIR) == [
|
||||
os.path.join("Music", "My Playlist", "My Playlist.info.json")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_feed_metadata_without_writeinfojson(dq_env):
|
||||
"""Nothing new appears for users who never asked for these files."""
|
||||
dq_env.YTDL_OPTIONS = {}
|
||||
|
||||
dq = DownloadQueue(dq_env, AsyncMock())
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
await dq.add(
|
||||
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||
"", "", 0, auto_start=False,
|
||||
)
|
||||
|
||||
assert _written_files(dq_env.DOWNLOAD_DIR) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feed_metadata_can_be_turned_off_by_the_user(dq_env):
|
||||
dq_env.YTDL_OPTIONS = {"writeinfojson": True, "allow_playlist_files": False}
|
||||
|
||||
dq = DownloadQueue(dq_env, AsyncMock())
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
await dq.add(
|
||||
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||
"", "", 0, auto_start=False,
|
||||
)
|
||||
|
||||
assert _written_files(dq_env.DOWNLOAD_DIR) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feed_metadata_failure_does_not_fail_the_add(dq_env):
|
||||
dq_env.YTDL_OPTIONS = {"writeinfojson": True}
|
||||
|
||||
dq = DownloadQueue(dq_env, AsyncMock())
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", _feed_extract(_PLAYLIST_FEED)), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()), \
|
||||
patch.object(
|
||||
DownloadQueue, "_DownloadQueue__write_feed_metadata_sync",
|
||||
side_effect=OSError("read-only filesystem"),
|
||||
):
|
||||
result = await dq.add(
|
||||
"https://example.com/playlist?list=PL123", "video", "auto", "any", "best",
|
||||
"", "", 0, auto_start=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert dq.pending.exists("https://example.com/v1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_pass_never_writes_feed_metadata(dq_env):
|
||||
"""The classification pass must not produce files: it runs before the add is
|
||||
known to succeed, and yt-dlp writes playlist files regardless of `download`."""
|
||||
dq_env.YTDL_OPTIONS = {"writeinfojson": True, "allow_playlist_files": True}
|
||||
captured: list = []
|
||||
|
||||
class FakeYoutubeDL:
|
||||
def __init__(self, params=None):
|
||||
captured.append(params)
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return {"_type": "video", "id": "v", "title": "V", "url": url, "webpage_url": url}
|
||||
|
||||
dq = DownloadQueue(dq_env, AsyncMock())
|
||||
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
|
||||
await dq.add(
|
||||
"https://example.com/watch?v=1", "video", "auto", "any", "best",
|
||||
"", "", 0, auto_start=False,
|
||||
)
|
||||
|
||||
assert captured[0]["allow_playlist_files"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
def fake_extract(self, url, *_args, **_kwargs):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -898,6 +1300,40 @@ def _make_download(dq_env, *, download_type="video", status="downloading", filen
|
||||
)
|
||||
|
||||
|
||||
def test_download_close_releases_status_queue(dq_env):
|
||||
download = _make_download(dq_env)
|
||||
status_queue = MagicMock()
|
||||
proc = MagicMock()
|
||||
download.status_queue = status_queue
|
||||
download.proc = proc
|
||||
|
||||
download.close()
|
||||
|
||||
proc.close.assert_called_once()
|
||||
assert download.status_queue is None
|
||||
|
||||
|
||||
def test_download_close_releases_status_queue_without_process(dq_env):
|
||||
download = _make_download(dq_env)
|
||||
download.status_queue = MagicMock()
|
||||
|
||||
download.close()
|
||||
|
||||
assert download.status_queue is None
|
||||
|
||||
|
||||
def test_download_close_releases_status_queue_when_process_close_fails(dq_env):
|
||||
download = _make_download(dq_env)
|
||||
download.status_queue = MagicMock()
|
||||
download.proc = MagicMock()
|
||||
download.proc.close.side_effect = RuntimeError('close failed')
|
||||
|
||||
with pytest.raises(RuntimeError, match='close failed'):
|
||||
download.close()
|
||||
|
||||
assert download.status_queue is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_download_cleanup_clears_filename_on_error(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for conservative audio metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from music_metadata import MusicMetadataPreProcessor
|
||||
|
||||
|
||||
def _preprocess(source_entry, info):
|
||||
processor = MusicMetadataPreProcessor(source_entry=source_entry)
|
||||
_, result = processor.run(info)
|
||||
return result
|
||||
|
||||
|
||||
def test_album_uses_existing_order_and_total_when_track_number_is_missing():
|
||||
result = _preprocess(
|
||||
{
|
||||
'playlist_index': '03',
|
||||
'playlist_count': 12,
|
||||
'playlist_title': 'Example Album',
|
||||
},
|
||||
{'title': 'Track', 'album': 'Example Album'},
|
||||
)
|
||||
|
||||
assert result['track_number'] == '3/12'
|
||||
assert result['album'] == 'Example Album'
|
||||
|
||||
|
||||
def test_official_track_number_wins_over_album_order():
|
||||
result = _preprocess(
|
||||
{'playlist_index': 3, 'playlist_count': 12},
|
||||
{'track_number': 7, 'album': 'Official Album'},
|
||||
)
|
||||
|
||||
assert result['track_number'] == '7/12'
|
||||
assert result['album'] == 'Official Album'
|
||||
|
||||
|
||||
def test_inline_official_track_total_is_preserved():
|
||||
result = _preprocess(
|
||||
{'playlist_count': 12},
|
||||
{'track_number': '4/10', 'album': 'Official Album'},
|
||||
)
|
||||
|
||||
assert result['track_number'] == '4/10'
|
||||
|
||||
|
||||
def test_source_track_number_and_total_are_retained_from_flat_extraction():
|
||||
result = _preprocess(
|
||||
{'track_number': 2, 'track_count': 9, 'playlist_index': 4},
|
||||
{'title': 'Track'},
|
||||
)
|
||||
|
||||
assert result['track_number'] == '2/9'
|
||||
|
||||
|
||||
def test_album_title_falls_back_to_source_playlist_title():
|
||||
result = _preprocess(
|
||||
{'playlist_title': 'Example Album'},
|
||||
{'track_number': 4},
|
||||
)
|
||||
|
||||
assert result['album'] == 'Example Album'
|
||||
assert result['track_number'] == 4
|
||||
|
||||
|
||||
def test_playlist_without_extractor_album_signals_is_not_changed():
|
||||
result = _preprocess(
|
||||
{
|
||||
'playlist_index': 3,
|
||||
'playlist_count': 12,
|
||||
'playlist_title': 'Example Playlist',
|
||||
},
|
||||
{'title': 'Track'},
|
||||
)
|
||||
|
||||
assert 'album' not in result
|
||||
assert 'track_number' not in result
|
||||
|
||||
|
||||
def test_regular_video_artwork_is_not_changed():
|
||||
thumbnails = [
|
||||
{'url': 'square.jpg', 'width': 500, 'height': 500},
|
||||
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
|
||||
]
|
||||
result = _preprocess({}, {'title': 'Regular Video', 'thumbnails': thumbnails.copy()})
|
||||
|
||||
assert result['thumbnails'] == thumbnails
|
||||
assert 'thumbnail' not in result
|
||||
|
||||
|
||||
def test_music_audio_prefers_largest_existing_square_thumbnail():
|
||||
result = _preprocess(
|
||||
{},
|
||||
{
|
||||
'track': 'Track',
|
||||
'thumbnails': [
|
||||
{'url': 'small-square.jpg', 'width': 200, 'height': 200},
|
||||
{'url': 'large-square.jpg', 'width': 1000, 'height': 1000},
|
||||
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert result['thumbnails'][-1]['url'] == 'large-square.jpg'
|
||||
assert result['thumbnail'] == 'large-square.jpg'
|
||||
|
||||
|
||||
def test_landscape_only_music_artwork_keeps_existing_order():
|
||||
thumbnails = [
|
||||
{'url': 'small.jpg', 'width': 640, 'height': 360},
|
||||
{'url': 'large.jpg', 'width': 1280, 'height': 720},
|
||||
]
|
||||
result = _preprocess(
|
||||
{},
|
||||
{'track': 'Track', 'thumbnails': thumbnails.copy()},
|
||||
)
|
||||
|
||||
assert result['thumbnails'] == thumbnails
|
||||
assert 'thumbnail' not in result
|
||||
@@ -146,12 +146,12 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
self.assertNotIn("formats", record["entry"])
|
||||
self.assertNotIn("description", record["entry"])
|
||||
|
||||
def test_completed_queue_does_not_persist_entry_or_transient_progress(self):
|
||||
def test_completed_queue_persists_only_failed_retry_context(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.status = "error"
|
||||
info.percent = 88
|
||||
info.speed = 123
|
||||
info.eta = 9
|
||||
@@ -167,12 +167,24 @@ class PersistentQueueTests(unittest.TestCase):
|
||||
payload = json.load(f)
|
||||
|
||||
record = payload["items"][0]["info"]
|
||||
self.assertNotIn("entry", record)
|
||||
self.assertEqual(
|
||||
record["entry"],
|
||||
{
|
||||
"playlist_index": "01",
|
||||
"playlist_title": "Playlist",
|
||||
},
|
||||
)
|
||||
self.assertNotIn("percent", record)
|
||||
self.assertNotIn("speed", record)
|
||||
self.assertNotIn("eta", record)
|
||||
self.assertEqual(record["filename"], "done.mp4")
|
||||
|
||||
info.status = "finished"
|
||||
pq.put(_FakeDownload(info))
|
||||
with open(path + ".json", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
self.assertNotIn("entry", payload["items"][0]["info"])
|
||||
|
||||
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
|
||||
@@ -821,6 +821,76 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(upd["subscription"]["title_regex"], "foo|bar")
|
||||
self.assertEqual(mgr.list_all()[0].title_regex, "foo|bar")
|
||||
|
||||
async def _add_one_subscription(self, mgr):
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
return_value=(
|
||||
{"_type": "channel", "title": "Videos"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/playlist?list=UULFabc",
|
||||
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",
|
||||
)
|
||||
return result["subscription"]["id"]
|
||||
|
||||
async def test_update_subscription_renames(self):
|
||||
"""Issue #1044: UULF-style uploads playlists all come back named 'Videos',
|
||||
so the user needs to be able to relabel them."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
sub_id = await self._add_one_subscription(mgr)
|
||||
self.assertEqual(mgr.list_all()[0].name, "Videos")
|
||||
|
||||
upd = await mgr.update_subscription(sub_id, {"name": " Jane's uploads \n"})
|
||||
self.assertEqual(upd["status"], "ok")
|
||||
# Surrounding and interior whitespace is collapsed to keep the name
|
||||
# a single-line label.
|
||||
self.assertEqual(upd["subscription"]["name"], "Jane's uploads")
|
||||
self.assertEqual(mgr.list_all()[0].name, "Jane's uploads")
|
||||
|
||||
async def test_update_subscription_rename_survives_reload(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = _Config(tmp)
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
sub_id = await self._add_one_subscription(mgr)
|
||||
await mgr.update_subscription(sub_id, {"name": "Renamed"})
|
||||
|
||||
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
self.assertEqual(reloaded.get(sub_id).name, "Renamed")
|
||||
|
||||
async def test_update_subscription_rejects_unusable_name(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
sub_id = await self._add_one_subscription(mgr)
|
||||
|
||||
for bad in ("", " ", "\n\t", 42, None, ["a"], "x" * 201):
|
||||
upd = await mgr.update_subscription(sub_id, {"name": bad})
|
||||
self.assertEqual(upd["status"], "error", f"expected {bad!r} to be rejected")
|
||||
self.assertEqual(mgr.list_all()[0].name, "Videos")
|
||||
|
||||
async def test_update_subscription_accepts_name_at_length_limit(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
sub_id = await self._add_one_subscription(mgr)
|
||||
|
||||
upd = await mgr.update_subscription(sub_id, {"name": "x" * 200})
|
||||
self.assertEqual(upd["status"], "ok")
|
||||
self.assertEqual(mgr.list_all()[0].name, "x" * 200)
|
||||
|
||||
async def test_update_subscription_skip_subscriber_only(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
@@ -1101,6 +1171,44 @@ class SubscriptionScanExtraOptsTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(captured_params[0].get("cookiefile"), "preset.txt")
|
||||
self.assertEqual(captured_params[0].get("extra"), "override")
|
||||
|
||||
async def test_scan_never_writes_playlist_sidecar_files(self):
|
||||
"""A subscription scan is a metadata probe. yt-dlp writes the
|
||||
playlist-level infojson/description/thumbnail regardless of ``download``,
|
||||
so without this a writeinfojson/writethumbnail user would get stray files
|
||||
in DOWNLOAD_DIR on every check interval. Issue #1040."""
|
||||
captured_params: list = []
|
||||
fake_ydl = _make_scan_capturing_fake_ydl(
|
||||
captured_params,
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = _Config(tmp)
|
||||
cfg.YTDL_OPTIONS = {"writeinfojson": True, "writethumbnail": True}
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
|
||||
with patch("subscriptions.yt_dlp.YoutubeDL", fake_ydl, create=True):
|
||||
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",
|
||||
ytdl_options_overrides={"allow_playlist_files": True},
|
||||
)
|
||||
|
||||
self.assertTrue(captured_params)
|
||||
self.assertIs(captured_params[0].get("allow_playlist_files"), False)
|
||||
|
||||
async def test_check_now_scan_applies_stored_subscription_presets(self):
|
||||
entries = [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}]
|
||||
|
||||
|
||||
+183
-16
@@ -10,7 +10,9 @@ import url_guard
|
||||
from url_guard import (
|
||||
validate_url,
|
||||
_address_allowed_at_connect,
|
||||
_address_is_global,
|
||||
_guarded_getaddrinfo,
|
||||
_proxy_endpoint,
|
||||
install_socket_guard,
|
||||
)
|
||||
|
||||
@@ -106,15 +108,32 @@ class AddressResolutionTests(unittest.TestCase):
|
||||
|
||||
|
||||
class ConnectAddressPolicyTests(unittest.TestCase):
|
||||
"""Connect-time policy: allow global + loopback, block everything else."""
|
||||
"""Connect-time policy: allow global, plus anything at a destination the
|
||||
caller has established is the operator's configured proxy."""
|
||||
|
||||
def test_global_allowed(self):
|
||||
self.assertTrue(_address_allowed_at_connect("142.250.1.1"))
|
||||
|
||||
def test_loopback_allowed(self):
|
||||
# Loopback stays reachable so locally-configured proxies keep working.
|
||||
self.assertTrue(_address_allowed_at_connect("127.0.0.1"))
|
||||
self.assertTrue(_address_allowed_at_connect("::1"))
|
||||
def test_loopback_blocked_by_default(self):
|
||||
# A blanket loopback allowance is what let manifest-derived media URLs
|
||||
# reach services on the server's own loopback interface.
|
||||
self.assertFalse(_address_allowed_at_connect("127.0.0.1"))
|
||||
self.assertFalse(_address_allowed_at_connect("::1"))
|
||||
|
||||
def test_loopback_allowed_only_when_opted_in(self):
|
||||
self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("::1", is_proxy_endpoint=True))
|
||||
|
||||
def test_proxy_opt_in_covers_any_internal_range(self):
|
||||
# A proxy is just as legitimately on the LAN or a VPN range as on
|
||||
# loopback (#1055): the allowance follows the operator's configured
|
||||
# endpoint, not a particular address family.
|
||||
self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_proxy_endpoint=True))
|
||||
self.assertTrue(_address_allowed_at_connect("fd00::1", is_proxy_endpoint=True))
|
||||
|
||||
def test_opt_in_still_rejects_non_addresses(self):
|
||||
self.assertFalse(_address_allowed_at_connect("not-an-ip", is_proxy_endpoint=True))
|
||||
|
||||
def test_link_local_metadata_blocked(self):
|
||||
self.assertFalse(_address_allowed_at_connect("169.254.169.254"))
|
||||
@@ -127,7 +146,78 @@ class ConnectAddressPolicyTests(unittest.TestCase):
|
||||
self.assertFalse(_address_allowed_at_connect("::ffff:169.254.169.254"))
|
||||
|
||||
|
||||
class TunnelledIPv4Tests(unittest.TestCase):
|
||||
"""IPv6 transition forms that carry an IPv4 address the outer address hides.
|
||||
|
||||
``is_global`` looks only at the outer address, so a form that tunnels an
|
||||
internal IPv4 has to be unwrapped before it is judged (GHSA-5mq5-qr7m-f4wx).
|
||||
"""
|
||||
|
||||
def test_nat64_well_known_prefix_blocked(self):
|
||||
# 2000::/3 global unicast on its face; carries the metadata address.
|
||||
self.assertFalse(_address_is_global("64:ff9b::a9fe:a9fe"))
|
||||
self.assertFalse(_address_is_global("64:ff9b::7f00:1"))
|
||||
self.assertFalse(_address_allowed_at_connect("64:ff9b::a9fe:a9fe"))
|
||||
|
||||
def test_nat64_carrying_a_public_address_allowed(self):
|
||||
self.assertTrue(_address_is_global("64:ff9b::8.8.8.8"))
|
||||
|
||||
def test_ipv4_compatible_form_blocked(self):
|
||||
# The deprecated ::/96 form, likewise global-looking to is_global.
|
||||
self.assertFalse(_address_is_global("::a9fe:a9fe"))
|
||||
self.assertFalse(_address_allowed_at_connect("::a9fe:a9fe"))
|
||||
|
||||
def test_sixtofour_and_teredo_stay_blocked(self):
|
||||
# Python rejects these ranges wholesale. Unwrapping must not promote a
|
||||
# blocked address to an allowed one just because the payload is global.
|
||||
self.assertFalse(_address_is_global("2002:a9fe:a9fe::"))
|
||||
self.assertFalse(_address_is_global("2002:0808:0808::"))
|
||||
self.assertFalse(_address_is_global("2001:0:4136:e378:8000:63bf:3fff:fdd2"))
|
||||
|
||||
def test_plain_addresses_unaffected(self):
|
||||
self.assertTrue(_address_is_global("142.250.1.1"))
|
||||
self.assertTrue(_address_is_global("2607:f8b0:4004:c07::64"))
|
||||
self.assertFalse(_address_is_global("not-an-ip"))
|
||||
|
||||
def test_tunnelled_form_blocked_at_ingress(self):
|
||||
with mock.patch(
|
||||
"url_guard.socket.getaddrinfo",
|
||||
return_value=_addrinfo("64:ff9b::a9fe:a9fe", family=socket.AF_INET6),
|
||||
):
|
||||
self.assertIsNotNone(validate_url("http://nat64.example/x"))
|
||||
|
||||
|
||||
class ProxyEndpointParsingTests(unittest.TestCase):
|
||||
def test_explicit_port(self):
|
||||
self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
|
||||
|
||||
def test_default_port_per_scheme(self):
|
||||
self.assertEqual(_proxy_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080))
|
||||
self.assertEqual(_proxy_endpoint("http://127.0.0.1"), ("127.0.0.1", 80))
|
||||
|
||||
def test_bare_host_port(self):
|
||||
self.assertEqual(_proxy_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080))
|
||||
|
||||
def test_hostname_lowercased(self):
|
||||
self.assertEqual(_proxy_endpoint("http://LocalHost.:9050"), ("localhost", 9050))
|
||||
|
||||
def test_ipv6_literal(self):
|
||||
self.assertEqual(_proxy_endpoint("http://[::1]:9050"), ("::1", 9050))
|
||||
|
||||
def test_empty_and_invalid(self):
|
||||
self.assertIsNone(_proxy_endpoint(""))
|
||||
self.assertIsNone(_proxy_endpoint(" "))
|
||||
self.assertIsNone(_proxy_endpoint(None))
|
||||
self.assertIsNone(_proxy_endpoint("http://"))
|
||||
|
||||
|
||||
class GuardedGetaddrinfoTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Default state: no proxy configured, so no loopback destination allowed.
|
||||
saved = set(url_guard._allowed_proxy_endpoints)
|
||||
url_guard._allowed_proxy_endpoints = set()
|
||||
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved))
|
||||
|
||||
def test_internal_only_raises(self):
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
@@ -139,11 +229,60 @@ class GuardedGetaddrinfoTests(unittest.TestCase):
|
||||
results = _guarded_getaddrinfo("mixed", 80)
|
||||
self.assertEqual([r[4][0] for r in results], ["142.250.1.1"])
|
||||
|
||||
def test_loopback_passes(self):
|
||||
def test_loopback_blocked_without_matching_proxy(self):
|
||||
# The advisory case: an m3u8 segment URL pointing at a loopback service.
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("localproxy", 9050)
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("127.0.0.1", 9999)
|
||||
|
||||
def test_loopback_allowed_at_configured_proxy_endpoint(self):
|
||||
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("127.0.0.1", 9050)
|
||||
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||
|
||||
def test_loopback_blocked_at_other_port_on_proxy_host(self):
|
||||
# Same host as the proxy, different port: still off limits.
|
||||
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("127.0.0.1", 9999)
|
||||
|
||||
def test_proxy_reachable_by_hostname(self):
|
||||
url_guard._allowed_proxy_endpoints = {("localhost", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("localhost", 9050)
|
||||
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||
|
||||
def test_string_port_is_normalised(self):
|
||||
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
||||
results = _guarded_getaddrinfo("127.0.0.1", "9050")
|
||||
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
||||
|
||||
def test_lan_proxy_reachable(self):
|
||||
# #1055: a socks5 proxy on the LAN, refused while the allowance was
|
||||
# loopback-only, which pushed operators to ALLOW_PRIVATE_ADDRESSES.
|
||||
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
||||
results = _guarded_getaddrinfo("10.1.20.30", 1080)
|
||||
self.assertEqual([r[4][0] for r in results], ["10.1.20.30"])
|
||||
|
||||
def test_other_lan_host_still_blocked(self):
|
||||
# The allowance is the proxy's endpoint, not its subnet.
|
||||
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.31")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("10.1.20.31", 1080)
|
||||
|
||||
def test_proxy_address_not_borrowable_by_another_host(self):
|
||||
# Matching is on the configured host string: a manifest URL that resolves
|
||||
# to the proxy's address under its own name gets no allowance.
|
||||
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
||||
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
||||
with self.assertRaises(socket.gaierror):
|
||||
_guarded_getaddrinfo("evil.example", 1080)
|
||||
|
||||
|
||||
class AllowPrivateBypassTests(unittest.TestCase):
|
||||
"""ALLOW_PRIVATE_ADDRESSES: trusted proxy/VPN environments opt out of the
|
||||
@@ -172,16 +311,44 @@ class AllowPrivateBypassTests(unittest.TestCase):
|
||||
|
||||
|
||||
class InstallSocketGuardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
original, saved = socket.getaddrinfo, set(url_guard._allowed_proxy_endpoints)
|
||||
self.addCleanup(lambda: setattr(socket, "getaddrinfo", original))
|
||||
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved))
|
||||
# Keep the host's own environment out of the assertions below.
|
||||
patcher = mock.patch("url_guard.urllib.request.getproxies", return_value={})
|
||||
self.getproxies = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_install_replaces_and_is_idempotent(self):
|
||||
original = socket.getaddrinfo
|
||||
try:
|
||||
install_socket_guard()
|
||||
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
||||
# Re-installing must not wrap the wrapper (real fn captured at import).
|
||||
install_socket_guard()
|
||||
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
||||
finally:
|
||||
socket.getaddrinfo = original
|
||||
install_socket_guard()
|
||||
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
||||
# Re-installing must not wrap the wrapper (real fn captured at import).
|
||||
install_socket_guard()
|
||||
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
||||
|
||||
def test_no_proxy_means_no_loopback_allowance(self):
|
||||
install_socket_guard()
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
||||
|
||||
def test_explicit_proxy_is_registered(self):
|
||||
install_socket_guard(proxy_urls=("socks5://127.0.0.1:9050",))
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 9050)})
|
||||
|
||||
def test_unset_proxy_option_is_ignored(self):
|
||||
# ytdl_opts.get('proxy') is None when the operator configured no proxy.
|
||||
install_socket_guard(proxy_urls=(None,))
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
||||
|
||||
def test_environment_proxies_are_registered(self):
|
||||
self.getproxies.return_value = {"http": "http://127.0.0.1:8080"}
|
||||
install_socket_guard()
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 8080)})
|
||||
|
||||
def test_endpoints_reset_between_installs(self):
|
||||
install_socket_guard(proxy_urls=("http://127.0.0.1:8080",))
|
||||
install_socket_guard(proxy_urls=(None,))
|
||||
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -73,12 +73,14 @@ import ytdl
|
||||
from ytdl import (
|
||||
Download,
|
||||
DownloadInfo,
|
||||
MusicMetadataPreProcessor,
|
||||
_compact_persisted_entry,
|
||||
_convert_srt_to_txt_file,
|
||||
_AlbumArtistPostProcessor,
|
||||
_resolve_outtmpl_fields,
|
||||
_sanitize_entry_for_pickle,
|
||||
_sanitize_path_component,
|
||||
_short_title_for_failed_url,
|
||||
)
|
||||
|
||||
# Detect whether the real yt-dlp is loaded (as opposed to the minimal fake
|
||||
@@ -201,9 +203,15 @@ class AlbumArtistRegistrationTests(unittest.TestCase):
|
||||
result = download._make_youtube_dl({'quiet': True})
|
||||
|
||||
self.assertIs(result, fake_ydl)
|
||||
postprocessor, = fake_ydl.add_post_processor.call_args.args
|
||||
album_artist_call = fake_ydl.add_post_processor.call_args_list[0]
|
||||
postprocessor, = album_artist_call.args
|
||||
self.assertIsInstance(postprocessor, _AlbumArtistPostProcessor)
|
||||
self.assertEqual(fake_ydl.add_post_processor.call_args.kwargs, {'when': 'pre_process'})
|
||||
self.assertEqual(album_artist_call.kwargs, {'when': 'pre_process'})
|
||||
metadata_pre_call = fake_ydl.add_post_processor.call_args_list[1]
|
||||
metadata_preprocessor, = metadata_pre_call.args
|
||||
self.assertIsInstance(metadata_preprocessor, MusicMetadataPreProcessor)
|
||||
self.assertEqual(metadata_pre_call.kwargs, {'when': 'pre_process'})
|
||||
self.assertEqual(fake_ydl.add_post_processor.call_count, 2)
|
||||
|
||||
def test_video_download_does_not_register_postprocessor(self):
|
||||
download = _make_test_download()
|
||||
@@ -801,5 +809,23 @@ class CompactPersistedEntryTests(unittest.TestCase):
|
||||
self.assertIsNone(_compact_persisted_entry({"id": "x", "title": "y"}))
|
||||
|
||||
|
||||
class ShortTitleForFailedUrlTests(unittest.TestCase):
|
||||
def test_uses_hostname_for_a_normal_url(self):
|
||||
self.assertEqual(
|
||||
_short_title_for_failed_url("https://example.com/watch?v=1"),
|
||||
"example.com",
|
||||
)
|
||||
|
||||
def test_falls_back_to_raw_value_when_there_is_no_hostname(self):
|
||||
# file:// URIs and bare search terms/video IDs have no netloc to extract.
|
||||
self.assertEqual(_short_title_for_failed_url("file:///etc/passwd"), "file:///etc/passwd")
|
||||
self.assertEqual(_short_title_for_failed_url("ytsearch:some query"), "ytsearch:some query")
|
||||
|
||||
def test_falls_back_to_raw_value_on_unparseable_input(self):
|
||||
# A malformed IPv6-looking host raises ValueError in urlsplit().hostname.
|
||||
malformed = "https://[::1/watch"
|
||||
self.assertEqual(_short_title_for_failed_url(malformed), malformed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+154
-16
@@ -30,12 +30,23 @@ all of these:
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
log = logging.getLogger('url_guard')
|
||||
|
||||
_ALLOWED_SCHEMES = ('http', 'https')
|
||||
|
||||
# Ports to assume when a configured proxy URL omits one, per proxy scheme.
|
||||
_PROXY_DEFAULT_PORTS = {
|
||||
'http': 80,
|
||||
'https': 443,
|
||||
'socks4': 1080,
|
||||
'socks4a': 1080,
|
||||
'socks5': 1080,
|
||||
'socks5h': 1080,
|
||||
}
|
||||
|
||||
# Hostnames that must be blocked without needing a lookup. ``localhost`` and any
|
||||
# subdomain of it are conventionally loopback, and the GCP metadata name is a
|
||||
# well-known SSRF target that may resolve via a resolver we don't control.
|
||||
@@ -50,6 +61,19 @@ def _hostname_is_blocked(hostname: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# IPv6 ranges that tunnel an IPv4 address at a fixed offset. ``is_global``
|
||||
# judges only the outer address, so an internal IPv4 wrapped in one of these can
|
||||
# pass a check the bare address would fail — 64:ff9b::a9fe:a9fe carries the cloud
|
||||
# metadata address but sits in the 2000::/3 global unicast range.
|
||||
_NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network('64:ff9b::/96')
|
||||
_IPV4_COMPATIBLE = ipaddress.ip_network('::/96')
|
||||
|
||||
# ``::`` and ``::1`` sit inside ::/96 without being IPv4-compatible addresses
|
||||
# (RFC 4291 reserves both), and 0.0.0.0/8 is not a routable destination anyway.
|
||||
# Reading a tunnelled address out of them would just misdescribe them.
|
||||
_UNUSABLE_IPV4 = ipaddress.ip_network('0.0.0.0/8')
|
||||
|
||||
|
||||
def _normalise_ip(addr: str):
|
||||
"""Parse *addr*, unwrapping IPv4-mapped IPv6 (e.g. ``::ffff:169.254.169.254``)
|
||||
so the embedded IPv4 address is judged on its own merits. Returns ``None``
|
||||
@@ -63,45 +87,155 @@ def _normalise_ip(addr: str):
|
||||
return ip
|
||||
|
||||
|
||||
def _address_is_global(addr: str) -> bool:
|
||||
ip = _normalise_ip(addr)
|
||||
return ip is not None and ip.is_global
|
||||
def _tunnelled_ipv4(ip):
|
||||
"""The IPv4 address an IPv6 transition form tunnels, or ``None``.
|
||||
|
||||
Covers 6to4 (``2002::/16``), Teredo (``2001::/32``), the NAT64 well-known
|
||||
prefix (``64:ff9b::/96``) and the deprecated IPv4-compatible form
|
||||
(``::/96``). IPv4-mapped is handled by ``_normalise_ip`` instead: that form
|
||||
*is* its embedded address rather than a tunnel to it.
|
||||
"""
|
||||
if not isinstance(ip, ipaddress.IPv6Address):
|
||||
return None
|
||||
if ip.sixtofour is not None:
|
||||
return ip.sixtofour
|
||||
if ip.teredo is not None:
|
||||
return ip.teredo[1]
|
||||
if ip in _NAT64_WELL_KNOWN_PREFIX or ip in _IPV4_COMPATIBLE:
|
||||
tunnelled = ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
|
||||
return None if tunnelled in _UNUSABLE_IPV4 else tunnelled
|
||||
return None
|
||||
|
||||
|
||||
def _address_allowed_at_connect(addr: str) -> bool:
|
||||
"""True if *addr* may be connected to at download time.
|
||||
def _ips_to_judge(addr: str) -> tuple:
|
||||
"""Every address a verdict on *addr* has to account for: the address itself
|
||||
plus any IPv4 it tunnels. Empty when *addr* is not a valid IP literal.
|
||||
|
||||
Permits global addresses and loopback — loopback so that locally-configured
|
||||
proxies (e.g. ``proxy: http://127.0.0.1:9050``) keep working. Blocks the SSRF
|
||||
targets that matter: link-local (cloud metadata at 169.254.169.254), private
|
||||
(RFC1918), unique-local and every other non-global, non-loopback range.
|
||||
A tunnelled address is judged on *both* halves, so unwrapping can only ever
|
||||
tighten the verdict. Returning the embedded address alone would be a way in:
|
||||
Python already rejects all of 2002::/16 and 2001::/32, and replacing
|
||||
``2002:0808:0808::`` with the global 8.8.8.8 would turn an address the guard
|
||||
blocks today into an allowed one.
|
||||
"""
|
||||
ip = _normalise_ip(addr)
|
||||
return ip is not None and (ip.is_global or ip.is_loopback)
|
||||
if ip is None:
|
||||
return ()
|
||||
tunnelled = _tunnelled_ipv4(ip)
|
||||
return (ip,) if tunnelled is None else (ip, tunnelled)
|
||||
|
||||
|
||||
def _address_is_global(addr: str) -> bool:
|
||||
ips = _ips_to_judge(addr)
|
||||
return bool(ips) and all(ip.is_global for ip in ips)
|
||||
|
||||
|
||||
def _address_allowed_at_connect(addr: str, is_proxy_endpoint: bool = False) -> bool:
|
||||
"""True if *addr* may be connected to at download time.
|
||||
|
||||
Permits global addresses, and anything at all when the destination is an
|
||||
operator-configured proxy (see ``_is_proxy_endpoint``). Internal addresses
|
||||
are otherwise refused with no blanket exception: media URLs that yt-dlp
|
||||
derives from a remote manifest are attacker-controlled and reach this policy
|
||||
without passing ``validate_url``, so any range opened here is a range a
|
||||
hostile playlist can read from the server's own network. Blocks link-local
|
||||
(cloud metadata at 169.254.169.254), private (RFC1918), loopback,
|
||||
unique-local and every other non-global range.
|
||||
"""
|
||||
ips = _ips_to_judge(addr)
|
||||
if not ips:
|
||||
return False
|
||||
return is_proxy_endpoint or all(ip.is_global for ip in ips)
|
||||
|
||||
|
||||
def _proxy_endpoint(proxy_url: str):
|
||||
"""Parse a proxy URL into a ``(hostname, port)`` pair, or ``None`` if it has
|
||||
no usable host. Used to scope the internal-address allowance to that endpoint
|
||||
alone."""
|
||||
if not isinstance(proxy_url, str) or not proxy_url.strip():
|
||||
return None
|
||||
candidate = proxy_url.strip()
|
||||
if '://' not in candidate:
|
||||
# Bare host:port, as accepted by the *_proxy environment variables.
|
||||
candidate = '//' + candidate
|
||||
try:
|
||||
parts = urlsplit(candidate)
|
||||
hostname, port = parts.hostname, parts.port
|
||||
except ValueError:
|
||||
return None
|
||||
if not hostname:
|
||||
return None
|
||||
if port is None:
|
||||
port = _PROXY_DEFAULT_PORTS.get(parts.scheme.lower())
|
||||
return (hostname.rstrip('.').lower(), port)
|
||||
|
||||
|
||||
def _collect_proxy_endpoints(proxy_urls) -> set:
|
||||
"""Endpoints of every proxy this download may legitimately dial: the explicit
|
||||
yt-dlp ``proxy`` option plus the ``*_proxy`` environment variables yt-dlp falls
|
||||
back to. All are operator-configured, unlike the URLs inside fetched media."""
|
||||
candidates = list(proxy_urls) + list(urllib.request.getproxies().values())
|
||||
return {ep for ep in map(_proxy_endpoint, candidates) if ep is not None}
|
||||
|
||||
|
||||
# Captured at import so re-installing the guard never wraps the wrapper.
|
||||
_real_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
# Populated by install_socket_guard; empty means no internal destination is allowed.
|
||||
_allowed_proxy_endpoints: set = set()
|
||||
|
||||
|
||||
def _normalise_port(port):
|
||||
if isinstance(port, str):
|
||||
try:
|
||||
return int(port)
|
||||
except ValueError:
|
||||
try:
|
||||
return socket.getservbyname(port)
|
||||
except OSError:
|
||||
return None
|
||||
return port
|
||||
|
||||
|
||||
def _is_proxy_endpoint(host, port) -> bool:
|
||||
"""True when host:port is exactly an endpoint the operator configured as a
|
||||
proxy. Matching is on the configured host *string*, not on the resolved
|
||||
address, so a hostile media URL cannot borrow the allowance by resolving to
|
||||
the same address under a different name."""
|
||||
if not _allowed_proxy_endpoints or host is None:
|
||||
return False
|
||||
return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_proxy_endpoints
|
||||
|
||||
|
||||
def _guarded_getaddrinfo(host, *args, **kwargs):
|
||||
results = _real_getaddrinfo(host, *args, **kwargs)
|
||||
allowed = [r for r in results if _address_allowed_at_connect(r[4][0])]
|
||||
# Mirrors getaddrinfo(host, port, ...): port is the first optional argument.
|
||||
port = args[0] if args else kwargs.get('port')
|
||||
is_proxy = _is_proxy_endpoint(host, port)
|
||||
allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_proxy)]
|
||||
if not allowed:
|
||||
raise socket.gaierror(f'Refusing to connect to non-global address for host {host!r}')
|
||||
return allowed
|
||||
|
||||
|
||||
def install_socket_guard(allow_private: bool = False) -> None:
|
||||
def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
|
||||
"""Enforce the no-internal-hosts policy at actual connection time.
|
||||
|
||||
``validate_url`` only checks the *submitted* URL string; yt-dlp then follows
|
||||
HTTP redirects and resolves media URLs from remote metadata without
|
||||
re-validating them. Installing this in the download subprocess re-checks
|
||||
every resolved address at connect time, covering redirects and DNS rebinding
|
||||
for any networking backend that resolves through Python's socket module
|
||||
(urllib, requests). Native resolvers — notably curl_cffi/libcurl used by
|
||||
``--impersonate`` — bypass this and rely on network isolation as the backstop.
|
||||
every resolved address at connect time, covering redirects, DNS rebinding and
|
||||
manifest-derived media URLs for any networking backend that resolves through
|
||||
Python's socket module (urllib, requests). Native resolvers — notably
|
||||
curl_cffi/libcurl used by ``--impersonate`` — bypass this and rely on network
|
||||
isolation as the backstop.
|
||||
|
||||
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
|
||||
the ``*_proxy`` environment variables are picked up automatically). A proxy is
|
||||
reachable at its own host:port wherever it lives — loopback, the LAN, a VPN
|
||||
range — and nothing else internal is. That costs proxied setups nothing and
|
||||
gives away nothing: yt-dlp resolves the proxy itself at exactly that host:port,
|
||||
and a media URL is either handed to the proxy unresolved or resolved on its own
|
||||
merits — never inheriting the proxy's allowance.
|
||||
|
||||
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
|
||||
installed at all, so proxy/VPN setups that route through private or Fake-IP
|
||||
@@ -109,6 +243,10 @@ def install_socket_guard(allow_private: bool = False) -> None:
|
||||
"""
|
||||
if allow_private:
|
||||
return
|
||||
_allowed_proxy_endpoints.clear()
|
||||
_allowed_proxy_endpoints.update(_collect_proxy_endpoints(proxy_urls))
|
||||
for host, port in sorted(_allowed_proxy_endpoints, key=lambda ep: (ep[0], ep[1] or 0)):
|
||||
log.info(f'Allowing connections to configured proxy {host}:{port}')
|
||||
socket.getaddrinfo = _guarded_getaddrinfo
|
||||
|
||||
|
||||
|
||||
+242
-13
@@ -23,10 +23,12 @@ from yt_dlp.postprocessor.common import PostProcessor
|
||||
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
|
||||
import bg_tasks
|
||||
from dl_formats import get_format, get_opts, AUDIO_FORMATS, merge_ytdl_option_layers
|
||||
from music_metadata import MusicMetadataPreProcessor
|
||||
from datetime import datetime
|
||||
from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf, to_json_compatible
|
||||
from subscriptions import _entry_id
|
||||
from url_guard import validate_url, install_socket_guard
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
log = logging.getLogger('ytdl')
|
||||
|
||||
@@ -497,7 +499,18 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
_COMPACT_ENTRY_EXTRA_KEYS = frozenset(("n_entries", "__last_playlist_index"))
|
||||
def _short_title_for_failed_url(url: str) -> str:
|
||||
"""A concise display title for a URL that failed before yt-dlp could extract a
|
||||
real title (unsupported URL, SSRF-rejected, extraction error). The full URL
|
||||
remains available in DownloadInfo.url and the error-detail panel."""
|
||||
try:
|
||||
hostname = urlsplit(url).hostname
|
||||
except ValueError:
|
||||
hostname = None
|
||||
return hostname or url
|
||||
|
||||
|
||||
_COMPACT_ENTRY_EXTRA_KEYS = frozenset(("n_entries", "__last_playlist_index", "track_number"))
|
||||
|
||||
|
||||
def _compact_persisted_entry(entry: Any) -> Optional[dict[str, Any]]:
|
||||
@@ -625,6 +638,13 @@ class Download:
|
||||
)
|
||||
if getattr(self.info, 'download_type', '') == 'audio':
|
||||
ydl.add_post_processor(_AlbumArtistPostProcessor(ydl), when='pre_process')
|
||||
ydl.add_post_processor(
|
||||
MusicMetadataPreProcessor(
|
||||
ydl,
|
||||
source_entry=getattr(self.info, 'entry', None),
|
||||
),
|
||||
when='pre_process',
|
||||
)
|
||||
return ydl
|
||||
|
||||
def _download(self):
|
||||
@@ -637,10 +657,12 @@ class Download:
|
||||
except OSError:
|
||||
pass
|
||||
# Re-validate every outbound connection at fetch time. validate_url only
|
||||
# saw the submitted URL string; this catches redirects and DNS rebinding
|
||||
# to internal hosts (cloud metadata, RFC1918) that it cannot. Skipped when
|
||||
# ALLOW_PRIVATE_ADDRESSES trusts the environment (e.g. Fake-IP proxies).
|
||||
install_socket_guard(self.allow_private)
|
||||
# saw the submitted URL string; this catches redirects, DNS rebinding and
|
||||
# attacker-controlled media URLs pulled from a remote manifest, none of
|
||||
# which it can see. The configured proxy is passed so that a proxy on an
|
||||
# internal address stays reachable at its own host:port without opening up
|
||||
# anything else. Skipped when ALLOW_PRIVATE_ADDRESSES trusts the environment.
|
||||
install_socket_guard(self.allow_private, proxy_urls=(self.ytdl_opts.get('proxy'),))
|
||||
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
||||
try:
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
@@ -787,8 +809,11 @@ class Download:
|
||||
|
||||
def close(self):
|
||||
log.info(f"Closing download process for: {self.info.title}")
|
||||
if self.started():
|
||||
self.proc.close()
|
||||
try:
|
||||
if self.started():
|
||||
self.proc.close()
|
||||
finally:
|
||||
self.status_queue = None
|
||||
|
||||
def running(self):
|
||||
try:
|
||||
@@ -921,8 +946,12 @@ class PersistentQueue:
|
||||
]
|
||||
return sorted(items, key=lambda item: item[1].timestamp)
|
||||
|
||||
def _should_persist_entry(self) -> bool:
|
||||
return self.identifier != "completed"
|
||||
def _should_persist_entry(self, info: DownloadInfo | dict[str, Any]) -> bool:
|
||||
# Failed downloads need their compact playlist/channel context so a
|
||||
# retry after a server restart still resolves the original outtmpl.
|
||||
# Successful completed entries continue to omit extractor metadata.
|
||||
status = info.get("status") if isinstance(info, dict) else info.status
|
||||
return self.identifier != "completed" or status == "error"
|
||||
|
||||
def _serialize_items(self):
|
||||
return [
|
||||
@@ -930,7 +959,7 @@ class PersistentQueue:
|
||||
"key": key,
|
||||
"info": _download_info_to_record(
|
||||
download.info,
|
||||
include_entry=self._should_persist_entry(),
|
||||
include_entry=self._should_persist_entry(download.info),
|
||||
),
|
||||
}
|
||||
for key, download in self.dict.items()
|
||||
@@ -949,7 +978,7 @@ class PersistentQueue:
|
||||
"key": item["key"],
|
||||
"info": _download_info_to_record(
|
||||
_download_info_from_record(item["info"]),
|
||||
include_entry=self._should_persist_entry(),
|
||||
include_entry=self._should_persist_entry(item["info"]),
|
||||
),
|
||||
}
|
||||
for item in items
|
||||
@@ -970,7 +999,7 @@ class PersistentQueue:
|
||||
"key": key,
|
||||
"info": _download_info_to_record(
|
||||
value,
|
||||
include_entry=self._should_persist_entry(),
|
||||
include_entry=self._should_persist_entry(value),
|
||||
),
|
||||
}
|
||||
for key, value in sorted(legacy_items, key=lambda item: item[1].timestamp)
|
||||
@@ -1287,6 +1316,14 @@ class DownloadQueue:
|
||||
'ignore_no_formats_error': True,
|
||||
'noplaylist': True,
|
||||
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
|
||||
# This is a classification pass, not a download. yt-dlp emits the
|
||||
# feed-level infojson/description/thumbnail from
|
||||
# __process_playlist_result without consulting `download`, so
|
||||
# without this a writeinfojson user gets stray files here — in
|
||||
# DOWNLOAD_DIR, under yt-dlp's pl_* names, even for an add that goes
|
||||
# on to fail. __write_feed_metadata writes them properly once the
|
||||
# feed is accepted. See issues #1040 and #660.
|
||||
'allow_playlist_files': False,
|
||||
}
|
||||
imp = user_opts.get('impersonate')
|
||||
if imp is not None:
|
||||
@@ -1350,6 +1387,81 @@ class DownloadQueue:
|
||||
self.pending.put(download)
|
||||
await self.notifier.added(dl)
|
||||
|
||||
def __write_feed_metadata_sync(self, entry, etype, download_type, folder,
|
||||
ytdl_options_presets, ytdl_options_overrides):
|
||||
"""Write the feed-level .info.json/description/thumbnail for a playlist
|
||||
or channel add, using the same output template its items will use.
|
||||
|
||||
yt-dlp produces these from __process_playlist_result, which ignores
|
||||
``download`` — so they used to fall out of the classification pass with
|
||||
yt-dlp's own pl_* names, in DOWNLOAD_DIR, ignoring the download's folder
|
||||
(issue #1040) and with no way to steer them (issue #660). Doing it here
|
||||
instead means the feed type is already known, so the file lands beside
|
||||
the items rather than in a differently-named sibling directory.
|
||||
|
||||
Re-runs yt-dlp on a copy of the feed with no entries: that reaches the
|
||||
playlist-file writing without re-extracting anything or touching
|
||||
yt-dlp's private write helpers.
|
||||
"""
|
||||
user_opts = self._build_ytdl_options(ytdl_options_presets, ytdl_options_overrides)
|
||||
wants = ('writeinfojson', 'writedescription', 'writethumbnail', 'write_all_thumbnails')
|
||||
if not any(user_opts.get(key) for key in wants):
|
||||
return
|
||||
# An explicit allow_playlist_files=false is the user asking for exactly
|
||||
# this to not happen.
|
||||
if user_opts.get('allow_playlist_files') is False:
|
||||
return
|
||||
|
||||
dldirectory, error_message = self.__calc_download_path(download_type, folder)
|
||||
if error_message is not None:
|
||||
return
|
||||
|
||||
template = (
|
||||
self.config.OUTPUT_TEMPLATE_CHANNEL if etype == 'channel'
|
||||
else self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||
) or self.config.OUTPUT_TEMPLATE
|
||||
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
params = {
|
||||
**user_opts,
|
||||
'quiet': not debug_logging,
|
||||
'verbose': debug_logging,
|
||||
'no_color': True,
|
||||
'skip_download': True,
|
||||
'extract_flat': True,
|
||||
'allow_playlist_files': True,
|
||||
'paths': {"home": dldirectory, "temp": self.config.TEMP_DIR},
|
||||
# Feed-level keys only; per-item names are resolved by __add_download.
|
||||
'outtmpl': {
|
||||
'pl_infojson': template,
|
||||
'pl_thumbnail': template,
|
||||
'pl_description': template,
|
||||
},
|
||||
}
|
||||
imp = user_opts.get('impersonate')
|
||||
if imp is not None:
|
||||
params['impersonate'] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(imp)
|
||||
|
||||
# A copy: process_ie_result mutates entries/requested_entries, and the
|
||||
# caller still needs the real feed dict to queue the items.
|
||||
feed = {k: v for k, v in entry.items() if k != 'entries'}
|
||||
feed['entries'] = []
|
||||
yt_dlp.YoutubeDL(params=params).process_ie_result(feed, download=False)
|
||||
|
||||
async def __write_feed_metadata(self, entry, etype, download_type, folder,
|
||||
ytdl_options_presets, ytdl_options_overrides):
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
self.__write_feed_metadata_sync, entry, etype, download_type, folder,
|
||||
ytdl_options_presets, ytdl_options_overrides,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
# Supplemental output must never fail the add.
|
||||
log.warning(f'Could not write {etype} metadata files: {exc}')
|
||||
|
||||
async def __add_entry(
|
||||
self,
|
||||
entry,
|
||||
@@ -1371,6 +1483,7 @@ class DownloadQueue:
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen=None,
|
||||
retry_entry=None,
|
||||
):
|
||||
if not entry:
|
||||
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
||||
@@ -1389,6 +1502,10 @@ class DownloadQueue:
|
||||
|
||||
if etype.startswith('url'):
|
||||
log.debug('Processing as a url')
|
||||
# retry_entry must ride along: extraction can hand back an
|
||||
# unprocessed url/url_transparent result, and dropping the retry
|
||||
# context here would send the retried item back to the root
|
||||
# directory instead of its original playlist folder.
|
||||
return await self.add(
|
||||
entry['url'],
|
||||
download_type,
|
||||
@@ -1409,6 +1526,7 @@ class DownloadQueue:
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
retry_entry,
|
||||
)
|
||||
elif etype == 'playlist' or etype == 'channel':
|
||||
if etype == 'playlist' and self.__is_channel_extraction(entry):
|
||||
@@ -1420,6 +1538,10 @@ class DownloadQueue:
|
||||
entries = list(entries)
|
||||
total_entries = len(entries)
|
||||
log.info(f'{etype} detected with {total_entries} entries')
|
||||
await self.__write_feed_metadata(
|
||||
entry, etype, download_type, folder,
|
||||
ytdl_options_presets, ytdl_options_overrides,
|
||||
)
|
||||
index_digits = len(str(total_entries))
|
||||
results = []
|
||||
if playlist_item_limit > 0:
|
||||
@@ -1517,6 +1639,59 @@ class DownloadQueue:
|
||||
return {'status': 'ok'}
|
||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||
|
||||
async def __record_add_failure(
|
||||
self,
|
||||
url,
|
||||
msg,
|
||||
download_type,
|
||||
codec,
|
||||
format,
|
||||
quality,
|
||||
folder,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
split_by_chapters,
|
||||
chapter_template,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
entry=None,
|
||||
):
|
||||
"""Surface a URL that failed before a DownloadInfo could be created (unsupported
|
||||
URL, SSRF-rejected, extraction error) as a failed entry in the done list, so the
|
||||
frontend shows it with the same red-cross/retry/error-detail treatment as a
|
||||
download that failed mid-stream, instead of only a toast and a server log line."""
|
||||
info = DownloadInfo(
|
||||
id=url,
|
||||
title=_short_title_for_failed_url(url),
|
||||
url=url,
|
||||
quality=quality,
|
||||
download_type=download_type,
|
||||
codec=codec,
|
||||
format=format,
|
||||
folder=folder,
|
||||
custom_name_prefix=custom_name_prefix,
|
||||
error=msg,
|
||||
entry=entry,
|
||||
playlist_item_limit=playlist_item_limit,
|
||||
split_by_chapters=split_by_chapters,
|
||||
chapter_template=chapter_template,
|
||||
subtitle_language=subtitle_language,
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=ytdl_options_presets,
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
)
|
||||
info.status = 'error'
|
||||
info.msg = msg
|
||||
download = Download(None, None, None, None, quality, format, {}, info)
|
||||
self.done.put(download)
|
||||
await self.notifier.completed(info)
|
||||
|
||||
async def add(
|
||||
self,
|
||||
url,
|
||||
@@ -1538,6 +1713,7 @@ class DownloadQueue:
|
||||
clip_end=None,
|
||||
already=None,
|
||||
_add_gen=None,
|
||||
retry_entry=None,
|
||||
):
|
||||
if ytdl_options_presets is None:
|
||||
ytdl_options_presets = []
|
||||
@@ -1562,6 +1738,12 @@ class DownloadQueue:
|
||||
None, partial(validate_url, url, allow_private=self.config.ALLOW_PRIVATE_ADDRESSES))
|
||||
if url_error is not None:
|
||||
log.warning('Rejected URL "%s": %s', url, url_error)
|
||||
await self.__record_add_failure(
|
||||
url, url_error, download_type, codec, format, quality, folder,
|
||||
custom_name_prefix, playlist_item_limit, split_by_chapters, chapter_template,
|
||||
subtitle_language, subtitle_mode, ytdl_options_presets, ytdl_options_overrides,
|
||||
clip_start, clip_end, retry_entry,
|
||||
)
|
||||
return {'status': 'error', 'msg': url_error}
|
||||
try:
|
||||
entry = await asyncio.get_running_loop().run_in_executor(
|
||||
@@ -1569,7 +1751,17 @@ class DownloadQueue:
|
||||
partial(self.__extract_info, url, ytdl_options_presets, ytdl_options_overrides),
|
||||
)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
return {'status': 'error', 'msg': str(exc)}
|
||||
msg = str(exc)
|
||||
await self.__record_add_failure(
|
||||
url, msg, download_type, codec, format, quality, folder,
|
||||
custom_name_prefix, playlist_item_limit, split_by_chapters, chapter_template,
|
||||
subtitle_language, subtitle_mode, ytdl_options_presets, ytdl_options_overrides,
|
||||
clip_start, clip_end, retry_entry,
|
||||
)
|
||||
return {'status': 'error', 'msg': msg}
|
||||
retry_context = _compact_persisted_entry(retry_entry)
|
||||
if isinstance(entry, dict) and retry_context is not None:
|
||||
entry = {**entry, **copy.deepcopy(retry_context)}
|
||||
return await self.__add_entry(
|
||||
entry,
|
||||
download_type,
|
||||
@@ -1590,6 +1782,43 @@ class DownloadQueue:
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
retry_entry,
|
||||
)
|
||||
|
||||
async def retry(self, id):
|
||||
if not self.done.exists(id):
|
||||
return {'status': 'error', 'msg': 'Failed download no longer exists.'}
|
||||
|
||||
info = self.done.get(id).info
|
||||
if info.status != 'error':
|
||||
return {'status': 'error', 'msg': 'Only failed downloads can be retried.'}
|
||||
|
||||
# The stored options were validated by parse_download_options when the
|
||||
# download was first submitted, but the configuration can have changed
|
||||
# since. Re-apply the same gates here so a retry can't resurrect
|
||||
# overrides or presets the current configuration no longer allows.
|
||||
overrides = info.ytdl_options_overrides if self.config.ALLOW_YTDL_OPTIONS_OVERRIDES else {}
|
||||
presets = [p for p in info.ytdl_options_presets if p in self.config.YTDL_OPTIONS_PRESETS]
|
||||
|
||||
return await self.add(
|
||||
info.url,
|
||||
info.download_type,
|
||||
info.codec,
|
||||
info.format,
|
||||
info.quality,
|
||||
info.folder,
|
||||
info.custom_name_prefix,
|
||||
info.playlist_item_limit,
|
||||
True,
|
||||
info.split_by_chapters,
|
||||
info.chapter_template,
|
||||
info.subtitle_language,
|
||||
info.subtitle_mode,
|
||||
presets,
|
||||
overrides,
|
||||
info.clip_start,
|
||||
info.clip_end,
|
||||
retry_entry=info.entry,
|
||||
)
|
||||
|
||||
async def add_entry(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 885 KiB After Width: | Height: | Size: 1.9 MiB |
+27
-1
@@ -958,7 +958,33 @@
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||
</td>
|
||||
<td>{{ entry[1].name }}</td>
|
||||
<td>
|
||||
@if (editingNameId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subName' + entry[0]"
|
||||
[(ngModel)]="nameEditDraft"
|
||||
[maxlength]="subscriptionNameMaxLength"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Subscription name for ' + entry[1].name" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveName(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditName()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-break">{{ entry[1].name }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditName(entry[0], entry[1].name)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Rename this subscription (display name only; does not affect the download folder)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||
<td>
|
||||
@if (editingTitleRegexId === entry[0]) {
|
||||
|
||||
+70
-1
@@ -19,6 +19,7 @@ class DownloadsServiceStub {
|
||||
customDirsChanged = new Subject<Record<string, string[]>>();
|
||||
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
||||
updated = new Subject<void>();
|
||||
retryCalls: string[] = [];
|
||||
|
||||
getCookieStatus() {
|
||||
return of({ status: 'ok', has_cookies: false });
|
||||
@@ -32,6 +33,11 @@ class DownloadsServiceStub {
|
||||
return of({ status: 'ok' as const });
|
||||
}
|
||||
|
||||
retry(id: string) {
|
||||
this.retryCalls.push(id);
|
||||
return of({ status: 'ok' as const });
|
||||
}
|
||||
|
||||
cancelAdd() {
|
||||
return of({ status: 'ok' as const });
|
||||
}
|
||||
@@ -75,7 +81,10 @@ class SubscriptionsServiceStub {
|
||||
return of({});
|
||||
}
|
||||
|
||||
update() {
|
||||
updateCalls: [string, unknown][] = [];
|
||||
|
||||
update(id: string, changes: unknown) {
|
||||
this.updateCalls.push([id, changes]);
|
||||
return of({ status: 'ok' as const });
|
||||
}
|
||||
|
||||
@@ -269,6 +278,33 @@ describe('App', () => {
|
||||
expect(payload.clipEnd).toBe('1:20');
|
||||
});
|
||||
|
||||
it('retries a failed download by its server-side queue id', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
const download = {
|
||||
id: 'vid1',
|
||||
title: 'Test Video',
|
||||
url: 'https://example.com/v',
|
||||
download_type: 'video',
|
||||
quality: 'best',
|
||||
format: 'any',
|
||||
folder: '',
|
||||
custom_name_prefix: '',
|
||||
playlist_item_limit: 0,
|
||||
status: 'error',
|
||||
msg: 'temporary failure',
|
||||
percent: 0,
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
filename: '',
|
||||
checked: false,
|
||||
};
|
||||
|
||||
app.retryDownload(download.url, download);
|
||||
|
||||
expect(downloads.retryCalls).toEqual([download.url]);
|
||||
});
|
||||
|
||||
it('blocks subscribe with invalid title regex', () => {
|
||||
const toasts = TestBed.inject(ToastService);
|
||||
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
||||
@@ -282,4 +318,37 @@ describe('App', () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renames a subscription and closes the inline editor', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||
|
||||
app.beginEditName('sub1', 'Videos');
|
||||
expect(app.editingNameId).toBe('sub1');
|
||||
expect(app.nameEditDraft).toBe('Videos');
|
||||
|
||||
app.nameEditDraft = ' Jane uploads ';
|
||||
app.saveName('sub1');
|
||||
|
||||
expect(subs.updateCalls).toEqual([['sub1', { name: 'Jane uploads' }]]);
|
||||
expect(app.editingNameId).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks renaming a subscription to an empty name', () => {
|
||||
const toasts = TestBed.inject(ToastService);
|
||||
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||
|
||||
app.beginEditName('sub1', 'Videos');
|
||||
app.nameEditDraft = ' ';
|
||||
app.saveName('sub1');
|
||||
|
||||
expect(subs.updateCalls.length).toBe(0);
|
||||
expect(app.editingNameId).toBe('sub1');
|
||||
expect(errorSpy).toHaveBeenCalledWith('Subscription name must not be empty');
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+32
-22
@@ -102,6 +102,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
skipSubscriberOnly = false;
|
||||
editingTitleRegexId: string | null = null;
|
||||
titleRegexEditDraft = '';
|
||||
editingNameId: string | null = null;
|
||||
nameEditDraft = '';
|
||||
readonly subscriptionNameMaxLength = 200;
|
||||
cachedSubs: [string, SubscriptionRow][] = [];
|
||||
selectedSubscriptionIds = new Set<string>();
|
||||
checkingSubscriptionIds = new Set<string>();
|
||||
@@ -663,6 +666,34 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
beginEditName(id: string, current: string | undefined) {
|
||||
this.editingNameId = id;
|
||||
this.nameEditDraft = current ?? '';
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
cancelEditName() {
|
||||
this.editingNameId = null;
|
||||
this.nameEditDraft = '';
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
saveName(id: string) {
|
||||
const name = (this.nameEditDraft || '').trim();
|
||||
if (!name) {
|
||||
this.toasts.error('Subscription name must not be empty');
|
||||
return;
|
||||
}
|
||||
this.subscriptionsSvc.update(id, { name }).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
this.toasts.error(error || 'Update subscription failed');
|
||||
return;
|
||||
}
|
||||
this.cancelEditName();
|
||||
});
|
||||
}
|
||||
|
||||
deleteSubscription(id: string) {
|
||||
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
@@ -1146,30 +1177,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
retryDownload(key: string, download: Download) {
|
||||
const payload = this.buildAddPayload({
|
||||
url: download.url,
|
||||
downloadType: download.download_type,
|
||||
codec: download.codec,
|
||||
quality: download.quality,
|
||||
format: download.format,
|
||||
folder: download.folder,
|
||||
customNamePrefix: download.custom_name_prefix,
|
||||
playlistItemLimit: download.playlist_item_limit,
|
||||
autoStart: true,
|
||||
splitByChapters: download.split_by_chapters,
|
||||
chapterTemplate: download.chapter_template,
|
||||
subtitleLanguage: download.subtitle_language,
|
||||
subtitleMode: download.subtitle_mode,
|
||||
ytdlOptionsPresets: download.ytdl_options_presets?.length
|
||||
? [...download.ytdl_options_presets]
|
||||
: [],
|
||||
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
|
||||
clipStart: download.clip_start != null ? String(download.clip_start) : '',
|
||||
clipEnd: download.clip_end != null ? String(download.clip_end) : '',
|
||||
});
|
||||
// Only remove the done-list record once the retry is confirmed queued —
|
||||
// deleting it eagerly would silently lose history if the re-add fails.
|
||||
this.downloads.add(payload)
|
||||
this.downloads.retry(key)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
|
||||
@@ -117,6 +117,14 @@ describe('DownloadsService', () => {
|
||||
req.flush({ presets: ['Preset A'] });
|
||||
});
|
||||
|
||||
it('retry() posts the failed download id', () => {
|
||||
service.retry('https://example.com/v').subscribe();
|
||||
const req = httpMock.expectOne('retry');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ id: 'https://example.com/v' });
|
||||
req.flush({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('cancelAdd posts to cancel-add', () => {
|
||||
service.cancelAdd().subscribe();
|
||||
const req = httpMock.expectOne('cancel-add');
|
||||
|
||||
@@ -169,6 +169,12 @@ export class DownloadsService {
|
||||
);
|
||||
}
|
||||
|
||||
public retry(id: string) {
|
||||
return this.http.post<Status>('retry', { id: id }).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
public startById(ids: string[]) {
|
||||
return this.http.post<Status>('start', {ids: ids}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
|
||||
@@ -13,7 +13,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.1"
|
||||
version = "3.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -24,72 +24,72 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user