mirror of
https://github.com/alexta69/metube.git
synced 2026-06-17 16:20:07 +00:00
Compare commits
7 Commits
2026.06.10
...
2026.06.16
| Author | SHA1 | Date | |
|---|---|---|---|
| b73e95f405 | |||
| 64d0d62878 | |||
| 37f7af0555 | |||
| 5aa7d033e2 | |||
| 5429200fba | |||
| 72d60ea55a | |||
| a9b2e07a59 |
@@ -347,7 +347,7 @@ example.com {
|
|||||||
|
|
||||||
## 🔄 Updating yt-dlp
|
## 🔄 Updating yt-dlp
|
||||||
|
|
||||||
MeTube is powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp), which requires frequent updates as video sites change their layouts. A nightly build automatically publishes a new Docker image whenever a new yt-dlp version is available, so keep your container up to date — [watchtower](https://github.com/nicholas-fedor/watchtower) works well for this.
|
MeTube is powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp), which requires frequent updates as video sites change their layouts. A new MeTube Docker image is published automatically when a new yt-dlp stable release is available, so keep your container up to date — [watchtower](https://github.com/nicholas-fedor/watchtower) works well for this. To follow yt-dlp's nightly channel instead, set `YTDL_NIGHTLY_UPDATE_TIME`.
|
||||||
|
|
||||||
## 🔧 Troubleshooting and submitting issues
|
## 🔧 Troubleshooting and submitting issues
|
||||||
|
|
||||||
|
|||||||
+39
-5
@@ -112,6 +112,13 @@ class Config:
|
|||||||
if not self.URL_PREFIX.endswith('/'):
|
if not self.URL_PREFIX.endswith('/'):
|
||||||
self.URL_PREFIX += '/'
|
self.URL_PREFIX += '/'
|
||||||
|
|
||||||
|
# A blank PUBLIC_HOST_AUDIO_URL (e.g. set empty in a compose file) bypasses the
|
||||||
|
# default via os.environ.get, which would leave audio links root-relative and 404.
|
||||||
|
# Fall back to the 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR. When
|
||||||
|
# PUBLIC_HOST_URL is also blank we leave it blank to preserve serving from web root.
|
||||||
|
if not self.PUBLIC_HOST_AUDIO_URL and self.PUBLIC_HOST_URL:
|
||||||
|
self.PUBLIC_HOST_AUDIO_URL = self._DEFAULTS['PUBLIC_HOST_AUDIO_URL']
|
||||||
|
|
||||||
for attr in ('PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL'):
|
for attr in ('PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL'):
|
||||||
val = getattr(self, attr)
|
val = getattr(self, attr)
|
||||||
if val and not val.endswith('/'):
|
if val and not val.endswith('/'):
|
||||||
@@ -130,6 +137,10 @@ class Config:
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
self._validate_int('MAX_CONCURRENT_DOWNLOADS', minimum=1)
|
||||||
|
self._validate_int('PORT', minimum=1, maximum=65535)
|
||||||
|
self._validate_int('CLEAR_COMPLETED_AFTER', minimum=0)
|
||||||
|
|
||||||
self._runtime_overrides = {}
|
self._runtime_overrides = {}
|
||||||
|
|
||||||
success,_ = self.load_ytdl_options()
|
success,_ = self.load_ytdl_options()
|
||||||
@@ -139,6 +150,20 @@ class Config:
|
|||||||
if not success:
|
if not success:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _validate_int(self, key, *, minimum=None, maximum=None):
|
||||||
|
raw = getattr(self, key)
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
log.error('Environment variable "%s" must be an integer, got "%s"', key, raw)
|
||||||
|
sys.exit(1)
|
||||||
|
if minimum is not None and value < minimum:
|
||||||
|
log.error('Environment variable "%s" must be >= %d, got "%s"', key, minimum, raw)
|
||||||
|
sys.exit(1)
|
||||||
|
if maximum is not None and value > maximum:
|
||||||
|
log.error('Environment variable "%s" must be <= %d, got "%s"', key, maximum, raw)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def set_runtime_override(self, key, value):
|
def set_runtime_override(self, key, value):
|
||||||
self._runtime_overrides[key] = value
|
self._runtime_overrides[key] = value
|
||||||
self.YTDL_OPTIONS[key] = value
|
self.YTDL_OPTIONS[key] = value
|
||||||
@@ -241,7 +266,13 @@ logging.getLogger().setLevel(parseLogLevel(str(config.LOGLEVEL)) or logging.INFO
|
|||||||
|
|
||||||
class ObjectSerializer(json.JSONEncoder):
|
class ObjectSerializer(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
# First try to use __dict__ for custom objects
|
# Prefer an explicit client-facing view when the object provides one
|
||||||
|
# (e.g. DownloadInfo / SubscriptionInfo) so server-only or bulky fields
|
||||||
|
# are never broadcast to browser clients.
|
||||||
|
to_public = getattr(obj, 'to_public_dict', None)
|
||||||
|
if callable(to_public):
|
||||||
|
return to_public()
|
||||||
|
# Fall back to __dict__ for other custom objects
|
||||||
if hasattr(obj, '__dict__'):
|
if hasattr(obj, '__dict__'):
|
||||||
return obj.__dict__
|
return obj.__dict__
|
||||||
# Convert iterables (generators, dict_items, etc.) to lists
|
# Convert iterables (generators, dict_items, etc.) to lists
|
||||||
@@ -827,10 +858,7 @@ async def cancel_add(request):
|
|||||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||||
async def subscribe(request):
|
async def subscribe(request):
|
||||||
post = await _read_json_request(request)
|
post = await _read_json_request(request)
|
||||||
try:
|
o = parse_download_options(post)
|
||||||
o = parse_download_options(post)
|
|
||||||
except web.HTTPBadRequest:
|
|
||||||
raise
|
|
||||||
cic = post.get('check_interval_minutes')
|
cic = post.get('check_interval_minutes')
|
||||||
if cic is None:
|
if cic is None:
|
||||||
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
|
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
|
||||||
@@ -964,6 +992,12 @@ async def upload_cookies(request):
|
|||||||
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
|
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
|
||||||
with open(tmp_cookie_path, 'wb') as f:
|
with open(tmp_cookie_path, 'wb') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
# Cookies are sensitive auth material; restrict to owner read/write only
|
||||||
|
# (the container's default umask would otherwise leave them group/world readable).
|
||||||
|
try:
|
||||||
|
os.chmod(tmp_cookie_path, 0o600)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning(f'Could not restrict permissions on cookies file: {exc}')
|
||||||
os.replace(tmp_cookie_path, COOKIES_PATH)
|
os.replace(tmp_cookie_path, COOKIES_PATH)
|
||||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||||
log.info(f'Cookies file uploaded ({size} bytes)')
|
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ class SubscriptionManager:
|
|||||||
self._subs: dict[str, SubscriptionInfo] = {}
|
self._subs: dict[str, SubscriptionInfo] = {}
|
||||||
self._url_index: dict[str, str] = {} # normalized url -> id
|
self._url_index: dict[str, str] = {} # normalized url -> id
|
||||||
self._pending_urls: set[str] = set()
|
self._pending_urls: set[str] = set()
|
||||||
|
self._checks_in_flight: set[str] = set() # subscription ids being checked right now
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._loop_task: Optional[asyncio.Task] = None
|
self._loop_task: Optional[asyncio.Task] = None
|
||||||
self._load_all()
|
self._load_all()
|
||||||
@@ -677,6 +678,22 @@ class SubscriptionManager:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
||||||
|
sid = sub.id
|
||||||
|
# Prevent overlapping checks for the same subscription (e.g. the periodic
|
||||||
|
# loop and a manual check-now firing together), which could double-queue
|
||||||
|
# entries and drop seen_ids via a read-modify-write race.
|
||||||
|
async with self._lock:
|
||||||
|
if sid in self._checks_in_flight:
|
||||||
|
log.info("Subscription check already in progress for %s, skipping", sub.name)
|
||||||
|
return
|
||||||
|
self._checks_in_flight.add(sid)
|
||||||
|
try:
|
||||||
|
await self._check_one_inner(sub)
|
||||||
|
finally:
|
||||||
|
async with self._lock:
|
||||||
|
self._checks_in_flight.discard(sid)
|
||||||
|
|
||||||
|
async def _check_one_inner(self, sub: SubscriptionInfo) -> None:
|
||||||
sid = sub.id
|
sid = sub.id
|
||||||
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
||||||
log.info("Checking subscription: %s", sub.name)
|
log.info("Checking subscription: %s", sub.name)
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ class ConfigTests(unittest.TestCase):
|
|||||||
self.assertEqual(c.PUBLIC_HOST_URL, "")
|
self.assertEqual(c.PUBLIC_HOST_URL, "")
|
||||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
|
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
|
||||||
|
|
||||||
|
def test_blank_audio_host_falls_back_to_audio_download_route(self):
|
||||||
|
# Regression: a present-but-blank PUBLIC_HOST_AUDIO_URL must not stay empty
|
||||||
|
# (which produced root-relative, 404ing audio links). It falls back to the
|
||||||
|
# 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR.
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
_base_env(PUBLIC_HOST_URL="https://ytdl.example.com", PUBLIC_HOST_AUDIO_URL=""),
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
c = Config()
|
||||||
|
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
|
||||||
|
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "audio_download/")
|
||||||
|
|
||||||
def test_public_host_url_already_slashed_unchanged(self):
|
def test_public_host_url_already_slashed_unchanged(self):
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
@@ -123,6 +136,29 @@ class ConfigTests(unittest.TestCase):
|
|||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
Config()
|
Config()
|
||||||
|
|
||||||
|
def test_invalid_max_concurrent_downloads_exits(self):
|
||||||
|
for bad in ("0", "-1", "abc"):
|
||||||
|
with patch.dict(os.environ, _base_env(MAX_CONCURRENT_DOWNLOADS=bad), clear=False):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
Config()
|
||||||
|
|
||||||
|
def test_invalid_port_exits(self):
|
||||||
|
for bad in ("0", "70000", "notaport"):
|
||||||
|
with patch.dict(os.environ, _base_env(PORT=bad), clear=False):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
Config()
|
||||||
|
|
||||||
|
def test_invalid_clear_completed_after_exits(self):
|
||||||
|
for bad in ("-5", "soon"):
|
||||||
|
with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER=bad), clear=False):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
Config()
|
||||||
|
|
||||||
|
def test_clear_completed_after_zero_allowed(self):
|
||||||
|
with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER="0"), clear=False):
|
||||||
|
c = Config()
|
||||||
|
self.assertEqual(c.CLEAR_COMPLETED_AFTER, "0")
|
||||||
|
|
||||||
def test_runtime_override_roundtrip(self):
|
def test_runtime_override_roundtrip(self):
|
||||||
with patch.dict(os.environ, _base_env(), clear=False):
|
with patch.dict(os.environ, _base_env(), clear=False):
|
||||||
c = Config()
|
c = Config()
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import tempfile
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import time
|
||||||
|
|
||||||
from ytdl import DownloadQueue
|
from ytdl import DownloadInfo, DownloadQueue
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -386,3 +387,303 @@ async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
|||||||
download = dq.pending.get("https://example.com/clip")
|
download = dq.pending.get("https://example.com/clip")
|
||||||
assert download.info.clip_start == 10.0
|
assert download.info.clip_start == 10.0
|
||||||
assert download.info.clip_end == 99.5
|
assert download.info.clip_end == 99.5
|
||||||
|
|
||||||
|
|
||||||
|
def _upcoming_entry(url: str, *, release_timestamp: float | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "live1",
|
||||||
|
"title": "Upcoming Stream",
|
||||||
|
"url": url,
|
||||||
|
"webpage_url": url,
|
||||||
|
"live_status": "is_upcoming",
|
||||||
|
"release_timestamp": release_timestamp if release_timestamp is not None else time.time() + 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_upcoming_stream_scheduled_without_starting(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-upcoming"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
result = await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video",
|
||||||
|
"auto",
|
||||||
|
"any",
|
||||||
|
"best",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert dq.queue.exists(url)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
assert download.info.status == "scheduled"
|
||||||
|
assert download.info.live_status == "is_upcoming"
|
||||||
|
assert download.info.live_release_timestamp is not None
|
||||||
|
start_mock.assert_not_called()
|
||||||
|
assert url in dq._scheduled_probe_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_scheduled_starts_when_live(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-upcoming"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video",
|
||||||
|
"auto",
|
||||||
|
"any",
|
||||||
|
"best",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
|
||||||
|
def fake_probe_extract(self, probe_url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||||
|
assert probe_url == url
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "live1",
|
||||||
|
"title": "Live Now",
|
||||||
|
"url": url,
|
||||||
|
"webpage_url": url,
|
||||||
|
"live_status": "is_live",
|
||||||
|
"formats": [{"format_id": "22"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_probe_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
assert url not in dq._scheduled_probe_at
|
||||||
|
assert download.info.live_status == "is_live"
|
||||||
|
assert download.info.status == "pending"
|
||||||
|
start_mock.assert_called_once_with(download)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_scheduled_re_registers_monitor(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-restart"
|
||||||
|
release = time.time() + 7200
|
||||||
|
|
||||||
|
info = DownloadInfo(
|
||||||
|
id="live1",
|
||||||
|
title="Upcoming Stream",
|
||||||
|
url=url,
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error=None,
|
||||||
|
entry=None,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
live_status="is_upcoming",
|
||||||
|
live_release_timestamp=release,
|
||||||
|
)
|
||||||
|
info.status = "scheduled"
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq._DownloadQueue__add_download(info, True)
|
||||||
|
|
||||||
|
assert dq.queue.exists(url)
|
||||||
|
assert dq.queue.get(url).info.status == "scheduled"
|
||||||
|
assert url in dq._scheduled_probe_at
|
||||||
|
start_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_transient_error_retries_without_failing(dq_env):
|
||||||
|
"""A single probe failure must not abandon the scheduled stream."""
|
||||||
|
import ytdl
|
||||||
|
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-transient"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video", "auto", "any", "best", "", "", 0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
|
||||||
|
def boom(self, *args, **kwargs):
|
||||||
|
raise ytdl.yt_dlp.utils.YoutubeDLError("temporary network glitch")
|
||||||
|
|
||||||
|
before = time.time()
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
# Still scheduled, still monitored, probe rescheduled into the future.
|
||||||
|
assert download.info.status == "scheduled"
|
||||||
|
assert url in dq._scheduled_probe_at
|
||||||
|
assert dq._scheduled_probe_at[url] >= before
|
||||||
|
assert dq._scheduled_probe_failures[url] == 1
|
||||||
|
notifier.completed.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_gives_up_after_max_failures(dq_env):
|
||||||
|
import ytdl
|
||||||
|
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-dead"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video", "auto", "any", "best", "", "", 0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
|
||||||
|
def boom(self, *args, **kwargs):
|
||||||
|
raise ytdl.yt_dlp.utils.YoutubeDLError("stream was deleted")
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||||
|
for _ in range(ytdl._LIVE_PROBE_MAX_FAILURES):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
assert url not in dq._scheduled_probe_at
|
||||||
|
assert not dq.queue.exists(url)
|
||||||
|
assert dq.done.exists(url)
|
||||||
|
assert download.info.status == "error"
|
||||||
|
notifier.completed.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_recovers_after_transient_then_starts(dq_env):
|
||||||
|
"""A transient failure followed by a successful live probe should start the download."""
|
||||||
|
import ytdl
|
||||||
|
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-recover"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video", "auto", "any", "best", "", "", 0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
# The scheduling placeholder error is set on add.
|
||||||
|
assert download.info.error
|
||||||
|
|
||||||
|
def boom(self, *args, **kwargs):
|
||||||
|
raise ytdl.yt_dlp.utils.YoutubeDLError("temporary glitch")
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
assert dq._scheduled_probe_failures[url] == 1
|
||||||
|
|
||||||
|
def live_now(self, *args, **kwargs):
|
||||||
|
return {
|
||||||
|
"_type": "video", "id": "live1", "title": "Live Now",
|
||||||
|
"url": url, "webpage_url": url, "live_status": "is_live",
|
||||||
|
"formats": [{"format_id": "22"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", live_now), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
assert url not in dq._scheduled_probe_at
|
||||||
|
assert url not in dq._scheduled_probe_failures
|
||||||
|
assert download.info.status == "pending"
|
||||||
|
# Placeholder error/msg cleared now that a real download is starting.
|
||||||
|
assert download.info.error is None
|
||||||
|
assert download.info.msg is None
|
||||||
|
start_mock.assert_called_once_with(download)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seconds_until_next_probe_none_when_empty(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
assert dq._seconds_until_next_probe() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_download_path_allows_subfolder(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
path, err = dq._DownloadQueue__calc_download_path("video", "sub/dir")
|
||||||
|
assert err is None
|
||||||
|
assert os.path.realpath(path) == os.path.join(os.path.realpath(dq_env.DOWNLOAD_DIR), "sub", "dir")
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_download_path_rejects_sibling_prefix_escape(dq_env):
|
||||||
|
"""A folder resolving to a sibling sharing a name prefix must be rejected.
|
||||||
|
|
||||||
|
Regression test: ``startswith`` would have accepted ``../downloads-secret``
|
||||||
|
when the base directory is ``.../downloads``.
|
||||||
|
"""
|
||||||
|
notifier = AsyncMock()
|
||||||
|
base = os.path.realpath(dq_env.DOWNLOAD_DIR)
|
||||||
|
sibling = base + "-secret"
|
||||||
|
os.makedirs(sibling, exist_ok=True)
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
escape_folder = os.path.join("..", os.path.basename(sibling), "x")
|
||||||
|
path, err = dq._DownloadQueue__calc_download_path("video", escape_folder)
|
||||||
|
assert path is None
|
||||||
|
assert err is not None and err["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_download_path_rejects_parent_escape(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
path, err = dq._DownloadQueue__calc_download_path("video", "../../etc")
|
||||||
|
assert path is None
|
||||||
|
assert err is not None and err["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_info_to_public_dict_excludes_server_only_fields():
|
||||||
|
info = DownloadInfo(
|
||||||
|
id="vid1",
|
||||||
|
title="Test Video",
|
||||||
|
url="https://example.com/watch?v=1",
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error=None,
|
||||||
|
entry={"id": "vid1", "huge": "x" * 100000},
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
)
|
||||||
|
info.subtitle_files = [{"filename": "a.srt", "size": 10}]
|
||||||
|
public = info.to_public_dict()
|
||||||
|
assert "entry" not in public
|
||||||
|
assert "subtitle_files" not in public
|
||||||
|
# Client-facing fields are still present.
|
||||||
|
assert public["url"] == "https://example.com/watch?v=1"
|
||||||
|
assert public["title"] == "Test Video"
|
||||||
|
assert public["status"] == "pending"
|
||||||
|
|||||||
+255
-17
@@ -24,6 +24,12 @@ from subscriptions import _entry_id
|
|||||||
|
|
||||||
log = logging.getLogger('ytdl')
|
log = logging.getLogger('ytdl')
|
||||||
|
|
||||||
|
_LIVE_CHECK_INTERVAL = 60
|
||||||
|
_LIVE_MAX_CHECK_INTERVAL = 3600
|
||||||
|
# Consecutive probe failures (network blips, rate limits, transient extractor
|
||||||
|
# errors) tolerated before a scheduled live download is abandoned as errored.
|
||||||
|
_LIVE_PROBE_MAX_FAILURES = 5
|
||||||
|
|
||||||
|
|
||||||
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
||||||
# sanitised when substituting playlist/channel titles into output templates so
|
# sanitised when substituting playlist/channel titles into output templates so
|
||||||
@@ -194,6 +200,8 @@ class DownloadInfo:
|
|||||||
ytdl_options_overrides=None,
|
ytdl_options_overrides=None,
|
||||||
clip_start=None,
|
clip_start=None,
|
||||||
clip_end=None,
|
clip_end=None,
|
||||||
|
live_status=None,
|
||||||
|
live_release_timestamp=None,
|
||||||
):
|
):
|
||||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||||
@@ -220,8 +228,24 @@ class DownloadInfo:
|
|||||||
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
||||||
self.clip_start = clip_start
|
self.clip_start = clip_start
|
||||||
self.clip_end = clip_end
|
self.clip_end = clip_end
|
||||||
|
self.live_status = live_status
|
||||||
|
self.live_release_timestamp = live_release_timestamp
|
||||||
self.subtitle_files = []
|
self.subtitle_files = []
|
||||||
|
|
||||||
|
# Fields that are useful server-side but must not be broadcast to browser
|
||||||
|
# clients: ``entry`` is the full yt-dlp info-dict (potentially large and
|
||||||
|
# re-sent on every progress tick) and ``subtitle_files`` is only used
|
||||||
|
# internally to derive the primary caption ``filename``.
|
||||||
|
_PUBLIC_EXCLUDED_FIELDS = ("entry", "subtitle_files")
|
||||||
|
|
||||||
|
def to_public_dict(self) -> dict:
|
||||||
|
"""Return the client-facing view, omitting server-only/bulky fields."""
|
||||||
|
return {
|
||||||
|
k: v
|
||||||
|
for k, v in self.__dict__.items()
|
||||||
|
if k not in self._PUBLIC_EXCLUDED_FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
"""BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files."""
|
"""BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files."""
|
||||||
self.__dict__.update(state)
|
self.__dict__.update(state)
|
||||||
@@ -292,6 +316,10 @@ class DownloadInfo:
|
|||||||
self.clip_start = None
|
self.clip_start = None
|
||||||
if not hasattr(self, "clip_end"):
|
if not hasattr(self, "clip_end"):
|
||||||
self.clip_end = None
|
self.clip_end = None
|
||||||
|
if not hasattr(self, "live_status"):
|
||||||
|
self.live_status = None
|
||||||
|
if not hasattr(self, "live_release_timestamp"):
|
||||||
|
self.live_release_timestamp = None
|
||||||
|
|
||||||
|
|
||||||
_PERSISTED_DOWNLOAD_FIELDS = (
|
_PERSISTED_DOWNLOAD_FIELDS = (
|
||||||
@@ -313,6 +341,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
|||||||
"ytdl_options_overrides",
|
"ytdl_options_overrides",
|
||||||
"clip_start",
|
"clip_start",
|
||||||
"clip_end",
|
"clip_end",
|
||||||
|
"live_status",
|
||||||
|
"live_release_timestamp",
|
||||||
"status",
|
"status",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"error",
|
"error",
|
||||||
@@ -568,7 +598,10 @@ class Download:
|
|||||||
self.info.filename = rel_name
|
self.info.filename = rel_name
|
||||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||||
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
||||||
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
# The thumbnail convertor always emits a .jpg, but yt-dlp may
|
||||||
|
# report the pre-conversion media/thumbnail extension
|
||||||
|
# (.webm/.mp4/.png/.webp/...). Normalise to .jpg regardless.
|
||||||
|
self.info.filename = os.path.splitext(self.info.filename)[0] + '.jpg'
|
||||||
|
|
||||||
# Handle chapter files
|
# Handle chapter files
|
||||||
log.debug(f"Update status for {self.info.title}: {status}")
|
log.debug(f"Update status for {self.info.title}: {status}")
|
||||||
@@ -631,8 +664,8 @@ class PersistentQueue:
|
|||||||
def __init__(self, name, path):
|
def __init__(self, name, path):
|
||||||
self.identifier = name
|
self.identifier = name
|
||||||
pdir = os.path.dirname(path)
|
pdir = os.path.dirname(path)
|
||||||
if not os.path.isdir(pdir):
|
if pdir and not os.path.isdir(pdir):
|
||||||
os.mkdir(pdir)
|
os.makedirs(pdir, exist_ok=True)
|
||||||
self.legacy_path = path
|
self.legacy_path = path
|
||||||
self.path = f"{path}.json"
|
self.path = f"{path}.json"
|
||||||
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
|
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
|
||||||
@@ -757,6 +790,10 @@ class DownloadQueue:
|
|||||||
self.done.load()
|
self.done.load()
|
||||||
self._add_generation = 0
|
self._add_generation = 0
|
||||||
self._canceled_urls = set() # URLs canceled during current playlist add
|
self._canceled_urls = set() # URLs canceled during current playlist add
|
||||||
|
self._scheduled_probe_at: dict[str, float] = {}
|
||||||
|
self._scheduled_probe_failures: dict[str, int] = {}
|
||||||
|
self._live_monitor_task: Optional[asyncio.Task] = None
|
||||||
|
self._live_monitor_wakeup = asyncio.Event()
|
||||||
|
|
||||||
def cancel_add(self):
|
def cancel_add(self):
|
||||||
self._add_generation += 1
|
self._add_generation += 1
|
||||||
@@ -772,9 +809,165 @@ class DownloadQueue:
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
log.info("Initializing DownloadQueue")
|
log.info("Initializing DownloadQueue")
|
||||||
|
self._start_live_monitor()
|
||||||
asyncio.create_task(self.__import_queue())
|
asyncio.create_task(self.__import_queue())
|
||||||
asyncio.create_task(self.__import_pending())
|
asyncio.create_task(self.__import_pending())
|
||||||
|
|
||||||
|
def _start_live_monitor(self) -> None:
|
||||||
|
if self._live_monitor_task is not None and not self._live_monitor_task.done():
|
||||||
|
return
|
||||||
|
self._live_monitor_task = asyncio.create_task(self._live_monitor_loop())
|
||||||
|
self._live_monitor_task.add_done_callback(
|
||||||
|
lambda t: log.error("Live monitor loop failed: %s", t.exception())
|
||||||
|
if not t.cancelled() and t.exception()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register_scheduled(self, download: Download) -> None:
|
||||||
|
self._scheduled_probe_at[download.info.url] = 0
|
||||||
|
self._scheduled_probe_failures.pop(download.info.url, None)
|
||||||
|
self._start_live_monitor()
|
||||||
|
self._wake_live_monitor()
|
||||||
|
|
||||||
|
def _unregister_scheduled(self, url: str) -> None:
|
||||||
|
self._scheduled_probe_at.pop(url, None)
|
||||||
|
self._scheduled_probe_failures.pop(url, None)
|
||||||
|
|
||||||
|
def _wake_live_monitor(self) -> None:
|
||||||
|
try:
|
||||||
|
self._live_monitor_wakeup.set()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _probe_interval_seconds(self, release_timestamp: Any) -> float:
|
||||||
|
if release_timestamp is not None:
|
||||||
|
try:
|
||||||
|
diff = float(release_timestamp) - time.time()
|
||||||
|
if diff > 0:
|
||||||
|
return max(_LIVE_CHECK_INTERVAL, min(diff, _LIVE_MAX_CHECK_INTERVAL))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return float(_LIVE_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
def _seconds_until_next_probe(self) -> Optional[float]:
|
||||||
|
"""Time until the earliest scheduled probe, or None when nothing is scheduled."""
|
||||||
|
if not self._scheduled_probe_at:
|
||||||
|
return None
|
||||||
|
return max(0.0, min(self._scheduled_probe_at.values()) - time.time())
|
||||||
|
|
||||||
|
async def _live_monitor_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
timeout = self._seconds_until_next_probe()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._live_monitor_wakeup.wait(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
self._live_monitor_wakeup.clear()
|
||||||
|
now = time.time()
|
||||||
|
due: list[Download] = []
|
||||||
|
for url, probe_at in list(self._scheduled_probe_at.items()):
|
||||||
|
if now < probe_at:
|
||||||
|
continue
|
||||||
|
if not self.queue.exists(url):
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
continue
|
||||||
|
download = self.queue.get(url)
|
||||||
|
if download.info.status != 'scheduled' or download.canceled:
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
continue
|
||||||
|
due.append(download)
|
||||||
|
for download in due:
|
||||||
|
try:
|
||||||
|
await self._probe_scheduled_download(download)
|
||||||
|
except Exception as exc:
|
||||||
|
# Defensive: _probe_scheduled_download handles its own errors,
|
||||||
|
# but never let an unexpected failure leave probe_at in the past
|
||||||
|
# (which would spin this loop) or kill the monitor task.
|
||||||
|
log.exception("Scheduled live probe crashed for %s: %s", download.info.url, exc)
|
||||||
|
if download.info.url in self._scheduled_probe_at:
|
||||||
|
self._scheduled_probe_at[download.info.url] = time.time() + _LIVE_CHECK_INTERVAL
|
||||||
|
|
||||||
|
async def _probe_scheduled_download(self, download: Download) -> None:
|
||||||
|
url = download.info.url
|
||||||
|
info = download.info
|
||||||
|
if info.status != 'scheduled' or download.canceled:
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry = await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
partial(
|
||||||
|
self.__extract_info,
|
||||||
|
url,
|
||||||
|
getattr(info, 'ytdl_options_presets', None),
|
||||||
|
getattr(info, 'ytdl_options_overrides', {}) or {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Treat all probe failures (transient network blips, rate limits,
|
||||||
|
# extractor errors) as recoverable up to a point: retry on the next
|
||||||
|
# interval and only give up after repeated consecutive failures so a
|
||||||
|
# momentary glitch doesn't abandon a stream the user is waiting for.
|
||||||
|
fails = self._scheduled_probe_failures.get(url, 0) + 1
|
||||||
|
self._scheduled_probe_failures[url] = fails
|
||||||
|
if fails >= _LIVE_PROBE_MAX_FAILURES:
|
||||||
|
log.warning(
|
||||||
|
"Giving up on scheduled live probe for %s after %d consecutive failures: %s",
|
||||||
|
info.title, fails, exc,
|
||||||
|
)
|
||||||
|
info.status = 'error'
|
||||||
|
info.msg = str(exc)
|
||||||
|
if not info.error:
|
||||||
|
info.error = str(exc)
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
self.queue.delete(url)
|
||||||
|
self.done.put(download)
|
||||||
|
await self.notifier.completed(info)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"Scheduled live probe failed for %s (attempt %d/%d), will retry: %s",
|
||||||
|
info.title, fails, _LIVE_PROBE_MAX_FAILURES, exc,
|
||||||
|
)
|
||||||
|
self._scheduled_probe_at[url] = time.time() + _LIVE_CHECK_INTERVAL
|
||||||
|
return
|
||||||
|
|
||||||
|
# Successful probe resets the transient-failure streak.
|
||||||
|
self._scheduled_probe_failures.pop(url, None)
|
||||||
|
|
||||||
|
release_ts = entry.get('release_timestamp')
|
||||||
|
live_status = entry.get('live_status')
|
||||||
|
if release_ts is not None:
|
||||||
|
info.live_release_timestamp = release_ts
|
||||||
|
if live_status is not None:
|
||||||
|
info.live_status = live_status
|
||||||
|
|
||||||
|
if live_status == 'is_upcoming':
|
||||||
|
self._scheduled_probe_at[url] = time.time() + self._probe_interval_seconds(release_ts)
|
||||||
|
await self.notifier.updated(info)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
info.status = 'pending'
|
||||||
|
# Clear the "scheduled to start at ..." placeholder now that the stream
|
||||||
|
# is live and a real download is about to begin.
|
||||||
|
info.error = None
|
||||||
|
info.msg = None
|
||||||
|
await self.notifier.updated(info)
|
||||||
|
asyncio.create_task(self.__start_download(download))
|
||||||
|
|
||||||
|
def _schedule_upcoming_download(self, download: Download) -> None:
|
||||||
|
download.info.status = 'scheduled'
|
||||||
|
self.queue.put(download)
|
||||||
|
self._register_scheduled(download)
|
||||||
|
|
||||||
|
def _force_start_scheduled(self, download: Download) -> None:
|
||||||
|
self._unregister_scheduled(download.info.url)
|
||||||
|
download.info.status = 'pending'
|
||||||
|
download.info.error = None
|
||||||
|
download.info.msg = None
|
||||||
|
asyncio.create_task(self.__start_download(download))
|
||||||
|
|
||||||
async def __start_download(self, download):
|
async def __start_download(self, download):
|
||||||
if download.canceled:
|
if download.canceled:
|
||||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||||
@@ -850,7 +1043,16 @@ class DownloadQueue:
|
|||||||
return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
||||||
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
||||||
real_base_directory = os.path.realpath(base_directory)
|
real_base_directory = os.path.realpath(base_directory)
|
||||||
if not dldirectory.startswith(real_base_directory):
|
# Use commonpath rather than startswith so that a sibling directory
|
||||||
|
# sharing a name prefix (e.g. base "/downloads" vs "/downloads-secret")
|
||||||
|
# cannot be reached via "../downloads-secret".
|
||||||
|
try:
|
||||||
|
inside_base = os.path.commonpath([real_base_directory, dldirectory]) == real_base_directory
|
||||||
|
except ValueError:
|
||||||
|
# Raised when paths are on different drives (Windows) or mix
|
||||||
|
# absolute/relative; treat as outside the base directory.
|
||||||
|
inside_base = False
|
||||||
|
if not inside_base:
|
||||||
return None, {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"'}
|
return None, {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"'}
|
||||||
if not os.path.isdir(dldirectory):
|
if not os.path.isdir(dldirectory):
|
||||||
if not self.config.CREATE_CUSTOM_DIRS:
|
if not self.config.CREATE_CUSTOM_DIRS:
|
||||||
@@ -886,9 +1088,16 @@ class DownloadQueue:
|
|||||||
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
||||||
ytdl_options['playlistend'] = playlist_item_limit
|
ytdl_options['playlistend'] = playlist_item_limit
|
||||||
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
||||||
|
is_upcoming = (
|
||||||
|
getattr(dl, 'live_status', None) == 'is_upcoming'
|
||||||
|
or getattr(dl, 'status', None) == 'scheduled'
|
||||||
|
)
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
self.queue.put(download)
|
if is_upcoming:
|
||||||
asyncio.create_task(self.__start_download(download))
|
self._schedule_upcoming_download(download)
|
||||||
|
else:
|
||||||
|
self.queue.put(download)
|
||||||
|
asyncio.create_task(self.__start_download(download))
|
||||||
else:
|
else:
|
||||||
self.pending.put(download)
|
self.pending.put(download)
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
@@ -1036,6 +1245,8 @@ class DownloadQueue:
|
|||||||
ytdl_options_overrides=ytdl_options_overrides,
|
ytdl_options_overrides=ytdl_options_overrides,
|
||||||
clip_start=clip_start,
|
clip_start=clip_start,
|
||||||
clip_end=clip_end,
|
clip_end=clip_end,
|
||||||
|
live_status=entry.get('live_status'),
|
||||||
|
live_release_timestamp=entry.get('release_timestamp'),
|
||||||
)
|
)
|
||||||
await self.__add_download(dl, auto_start)
|
await self.__add_download(dl, auto_start)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
@@ -1156,13 +1367,21 @@ class DownloadQueue:
|
|||||||
|
|
||||||
async def start_pending(self, ids):
|
async def start_pending(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.pending.exists(id):
|
if self.pending.exists(id):
|
||||||
log.warning(f'requested start for non-existent download {id}')
|
dl = self.pending.get(id)
|
||||||
|
self.pending.delete(id)
|
||||||
|
if getattr(dl.info, 'live_status', None) == 'is_upcoming':
|
||||||
|
self._schedule_upcoming_download(dl)
|
||||||
|
else:
|
||||||
|
self.queue.put(dl)
|
||||||
|
asyncio.create_task(self.__start_download(dl))
|
||||||
continue
|
continue
|
||||||
dl = self.pending.get(id)
|
if self.queue.exists(id):
|
||||||
self.queue.put(dl)
|
dl = self.queue.get(id)
|
||||||
self.pending.delete(id)
|
if dl.info.status == 'scheduled':
|
||||||
asyncio.create_task(self.__start_download(dl))
|
self._force_start_scheduled(dl)
|
||||||
|
continue
|
||||||
|
log.warning(f'requested start for non-existent download {id}')
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
@@ -1177,6 +1396,8 @@ class DownloadQueue:
|
|||||||
log.warning(f'requested cancel for non-existent download {id}')
|
log.warning(f'requested cancel for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
dl = self.queue.get(id)
|
dl = self.queue.get(id)
|
||||||
|
if dl.info.status == 'scheduled':
|
||||||
|
self._unregister_scheduled(id)
|
||||||
if dl.started():
|
if dl.started():
|
||||||
dl.cancel()
|
dl.cancel()
|
||||||
else:
|
else:
|
||||||
@@ -1192,11 +1413,28 @@ class DownloadQueue:
|
|||||||
continue
|
continue
|
||||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||||
dl = self.done.get(id)
|
dl = self.done.get(id)
|
||||||
try:
|
dldirectory, calc_error = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||||
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
if calc_error is not None or not dldirectory:
|
||||||
os.remove(os.path.join(dldirectory, dl.info.filename))
|
log.warning(f'deleting files for download {id} skipped: could not resolve download directory')
|
||||||
except Exception as e:
|
else:
|
||||||
log.warning(f'deleting file for download {id} failed with error message {e!r}')
|
# Remove the primary output plus any per-chapter / per-subtitle
|
||||||
|
# outputs. Each filename is relative to the download directory.
|
||||||
|
rel_names = []
|
||||||
|
if getattr(dl.info, 'filename', None):
|
||||||
|
rel_names.append(dl.info.filename)
|
||||||
|
for extra in (getattr(dl.info, 'chapter_files', None) or []):
|
||||||
|
if isinstance(extra, dict) and extra.get('filename'):
|
||||||
|
rel_names.append(extra['filename'])
|
||||||
|
for extra in (getattr(dl.info, 'subtitle_files', None) or []):
|
||||||
|
if isinstance(extra, dict) and extra.get('filename'):
|
||||||
|
rel_names.append(extra['filename'])
|
||||||
|
for rel_name in rel_names:
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(dldirectory, rel_name))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except OSError as e:
|
||||||
|
log.warning(f'deleting file "{rel_name}" for download {id} failed with error message {e!r}')
|
||||||
self.done.delete(id)
|
self.done.delete(id)
|
||||||
await self.notifier.cleared(id)
|
await self.notifier.cleared(id)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|||||||
+13
-13
@@ -23,14 +23,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.14",
|
"@angular/animations": "^21.2.17",
|
||||||
"@angular/common": "^21.2.14",
|
"@angular/common": "^21.2.17",
|
||||||
"@angular/compiler": "^21.2.14",
|
"@angular/compiler": "^21.2.17",
|
||||||
"@angular/core": "^21.2.14",
|
"@angular/core": "^21.2.17",
|
||||||
"@angular/forms": "^21.2.14",
|
"@angular/forms": "^21.2.17",
|
||||||
"@angular/platform-browser": "^21.2.14",
|
"@angular/platform-browser": "^21.2.17",
|
||||||
"@angular/platform-browser-dynamic": "^21.2.14",
|
"@angular/platform-browser-dynamic": "^21.2.17",
|
||||||
"@angular/service-worker": "^21.2.14",
|
"@angular/service-worker": "^21.2.17",
|
||||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
@@ -48,16 +48,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "21.1.0",
|
"@angular-eslint/builder": "21.1.0",
|
||||||
"@angular/build": "^21.2.13",
|
"@angular/build": "^21.2.15",
|
||||||
"@angular/cli": "^21.2.13",
|
"@angular/cli": "^21.2.15",
|
||||||
"@angular/compiler-cli": "^21.2.14",
|
"@angular/compiler-cli": "^21.2.17",
|
||||||
"@angular/localize": "^21.2.14",
|
"@angular/localize": "^21.2.17",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"angular-eslint": "21.1.0",
|
"angular-eslint": "21.1.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "8.47.0",
|
"typescript-eslint": "8.47.0",
|
||||||
"vitest": "^4.1.7"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+552
-555
File diff suppressed because it is too large
Load Diff
+21
-4
@@ -706,16 +706,31 @@
|
|||||||
</td>
|
</td>
|
||||||
<td title="{{ download.value.filename }}">
|
<td title="{{ download.value.filename }}">
|
||||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||||
<div>{{ download.value.title }} </div>
|
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||||
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
<span>{{ download.value.title }}</span>
|
||||||
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
||||||
|
<span class="badge bg-danger">LIVE</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (download.value.status === 'scheduled') {
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<fa-icon [icon]="faClock" />
|
||||||
|
Waiting for stream
|
||||||
|
@if (liveCountdownSeconds(download.value); as secs) {
|
||||||
|
- starts in {{ secs | eta }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
||||||
|
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ download.value.speed | speed }}</td>
|
<td>{{ download.value.speed | speed }}</td>
|
||||||
<td>{{ download.value.eta | eta }}</td>
|
<td>{{ download.value.eta | eta }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
@if (download.value.status === 'pending') {
|
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||||
@@ -1063,3 +1078,5 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<app-toast-container />
|
||||||
|
|||||||
+36
-3
@@ -4,6 +4,7 @@ import { Subject, of } from 'rxjs';
|
|||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { DownloadsService } from './services/downloads.service';
|
import { DownloadsService } from './services/downloads.service';
|
||||||
import { SubscriptionsService } from './services/subscriptions.service';
|
import { SubscriptionsService } from './services/subscriptions.service';
|
||||||
|
import { ToastService } from './services/toast.service';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
|
|
||||||
class DownloadsServiceStub {
|
class DownloadsServiceStub {
|
||||||
@@ -182,6 +183,37 @@ describe('App', () => {
|
|||||||
expect(payload.ytdlOptionsOverrides).toBe('');
|
expect(payload.ytdlOptionsOverrides).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows waiting badge for scheduled live stream', () => {
|
||||||
|
downloads.queue.set('https://example.com/live', {
|
||||||
|
id: 'live1',
|
||||||
|
title: 'Upcoming Stream',
|
||||||
|
url: 'https://example.com/live',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'scheduled',
|
||||||
|
live_status: 'is_upcoming',
|
||||||
|
live_release_timestamp: Date.now() / 1000 + 3600,
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
downloads.queueChanged.next();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.textContent).toContain('Waiting for stream');
|
||||||
|
expect(root.textContent).toContain('starts in');
|
||||||
|
});
|
||||||
|
|
||||||
it('includes titleRegex in subscribe payload', () => {
|
it('includes titleRegex in subscribe payload', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
@@ -232,7 +264,8 @@ describe('App', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('blocks subscribe with invalid title regex', () => {
|
it('blocks subscribe with invalid title regex', () => {
|
||||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
const toasts = TestBed.inject(ToastService);
|
||||||
|
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||||
@@ -240,7 +273,7 @@ describe('App', () => {
|
|||||||
app.titleRegex = '[';
|
app.titleRegex = '[';
|
||||||
app.addSubscription();
|
app.addSubscription();
|
||||||
expect(subs.subscribeCalls.length).toBe(0);
|
expect(subs.subscribeCalls.length).toBe(0);
|
||||||
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||||
alertSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+96
-93
@@ -13,6 +13,8 @@ import { CookieService } from 'ngx-cookie-service';
|
|||||||
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
||||||
import { MeTubeSocket } from './services/metube-socket.service';
|
import { MeTubeSocket } from './services/metube-socket.service';
|
||||||
import { SubscriptionsService } from './services/subscriptions.service';
|
import { SubscriptionsService } from './services/subscriptions.service';
|
||||||
|
import { ToastService } from './services/toast.service';
|
||||||
|
import { BatchUrlsService, BatchUrlFilter } from './services/batch-urls.service';
|
||||||
import { SubscriptionRow } from './interfaces/subscription';
|
import { SubscriptionRow } from './interfaces/subscription';
|
||||||
import { Themes } from './theme';
|
import { Themes } from './theme';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +34,7 @@ import {
|
|||||||
State,
|
State,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
||||||
import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/';
|
import { SelectAllCheckboxComponent, ItemCheckboxComponent, ToastContainerComponent } from './components/';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -50,6 +52,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
|||||||
FileSizePipe,
|
FileSizePipe,
|
||||||
SelectAllCheckboxComponent,
|
SelectAllCheckboxComponent,
|
||||||
ItemCheckboxComponent,
|
ItemCheckboxComponent,
|
||||||
|
ToastContainerComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.sass',
|
styleUrl: './app.sass',
|
||||||
@@ -57,6 +60,8 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
|||||||
export class App implements AfterViewInit, OnInit, OnDestroy {
|
export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
downloads = inject(DownloadsService);
|
downloads = inject(DownloadsService);
|
||||||
subscriptionsSvc = inject(SubscriptionsService);
|
subscriptionsSvc = inject(SubscriptionsService);
|
||||||
|
private toasts = inject(ToastService);
|
||||||
|
private batchUrls = inject(BatchUrlsService);
|
||||||
private socket = inject(MeTubeSocket);
|
private socket = inject(MeTubeSocket);
|
||||||
private cookieService = inject(CookieService);
|
private cookieService = inject(CookieService);
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
@@ -132,6 +137,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
lastCopiedErrorId: string | null = null;
|
lastCopiedErrorId: string | null = null;
|
||||||
private previousDownloadType = 'video';
|
private previousDownloadType = 'video';
|
||||||
private addRequestSub?: Subscription;
|
private addRequestSub?: Subscription;
|
||||||
|
private liveCountdownTimer?: ReturnType<typeof setInterval>;
|
||||||
private selectionsByType: Record<string, {
|
private selectionsByType: Record<string, {
|
||||||
codec: string;
|
codec: string;
|
||||||
format: string;
|
format: string;
|
||||||
@@ -285,6 +291,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// Subscribe to download updates
|
// Subscribe to download updates
|
||||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
|
this.syncLiveCountdownTimer();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
@@ -295,6 +302,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// Subscribe to real-time updates
|
// Subscribe to real-time updates
|
||||||
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
|
this.syncLiveCountdownTimer();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,6 +345,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.addRequestSub?.unsubscribe();
|
this.addRequestSub?.unsubscribe();
|
||||||
|
if (this.liveCountdownTimer) {
|
||||||
|
clearInterval(this.liveCountdownTimer);
|
||||||
|
}
|
||||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +420,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
const date = new Date(data['update_time'] * 1000);
|
const date = new Date(data['update_time'] * 1000);
|
||||||
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
||||||
}else{
|
}else{
|
||||||
alert("Error reload yt-dlp options: "+data['msg']);
|
this.toasts.error("Error reloading yt-dlp options: " + data['msg']);
|
||||||
}
|
}
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
}
|
}
|
||||||
@@ -484,11 +495,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed);
|
const parsed = JSON.parse(trimmed);
|
||||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||||
alert('Custom yt-dlp options must be a JSON object');
|
this.toasts.error('Custom yt-dlp options must be a JSON object');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Custom yt-dlp options must be valid JSON');
|
this.toasts.error('Custom yt-dlp options must be valid JSON');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -519,7 +530,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
||||||
const error = this.getStatusError(refreshRes);
|
const error = this.getStatusError(refreshRes);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Refresh subscriptions failed');
|
this.toasts.error(error || 'Refresh subscriptions failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
@@ -563,7 +574,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
const payload = this.buildAddPayload();
|
const payload = this.buildAddPayload();
|
||||||
if (!payload.url?.trim()) {
|
if (!payload.url?.trim()) {
|
||||||
alert('Please enter a URL');
|
this.toasts.error('Please enter a URL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tr = (this.titleRegex || '').trim();
|
const tr = (this.titleRegex || '').trim();
|
||||||
@@ -571,12 +582,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
void RegExp(tr);
|
void RegExp(tr);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Invalid subscription title filter (regex)');
|
this.toasts.error('Invalid subscription title filter (regex)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
||||||
alert('Chapter template must include %(section_number)');
|
this.toasts.error('Chapter template must include %(section_number)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||||
@@ -605,7 +616,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
next: (res) => {
|
next: (res) => {
|
||||||
const r = res as { status?: string; msg?: string };
|
const r = res as { status?: string; msg?: string };
|
||||||
if (r.status === 'error') {
|
if (r.status === 'error') {
|
||||||
alert(r.msg || 'Subscribe failed');
|
this.toasts.error(r.msg || 'Subscribe failed');
|
||||||
} else {
|
} else {
|
||||||
this.addUrl = '';
|
this.addUrl = '';
|
||||||
this.titleRegex = '';
|
this.titleRegex = '';
|
||||||
@@ -633,14 +644,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
void RegExp(raw);
|
void RegExp(raw);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Invalid subscription title filter (regex)');
|
this.toasts.error('Invalid subscription title filter (regex)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
|
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Update subscription failed');
|
this.toasts.error(error || 'Update subscription failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cancelEditTitleRegex();
|
this.cancelEditTitleRegex();
|
||||||
@@ -651,7 +662,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Delete subscription failed');
|
this.toasts.error(error || 'Delete subscription failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedSubscriptionIds.delete(id);
|
this.selectedSubscriptionIds.delete(id);
|
||||||
@@ -667,7 +678,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.delete(ids).subscribe((res) => {
|
this.subscriptionsSvc.delete(ids).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Delete subscriptions failed');
|
this.toasts.error(error || 'Delete subscriptions failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedSubscriptionIds.clear();
|
this.selectedSubscriptionIds.clear();
|
||||||
@@ -693,7 +704,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
.subscribe((res) => {
|
.subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Subscription check failed');
|
this.toasts.error(error || 'Subscription check failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.refreshSubscriptionsWithAlert();
|
this.refreshSubscriptionsWithAlert();
|
||||||
@@ -740,7 +751,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
.subscribe((res) => {
|
.subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Subscription check failed');
|
this.toasts.error(error || 'Subscription check failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.refreshSubscriptionsWithAlert();
|
this.refreshSubscriptionsWithAlert();
|
||||||
@@ -763,7 +774,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
|
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Update subscription failed');
|
this.toasts.error(error || 'Update subscription failed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1060,20 +1071,19 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Validate chapter template if chapter splitting is enabled
|
// Validate chapter template if chapter splitting is enabled
|
||||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
||||||
alert('Chapter template must include %(section_number)');
|
this.toasts.error('Chapter template must include %(section_number)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug('Downloading:', payload);
|
|
||||||
this.addInProgress = true;
|
this.addInProgress = true;
|
||||||
this.cancelRequested = false;
|
this.cancelRequested = false;
|
||||||
this.addRequestSub?.unsubscribe();
|
this.addRequestSub?.unsubscribe();
|
||||||
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
|
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
|
||||||
if (status.status === 'error' && !this.cancelRequested) {
|
if (status.status === 'error' && !this.cancelRequested) {
|
||||||
alert(`Error adding URL: ${status.msg}`);
|
this.toasts.error(`Error adding URL: ${status.msg}`);
|
||||||
} else if (status.status !== 'error') {
|
} else if (status.status !== 'error') {
|
||||||
this.addUrl = '';
|
this.addUrl = '';
|
||||||
}
|
}
|
||||||
@@ -1106,6 +1116,26 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.downloads.startById([id]).subscribe();
|
this.downloads.startById([id]).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveCountdownSeconds(download: Download): number | null {
|
||||||
|
const ts = download.live_release_timestamp;
|
||||||
|
if (ts == null || download.status !== 'scheduled') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.max(0, ts - Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncLiveCountdownTimer() {
|
||||||
|
const hasScheduled = Array.from(this.downloads.queue.values()).some(
|
||||||
|
(download) => download.status === 'scheduled',
|
||||||
|
);
|
||||||
|
if (hasScheduled && !this.liveCountdownTimer) {
|
||||||
|
this.liveCountdownTimer = setInterval(() => this.cdr.markForCheck(), 1000);
|
||||||
|
} else if (!hasScheduled && this.liveCountdownTimer) {
|
||||||
|
clearInterval(this.liveCountdownTimer);
|
||||||
|
this.liveCountdownTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
retryDownload(key: string, download: Download) {
|
retryDownload(key: string, download: Download) {
|
||||||
this.addDownload({
|
this.addDownload({
|
||||||
url: download.url,
|
url: download.url,
|
||||||
@@ -1159,19 +1189,39 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadSelectedFiles() {
|
// Chromium-based browsers silently drop programmatic downloads beyond ~10 when
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// triggered in a tight loop. Trigger in batches with a short pause in between so
|
||||||
this.downloads.done.forEach((dl, _) => {
|
// large selections download cleanly. See issue #1008.
|
||||||
|
private static readonly DOWNLOAD_BATCH_SIZE = 10;
|
||||||
|
private static readonly DOWNLOAD_BATCH_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
async downloadSelectedFiles() {
|
||||||
|
const selected: Download[] = [];
|
||||||
|
this.downloads.done.forEach((dl) => {
|
||||||
if (dl.status === 'finished' && dl.checked) {
|
if (dl.status === 'finished' && dl.checked) {
|
||||||
const link = document.createElement('a');
|
selected.push(dl);
|
||||||
link.href = this.buildDownloadLink(dl);
|
|
||||||
link.setAttribute('download', dl.filename);
|
|
||||||
link.setAttribute('target', '_self');
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < selected.length; i++) {
|
||||||
|
const dl = selected[i];
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = this.buildDownloadLink(dl);
|
||||||
|
link.setAttribute('download', dl.filename);
|
||||||
|
link.setAttribute('target', '_self');
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(i + 1) % App.DOWNLOAD_BATCH_SIZE === 0 &&
|
||||||
|
i + 1 < selected.length
|
||||||
|
) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, App.DOWNLOAD_BATCH_DELAY_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDownloadLink(download: Download) {
|
buildDownloadLink(download: Download) {
|
||||||
@@ -1215,10 +1265,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// file into memory only to have navigator.canShare reject it.
|
// file into memory only to have navigator.canShare reject it.
|
||||||
if (download.size && download.size > App.SHARE_SIZE_WARN_BYTES) {
|
if (download.size && download.size > App.SHARE_SIZE_WARN_BYTES) {
|
||||||
const sizeMb = Math.round(download.size / 1024 / 1024);
|
const sizeMb = Math.round(download.size / 1024 / 1024);
|
||||||
const proceed = window.confirm(
|
const proceed = await this.toasts.confirm(
|
||||||
`This file is ${sizeMb} MB. iOS' share sheet often refuses files ` +
|
`This file is ${sizeMb} MB. iOS' share sheet often refuses files ` +
|
||||||
`larger than ~100 MB and the share will silently fail. ` +
|
`larger than ~100 MB and the share will silently fail. ` +
|
||||||
`Try anyway? (Use the download button instead if it fails.)`
|
`Try anyway? (Use the download button instead if it fails.)`,
|
||||||
|
'Try anyway',
|
||||||
|
'Cancel',
|
||||||
);
|
);
|
||||||
if (!proceed) return;
|
if (!proceed) return;
|
||||||
}
|
}
|
||||||
@@ -1239,7 +1291,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// download button right next to this one instead of staring at
|
// download button right next to this one instead of staring at
|
||||||
// a button that quietly did nothing.
|
// a button that quietly did nothing.
|
||||||
console.warn('navigator.canShare rejected payload for', download.filename);
|
console.warn('navigator.canShare rejected payload for', download.filename);
|
||||||
window.alert(
|
this.toasts.error(
|
||||||
`Your device's share sheet doesn't accept this file ` +
|
`Your device's share sheet doesn't accept this file ` +
|
||||||
`(most likely because it's too large). ` +
|
`(most likely because it's too large). ` +
|
||||||
`Please use the download button instead.`
|
`Please use the download button instead.`
|
||||||
@@ -1252,7 +1304,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// AbortError = user dismissed the share sheet → silent no-op.
|
// AbortError = user dismissed the share sheet → silent no-op.
|
||||||
if (e.name === 'AbortError') return;
|
if (e.name === 'AbortError') return;
|
||||||
console.error('Share failed:', err);
|
console.error('Share failed:', err);
|
||||||
window.alert(
|
this.toasts.error(
|
||||||
`Share failed: ${e.message || 'unknown error'}. ` +
|
`Share failed: ${e.message || 'unknown error'}. ` +
|
||||||
`Please use the download button instead.`
|
`Please use the download button instead.`
|
||||||
);
|
);
|
||||||
@@ -1344,7 +1396,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
.map(url => url.trim())
|
.map(url => url.trim())
|
||||||
.filter(url => url.length > 0);
|
.filter(url => url.length > 0);
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
alert('No valid URLs found.');
|
this.toasts.error('No valid URLs found.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.importInProgress = true;
|
this.importInProgress = true;
|
||||||
@@ -1409,62 +1461,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
|
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||||
exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
exportBatchUrls(filter: BatchUrlFilter): void {
|
||||||
let urls: string[];
|
this.batchUrls.export(filter);
|
||||||
if (filter === 'pending') {
|
|
||||||
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
|
|
||||||
} else if (filter === 'completed') {
|
|
||||||
// Only finished downloads in the "done" Map
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
|
|
||||||
} else if (filter === 'failed') {
|
|
||||||
// Only error downloads from the "done" Map
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
|
|
||||||
} else {
|
|
||||||
// All: pending + both finished and error in done
|
|
||||||
urls = [
|
|
||||||
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
|
|
||||||
...Array.from(this.downloads.done.values()).map(dl => dl.url)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (!urls.length) {
|
|
||||||
alert('No URLs found for the selected filter.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = urls.join('\n');
|
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = downloadUrl;
|
|
||||||
a.download = 'metube_urls.txt';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
|
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||||
copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
copyBatchUrls(filter: BatchUrlFilter): void {
|
||||||
let urls: string[];
|
this.batchUrls.copy(filter);
|
||||||
if (filter === 'pending') {
|
|
||||||
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
|
|
||||||
} else if (filter === 'completed') {
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
|
|
||||||
} else if (filter === 'failed') {
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
|
|
||||||
} else {
|
|
||||||
urls = [
|
|
||||||
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
|
|
||||||
...Array.from(this.downloads.done.values()).map(dl => dl.url)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (!urls.length) {
|
|
||||||
alert('No URLs found for the selected filter.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = urls.join('\n');
|
|
||||||
navigator.clipboard.writeText(content)
|
|
||||||
.then(() => alert('URLs copied to clipboard.'))
|
|
||||||
.catch(() => alert('Failed to copy URLs.'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchVersionInfo(): void {
|
fetchVersionInfo(): void {
|
||||||
@@ -1524,7 +1527,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
const fail = (err?: unknown) => {
|
const fail = (err?: unknown) => {
|
||||||
console.error('Clipboard write failed:', err);
|
console.error('Clipboard write failed:', err);
|
||||||
alert('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
|
this.toasts.error('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
|
||||||
};
|
};
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
navigator.clipboard.writeText(text).then(done).catch(fail);
|
navigator.clipboard.writeText(text).then(done).catch(fail);
|
||||||
@@ -1560,7 +1563,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.hasCookies = true;
|
this.hasCookies = true;
|
||||||
} else {
|
} else {
|
||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
this.toasts.error(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||||
}
|
}
|
||||||
this.cookieUploadInProgress = false;
|
this.cookieUploadInProgress = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -1569,7 +1572,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
this.cookieUploadInProgress = false;
|
this.cookieUploadInProgress = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
alert('Error uploading cookies.');
|
this.toasts.error('Error uploading cookies.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1603,11 +1606,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
this.toasts.error(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
alert('Error deleting cookies.');
|
this.toasts.error('Error deleting cookies.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1631,7 +1634,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
speed += download.speed || 0;
|
speed += download.speed || 0;
|
||||||
} else if (download.status === 'preparing') {
|
} else if (download.status === 'preparing') {
|
||||||
active++;
|
active++;
|
||||||
} else if (download.status === 'pending') {
|
} else if (download.status === 'pending' || download.status === 'scheduled') {
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { SelectAllCheckboxComponent } from './master-checkbox.component';
|
export { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||||
export { ItemCheckboxComponent } from './slave-checkbox.component';
|
export { ItemCheckboxComponent } from './slave-checkbox.component';
|
||||||
|
export { ToastContainerComponent } from './toast-container.component';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { faCheckCircle, faTimesCircle, faInfoCircle, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-toast-container',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [FontAwesomeModule],
|
||||||
|
template: `
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1100;" aria-live="polite" aria-atomic="true">
|
||||||
|
@for (toast of toasts.toasts(); track toast.id) {
|
||||||
|
<div class="toast show align-items-center border-0 mb-2"
|
||||||
|
[class.text-bg-danger]="toast.level === 'error'"
|
||||||
|
[class.text-bg-success]="toast.level === 'success'"
|
||||||
|
[class.text-bg-primary]="toast.level === 'info'"
|
||||||
|
role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body d-flex align-items-start gap-2">
|
||||||
|
@if (toast.level === 'error') {
|
||||||
|
<fa-icon [icon]="faTimesCircle" class="mt-1" />
|
||||||
|
} @else if (toast.level === 'success') {
|
||||||
|
<fa-icon [icon]="faCheckCircle" class="mt-1" />
|
||||||
|
} @else {
|
||||||
|
<fa-icon [icon]="faInfoCircle" class="mt-1" />
|
||||||
|
}
|
||||||
|
<span style="white-space: pre-line;">{{ toast.message }}</span>
|
||||||
|
</div>
|
||||||
|
@if (!toast.actions) {
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto"
|
||||||
|
aria-label="Close" (click)="toasts.dismiss(toast.id)"></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (toast.actions) {
|
||||||
|
<div class="d-flex justify-content-end gap-2 px-3 pb-2">
|
||||||
|
@for (action of toast.actions; track action.label) {
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
[class.btn-light]="!action.primary"
|
||||||
|
[class.btn-outline-light]="action.primary"
|
||||||
|
(click)="toasts.respond(toast.id, action.value)">
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ToastContainerComponent {
|
||||||
|
protected readonly toasts = inject(ToastService);
|
||||||
|
protected readonly faCheckCircle = faCheckCircle;
|
||||||
|
protected readonly faTimesCircle = faTimesCircle;
|
||||||
|
protected readonly faInfoCircle = faInfoCircle;
|
||||||
|
protected readonly faXmark = faXmark;
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export interface Download {
|
|||||||
ytdl_options_overrides?: Record<string, unknown>;
|
ytdl_options_overrides?: Record<string, unknown>;
|
||||||
clip_start?: number;
|
clip_start?: number;
|
||||||
clip_end?: number;
|
clip_end?: number;
|
||||||
|
live_status?: string;
|
||||||
|
live_release_timestamp?: number;
|
||||||
status: string;
|
status: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { DownloadsService } from './downloads.service';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
|
export type BatchUrlFilter = 'pending' | 'completed' | 'failed' | 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates collecting download URLs by status and exporting/copying them.
|
||||||
|
* Extracted from the main app component to keep it focused on view concerns.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BatchUrlsService {
|
||||||
|
private downloads = inject(DownloadsService);
|
||||||
|
private toasts = inject(ToastService);
|
||||||
|
|
||||||
|
collect(filter: BatchUrlFilter): string[] {
|
||||||
|
const queueUrls = () => Array.from(this.downloads.queue.values()).map((dl) => dl.url);
|
||||||
|
const doneUrls = (status?: string) =>
|
||||||
|
Array.from(this.downloads.done.values())
|
||||||
|
.filter((dl) => status === undefined || dl.status === status)
|
||||||
|
.map((dl) => dl.url);
|
||||||
|
switch (filter) {
|
||||||
|
case 'pending':
|
||||||
|
return queueUrls();
|
||||||
|
case 'completed':
|
||||||
|
return doneUrls('finished');
|
||||||
|
case 'failed':
|
||||||
|
return doneUrls('error');
|
||||||
|
default:
|
||||||
|
return [...queueUrls(), ...doneUrls()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export(filter: BatchUrlFilter): void {
|
||||||
|
const urls = this.collect(filter);
|
||||||
|
if (!urls.length) {
|
||||||
|
this.toasts.info('No URLs found for the selected filter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([urls.join('\n')], { type: 'text/plain' });
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = 'metube_urls.txt';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(filter: BatchUrlFilter): void {
|
||||||
|
const urls = this.collect(filter);
|
||||||
|
if (!urls.length) {
|
||||||
|
this.toasts.info('No URLs found for the selected filter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(urls.join('\n'))
|
||||||
|
.then(() => this.toasts.success('URLs copied to clipboard.'))
|
||||||
|
.catch(() => this.toasts.error('Failed to copy URLs.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export { DownloadsService } from './downloads.service';
|
export { DownloadsService } from './downloads.service';
|
||||||
export { MeTubeSocket } from './metube-socket.service';
|
export { MeTubeSocket } from './metube-socket.service';
|
||||||
|
export { ToastService } from './toast.service';
|
||||||
|
export { BatchUrlsService } from './batch-urls.service';
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type ToastLevel = 'info' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
level: ToastLevel;
|
||||||
|
message: string;
|
||||||
|
actions?: ToastAction[];
|
||||||
|
/** Resolver for confirm() toasts; resolved when the user picks an action or dismisses. */
|
||||||
|
_resolve?: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight non-blocking notification service. Replaces the blocking
|
||||||
|
* window.alert()/confirm() dialogs that previously littered the app component.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ToastService {
|
||||||
|
private counter = 0;
|
||||||
|
readonly toasts = signal<Toast[]>([]);
|
||||||
|
|
||||||
|
info(message: string): void {
|
||||||
|
this.show('info', message, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string): void {
|
||||||
|
this.show('success', message, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string): void {
|
||||||
|
this.show('error', message, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a confirmation toast with confirm/cancel actions. Resolves true when
|
||||||
|
* confirmed, false when cancelled or auto-dismissed.
|
||||||
|
*/
|
||||||
|
confirm(message: string, confirmLabel = 'OK', cancelLabel = 'Cancel'): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const id = ++this.counter;
|
||||||
|
this.toasts.update((list) => [
|
||||||
|
...list,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
level: 'info',
|
||||||
|
message,
|
||||||
|
actions: [
|
||||||
|
{ label: cancelLabel, value: false },
|
||||||
|
{ label: confirmLabel, value: true, primary: true },
|
||||||
|
],
|
||||||
|
_resolve: resolve,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(id: number, value: boolean): void {
|
||||||
|
const toast = this.toasts().find((t) => t.id === id);
|
||||||
|
toast?._resolve?.(value);
|
||||||
|
this.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: number): void {
|
||||||
|
const toast = this.toasts().find((t) => t.id === id);
|
||||||
|
// A confirm toast dismissed without an explicit choice resolves to false.
|
||||||
|
toast?._resolve?.(false);
|
||||||
|
this.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(id: number): void {
|
||||||
|
this.toasts.update((list) => list.filter((t) => t.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(level: ToastLevel, message: string, autoDismissMs: number): void {
|
||||||
|
const id = ++this.counter;
|
||||||
|
this.toasts.update((list) => [...list, { id, level, message }]);
|
||||||
|
setTimeout(() => this.remove(id), autoDismissMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
version = "3.13.5"
|
version = "3.14.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohappyeyeballs" },
|
{ name = "aiohappyeyeballs" },
|
||||||
@@ -24,59 +24,72 @@ dependencies = [
|
|||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
{ name = "yarl" },
|
{ name = "yarl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/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/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -93,14 +106,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.13.0"
|
version = "4.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -301,38 +314,48 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "curl-cffi"
|
name = "curl-cffi"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "cffi" },
|
{ name = "cffi" },
|
||||||
|
{ name = "rich" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" },
|
{ url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deno"
|
name = "deno"
|
||||||
version = "2.8.1"
|
version = "2.8.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/43/eb/b743a520cdd668e070a4535296123f1c62d054b00699f58e49c32ab5925c/deno-2.8.1.tar.gz", hash = "sha256:fb65e568bef30b1a7e63f033713f1a6792a8456e339febdb7d638c6bb2c4c008", size = 8167, upload-time = "2026-05-27T13:01:06.508Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/db/ec/98f972cca4ca734534947bdf28b86a99e9ed8a5a31a061be2dec9b4105c2/deno-2.8.3.tar.gz", hash = "sha256:5413f4a1814ac3b8e441ca7fe9eb677a4152a42eef05efd56a61d750088a7fc8", size = 8163, upload-time = "2026-06-11T16:13:53.129Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/71/f9dc8ad874dcc26cf1a154c8f89d77e1155ced1f6a64be9d21127bd555ce/deno-2.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:733e24e4883c9516534cae7a10277048ffea7cc034f9f726e8c145d48ba75d19", size = 42749443, upload-time = "2026-05-27T13:00:49.439Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/42/71f88d6c04c7f4335f94c1c9eb5aee7e928e1a53056bb9b20c0c4d889f48/deno-2.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:57825615e8d4182160401b56907b2e36712aed55a5dfdef2c0e3089379bb6988", size = 42314540, upload-time = "2026-06-11T16:13:39.134Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/db/1721aca1a9bd132a3f721bd547022534fcdd6221701561c5e33705cdfb6d/deno-2.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b4638504f3730f9b25229db08d1bce87bc64f52498f9f8f5aeba702c7ff2115", size = 39460813, upload-time = "2026-05-27T13:00:52.578Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/95/032101e28532fa9ed28ca7dc737934d4631544fa3b11f4efe3573970a05a/deno-2.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:05dbbbd8edf1907abcbcf55395e63f00d74c2672b6039dd1ecd5656baebaa56c", size = 38101511, upload-time = "2026-06-11T16:13:42.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/20/b50646f865562b8f21532cad6f9804b126efd4030cfd0c5e1d11217b15ca/deno-2.8.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:91f29a2df4cb6135872d68f38005c08fe0c389c84cb7349d7399eedbfbe19829", size = 43893026, upload-time = "2026-05-27T13:00:55.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/24/68c1d2d79933738acb8e5ef3a4584f57c6d5deb56c84073a5f4079c24647/deno-2.8.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:9fdb7574a6437fdd59f668e745dc3b3e32725fa360b64204a6b000689fa534e9", size = 42053660, upload-time = "2026-06-11T16:13:45.006Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/1e/75b84e7096b53f077ac01f9b5c979b7b42b0a6497f56d7c4ae072381e059/deno-2.8.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:f0958910ffa88f6b6e142129ab34b7a3aec361fc63904ff803ce1594beca230a", size = 46038250, upload-time = "2026-05-27T13:00:59.662Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/56/0d23c6daa1b139c31591df801feda3ac7d0d6225f1686cbf5caaa5db5c27/deno-2.8.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:9ca5727e5650f8459f39875f0af4b2242ead77da4ce8a8d52e86647036f3d63a", size = 43800396, upload-time = "2026-06-11T16:13:47.821Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/d6/14f6cf25025644bb8b7d9b4606780b5ec6ee429a55c0d1f05681718c7fc0/deno-2.8.1-py3-none-win_amd64.whl", hash = "sha256:71ec55c0a0944beee376aa824722734cf3e617661bca5e143caa83991921e4f5", size = 41962337, upload-time = "2026-05-27T13:01:03.453Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/aa/e35b736205b89c98f31ed4abeebaa7846774e51006b14601e4edb7728e67/deno-2.8.3-py3-none-win_amd64.whl", hash = "sha256:37da75ee91448e4e6f5626f0d5cb18e295dbb6cff5cf780fab1f92ebbbd44071", size = 41552180, upload-time = "2026-06-11T16:13:50.711Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -428,11 +451,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.17"
|
version = "3.18"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -453,6 +476,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -462,6 +497,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "metube"
|
name = "metube"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -745,7 +789,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pylint"
|
name = "pylint"
|
||||||
version = "4.0.5"
|
version = "4.0.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "astroid" },
|
{ name = "astroid" },
|
||||||
@@ -756,14 +800,14 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "tomlkit" },
|
{ name = "tomlkit" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.3"
|
version = "9.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -772,23 +816,23 @@ dependencies = [
|
|||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-aiohttp"
|
name = "pytest-aiohttp"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/51/4d/c6621fc79022f6c84a806e23d9b7eca24fae4f3ee779219bbe524339d666/pytest_aiohttp-1.1.1.tar.gz", hash = "sha256:3aa9c9fe26e543eaccc7eb0add381c685ba3ed3e2fed0af74540f63bcd31458d", size = 13704, upload-time = "2026-06-07T23:56:34.173Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/b0/5056ed4c3f68a4db2b4a39fb0ec61b1e4cf1d89ee14effe5261cc587264c/pytest_aiohttp-1.1.1-py3-none-any.whl", hash = "sha256:f293441ad4f8446a1e12257130c26c7de03a615c2a5572a8cb046e5b3b4e5211", size = 9007, upload-time = "2026-06-07T23:56:33.333Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -817,15 +861,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-socketio"
|
name = "python-socketio"
|
||||||
version = "5.16.2"
|
version = "5.16.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bidict" },
|
{ name = "bidict" },
|
||||||
{ name = "python-engineio" },
|
{ name = "python-engineio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/07/dd/6fd4112b941f7d39b8171b6ba17902609bd8fa2059c3812a3c29dade13e7/python_socketio-5.16.2.tar.gz", hash = "sha256:ad88c228d921646efa436c0a0df217e364ef30ec072df4041484e54d49c15989", size = 128011, upload-time = "2026-05-21T22:03:44.418Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl", hash = "sha256:bef2da3374fd533aed4297f57b4f6512b52aa51604cb0da2165f401291c5ca20", size = 82137, upload-time = "2026-05-21T22:03:42.616Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -843,6 +887,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "15.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simple-websocket"
|
name = "simple-websocket"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user