Compare commits

..

2 Commits

Author SHA1 Message Date
AutoUpdater e30a24ff70 upgrade yt-dlp from 2026.3.17 to 2026.6.9 2026-06-10 00:36:22 +00:00
Alex Shnitman ee20512410 add option for following nightly yt-dlp releases (closes #999) 2026-06-06 09:42:26 +03:00
7 changed files with 178 additions and 10 deletions
+1
View File
@@ -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
+72 -4
View File
@@ -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)
+16
View File
@@ -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()
+29
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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() {
Generated
+4 -4
View File
@@ -1060,11 +1060,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2026.3.17"
version = "2026.6.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" }
sdist = { url = "https://files.pythonhosted.org/packages/88/a4/1b0979d28f87774bb67fbbc66bce44f9dd1aa0e547a99e22985fac945c33/yt_dlp-2026.6.9.tar.gz", hash = "sha256:d50fcb95f48d61bedde33e408c1881d4c279e51c31354a599ce09e96ba0f4b86", size = 3030590, upload-time = "2026-06-09T23:27:14.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ee/188a3dadf9dfdac713243521f919feca1cd091d4358c9ea7e8ebb710a7cc/yt_dlp-2026.6.9-py3-none-any.whl", hash = "sha256:442ba4c75724b9496144c8434b617962ee08d0ee7c26ec663848fe9b78d5a3e4", size = 3169035, upload-time = "2026-06-09T23:27:12.58Z" },
]
[package.optional-dependencies]
@@ -1072,7 +1072,7 @@ curl-cffi = [
{ name = "curl-cffi", marker = "implementation_name == 'cpython'" },
]
default = [
{ name = "brotli", marker = "implementation_name == 'cpython'" },
{ name = "brotli", marker = "implementation_name == 'cpython' and sys_platform != 'ios'" },
{ name = "brotlicffi", marker = "implementation_name != 'cpython'" },
{ name = "certifi" },
{ name = "mutagen" },