Compare commits

...

9 Commits

Author SHA1 Message Date
Alex Shnitman ce897ee009 fix open download of cookie files 2026-06-20 09:52:34 +03:00
Alex Shnitman dd1b4c2436 upgrade dependencies 2026-06-20 09:52:34 +03:00
Alex 8752b500d6 Merge pull request #1004 from akeeton/docker-audio-download-dir
Create AUDIO_DOWNLOAD_DIR in Docker image
2026-06-18 06:45:50 +03:00
Andrew Keeton 04b9366764 Incorporate PR feedback
Move the default assignment of AUDIO_DOWNLOAD_DIR from the Dockerfile to docker-entrypoint.sh, and change the default value from "/downloads" to $DOWNLOAD_DIR.
2026-06-17 17:17:44 -04:00
Alex Shnitman b73e95f405 upgrade dependencies 2026-06-16 21:57:07 +03:00
Alex Shnitman 64d0d62878 fix empty PUBLIC_HOST_AUDIO_URL handling (closes #1010) 2026-06-16 21:47:07 +03:00
Alex Shnitman 37f7af0555 fix batch download (closes #1008) 2026-06-16 21:37:05 +03:00
Alex Shnitman 5aa7d033e2 review fixes 2026-06-16 21:35:07 +03:00
Andrew Keeton d157444877 Create AUDIO_DOWNLOAD_DIR in Docker image 2026-06-11 17:22:55 -04:00
18 changed files with 906 additions and 494 deletions
+64 -7
View File
@@ -16,7 +16,7 @@ import logging
import json import json
import pathlib import pathlib
import re import re
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
from watchfiles import DefaultFilter, Change, awatch from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue, Download from ytdl import DownloadQueueNotifier, DownloadQueue, Download
@@ -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
@@ -255,7 +286,30 @@ class ObjectSerializer(json.JSONEncoder):
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
serializer = ObjectSerializer() serializer = ObjectSerializer()
app = web.Application()
_STATE_DIR_REAL = os.path.realpath(config.STATE_DIR)
def _is_within_state_dir(real_target: str) -> bool:
return real_target == _STATE_DIR_REAL or real_target.startswith(_STATE_DIR_REAL + os.sep)
@web.middleware
async def state_dir_guard(request, handler):
for prefix, base in (
(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR),
(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR),
):
if request.path.startswith(prefix):
rel = unquote(request.path[len(prefix):])
target = os.path.realpath(os.path.join(base, rel))
if _is_within_state_dir(target):
raise web.HTTPNotFound()
break
return await handler(request)
app = web.Application(middlewares=[state_dir_guard])
_cors_origins = [o.strip() for o in config.CORS_ALLOWED_ORIGINS.split(',') if o.strip()] if config.CORS_ALLOWED_ORIGINS else [] _cors_origins = [o.strip() for o in config.CORS_ALLOWED_ORIGINS.split(',') if o.strip()] if config.CORS_ALLOWED_ORIGINS else []
sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else []) sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else [])
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io') sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
@@ -827,10 +881,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 +1015,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)')
+17
View File
@@ -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)
+41
View File
@@ -3,10 +3,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from aiohttp import web from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import main import main
@@ -306,3 +309,41 @@ async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.subscribe(req) await main.subscribe(req)
main.submgr.add_subscription.assert_not_awaited() main.submgr.add_subscription.assert_not_awaited()
def test_is_within_state_dir_blocks_state_subtree():
state_dir = main._STATE_DIR_REAL
assert main._is_within_state_dir(state_dir)
assert main._is_within_state_dir(os.path.join(state_dir, "cookies.txt"))
assert main._is_within_state_dir(os.path.join(state_dir, "queue", "item.json"))
def test_is_within_state_dir_allows_sibling_downloads():
download_dir = os.path.realpath(main.config.DOWNLOAD_DIR)
assert not main._is_within_state_dir(os.path.join(download_dir, "video.mp4"))
assert not main._is_within_state_dir("/tmp/unrelated/video.mp4")
@pytest.mark.asyncio
async def test_download_blocks_state_dir_files(monkeypatch):
download_dir = Path(main.config.DOWNLOAD_DIR)
state_dir = download_dir / ".metube"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
(download_dir / "video.mp4").write_bytes(b"video")
monkeypatch.setattr(main.config, "STATE_DIR", str(state_dir))
monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir)))
try:
async with TestClient(TestServer(main.app)) as client:
blocked = await client.get("/download/.metube/cookies.txt")
assert blocked.status == 404
allowed = await client.get("/download/video.mp4")
assert allowed.status == 200
assert await allowed.read() == b"video"
finally:
(state_dir / "cookies.txt").unlink(missing_ok=True)
(download_dir / "video.mp4").unlink(missing_ok=True)
state_dir.rmdir()
+36
View File
@@ -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()
+60
View File
@@ -627,3 +627,63 @@ def test_seconds_until_next_probe_none_when_empty(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier) dq = DownloadQueue(dq_env, notifier)
assert dq._seconds_until_next_probe() is None 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"
+52 -9
View File
@@ -232,6 +232,20 @@ class DownloadInfo:
self.live_release_timestamp = live_release_timestamp 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)
@@ -584,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}")
@@ -647,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}")
@@ -1026,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:
@@ -1387,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'}
+4 -3
View File
@@ -2,11 +2,12 @@
PUID="${UID:-$PUID}" PUID="${UID:-$PUID}"
PGID="${GID:-$PGID}" PGID="${GID:-$PGID}"
AUDIO_DOWNLOAD_DIR="${AUDIO_DOWNLOAD_DIR:-$DOWNLOAD_DIR}"
echo "Setting umask to ${UMASK}" echo "Setting umask to ${UMASK}"
umask ${UMASK} umask ${UMASK}
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})" echo "Creating download directory (${DOWNLOAD_DIR}), audio download directory (${AUDIO_DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" mkdir -p "${DOWNLOAD_DIR}" "${AUDIO_DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
do_upgrade() { do_upgrade() {
echo "Upgrading yt-dlp to nightly channel..." echo "Upgrading yt-dlp to nightly channel..."
@@ -56,7 +57,7 @@ if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
fi fi
if [ "${CHOWN_DIRS:-true}" != "false" ]; then if [ "${CHOWN_DIRS:-true}" != "false" ]; then
echo "Changing ownership of download and state directories to ${PUID}:${PGID}" echo "Changing ownership of download and state directories to ${PUID}:${PGID}"
chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${AUDIO_DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
fi fi
if nightly_enabled; then if nightly_enabled; then
echo "YTDL_NIGHTLY_UPDATE_TIME is set to ${YTDL_NIGHTLY_UPDATE_TIME}; upgrading yt-dlp on startup" echo "YTDL_NIGHTLY_UPDATE_TIME is set to ${YTDL_NIGHTLY_UPDATE_TIME}; upgrading yt-dlp on startup"
+3 -3
View File
@@ -48,8 +48,8 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-eslint/builder": "21.1.0", "@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.2.14", "@angular/build": "^21.2.16",
"@angular/cli": "^21.2.14", "@angular/cli": "^21.2.16",
"@angular/compiler-cli": "^21.2.17", "@angular/compiler-cli": "^21.2.17",
"@angular/localize": "^21.2.17", "@angular/localize": "^21.2.17",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@@ -58,6 +58,6 @@
"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.8" "vitest": "^4.1.9"
} }
} }
+327 -360
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1078,3 +1078,5 @@
} }
</div> </div>
</footer> </footer>
<app-toast-container />
+5 -3
View File
@@ -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 {
@@ -263,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;
@@ -271,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();
}); });
}); });
+69 -92
View File
@@ -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);
@@ -415,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();
} }
@@ -490,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;
@@ -525,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();
@@ -569,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();
@@ -577,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)) {
@@ -611,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 = '';
@@ -639,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();
@@ -657,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);
@@ -673,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();
@@ -699,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();
@@ -746,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();
@@ -769,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');
} }
}); });
} }
@@ -1066,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 = '';
} }
@@ -1185,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) {
@@ -1241,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;
} }
@@ -1265,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.`
@@ -1278,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.`
); );
@@ -1370,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;
@@ -1435,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 {
@@ -1550,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);
@@ -1586,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 = '';
@@ -1595,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.');
} }
}); });
} }
@@ -1629,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.');
} }
}); });
} }
+1
View File
@@ -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;
}
+62
View File
@@ -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.'));
}
}
+2
View File
@@ -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';
+86
View File
@@ -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);
}
}
Generated
+15 -15
View File
@@ -106,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]]
@@ -194,11 +194,11 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.5.20" version = "2026.6.17"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
] ]
[[package]] [[package]]
@@ -789,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" },
@@ -800,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.1"
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'" },
@@ -816,9 +816,9 @@ 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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
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/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[package]] [[package]]
@@ -861,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]]