mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
Compare commits
2 Commits
2026.05.29
...
2026.06.06
| Author | SHA1 | Date | |
|---|---|---|---|
| ee20512410 | |||
| 897d52cd0d |
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})"
|
||||
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
|
||||
|
||||
+1
-1
@@ -926,7 +926,7 @@
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</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">Sub. title filter</span></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>
|
||||
|
||||
@@ -202,8 +202,6 @@ main
|
||||
margin-bottom: 0.4rem
|
||||
|
||||
.help-title
|
||||
text-decoration: underline dotted
|
||||
text-underline-offset: 0.2em
|
||||
cursor: help
|
||||
|
||||
&:focus
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user