mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Compare commits
46 Commits
2026.04.28
...
e2c777842e
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c777842e | |||
| c34a18de7a | |||
| d6bcf182c5 | |||
| ad90609c9b | |||
| 5315630ab0 | |||
| 54463baf0e | |||
| b00d4785ee | |||
| 96e88a3555 | |||
| 49a46a7d1c | |||
| 961b54aa83 | |||
| e0549d6c24 | |||
| f315b75bb2 | |||
| c2c129db61 | |||
| 363f159a0a | |||
| 38c0ca22f4 | |||
| 24ae8f0742 | |||
| 0a946cc352 | |||
| 51fd203b71 | |||
| d136344c26 | |||
| 33f1412fac | |||
| ce897ee009 | |||
| dd1b4c2436 | |||
| 8752b500d6 | |||
| 04b9366764 | |||
| b73e95f405 | |||
| 64d0d62878 | |||
| 37f7af0555 | |||
| 5aa7d033e2 | |||
| 5429200fba | |||
| 72d60ea55a | |||
| a9b2e07a59 | |||
| d157444877 | |||
| e30a24ff70 | |||
| ee20512410 | |||
| 897d52cd0d | |||
| baa72c0e94 | |||
| 66d8fa570b | |||
| cf2d2dd465 | |||
| 0b5617e96c | |||
| 56c0ad3b5f | |||
| 4478d1394e | |||
| ad92607a21 | |||
| 6ff364aacf | |||
| 39a8948976 | |||
| f0348581c2 | |||
| e2773db65a |
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get commits since last release
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.AUTOUPDATE_PAT }}
|
||||
-
|
||||
|
||||
@@ -9,7 +9,7 @@ If an addition would exceed the limit, trim existing prose elsewhere — prefer
|
||||
## Tech stack
|
||||
|
||||
- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp
|
||||
- **Frontend:** Angular 21, TypeScript, Bootstrap 5, SASS, ngx-socket-io
|
||||
- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io
|
||||
- **Package managers:** uv (Python), pnpm (frontend)
|
||||
- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
* __YTDL_OPTIONS_PRESETS__: Named bundles of yt-dlp options, selectable per download in the UI. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for format and examples.
|
||||
* __YTDL_OPTIONS_PRESETS_FILE__: Path to a JSON file containing presets. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options).
|
||||
* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show a free-text field in the UI for per-download yt-dlp option overrides. Defaults to `false`. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details and security considerations.
|
||||
* __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled).
|
||||
|
||||
### 🌐 Web Server & URLs
|
||||
|
||||
@@ -346,7 +347,7 @@ example.com {
|
||||
|
||||
## 🔄 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
|
||||
|
||||
|
||||
+136
-11
@@ -4,8 +4,10 @@
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
from aiohttp.web import GracefulExit
|
||||
from aiohttp.log import access_logger
|
||||
import ssl
|
||||
import socket
|
||||
@@ -14,7 +16,7 @@ import logging
|
||||
import json
|
||||
import pathlib
|
||||
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 ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||
@@ -23,6 +25,22 @@ from yt_dlp.version import __version__ as yt_dlp_version
|
||||
|
||||
log = logging.getLogger('main')
|
||||
|
||||
_NIGHTLY_TIME_RE = re.compile(r'^([01]\d|2[0-3]):[0-5]\d$')
|
||||
_RESTART_FOR_UPDATE = False
|
||||
|
||||
def _request_graceful_exit() -> None:
|
||||
raise GracefulExit()
|
||||
|
||||
|
||||
def seconds_until_next_daily_time(time_hhmm: str, now: datetime | None = None) -> float:
|
||||
"""Seconds until the next occurrence of HH:MM in local time."""
|
||||
now = now or datetime.now()
|
||||
hour, minute = map(int, time_hhmm.split(':'))
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
def parseLogLevel(logLevel):
|
||||
if not isinstance(logLevel, str):
|
||||
return None
|
||||
@@ -73,6 +91,7 @@ class Config:
|
||||
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||
'LOGLEVEL': 'INFO',
|
||||
'ENABLE_ACCESSLOG': 'false',
|
||||
'YTDL_NIGHTLY_UPDATE_TIME': '',
|
||||
}
|
||||
|
||||
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES')
|
||||
@@ -93,6 +112,13 @@ class Config:
|
||||
if not self.URL_PREFIX.endswith('/'):
|
||||
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'):
|
||||
val = getattr(self, attr)
|
||||
if val and not val.endswith('/'):
|
||||
@@ -104,6 +130,17 @@ class Config:
|
||||
if self.YTDL_OPTIONS_PRESETS_FILE and self.YTDL_OPTIONS_PRESETS_FILE.startswith('.'):
|
||||
self.YTDL_OPTIONS_PRESETS_FILE = str(Path(self.YTDL_OPTIONS_PRESETS_FILE).resolve())
|
||||
|
||||
if self.YTDL_NIGHTLY_UPDATE_TIME and not _NIGHTLY_TIME_RE.match(self.YTDL_NIGHTLY_UPDATE_TIME):
|
||||
log.error(
|
||||
'Environment variable "YTDL_NIGHTLY_UPDATE_TIME" must be HH:MM (24-hour), got "%s"',
|
||||
self.YTDL_NIGHTLY_UPDATE_TIME,
|
||||
)
|
||||
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 = {}
|
||||
|
||||
success,_ = self.load_ytdl_options()
|
||||
@@ -113,6 +150,20 @@ class Config:
|
||||
if not success:
|
||||
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):
|
||||
self._runtime_overrides[key] = value
|
||||
self.YTDL_OPTIONS[key] = value
|
||||
@@ -215,7 +266,13 @@ logging.getLogger().setLevel(parseLogLevel(str(config.LOGLEVEL)) or logging.INFO
|
||||
|
||||
class ObjectSerializer(json.JSONEncoder):
|
||||
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__'):
|
||||
return obj.__dict__
|
||||
# Convert iterables (generators, dict_items, etc.) to lists
|
||||
@@ -229,7 +286,30 @@ class ObjectSerializer(json.JSONEncoder):
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
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 []
|
||||
sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else [])
|
||||
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
||||
@@ -465,8 +545,18 @@ class Notifier(DownloadQueueNotifier):
|
||||
await sio.emit('cleared', serializer.encode(id))
|
||||
|
||||
dqueue = DownloadQueue(config, Notifier())
|
||||
app.on_startup.append(lambda app: dqueue.initialize())
|
||||
app.on_cleanup.append(lambda app: Download.shutdown_manager())
|
||||
|
||||
|
||||
async def _download_queue_startup(app):
|
||||
await dqueue.initialize()
|
||||
|
||||
|
||||
async def _shutdown_download_manager(app):
|
||||
Download.shutdown_manager()
|
||||
|
||||
|
||||
app.on_startup.append(_download_queue_startup)
|
||||
app.on_cleanup.append(_shutdown_download_manager)
|
||||
|
||||
|
||||
class MetubeSubscriptionNotifier(SubscriptionNotifier):
|
||||
@@ -486,7 +576,13 @@ class MetubeSubscriptionNotifier(SubscriptionNotifier):
|
||||
|
||||
|
||||
submgr = SubscriptionManager(config, dqueue, MetubeSubscriptionNotifier())
|
||||
app.on_cleanup.append(lambda app: submgr.close())
|
||||
|
||||
|
||||
async def _shutdown_subscriptions(app):
|
||||
submgr.close()
|
||||
|
||||
|
||||
app.on_cleanup.append(_shutdown_subscriptions)
|
||||
|
||||
|
||||
async def _subscription_loop_startup(app):
|
||||
@@ -496,6 +592,26 @@ async def _subscription_loop_startup(app):
|
||||
|
||||
app.on_startup.append(_subscription_loop_startup)
|
||||
|
||||
|
||||
async def _schedule_nightly_update() -> None:
|
||||
global _RESTART_FOR_UPDATE
|
||||
time_hhmm = config.YTDL_NIGHTLY_UPDATE_TIME
|
||||
if not time_hhmm:
|
||||
return
|
||||
delay = seconds_until_next_daily_time(time_hhmm)
|
||||
log.info('Next yt-dlp nightly update in %.0f seconds (at %s local time)', delay, time_hhmm)
|
||||
await asyncio.sleep(delay)
|
||||
log.info('Scheduled yt-dlp nightly update: requesting restart')
|
||||
_RESTART_FOR_UPDATE = True
|
||||
asyncio.get_running_loop().call_soon(_request_graceful_exit)
|
||||
|
||||
|
||||
async def _start_nightly_update_schedule(app):
|
||||
asyncio.create_task(_schedule_nightly_update())
|
||||
|
||||
|
||||
app.on_startup.append(_start_nightly_update_schedule)
|
||||
|
||||
class FileOpsFilter(DefaultFilter):
|
||||
def __call__(self, change_type: int, path: str) -> bool:
|
||||
# Check if this path matches our YTDL_OPTIONS_FILE
|
||||
@@ -542,8 +658,12 @@ async def watch_files():
|
||||
log.info(f'Starting Watch File: {config.YTDL_OPTIONS_FILE}')
|
||||
asyncio.create_task(_watch_files())
|
||||
|
||||
async def _watch_files_startup(app):
|
||||
await watch_files()
|
||||
|
||||
|
||||
if config.YTDL_OPTIONS_FILE:
|
||||
app.on_startup.append(lambda app: watch_files())
|
||||
app.on_startup.append(_watch_files_startup)
|
||||
|
||||
|
||||
async def _read_json_request(request: web.Request) -> dict:
|
||||
@@ -761,10 +881,7 @@ async def cancel_add(request):
|
||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||
async def subscribe(request):
|
||||
post = await _read_json_request(request)
|
||||
try:
|
||||
o = parse_download_options(post)
|
||||
except web.HTTPBadRequest:
|
||||
raise
|
||||
o = parse_download_options(post)
|
||||
cic = post.get('check_interval_minutes')
|
||||
if cic is None:
|
||||
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
|
||||
@@ -898,6 +1015,12 @@ async def upload_cookies(request):
|
||||
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
|
||||
with open(tmp_cookie_path, 'wb') as f:
|
||||
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)
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||
@@ -1122,3 +1245,5 @@ if __name__ == '__main__':
|
||||
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port(), ssl_context=ssl_context, access_log=isAccessLogEnabled())
|
||||
else:
|
||||
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port(), access_log=isAccessLogEnabled())
|
||||
if _RESTART_FOR_UPDATE:
|
||||
sys.exit(42)
|
||||
|
||||
+84
-3
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import collections.abc
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -17,6 +18,25 @@ STATE_SCHEMA_VERSION = 2
|
||||
_BYTES_MARKER = "__metube_bytes__"
|
||||
_DATETIME_MARKER = "__metube_datetime__"
|
||||
|
||||
# Errnos that signal the filesystem cannot support the temp-file + rename
|
||||
# atomic-write strategy (for example an NFS-backed state dir returning EPERM on
|
||||
# mkstemp). These are safe to fall back on because they mean the atomic
|
||||
# mechanism is unavailable, not that the data write itself failed. Errors like
|
||||
# ENOSPC/EIO are deliberately excluded so a genuine storage failure surfaces
|
||||
# instead of silently truncating an existing good state file.
|
||||
_ATOMIC_UNSUPPORTED_ERRNOS = frozenset(
|
||||
e
|
||||
for e in (
|
||||
errno.EPERM,
|
||||
errno.EACCES,
|
||||
errno.ENOSYS,
|
||||
errno.EINVAL,
|
||||
getattr(errno, "EOPNOTSUPP", None),
|
||||
getattr(errno, "ENOTSUP", None),
|
||||
)
|
||||
if e is not None
|
||||
)
|
||||
|
||||
|
||||
def to_json_compatible(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
@@ -62,6 +82,7 @@ class AtomicJsonStore:
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.schema_version = schema_version
|
||||
self._direct_write_fallback_warned = False
|
||||
|
||||
def _ensure_parent(self) -> None:
|
||||
parent = os.path.dirname(self.path)
|
||||
@@ -96,6 +117,16 @@ class AtomicJsonStore:
|
||||
def save(self, data: dict[str, Any]) -> None:
|
||||
self._ensure_parent()
|
||||
payload = self._build_payload(data)
|
||||
try:
|
||||
self._atomic_write(payload)
|
||||
except OSError as exc:
|
||||
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
|
||||
raise
|
||||
self._warn_direct_write_fallback(exc)
|
||||
self._direct_write(payload)
|
||||
|
||||
def _atomic_write(self, payload: dict[str, Any]) -> None:
|
||||
text = self._serialize(payload)
|
||||
parent = os.path.dirname(self.path) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix=f".{os.path.basename(self.path)}.",
|
||||
@@ -105,10 +136,9 @@ class AtomicJsonStore:
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.write("\n")
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
self._best_effort_fsync(f.fileno())
|
||||
os.replace(tmp_path, self.path)
|
||||
self._fsync_directory(parent)
|
||||
except Exception:
|
||||
@@ -118,6 +148,57 @@ class AtomicJsonStore:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _direct_write(self, payload: dict[str, Any]) -> None:
|
||||
# Serialize before truncating so a serialization failure never destroys
|
||||
# the existing state file (the atomic path gets this for free via its
|
||||
# temp file).
|
||||
text = self._serialize(payload)
|
||||
# Create with 0o600 so the fallback keeps the owner-only permissions the
|
||||
# atomic path gets from mkstemp; state files can contain URLs and
|
||||
# per-download option overrides that must not leak on shared mounts.
|
||||
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
# The 0o600 mode above only applies when the file is created; force
|
||||
# it on rewrites too so an existing, broadly-permissioned state file
|
||||
# is tightened to match the atomic path. Best-effort because some
|
||||
# network filesystems reject chmod, and that must not re-crash save.
|
||||
try:
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
f.write(text)
|
||||
f.flush()
|
||||
self._best_effort_fsync(f.fileno())
|
||||
# Make the new directory entry durable too, matching the atomic path.
|
||||
self._fsync_directory(os.path.dirname(self.path) or ".")
|
||||
|
||||
@staticmethod
|
||||
def _best_effort_fsync(fileno: int) -> None:
|
||||
# Tolerate fsync being unsupported on the underlying filesystem (for
|
||||
# example a network mount that returns EINVAL/ENOSYS), but let genuine
|
||||
# storage failures such as ENOSPC/EIO surface so a non-durable write is
|
||||
# never reported as success. An unsupported fsync must not by itself
|
||||
# abandon the atomic rename path.
|
||||
try:
|
||||
os.fsync(fileno)
|
||||
except OSError as exc:
|
||||
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _serialize(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
def _warn_direct_write_fallback(self, exc: OSError) -> None:
|
||||
if self._direct_write_fallback_warned:
|
||||
return
|
||||
self._direct_write_fallback_warned = True
|
||||
log.warning(
|
||||
"Atomic state write failed for %s (%s); falling back to direct write",
|
||||
self.path,
|
||||
exc,
|
||||
)
|
||||
|
||||
def quarantine_invalid_file(self, exc: Exception) -> None:
|
||||
if not os.path.exists(self.path):
|
||||
return
|
||||
|
||||
+34
-1
@@ -312,6 +312,7 @@ class SubscriptionManager:
|
||||
self._subs: dict[str, SubscriptionInfo] = {}
|
||||
self._url_index: dict[str, str] = {} # normalized url -> id
|
||||
self._pending_urls: set[str] = set()
|
||||
self._checks_in_flight: set[str] = set() # subscription ids being checked right now
|
||||
self._lock = asyncio.Lock()
|
||||
self._loop_task: Optional[asyncio.Task] = None
|
||||
self._load_all()
|
||||
@@ -677,6 +678,22 @@ class SubscriptionManager:
|
||||
return {"status": "ok"}
|
||||
|
||||
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
|
||||
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
||||
log.info("Checking subscription: %s", sub.name)
|
||||
@@ -700,7 +717,7 @@ class SubscriptionManager:
|
||||
entries = [ent for ent in entries if _is_media_entry(ent)]
|
||||
|
||||
etype = (info or {}).get("_type") or "video"
|
||||
if etype == "video" or not entries:
|
||||
if etype == "video":
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if cur:
|
||||
@@ -715,6 +732,22 @@ class SubscriptionManager:
|
||||
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
|
||||
await self.notifier.subscription_updated(sub)
|
||||
return
|
||||
if not entries:
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if cur:
|
||||
previous = copy.deepcopy(cur)
|
||||
cur.last_checked = time.time()
|
||||
cur.error = None
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs[sid] = previous
|
||||
raise
|
||||
sub = cur
|
||||
log.warning("Subscription check finished for %s: No entries found", sub.name)
|
||||
await self.notifier.subscription_updated(sub)
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
import main
|
||||
|
||||
@@ -306,3 +309,41 @@ async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.subscribe(req)
|
||||
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()
|
||||
|
||||
@@ -51,6 +51,19 @@ class ConfigTests(unittest.TestCase):
|
||||
self.assertEqual(c.PUBLIC_HOST_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):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
@@ -107,6 +120,45 @@ class ConfigTests(unittest.TestCase):
|
||||
c = Config()
|
||||
self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES)
|
||||
|
||||
def test_ytdl_nightly_update_time_empty_default(self):
|
||||
with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME=""), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.YTDL_NIGHTLY_UPDATE_TIME, "")
|
||||
|
||||
def test_ytdl_nightly_update_time_valid(self):
|
||||
with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME="04:00"), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.YTDL_NIGHTLY_UPDATE_TIME, "04:00")
|
||||
|
||||
def test_ytdl_nightly_update_time_invalid_exits(self):
|
||||
for bad in ("25:00", "4am", "12:60"):
|
||||
with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
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):
|
||||
with patch.dict(os.environ, _base_env(), clear=False):
|
||||
c = Config()
|
||||
|
||||
@@ -7,8 +7,9 @@ import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import time
|
||||
|
||||
from ytdl import DownloadQueue
|
||||
from ytdl import DownloadInfo, DownloadQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -221,6 +222,103 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_download_uses_output_template_when_channel_template_empty(dq_env):
|
||||
"""Channel tabs reported as playlists must honor OUTPUT_TEMPLATE when OUTPUT_TEMPLATE_CHANNEL is empty."""
|
||||
notifier = AsyncMock()
|
||||
dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s"
|
||||
dq_env.OUTPUT_TEMPLATE_CHANNEL = ""
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = ""
|
||||
|
||||
channel_id = "UCabcd123"
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "playlist",
|
||||
"id": channel_id,
|
||||
"channel_id": channel_id,
|
||||
"channel": "Odin",
|
||||
"title": "Odin - Videos",
|
||||
"entries": [
|
||||
{
|
||||
"id": "vid1",
|
||||
"title": "Salvia Plath - Pondering",
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"webpage_url": "https://example.com/watch?v=1",
|
||||
"channel": "Odin",
|
||||
"upload_date": "20130804",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
result = await dq.add(
|
||||
"https://www.youtube.com/@odin/videos",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
url = "https://example.com/watch?v=1"
|
||||
assert dq.pending.exists(url)
|
||||
download = dq.pending.get(url)
|
||||
assert download.output_template.startswith("Odin [YT]/")
|
||||
assert "Odin - Videos" not in download.output_template
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playlist_download_not_treated_as_channel(dq_env):
|
||||
"""Real playlists (id != channel_id) must not be promoted to channel downloads."""
|
||||
notifier = AsyncMock()
|
||||
dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s"
|
||||
dq_env.OUTPUT_TEMPLATE_CHANNEL = ""
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "playlist",
|
||||
"id": "PLxyz789",
|
||||
"channel_id": "UCabcd123",
|
||||
"channel": "Odin",
|
||||
"title": "My Playlist",
|
||||
"entries": [
|
||||
{
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"webpage_url": "https://example.com/watch?v=1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
result = await dq.add(
|
||||
"https://www.youtube.com/playlist?list=PLxyz789",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
url = "https://example.com/watch?v=1"
|
||||
assert dq.pending.exists(url)
|
||||
download = dq.pending.get(url)
|
||||
assert download.output_template.startswith("My Playlist/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_merges_global_preset_and_override_options(dq_env):
|
||||
notifier = AsyncMock()
|
||||
@@ -386,3 +484,303 @@ async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
||||
download = dq.pending.get("https://example.com/clip")
|
||||
assert download.info.clip_start == 10.0
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for nightly yt-dlp update scheduling helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from main import seconds_until_next_daily_time
|
||||
|
||||
|
||||
class NightlyUpdateTests(unittest.TestCase):
|
||||
def test_seconds_until_later_today(self):
|
||||
now = datetime(2026, 6, 4, 10, 0, 0)
|
||||
delay = seconds_until_next_daily_time("15:30", now)
|
||||
self.assertEqual(delay, 5 * 3600 + 30 * 60)
|
||||
|
||||
def test_seconds_until_wraps_to_next_day(self):
|
||||
now = datetime(2026, 6, 4, 18, 0, 0)
|
||||
delay = seconds_until_next_daily_time("04:00", now)
|
||||
self.assertEqual(delay, 10 * 3600)
|
||||
|
||||
def test_seconds_until_same_minute_is_next_day(self):
|
||||
now = datetime(2026, 6, 4, 4, 0, 30)
|
||||
delay = seconds_until_next_daily_time("04:00", now)
|
||||
self.assertAlmostEqual(delay, 24 * 3600 - 30, delta=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
|
||||
|
||||
@@ -21,6 +22,135 @@ class StateStoreTests(unittest.TestCase):
|
||||
self.assertEqual(payload["schema_version"], 2)
|
||||
self.assertEqual(payload["items"][0]["info"]["title"], "hello")
|
||||
|
||||
def test_save_falls_back_to_direct_write_when_mkstemp_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with self.assertLogs("state_store", level="WARNING") as logs:
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertTrue(os.path.exists(path))
|
||||
self.assertTrue(any(path in message for message in logs.output))
|
||||
# Fallback keeps owner-only permissions, matching the atomic path.
|
||||
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
||||
payload = store.load()
|
||||
self.assertEqual(payload["items"], [{"key": "a"}])
|
||||
|
||||
def test_fallback_tightens_permissions_on_existing_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
os.chmod(path, 0o644)
|
||||
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
||||
self.assertEqual(store.load()["items"], [{"key": "a"}])
|
||||
|
||||
def test_save_falls_back_to_direct_write_when_replace_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with patch(
|
||||
"state_store.os.replace",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertTrue(os.path.exists(path))
|
||||
payload = store.load()
|
||||
self.assertEqual(payload["items"], [{"key": "a"}])
|
||||
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
|
||||
|
||||
def test_save_reraises_when_atomic_and_direct_write_fail(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
with patch(
|
||||
"state_store.os.open",
|
||||
side_effect=PermissionError(13, "Permission denied"),
|
||||
):
|
||||
with self.assertRaises(PermissionError) as ctx:
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertEqual(ctx.exception.errno, 13)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
def test_unsupported_fsync_keeps_atomic_path(self):
|
||||
# fsync being unsupported (EINVAL/ENOSYS) must not by itself trigger the
|
||||
# direct-write fallback; the atomic temp-file + rename path still runs.
|
||||
import errno as _errno
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
|
||||
with patch(
|
||||
"state_store.os.fsync",
|
||||
side_effect=OSError(_errno.EINVAL, "Invalid argument"),
|
||||
):
|
||||
with self.assertNoLogs("state_store", level="WARNING"):
|
||||
store.save({"items": [{"key": "a"}]})
|
||||
|
||||
self.assertEqual(store.load()["items"], [{"key": "a"}])
|
||||
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
|
||||
|
||||
def test_save_reraises_and_preserves_state_on_non_atomic_errno(self):
|
||||
# A storage failure such as ENOSPC is not an "atomic unavailable"
|
||||
# signal, so it must surface instead of falling back to a direct write
|
||||
# that would truncate the existing good state file.
|
||||
import errno as _errno
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
store.save({"items": [{"key": "good"}]})
|
||||
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=OSError(_errno.ENOSPC, "No space left on device"),
|
||||
):
|
||||
with self.assertRaises(OSError) as ctx:
|
||||
store.save({"items": [{"key": "new"}]})
|
||||
|
||||
self.assertEqual(ctx.exception.errno, _errno.ENOSPC)
|
||||
# Existing state is untouched.
|
||||
self.assertEqual(store.load()["items"], [{"key": "good"}])
|
||||
|
||||
def test_serialization_failure_preserves_existing_state(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
store = AtomicJsonStore(path, kind="persistent_queue:queue")
|
||||
store.save({"items": [{"key": "good"}]})
|
||||
|
||||
# Even on the fallback path, a non-serializable payload must raise
|
||||
# before the existing good state file is touched.
|
||||
with patch(
|
||||
"state_store.tempfile.mkstemp",
|
||||
side_effect=PermissionError(1, "Operation not permitted"),
|
||||
):
|
||||
with self.assertRaises(TypeError):
|
||||
store.save({"items": object()})
|
||||
|
||||
self.assertEqual(store.load()["items"], [{"key": "good"}])
|
||||
|
||||
def test_invalid_file_is_quarantined(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue.json")
|
||||
|
||||
+276
-18
@@ -24,6 +24,12 @@ from subscriptions import _entry_id
|
||||
|
||||
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-
|
||||
# sanitised when substituting playlist/channel titles into output templates so
|
||||
@@ -194,6 +200,8 @@ class DownloadInfo:
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=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.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.clip_start = clip_start
|
||||
self.clip_end = clip_end
|
||||
self.live_status = live_status
|
||||
self.live_release_timestamp = live_release_timestamp
|
||||
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):
|
||||
"""BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files."""
|
||||
self.__dict__.update(state)
|
||||
@@ -292,6 +316,10 @@ class DownloadInfo:
|
||||
self.clip_start = None
|
||||
if not hasattr(self, "clip_end"):
|
||||
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 = (
|
||||
@@ -313,6 +341,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
||||
"ytdl_options_overrides",
|
||||
"clip_start",
|
||||
"clip_end",
|
||||
"live_status",
|
||||
"live_release_timestamp",
|
||||
"status",
|
||||
"timestamp",
|
||||
"error",
|
||||
@@ -568,7 +598,10 @@ class Download:
|
||||
self.info.filename = rel_name
|
||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||
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
|
||||
log.debug(f"Update status for {self.info.title}: {status}")
|
||||
@@ -631,8 +664,8 @@ class PersistentQueue:
|
||||
def __init__(self, name, path):
|
||||
self.identifier = name
|
||||
pdir = os.path.dirname(path)
|
||||
if not os.path.isdir(pdir):
|
||||
os.mkdir(pdir)
|
||||
if pdir and not os.path.isdir(pdir):
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
self.legacy_path = path
|
||||
self.path = f"{path}.json"
|
||||
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
|
||||
@@ -757,11 +790,25 @@ class DownloadQueue:
|
||||
self.done.load()
|
||||
self._add_generation = 0
|
||||
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):
|
||||
self._add_generation += 1
|
||||
log.info('Playlist add operation canceled by user')
|
||||
|
||||
@staticmethod
|
||||
def __is_channel_extraction(entry):
|
||||
"""Return True when yt-dlp reported a channel tab as a playlist.
|
||||
|
||||
YouTube channel tabs are extracted with ``_type: 'playlist'`` but set
|
||||
``id`` equal to ``channel_id``; real playlists keep a distinct id.
|
||||
"""
|
||||
channel_id = entry.get('channel_id')
|
||||
return bool(channel_id) and entry.get('id') == channel_id
|
||||
|
||||
async def __import_queue(self):
|
||||
for k, v in self.queue.saved_items():
|
||||
await self.__add_download(v, True)
|
||||
@@ -772,9 +819,165 @@ class DownloadQueue:
|
||||
|
||||
async def initialize(self):
|
||||
log.info("Initializing DownloadQueue")
|
||||
self._start_live_monitor()
|
||||
asyncio.create_task(self.__import_queue())
|
||||
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):
|
||||
if download.canceled:
|
||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||
@@ -850,7 +1053,16 @@ class DownloadQueue:
|
||||
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))
|
||||
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}"'}
|
||||
if not os.path.isdir(dldirectory):
|
||||
if not self.config.CREATE_CUSTOM_DIRS:
|
||||
@@ -886,9 +1098,16 @@ class DownloadQueue:
|
||||
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
||||
ytdl_options['playlistend'] = playlist_item_limit
|
||||
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:
|
||||
self.queue.put(download)
|
||||
asyncio.create_task(self.__start_download(download))
|
||||
if is_upcoming:
|
||||
self._schedule_upcoming_download(download)
|
||||
else:
|
||||
self.queue.put(download)
|
||||
asyncio.create_task(self.__start_download(download))
|
||||
else:
|
||||
self.pending.put(download)
|
||||
await self.notifier.added(dl)
|
||||
@@ -952,6 +1171,8 @@ class DownloadQueue:
|
||||
_add_gen,
|
||||
)
|
||||
elif etype == 'playlist' or etype == 'channel':
|
||||
if etype == 'playlist' and self.__is_channel_extraction(entry):
|
||||
etype = 'channel'
|
||||
log.debug(f'Processing as a {etype}')
|
||||
entries = entry['entries']
|
||||
# Convert generator to list if needed (for len() and slicing operations)
|
||||
@@ -971,7 +1192,15 @@ class DownloadQueue:
|
||||
if "id" not in etr:
|
||||
etr["id"] = _entry_id(etr)
|
||||
etr["_type"] = "video"
|
||||
etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
|
||||
if etype == 'channel':
|
||||
etr["channel"] = (
|
||||
entry.get("channel")
|
||||
or entry.get("uploader")
|
||||
or entry.get("title")
|
||||
or entry.get("id")
|
||||
)
|
||||
else:
|
||||
etr["playlist"] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
|
||||
etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index)
|
||||
etr[f"{etype}_count"] = total_entries
|
||||
etr[f"{etype}_autonumber"] = index
|
||||
@@ -1036,6 +1265,8 @@ class DownloadQueue:
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
live_status=entry.get('live_status'),
|
||||
live_release_timestamp=entry.get('release_timestamp'),
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
@@ -1156,13 +1387,21 @@ class DownloadQueue:
|
||||
|
||||
async def start_pending(self, ids):
|
||||
for id in ids:
|
||||
if not self.pending.exists(id):
|
||||
log.warning(f'requested start for non-existent download {id}')
|
||||
if self.pending.exists(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
|
||||
dl = self.pending.get(id)
|
||||
self.queue.put(dl)
|
||||
self.pending.delete(id)
|
||||
asyncio.create_task(self.__start_download(dl))
|
||||
if self.queue.exists(id):
|
||||
dl = self.queue.get(id)
|
||||
if dl.info.status == 'scheduled':
|
||||
self._force_start_scheduled(dl)
|
||||
continue
|
||||
log.warning(f'requested start for non-existent download {id}')
|
||||
return {'status': 'ok'}
|
||||
|
||||
async def cancel(self, ids):
|
||||
@@ -1177,6 +1416,8 @@ class DownloadQueue:
|
||||
log.warning(f'requested cancel for non-existent download {id}')
|
||||
continue
|
||||
dl = self.queue.get(id)
|
||||
if dl.info.status == 'scheduled':
|
||||
self._unregister_scheduled(id)
|
||||
if dl.started():
|
||||
dl.cancel()
|
||||
else:
|
||||
@@ -1192,11 +1433,28 @@ class DownloadQueue:
|
||||
continue
|
||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||
dl = self.done.get(id)
|
||||
try:
|
||||
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||
os.remove(os.path.join(dldirectory, dl.info.filename))
|
||||
except Exception as e:
|
||||
log.warning(f'deleting file for download {id} failed with error message {e!r}')
|
||||
dldirectory, calc_error = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||
if calc_error is not None or not dldirectory:
|
||||
log.warning(f'deleting files for download {id} skipped: could not resolve download directory')
|
||||
else:
|
||||
# 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)
|
||||
await self.notifier.cleared(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
+55
-5
@@ -2,11 +2,54 @@
|
||||
|
||||
PUID="${UID:-$PUID}"
|
||||
PGID="${GID:-$PGID}"
|
||||
AUDIO_DOWNLOAD_DIR="${AUDIO_DOWNLOAD_DIR:-$DOWNLOAD_DIR}"
|
||||
|
||||
echo "Setting umask to ${UMASK}"
|
||||
umask ${UMASK}
|
||||
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
||||
mkdir -p "${DOWNLOAD_DIR}" "${STATE_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}" "${AUDIO_DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||
|
||||
do_upgrade() {
|
||||
echo "Upgrading yt-dlp to nightly channel..."
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
||||
echo "pip not found; attempting ensurepip"
|
||||
python3 -m ensurepip --upgrade >/dev/null 2>&1 || true
|
||||
fi
|
||||
if ! python3 -m pip install -U --pre "yt-dlp[default,curl-cffi,deno]"; then
|
||||
echo "Warning: yt-dlp nightly upgrade failed; continuing with existing installation"
|
||||
return 1
|
||||
fi
|
||||
echo "yt-dlp nightly upgrade complete"
|
||||
return 0
|
||||
}
|
||||
|
||||
run_supervised() {
|
||||
while true; do
|
||||
"$@" &
|
||||
child_pid=$!
|
||||
trap 'kill -TERM "$child_pid" 2>/dev/null; wait "$child_pid" 2>/dev/null' TERM INT
|
||||
wait "$child_pid"
|
||||
exit_code=$?
|
||||
trap - TERM INT
|
||||
if [ "$exit_code" -eq 42 ]; then
|
||||
echo "MeTube requested yt-dlp update restart (exit 42)"
|
||||
do_upgrade || true
|
||||
continue
|
||||
fi
|
||||
return "$exit_code"
|
||||
done
|
||||
}
|
||||
|
||||
nightly_enabled() {
|
||||
[ -n "${YTDL_NIGHTLY_UPDATE_TIME}" ]
|
||||
}
|
||||
|
||||
disable_nightly_for_non_root() {
|
||||
if nightly_enabled; then
|
||||
echo "YTDL_NIGHTLY_UPDATE_TIME is set but this container runs as a non-root user; nightly yt-dlp updates are not supported. Ignoring YTDL_NIGHTLY_UPDATE_TIME."
|
||||
unset YTDL_NIGHTLY_UPDATE_TIME
|
||||
fi
|
||||
}
|
||||
|
||||
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
||||
if [ "${PUID}" -eq 0 ]; then
|
||||
@@ -14,15 +57,22 @@ if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
||||
fi
|
||||
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
|
||||
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
|
||||
if nightly_enabled; then
|
||||
echo "YTDL_NIGHTLY_UPDATE_TIME is set to ${YTDL_NIGHTLY_UPDATE_TIME}; upgrading yt-dlp on startup"
|
||||
do_upgrade || true
|
||||
fi
|
||||
echo "Starting BgUtils POT Provider"
|
||||
gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
||||
echo "Running MeTube as user ${PUID}:${PGID}"
|
||||
exec gosu "${PUID}":"${PGID}" python3 app/main.py
|
||||
run_supervised gosu "${PUID}":"${PGID}" python3 app/main.py
|
||||
exit $?
|
||||
else
|
||||
echo "User set by docker; running MeTube as `id -u`:`id -g`"
|
||||
disable_nightly_for_non_root
|
||||
echo "Starting BgUtils POT Provider"
|
||||
bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
||||
exec python3 app/main.py
|
||||
run_supervised python3 app/main.py
|
||||
exit $?
|
||||
fi
|
||||
|
||||
+26
-25
@@ -5,7 +5,7 @@
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:watch": "ng build --watch",
|
||||
"build:watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
@@ -21,43 +21,44 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.9",
|
||||
"@angular/common": "^21.2.9",
|
||||
"@angular/compiler": "^21.2.9",
|
||||
"@angular/core": "^21.2.9",
|
||||
"@angular/forms": "^21.2.9",
|
||||
"@angular/platform-browser": "^21.2.9",
|
||||
"@angular/platform-browser-dynamic": "^21.2.9",
|
||||
"@angular/service-worker": "^21.2.9",
|
||||
"@angular/animations": "^22.0.6",
|
||||
"@angular/common": "^22.0.6",
|
||||
"@angular/compiler": "^22.0.6",
|
||||
"@angular/core": "^22.0.6",
|
||||
"@angular/forms": "^22.0.6",
|
||||
"@angular/platform-browser": "^22.0.6",
|
||||
"@angular/platform-browser-dynamic": "^22.0.6",
|
||||
"@angular/service-worker": "^22.0.6",
|
||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||
"@ng-select/ng-select": "^21.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "^23.2.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-cookie-service": "^21.3.1",
|
||||
"ngx-cookie-service": "^22.0.0",
|
||||
"ngx-socket-io": "~4.10.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "21.1.0",
|
||||
"@angular/build": "^21.2.7",
|
||||
"@angular/cli": "^21.2.7",
|
||||
"@angular/compiler-cli": "^21.2.9",
|
||||
"@angular/localize": "^21.2.9",
|
||||
"@angular-eslint/builder": "22.0.0",
|
||||
"@angular/build": "^22.0.5",
|
||||
"@angular/cli": "^22.0.5",
|
||||
"@angular/compiler-cli": "^22.0.6",
|
||||
"@angular/localize": "^22.0.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"angular-eslint": "21.1.0",
|
||||
"angular-eslint": "22.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.47.0",
|
||||
"vitest": "^4.1.5"
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "8.62.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1832
-1885
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptorsFromDi, withXhr } from '@angular/common/http';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -12,6 +12,6 @@ export const appConfig: ApplicationConfig = {
|
||||
// or after 30 seconds (whichever comes first).
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
}),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClient(withXhr(), withInterceptorsFromDi()),
|
||||
]
|
||||
};
|
||||
|
||||
+73
-67
@@ -279,13 +279,12 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Subtitle output format for captions mode." triggers="click" autoClose="outside" container="body">Format</span>
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Subtitle output format for captions mode">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (f of formatOptions; track f.id) {
|
||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||
}
|
||||
@@ -294,7 +293,7 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Language</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Subtitle language (you can type any language code)." triggers="click" autoClose="outside" container="body">Language</span>
|
||||
<input class="form-control"
|
||||
type="text"
|
||||
list="subtitleLanguageOptions"
|
||||
@@ -302,8 +301,7 @@
|
||||
[(ngModel)]="subtitleLanguage"
|
||||
(change)="subtitleLanguageChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
placeholder="e.g. en, es, zh-Hans"
|
||||
ngbTooltip="Subtitle language (you can type any language code)">
|
||||
placeholder="e.g. en, es, zh-Hans">
|
||||
<datalist id="subtitleLanguageOptions">
|
||||
@for (lang of subtitleLanguages; track lang.id) {
|
||||
<option [value]="lang.id">{{ lang.text }}</option>
|
||||
@@ -313,13 +311,12 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subtitle Source</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Choose manual, auto, or fallback preference for captions mode." triggers="click" autoClose="outside" container="body">Subtitle Source</span>
|
||||
<select class="form-select"
|
||||
name="subtitleMode"
|
||||
[(ngModel)]="subtitleMode"
|
||||
(change)="subtitleModeChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (mode of subtitleModes; track mode.id) {
|
||||
<option [ngValue]="mode.id">{{ mode.text }}</option>
|
||||
}
|
||||
@@ -375,34 +372,29 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Download Folder</span>
|
||||
@if (customDirs$ | async; as customDirs) {
|
||||
<ng-select [items]="customDirs"
|
||||
<span class="input-group-text help-title" ngbPopover="Type to filter existing folders, or enter a new folder name." triggers="click" autoClose="outside" container="body">Download Folder</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
[addTag]="allowCustomDir.bind(this)"
|
||||
addTagText="Create directory"
|
||||
bindLabel="folder"
|
||||
name="folder"
|
||||
[(ngModel)]="folder"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
[virtualScroll]="true"
|
||||
[clearable]="true"
|
||||
[loading]="downloads.loading"
|
||||
[searchable]="true"
|
||||
[closeOnSelect]="true"
|
||||
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
|
||||
}
|
||||
[ngbTypeahead]="searchFolder"
|
||||
[editable]="!!downloads.configuration['CREATE_CUSTOM_DIRS']"
|
||||
(focus)="folderFocus$.next($any($event.target).value)"
|
||||
(click)="folderClick$.next($any($event.target).value)"
|
||||
#folderTypeahead="ngbTypeahead"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Custom Name Prefix</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Add a prefix to downloaded filenames." triggers="click" autoClose="outside" container="body">Custom Name Prefix</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
placeholder="Default"
|
||||
name="customNamePrefix"
|
||||
[(ngModel)]="customNamePrefix"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Add a prefix to downloaded filenames">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@@ -411,18 +403,16 @@
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
||||
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Split video into separate files by chapters">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (splitByChapters) {
|
||||
<div class="col">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Template</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Output template for chapter files." triggers="click" autoClose="outside" container="body">Template</span>
|
||||
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
|
||||
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Output template for chapter files">
|
||||
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -431,28 +421,26 @@
|
||||
@if (downloadType === 'video' || downloadType === 'audio') {
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Clip start</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Optional start time (seconds, M:SS, or H:MM:SS). Blank = from start or YouTube &t= in URL." triggers="click" autoClose="outside" container="body">Clip start</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="clipStart"
|
||||
[(ngModel)]="clipStart"
|
||||
(change)="clipStartChanged()"
|
||||
placeholder="e.g. 2:26"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Optional start time (seconds, M:SS, or H:MM:SS). Blank = from start or YouTube &t= in URL.">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Clip end</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Optional end time. Blank = until end of media." triggers="click" autoClose="outside" container="body">Clip end</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="clipEnd"
|
||||
[(ngModel)]="clipEnd"
|
||||
(change)="clipEndChanged()"
|
||||
placeholder="e.g. 3:24"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Optional end time. Blank = until end of media.">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -463,13 +451,12 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Auto Start</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Automatically start downloads when added." triggers="click" autoClose="outside" container="body">Auto Start</span>
|
||||
<select class="form-select"
|
||||
name="autoStart"
|
||||
[(ngModel)]="autoStart"
|
||||
(change)="autoStartChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Automatically start downloads when added">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
<option [ngValue]="true">Yes</option>
|
||||
<option [ngValue]="false">No</option>
|
||||
</select>
|
||||
@@ -477,7 +464,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Items Limit</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Maximum number of items to download from a playlist or channel (0 = no limit)." triggers="click" autoClose="outside" container="body">Items Limit</span>
|
||||
<input type="number"
|
||||
min="0"
|
||||
class="form-control"
|
||||
@@ -485,13 +472,12 @@
|
||||
name="playlistItemLimit"
|
||||
(keydown)="isNumber($event)"
|
||||
[(ngModel)]="playlistItemLimit"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subscription Check (min)</span>
|
||||
<span class="input-group-text help-title" ngbPopover="How often to poll subscriptions for new videos." triggers="click" autoClose="outside" container="body">Subscription Check (min)</span>
|
||||
<input type="number"
|
||||
min="1"
|
||||
class="form-control"
|
||||
@@ -499,29 +485,33 @@
|
||||
(keydown)="isNumber($event)"
|
||||
[(ngModel)]="checkIntervalMinutes"
|
||||
(ngModelChange)="checkIntervalChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="How often to poll subscriptions for new videos">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subscription Title Filter</span>
|
||||
<span class="input-group-text help-title" ngbPopover="In subscriptions, only titles matching this Python-style regex are queued. Empty = all. Case-sensitive; use (?i) in the pattern for case-insensitive." triggers="click" autoClose="outside" container="body">Subscription Title Filter</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="titleRegex"
|
||||
[(ngModel)]="titleRegex"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
placeholder="Optional regex"
|
||||
ngbTooltip="In subscriptions, only titles matching this Python-style regex are queued. Empty = all. Case-sensitive; use (?i) in the pattern for case-insensitive.">
|
||||
placeholder="Optional regex">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-skip-subscriber-only"
|
||||
name="skipSubscriberOnly" [(ngModel)]="skipSubscriberOnly"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="When enabled, subscription checks skip videos marked members-only by yt-dlp (channel Join). Ignored for one-off downloads." />
|
||||
<label class="form-check-label" for="checkbox-skip-subscriber-only">Skip members-only subscription videos</label>
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading" />
|
||||
<label class="form-check-label" for="checkbox-skip-subscriber-only">
|
||||
<span class="help-title" tabindex="0" role="button"
|
||||
ngbPopover="When enabled, subscription checks skip videos marked members-only by yt-dlp (channel Join). Ignored for one-off downloads."
|
||||
triggers="click" autoClose="outside" container="body"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation()"
|
||||
(keydown.enter)="$event.preventDefault(); $event.stopPropagation()"
|
||||
(keydown.space)="$event.preventDefault(); $event.stopPropagation()">Skip members-only subscription videos</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -531,7 +521,7 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-12" [class.col-md-6]="allowYtdlOptionsOverrides()">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Option Presets</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Choose one or more yt-dlp option presets configured on the server (applied in order)." triggers="click" autoClose="outside" container="body">Option Presets</span>
|
||||
<ng-select
|
||||
class="flex-grow-1"
|
||||
name="ytdlOptionsPresets"
|
||||
@@ -541,22 +531,20 @@
|
||||
placeholder="Default"
|
||||
[(ngModel)]="ytdlOptionsPresets"
|
||||
(ngModelChange)="ytdlOptionsPresetsChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Choose one or more yt-dlp option presets configured on the server (applied in order)" />
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading" />
|
||||
</div>
|
||||
</div>
|
||||
@if (allowYtdlOptionsOverrides()) {
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Custom yt-dlp Options</span>
|
||||
<span class="input-group-text help-title" ngbPopover="Optional per-download yt-dlp overrides as a JSON object." triggers="click" autoClose="outside" container="body">Custom yt-dlp Options</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
placeholder='e.g. {"writesubtitles": true}'
|
||||
name="ytdlOptionsOverrides"
|
||||
[(ngModel)]="ytdlOptionsOverrides"
|
||||
(change)="ytdlOptionsOverridesChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Optional per-download yt-dlp overrides as a JSON object">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -566,7 +554,7 @@
|
||||
<div class="settings-section-label">Tools</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="action-group-label">Cookies</div>
|
||||
<div class="action-group-label help-title" ngbPopover="Upload a cookies.txt file from your browser to authenticate restricted or private downloads." triggers="click" autoClose="outside" container="body">Cookies</div>
|
||||
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
||||
(change)="onCookieFileSelect($event)"
|
||||
[disabled]="cookieUploadInProgress || addInProgress">
|
||||
@@ -574,8 +562,7 @@
|
||||
<label class="btn mb-0"
|
||||
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
||||
[class.disabled]="cookieUploadInProgress || addInProgress"
|
||||
for="cookie-upload"
|
||||
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
||||
for="cookie-upload">
|
||||
@if (cookieUploadInProgress) {
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
} @else {
|
||||
@@ -719,16 +706,31 @@
|
||||
</td>
|
||||
<td title="{{ download.value.filename }}">
|
||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||
<div>{{ download.value.title }} </div>
|
||||
<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 class="d-flex align-items-center flex-wrap gap-2">
|
||||
<span>{{ download.value.title }}</span>
|
||||
@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>
|
||||
</td>
|
||||
<td>{{ download.value.speed | speed }}</td>
|
||||
<td>{{ download.value.eta | eta }}</td>
|
||||
<td>
|
||||
<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]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
@@ -854,6 +856,9 @@
|
||||
@if (entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
||||
}
|
||||
@if (entry[1].filename && canShareDownloads()) {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Share result file for ' + entry[1].title" (click)="shareDownload(entry[1])"><fa-icon [icon]="faShareNodes" /></button>
|
||||
}
|
||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
@@ -936,8 +941,7 @@
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col" class="text-nowrap"
|
||||
ngbTooltip="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads.">Sub. title filter</th>
|
||||
<th scope="col" class="text-nowrap"><span class="help-title" ngbPopover="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads." triggers="click" autoClose="outside" container="body">Filter</span></th>
|
||||
<th scope="col" class="text-nowrap">Interval (min)</th>
|
||||
<th scope="col" class="text-nowrap">Last checked</th>
|
||||
<th scope="col">Status</th>
|
||||
@@ -1074,3 +1078,5 @@
|
||||
}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<app-toast-container />
|
||||
|
||||
@@ -201,6 +201,12 @@ main
|
||||
color: var(--bs-secondary-color)
|
||||
margin-bottom: 0.4rem
|
||||
|
||||
.help-title
|
||||
cursor: help
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
|
||||
.cookie-status
|
||||
font-size: 0.8rem
|
||||
margin-top: 0.35rem
|
||||
|
||||
+36
-3
@@ -4,6 +4,7 @@ import { Subject, of } from 'rxjs';
|
||||
import { App } from './app';
|
||||
import { DownloadsService } from './services/downloads.service';
|
||||
import { SubscriptionsService } from './services/subscriptions.service';
|
||||
import { ToastService } from './services/toast.service';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
|
||||
class DownloadsServiceStub {
|
||||
@@ -182,6 +183,37 @@ describe('App', () => {
|
||||
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', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
@@ -232,7 +264,8 @@ describe('App', () => {
|
||||
});
|
||||
|
||||
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 app = fixture.componentInstance;
|
||||
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||
@@ -240,7 +273,7 @@ describe('App', () => {
|
||||
app.titleRegex = '[';
|
||||
app.addSubscription();
|
||||
expect(subs.subscribeCalls.length).toBe(0);
|
||||
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||
alertSpy.mockRestore();
|
||||
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+198
-125
@@ -1,17 +1,20 @@
|
||||
import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
|
||||
import { DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Observable, Subject, Subscription, from, map, distinctUntilChanged, finalize, mergeMap, takeUntil, tap } from 'rxjs';
|
||||
import { Observable, OperatorFunction, Subject, Subscription, from, map, merge, debounceTime, distinctUntilChanged, filter, finalize, mergeMap, takeUntil, tap } from 'rxjs';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay } from '@fortawesome/free-solid-svg-icons';
|
||||
import { NgbModule, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay, faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
||||
import { MeTubeSocket } from './services/metube-socket.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 { Themes } from './theme';
|
||||
import {
|
||||
@@ -31,7 +34,7 @@ import {
|
||||
State,
|
||||
} from './interfaces';
|
||||
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
||||
import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/';
|
||||
import { SelectAllCheckboxComponent, ItemCheckboxComponent, ToastContainerComponent } from './components/';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -40,7 +43,6 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
||||
FormsModule,
|
||||
NgTemplateOutlet,
|
||||
KeyValuePipe,
|
||||
AsyncPipe,
|
||||
DatePipe,
|
||||
FontAwesomeModule,
|
||||
NgbModule,
|
||||
@@ -50,6 +52,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
||||
FileSizePipe,
|
||||
SelectAllCheckboxComponent,
|
||||
ItemCheckboxComponent,
|
||||
ToastContainerComponent,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.sass',
|
||||
@@ -57,6 +60,9 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
||||
export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
downloads = inject(DownloadsService);
|
||||
subscriptionsSvc = inject(SubscriptionsService);
|
||||
private toasts = inject(ToastService);
|
||||
private batchUrls = inject(BatchUrlsService);
|
||||
private socket = inject(MeTubeSocket);
|
||||
private cookieService = inject(CookieService);
|
||||
private http = inject(HttpClient);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
@@ -105,8 +111,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
cookieUploadInProgress = false;
|
||||
themes: Theme[] = Themes;
|
||||
activeTheme: Theme | undefined;
|
||||
customDirs$!: Observable<string[]>;
|
||||
showBatchPanel = false;
|
||||
readonly folderTypeahead = viewChild<NgbTypeahead>('folderTypeahead');
|
||||
folderFocus$ = new Subject<string>();
|
||||
folderClick$ = new Subject<string>();
|
||||
showBatchPanel = false;
|
||||
batchImportModalOpen = false;
|
||||
batchImportText = '';
|
||||
batchImportStatus = '';
|
||||
@@ -129,6 +137,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
lastCopiedErrorId: string | null = null;
|
||||
private previousDownloadType = 'video';
|
||||
private addRequestSub?: Subscription;
|
||||
private liveCountdownTimer?: ReturnType<typeof setInterval>;
|
||||
private selectionsByType: Record<string, {
|
||||
codec: string;
|
||||
format: string;
|
||||
@@ -185,6 +194,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
faUpload = faUpload;
|
||||
faPause = faPause;
|
||||
faPlay = faPlay;
|
||||
faShareNodes = faShareNodes;
|
||||
subtitleLanguages = [
|
||||
{ id: 'en', text: 'English' },
|
||||
{ id: 'ar', text: 'Arabic' },
|
||||
@@ -281,6 +291,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
// Subscribe to download updates
|
||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.updateMetrics();
|
||||
this.syncLiveCountdownTimer();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
@@ -291,6 +302,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
// Subscribe to real-time updates
|
||||
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.updateMetrics();
|
||||
this.syncLiveCountdownTimer();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
|
||||
@@ -308,7 +320,6 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.getConfiguration();
|
||||
this.getYtdlOptionsUpdateTime();
|
||||
this.getYtdlOptionPresets();
|
||||
this.customDirs$ = this.getMatchingCustomDir();
|
||||
this.setTheme(this.activeTheme!);
|
||||
|
||||
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
||||
@@ -327,18 +338,24 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
// Initialize action button states for already-loaded entries.
|
||||
this.updateDoneActionButtons();
|
||||
this.fetchVersionInfo();
|
||||
this.socket.fromEvent('connect')
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.fetchVersionInfo());
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.addRequestSub?.unsubscribe();
|
||||
if (this.liveCountdownTimer) {
|
||||
clearInterval(this.liveCountdownTimer);
|
||||
}
|
||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||
}
|
||||
|
||||
// workaround to allow fetching of Map values in the order they were inserted
|
||||
// https://github.com/angular/angular/issues/31420
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
asIsOrder() {
|
||||
return 1;
|
||||
}
|
||||
@@ -375,34 +392,26 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
return this.downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] === true;
|
||||
}
|
||||
|
||||
allowCustomDir(tag: string) {
|
||||
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
|
||||
return tag;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
searchFolder: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) => {
|
||||
const debouncedText$ = text$.pipe(debounceTime(150), distinctUntilChanged());
|
||||
const clicksWithClosedPopup$ = this.folderClick$.pipe(
|
||||
filter(() => !this.folderTypeahead()?.isPopupOpen()),
|
||||
);
|
||||
return merge(debouncedText$, this.folderFocus$, clicksWithClosedPopup$).pipe(
|
||||
map(term => {
|
||||
const dirs = this.isAudioType()
|
||||
? (this.downloads.customDirs?.['audio_download_dir'] ?? [])
|
||||
: (this.downloads.customDirs?.['download_dir'] ?? []);
|
||||
const t = (term ?? '').toLowerCase();
|
||||
return (t === '' ? dirs : dirs.filter(d => d.toLowerCase().includes(t))).slice(0, 10);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
isAudioType() {
|
||||
return this.downloadType === 'audio';
|
||||
}
|
||||
|
||||
getMatchingCustomDir() : Observable<string[]> {
|
||||
return this.downloads.customDirsChanged.asObservable().pipe(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
map((output: any) => {
|
||||
// Keep logic consistent with app/ytdl.py
|
||||
if (this.isAudioType()) {
|
||||
console.debug("Showing audio-specific download directories");
|
||||
return output["audio_download_dir"];
|
||||
} else {
|
||||
console.debug("Showing default download directories");
|
||||
return output["download_dir"];
|
||||
}
|
||||
}),
|
||||
distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr))
|
||||
);
|
||||
}
|
||||
|
||||
getYtdlOptionsUpdateTime() {
|
||||
this.downloads.ytdlOptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -411,7 +420,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
const date = new Date(data['update_time'] * 1000);
|
||||
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
||||
}else{
|
||||
alert("Error reload yt-dlp options: "+data['msg']);
|
||||
this.toasts.error("Error reloading yt-dlp options: " + data['msg']);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
@@ -486,11 +495,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
alert('Custom yt-dlp options must be valid JSON');
|
||||
this.toasts.error('Custom yt-dlp options must be valid JSON');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -521,7 +530,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
||||
const error = this.getStatusError(refreshRes);
|
||||
if (error) {
|
||||
alert(error || 'Refresh subscriptions failed');
|
||||
this.toasts.error(error || 'Refresh subscriptions failed');
|
||||
return;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
@@ -565,7 +574,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
}
|
||||
const payload = this.buildAddPayload();
|
||||
if (!payload.url?.trim()) {
|
||||
alert('Please enter a URL');
|
||||
this.toasts.error('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
const tr = (this.titleRegex || '').trim();
|
||||
@@ -573,12 +582,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
try {
|
||||
void RegExp(tr);
|
||||
} catch {
|
||||
alert('Invalid subscription title filter (regex)');
|
||||
this.toasts.error('Invalid subscription title filter (regex)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||
@@ -607,7 +616,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
next: (res) => {
|
||||
const r = res as { status?: string; msg?: string };
|
||||
if (r.status === 'error') {
|
||||
alert(r.msg || 'Subscribe failed');
|
||||
this.toasts.error(r.msg || 'Subscribe failed');
|
||||
} else {
|
||||
this.addUrl = '';
|
||||
this.titleRegex = '';
|
||||
@@ -635,14 +644,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
try {
|
||||
void RegExp(raw);
|
||||
} catch {
|
||||
alert('Invalid subscription title filter (regex)');
|
||||
this.toasts.error('Invalid subscription title filter (regex)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Update subscription failed');
|
||||
this.toasts.error(error || 'Update subscription failed');
|
||||
return;
|
||||
}
|
||||
this.cancelEditTitleRegex();
|
||||
@@ -653,7 +662,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Delete subscription failed');
|
||||
this.toasts.error(error || 'Delete subscription failed');
|
||||
return;
|
||||
}
|
||||
this.selectedSubscriptionIds.delete(id);
|
||||
@@ -669,7 +678,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.delete(ids).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Delete subscriptions failed');
|
||||
this.toasts.error(error || 'Delete subscriptions failed');
|
||||
return;
|
||||
}
|
||||
this.selectedSubscriptionIds.clear();
|
||||
@@ -695,7 +704,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
.subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Subscription check failed');
|
||||
this.toasts.error(error || 'Subscription check failed');
|
||||
return;
|
||||
}
|
||||
this.refreshSubscriptionsWithAlert();
|
||||
@@ -742,7 +751,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
.subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Subscription check failed');
|
||||
this.toasts.error(error || 'Subscription check failed');
|
||||
return;
|
||||
}
|
||||
this.refreshSubscriptionsWithAlert();
|
||||
@@ -765,7 +774,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Update subscription failed');
|
||||
this.toasts.error(error || 'Update subscription failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1062,20 +1071,19 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
|
||||
// Validate chapter template if chapter splitting is enabled
|
||||
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;
|
||||
}
|
||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Downloading:', payload);
|
||||
this.addInProgress = true;
|
||||
this.cancelRequested = false;
|
||||
this.addRequestSub?.unsubscribe();
|
||||
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
|
||||
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') {
|
||||
this.addUrl = '';
|
||||
}
|
||||
@@ -1108,6 +1116,26 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
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) {
|
||||
this.addDownload({
|
||||
url: download.url,
|
||||
@@ -1161,19 +1189,39 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
downloadSelectedFiles() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
this.downloads.done.forEach((dl, _) => {
|
||||
// Chromium-based browsers silently drop programmatic downloads beyond ~10 when
|
||||
// triggered in a tight loop. Trigger in batches with a short pause in between so
|
||||
// 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) {
|
||||
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);
|
||||
selected.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -1189,6 +1237,80 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
return baseDir + encodeURIComponent(download.filename);
|
||||
}
|
||||
|
||||
// Web Share API support — primarily for iOS Safari / Chrome, lets the user
|
||||
// hand the downloaded file off to the platform share sheet (Photos.app,
|
||||
// Files, third-party apps, AirDrop). Falls back silently to the standard
|
||||
// download flow on platforms without navigator.share / canShare.
|
||||
canShareDownloads(): boolean {
|
||||
// navigator.share alone is not enough — Desktop Safari implements
|
||||
// navigator.share but not canShare with files. We explicitly require
|
||||
// both, since we always intend to share a file (not a URL).
|
||||
return typeof navigator !== 'undefined'
|
||||
&& typeof navigator.share === 'function'
|
||||
&& typeof navigator.canShare === 'function';
|
||||
}
|
||||
|
||||
// Conservative warning threshold for the share sheet — iOS' actual
|
||||
// refusal limit varies between ~50 MB (older versions) and ~150 MB
|
||||
// (recent ones). 80 MB warns the user before the time-wasting fetch+
|
||||
// copy of a too-large file that the platform will then reject.
|
||||
private static readonly SHARE_SIZE_WARN_BYTES = 80 * 1024 * 1024;
|
||||
|
||||
async shareDownload(download: Download): Promise<void> {
|
||||
if (!this.canShareDownloads()) {
|
||||
return;
|
||||
}
|
||||
// Pre-flight size check: warn the user about the iOS share-sheet
|
||||
// soft-fail on large files, before we spend time fetching the whole
|
||||
// file into memory only to have navigator.canShare reject it.
|
||||
if (download.size && download.size > App.SHARE_SIZE_WARN_BYTES) {
|
||||
const sizeMb = Math.round(download.size / 1024 / 1024);
|
||||
const proceed = await this.toasts.confirm(
|
||||
`This file is ${sizeMb} MB. iOS' share sheet often refuses files ` +
|
||||
`larger than ~100 MB and the share will silently fail. ` +
|
||||
`Try anyway? (Use the download button instead if it fails.)`,
|
||||
'Try anyway',
|
||||
'Cancel',
|
||||
);
|
||||
if (!proceed) return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(this.buildDownloadLink(download));
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} fetching file for share`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], download.filename, {
|
||||
type: blob.type || 'application/octet-stream',
|
||||
});
|
||||
const payload: ShareData = { files: [file], title: download.title };
|
||||
if (!navigator.canShare(payload)) {
|
||||
// The platform refused the payload — most commonly because the
|
||||
// file is too large for the iOS share sheet, or the MIME type
|
||||
// isn't accepted. Tell the user so they can fall back to the
|
||||
// download button right next to this one instead of staring at
|
||||
// a button that quietly did nothing.
|
||||
console.warn('navigator.canShare rejected payload for', download.filename);
|
||||
this.toasts.error(
|
||||
`Your device's share sheet doesn't accept this file ` +
|
||||
`(most likely because it's too large). ` +
|
||||
`Please use the download button instead.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await navigator.share(payload);
|
||||
} catch (err) {
|
||||
const e = err as { name?: string; message?: string };
|
||||
// AbortError = user dismissed the share sheet → silent no-op.
|
||||
if (e.name === 'AbortError') return;
|
||||
console.error('Share failed:', err);
|
||||
this.toasts.error(
|
||||
`Share failed: ${e.message || 'unknown error'}. ` +
|
||||
`Please use the download button instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
buildResultItemTooltip(download: Download) {
|
||||
const parts = [];
|
||||
if (download.msg) {
|
||||
@@ -1274,7 +1396,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
if (urls.length === 0) {
|
||||
alert('No valid URLs found.');
|
||||
this.toasts.error('No valid URLs found.');
|
||||
return;
|
||||
}
|
||||
this.importInProgress = true;
|
||||
@@ -1339,66 +1461,17 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||
exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
||||
let urls: string[];
|
||||
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);
|
||||
exportBatchUrls(filter: BatchUrlFilter): void {
|
||||
this.batchUrls.export(filter);
|
||||
}
|
||||
|
||||
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||
copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
||||
let urls: string[];
|
||||
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.'));
|
||||
copyBatchUrls(filter: BatchUrlFilter): void {
|
||||
this.batchUrls.copy(filter);
|
||||
}
|
||||
|
||||
fetchVersionInfo(): void {
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`;
|
||||
const versionUrl = `${baseUrl}version`;
|
||||
this.http.get<{ 'yt-dlp': string, version: string }>(versionUrl)
|
||||
@@ -1454,7 +1527,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
};
|
||||
const fail = (err?: unknown) => {
|
||||
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) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(fail);
|
||||
@@ -1490,7 +1563,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.hasCookies = true;
|
||||
} else {
|
||||
this.refreshCookieStatus();
|
||||
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
this.toasts.error(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
}
|
||||
this.cookieUploadInProgress = false;
|
||||
input.value = '';
|
||||
@@ -1499,7 +1572,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.refreshCookieStatus();
|
||||
this.cookieUploadInProgress = false;
|
||||
input.value = '';
|
||||
alert('Error uploading cookies.');
|
||||
this.toasts.error('Error uploading cookies.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1533,11 +1606,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.refreshCookieStatus();
|
||||
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
this.toasts.error(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
},
|
||||
error: () => {
|
||||
this.refreshCookieStatus();
|
||||
alert('Error deleting cookies.');
|
||||
this.toasts.error('Error deleting cookies.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1561,7 +1634,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
speed += download.speed || 0;
|
||||
} else if (download.status === 'preparing') {
|
||||
active++;
|
||||
} else if (download.status === 'pending') {
|
||||
} else if (download.status === 'pending' || download.status === 'scheduled') {
|
||||
queued++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
export { ItemCheckboxComponent } from './slave-checkbox.component';
|
||||
export { ItemCheckboxComponent } from './slave-checkbox.component';
|
||||
export { ToastContainerComponent } from './toast-container.component';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ElementRef, viewChild, output, input } from "@angular/core";
|
||||
import { Component, ElementRef, viewChild, output, input, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { Checkable } from "../interfaces";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
|
||||
@@ -10,7 +10,10 @@ import { FormsModule } from "@angular/forms";
|
||||
<label class="form-check-label visually-hidden" for="{{id()}}-select-all">Select all</label>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
import { Checkable } from '../interfaces';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -11,7 +11,10 @@ import { FormsModule } from '@angular/forms';
|
||||
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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>;
|
||||
clip_start?: number;
|
||||
clip_end?: number;
|
||||
live_status?: string;
|
||||
live_release_timestamp?: number;
|
||||
status: string;
|
||||
msg: string;
|
||||
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 { 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);
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,13 @@
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user