mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
add option for following nightly yt-dlp releases (closes #999)
This commit is contained in:
@@ -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__: 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).
|
* __YTDL_OPTIONS_PRESETS_FILE__: Path to a JSON file containing presets. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options).
|
||||||
* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show a free-text field in the UI for per-download yt-dlp option overrides. Defaults to `false`. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details and security considerations.
|
* __ALLOW_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
|
### 🌐 Web Server & URLs
|
||||||
|
|
||||||
|
|||||||
+72
-4
@@ -4,8 +4,10 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from aiohttp.web import GracefulExit
|
||||||
from aiohttp.log import access_logger
|
from aiohttp.log import access_logger
|
||||||
import ssl
|
import ssl
|
||||||
import socket
|
import socket
|
||||||
@@ -23,6 +25,22 @@ from yt_dlp.version import __version__ as yt_dlp_version
|
|||||||
|
|
||||||
log = logging.getLogger('main')
|
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):
|
def parseLogLevel(logLevel):
|
||||||
if not isinstance(logLevel, str):
|
if not isinstance(logLevel, str):
|
||||||
return None
|
return None
|
||||||
@@ -73,6 +91,7 @@ class Config:
|
|||||||
'MAX_CONCURRENT_DOWNLOADS': '3',
|
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||||
'LOGLEVEL': 'INFO',
|
'LOGLEVEL': 'INFO',
|
||||||
'ENABLE_ACCESSLOG': 'false',
|
'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')
|
_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('.'):
|
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())
|
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 = {}
|
self._runtime_overrides = {}
|
||||||
|
|
||||||
success,_ = self.load_ytdl_options()
|
success,_ = self.load_ytdl_options()
|
||||||
@@ -465,8 +491,18 @@ class Notifier(DownloadQueueNotifier):
|
|||||||
await sio.emit('cleared', serializer.encode(id))
|
await sio.emit('cleared', serializer.encode(id))
|
||||||
|
|
||||||
dqueue = DownloadQueue(config, Notifier())
|
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):
|
class MetubeSubscriptionNotifier(SubscriptionNotifier):
|
||||||
@@ -486,7 +522,13 @@ class MetubeSubscriptionNotifier(SubscriptionNotifier):
|
|||||||
|
|
||||||
|
|
||||||
submgr = SubscriptionManager(config, dqueue, MetubeSubscriptionNotifier())
|
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):
|
async def _subscription_loop_startup(app):
|
||||||
@@ -496,6 +538,26 @@ async def _subscription_loop_startup(app):
|
|||||||
|
|
||||||
app.on_startup.append(_subscription_loop_startup)
|
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):
|
class FileOpsFilter(DefaultFilter):
|
||||||
def __call__(self, change_type: int, path: str) -> bool:
|
def __call__(self, change_type: int, path: str) -> bool:
|
||||||
# Check if this path matches our YTDL_OPTIONS_FILE
|
# 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}')
|
log.info(f'Starting Watch File: {config.YTDL_OPTIONS_FILE}')
|
||||||
asyncio.create_task(_watch_files())
|
asyncio.create_task(_watch_files())
|
||||||
|
|
||||||
|
async def _watch_files_startup(app):
|
||||||
|
await watch_files()
|
||||||
|
|
||||||
|
|
||||||
if config.YTDL_OPTIONS_FILE:
|
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:
|
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())
|
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port(), ssl_context=ssl_context, access_log=isAccessLogEnabled())
|
||||||
else:
|
else:
|
||||||
web.run_app(app, host=config.HOST, port=int(config.PORT), reuse_port=supports_reuse_port(), access_log=isAccessLogEnabled())
|
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)
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ class ConfigTests(unittest.TestCase):
|
|||||||
c = Config()
|
c = Config()
|
||||||
self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES)
|
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):
|
def test_runtime_override_roundtrip(self):
|
||||||
with patch.dict(os.environ, _base_env(), clear=False):
|
with patch.dict(os.environ, _base_env(), clear=False):
|
||||||
c = Config()
|
c = Config()
|
||||||
|
|||||||
@@ -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()
|
||||||
+51
-2
@@ -8,6 +8,48 @@ umask ${UMASK}
|
|||||||
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
||||||
mkdir -p "${DOWNLOAD_DIR}" "${STATE_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 [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
||||||
if [ "${PUID}" -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"
|
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}"
|
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}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||||
fi
|
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"
|
echo "Starting BgUtils POT Provider"
|
||||||
gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
||||||
echo "Running MeTube as user ${PUID}:${PGID}"
|
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
|
else
|
||||||
echo "User set by docker; running MeTube as `id -u`:`id -g`"
|
echo "User set by docker; running MeTube as `id -u`:`id -g`"
|
||||||
|
disable_nightly_for_non_root
|
||||||
echo "Starting BgUtils POT Provider"
|
echo "Starting BgUtils POT Provider"
|
||||||
bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
||||||
exec python3 app/main.py
|
run_supervised python3 app/main.py
|
||||||
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faC
|
|||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
||||||
|
import { MeTubeSocket } from './services/metube-socket.service';
|
||||||
import { SubscriptionsService } from './services/subscriptions.service';
|
import { SubscriptionsService } from './services/subscriptions.service';
|
||||||
import { SubscriptionRow } from './interfaces/subscription';
|
import { SubscriptionRow } from './interfaces/subscription';
|
||||||
import { Themes } from './theme';
|
import { Themes } from './theme';
|
||||||
@@ -56,6 +57,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
|||||||
export class App implements AfterViewInit, OnInit, OnDestroy {
|
export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
downloads = inject(DownloadsService);
|
downloads = inject(DownloadsService);
|
||||||
subscriptionsSvc = inject(SubscriptionsService);
|
subscriptionsSvc = inject(SubscriptionsService);
|
||||||
|
private socket = inject(MeTubeSocket);
|
||||||
private cookieService = inject(CookieService);
|
private cookieService = inject(CookieService);
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
@@ -328,6 +330,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// Initialize action button states for already-loaded entries.
|
// Initialize action button states for already-loaded entries.
|
||||||
this.updateDoneActionButtons();
|
this.updateDoneActionButtons();
|
||||||
this.fetchVersionInfo();
|
this.fetchVersionInfo();
|
||||||
|
this.socket.fromEvent('connect')
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(() => this.fetchVersionInfo());
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
|||||||
Reference in New Issue
Block a user