diff --git a/README.md b/README.md index d6f80aa..5d5a25f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/main.py b/app/main.py index 61d7e6e..f5154ca 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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') @@ -104,6 +123,13 @@ 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._runtime_overrides = {} success,_ = self.load_ytdl_options() @@ -465,8 +491,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 +522,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 +538,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 +604,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: @@ -1122,3 +1188,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) diff --git a/app/tests/test_config.py b/app/tests/test_config.py index 6b096bf..8105e79 100644 --- a/app/tests/test_config.py +++ b/app/tests/test_config.py @@ -107,6 +107,22 @@ 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_runtime_override_roundtrip(self): with patch.dict(os.environ, _base_env(), clear=False): c = Config() diff --git a/app/tests/test_nightly_update.py b/app/tests/test_nightly_update.py new file mode 100644 index 0000000..749ca8a --- /dev/null +++ b/app/tests/test_nightly_update.py @@ -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() diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5f07bc5..e1d976c 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,6 +8,48 @@ 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}" +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 echo "Warning: it is not recommended to run as root user, please check your setting of the PUID/PGID (or legacy UID/GID) environment variables" @@ -16,13 +58,20 @@ if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then echo "Changing ownership of download and state directories to ${PUID}:${PGID}" chown -R "${PUID}":"${PGID}" /app "${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 diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index f1a2edb..5dc8709 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -11,6 +11,7 @@ import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faC 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 { SubscriptionRow } from './interfaces/subscription'; import { Themes } from './theme'; @@ -56,6 +57,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/ export class App implements AfterViewInit, OnInit, OnDestroy { downloads = inject(DownloadsService); subscriptionsSvc = inject(SubscriptionsService); + private socket = inject(MeTubeSocket); private cookieService = inject(CookieService); private http = inject(HttpClient); private cdr = inject(ChangeDetectorRef); @@ -328,6 +330,9 @@ 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() {