mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Compare commits
47 Commits
| 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 | |||
| 5d96a581b9 |
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
||||||
-
|
-
|
||||||
name: Checkout
|
name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
id: date
|
id: date
|
||||||
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Get commits since last release
|
- name: Get commits since last release
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout
|
name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.AUTOUPDATE_PAT }}
|
token: ${{ secrets.AUTOUPDATE_PAT }}
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ If an addition would exceed the limit, trim existing prose elsewhere — prefer
|
|||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp
|
- **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)
|
- **Package managers:** uv (Python), pnpm (frontend)
|
||||||
- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)
|
- **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__: 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
|
||||||
|
|
||||||
@@ -346,7 +347,7 @@ example.com {
|
|||||||
|
|
||||||
## 🔄 Updating yt-dlp
|
## 🔄 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
|
## 🔧 Troubleshooting and submitting issues
|
||||||
|
|
||||||
|
|||||||
+149
-13
@@ -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
|
||||||
@@ -14,15 +16,31 @@ import logging
|
|||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
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 watchfiles import DefaultFilter, Change, awatch
|
||||||
|
|
||||||
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||||
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo
|
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo, coerce_optional_bool
|
||||||
from yt_dlp.version import __version__ as yt_dlp_version
|
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')
|
||||||
@@ -93,6 +112,13 @@ class Config:
|
|||||||
if not self.URL_PREFIX.endswith('/'):
|
if not self.URL_PREFIX.endswith('/'):
|
||||||
self.URL_PREFIX += '/'
|
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'):
|
for attr in ('PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL'):
|
||||||
val = getattr(self, attr)
|
val = getattr(self, attr)
|
||||||
if val and not val.endswith('/'):
|
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('.'):
|
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._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 = {}
|
self._runtime_overrides = {}
|
||||||
|
|
||||||
success,_ = self.load_ytdl_options()
|
success,_ = self.load_ytdl_options()
|
||||||
@@ -113,6 +150,20 @@ class Config:
|
|||||||
if not success:
|
if not success:
|
||||||
sys.exit(1)
|
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):
|
def set_runtime_override(self, key, value):
|
||||||
self._runtime_overrides[key] = value
|
self._runtime_overrides[key] = value
|
||||||
self.YTDL_OPTIONS[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):
|
class ObjectSerializer(json.JSONEncoder):
|
||||||
def default(self, obj):
|
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__'):
|
if hasattr(obj, '__dict__'):
|
||||||
return obj.__dict__
|
return obj.__dict__
|
||||||
# Convert iterables (generators, dict_items, etc.) to lists
|
# Convert iterables (generators, dict_items, etc.) to lists
|
||||||
@@ -229,7 +286,30 @@ class ObjectSerializer(json.JSONEncoder):
|
|||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
serializer = ObjectSerializer()
|
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 []
|
_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 = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else [])
|
||||||
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
||||||
@@ -465,8 +545,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 +576,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 +592,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 +658,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:
|
||||||
@@ -761,10 +881,7 @@ async def cancel_add(request):
|
|||||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||||
async def subscribe(request):
|
async def subscribe(request):
|
||||||
post = await _read_json_request(request)
|
post = await _read_json_request(request)
|
||||||
try:
|
o = parse_download_options(post)
|
||||||
o = parse_download_options(post)
|
|
||||||
except web.HTTPBadRequest:
|
|
||||||
raise
|
|
||||||
cic = post.get('check_interval_minutes')
|
cic = post.get('check_interval_minutes')
|
||||||
if cic is None:
|
if cic is None:
|
||||||
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
|
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
|
||||||
@@ -777,6 +894,15 @@ async def subscribe(request):
|
|||||||
if o.get('clip_start') is not None or o.get('clip_end') is not None:
|
if o.get('clip_start') is not None or o.get('clip_end') is not None:
|
||||||
raise web.HTTPBadRequest(reason='clip options are not supported for subscriptions')
|
raise web.HTTPBadRequest(reason='clip options are not supported for subscriptions')
|
||||||
|
|
||||||
|
try:
|
||||||
|
skip_subscriber_only = coerce_optional_bool(
|
||||||
|
post.get('skip_subscriber_only'),
|
||||||
|
default=False,
|
||||||
|
field_name='skip_subscriber_only',
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise web.HTTPBadRequest(reason=str(exc)) from exc
|
||||||
|
|
||||||
result = await submgr.add_subscription(
|
result = await submgr.add_subscription(
|
||||||
o['url'],
|
o['url'],
|
||||||
check_interval_minutes=cic,
|
check_interval_minutes=cic,
|
||||||
@@ -795,6 +921,7 @@ async def subscribe(request):
|
|||||||
ytdl_options_presets=o['ytdl_options_presets'],
|
ytdl_options_presets=o['ytdl_options_presets'],
|
||||||
ytdl_options_overrides=o['ytdl_options_overrides'],
|
ytdl_options_overrides=o['ytdl_options_overrides'],
|
||||||
title_regex=post.get('title_regex'),
|
title_regex=post.get('title_regex'),
|
||||||
|
skip_subscriber_only=skip_subscriber_only,
|
||||||
)
|
)
|
||||||
return web.Response(text=serializer.encode(result))
|
return web.Response(text=serializer.encode(result))
|
||||||
|
|
||||||
@@ -813,7 +940,8 @@ async def subscriptions_update(request):
|
|||||||
changes = {
|
changes = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in post.items()
|
for k, v in post.items()
|
||||||
if k != 'id' and k in ('enabled', 'check_interval_minutes', 'name', 'title_regex')
|
if k != 'id'
|
||||||
|
and k in ('enabled', 'check_interval_minutes', 'name', 'title_regex', 'skip_subscriber_only')
|
||||||
}
|
}
|
||||||
if not changes:
|
if not changes:
|
||||||
raise web.HTTPBadRequest(reason='no valid fields to update')
|
raise web.HTTPBadRequest(reason='no valid fields to update')
|
||||||
@@ -887,6 +1015,12 @@ async def upload_cookies(request):
|
|||||||
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
|
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
|
||||||
with open(tmp_cookie_path, 'wb') as f:
|
with open(tmp_cookie_path, 'wb') as f:
|
||||||
f.write(content)
|
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)
|
os.replace(tmp_cookie_path, COOKIES_PATH)
|
||||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||||
log.info(f'Cookies file uploaded ({size} bytes)')
|
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||||
@@ -1111,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())
|
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)
|
||||||
|
|||||||
+84
-3
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import collections.abc
|
import collections.abc
|
||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -17,6 +18,25 @@ STATE_SCHEMA_VERSION = 2
|
|||||||
_BYTES_MARKER = "__metube_bytes__"
|
_BYTES_MARKER = "__metube_bytes__"
|
||||||
_DATETIME_MARKER = "__metube_datetime__"
|
_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:
|
def to_json_compatible(value: Any) -> Any:
|
||||||
if value is None or isinstance(value, (bool, int, float, str)):
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
@@ -62,6 +82,7 @@ class AtomicJsonStore:
|
|||||||
self.path = path
|
self.path = path
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.schema_version = schema_version
|
self.schema_version = schema_version
|
||||||
|
self._direct_write_fallback_warned = False
|
||||||
|
|
||||||
def _ensure_parent(self) -> None:
|
def _ensure_parent(self) -> None:
|
||||||
parent = os.path.dirname(self.path)
|
parent = os.path.dirname(self.path)
|
||||||
@@ -96,6 +117,16 @@ class AtomicJsonStore:
|
|||||||
def save(self, data: dict[str, Any]) -> None:
|
def save(self, data: dict[str, Any]) -> None:
|
||||||
self._ensure_parent()
|
self._ensure_parent()
|
||||||
payload = self._build_payload(data)
|
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 "."
|
parent = os.path.dirname(self.path) or "."
|
||||||
fd, tmp_path = tempfile.mkstemp(
|
fd, tmp_path = tempfile.mkstemp(
|
||||||
prefix=f".{os.path.basename(self.path)}.",
|
prefix=f".{os.path.basename(self.path)}.",
|
||||||
@@ -105,10 +136,9 @@ class AtomicJsonStore:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
|
f.write(text)
|
||||||
f.write("\n")
|
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
self._best_effort_fsync(f.fileno())
|
||||||
os.replace(tmp_path, self.path)
|
os.replace(tmp_path, self.path)
|
||||||
self._fsync_directory(parent)
|
self._fsync_directory(parent)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -118,6 +148,57 @@ class AtomicJsonStore:
|
|||||||
pass
|
pass
|
||||||
raise
|
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:
|
def quarantine_invalid_file(self, exc: Exception) -> None:
|
||||||
if not os.path.exists(self.path):
|
if not os.path.exists(self.path):
|
||||||
return
|
return
|
||||||
|
|||||||
+96
-3
@@ -127,6 +127,21 @@ def _entry_id(entry: dict) -> Optional[str]:
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subscriber_only_entry(entry: dict) -> bool:
|
||||||
|
"""True when yt-dlp marks the entry as channel member-only (subscriber_only availability)."""
|
||||||
|
return str(entry.get("availability") or "") == "subscriber_only"
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_optional_bool(value: Any, *, default: bool = False, field_name: str = "value") -> bool:
|
||||||
|
"""Parse optional JSON booleans for subscription settings."""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return _coerce_bool(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{field_name} must be a boolean") from exc
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SubscriptionInfo:
|
class SubscriptionInfo:
|
||||||
id: str
|
id: str
|
||||||
@@ -149,6 +164,7 @@ class SubscriptionInfo:
|
|||||||
ytdl_options_presets: list[str] = field(default_factory=list)
|
ytdl_options_presets: list[str] = field(default_factory=list)
|
||||||
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
|
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
|
||||||
title_regex: str = ""
|
title_regex: str = ""
|
||||||
|
skip_subscriber_only: bool = False
|
||||||
last_checked: Optional[float] = None
|
last_checked: Optional[float] = None
|
||||||
seen_ids: list[str] = field(default_factory=list)
|
seen_ids: list[str] = field(default_factory=list)
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
@@ -170,6 +186,7 @@ class SubscriptionInfo:
|
|||||||
"quality": self.quality,
|
"quality": self.quality,
|
||||||
"folder": self.folder,
|
"folder": self.folder,
|
||||||
"title_regex": self.title_regex,
|
"title_regex": self.title_regex,
|
||||||
|
"skip_subscriber_only": self.skip_subscriber_only,
|
||||||
"last_checked": self.last_checked,
|
"last_checked": self.last_checked,
|
||||||
"seen_count": len(self.seen_ids),
|
"seen_count": len(self.seen_ids),
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
@@ -198,6 +215,7 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
|
|||||||
"ytdl_options_presets": list(sub.ytdl_options_presets),
|
"ytdl_options_presets": list(sub.ytdl_options_presets),
|
||||||
"ytdl_options_overrides": sub.ytdl_options_overrides,
|
"ytdl_options_overrides": sub.ytdl_options_overrides,
|
||||||
"title_regex": sub.title_regex,
|
"title_regex": sub.title_regex,
|
||||||
|
"skip_subscriber_only": sub.skip_subscriber_only,
|
||||||
"last_checked": sub.last_checked,
|
"last_checked": sub.last_checked,
|
||||||
"seen_ids": list(sub.seen_ids),
|
"seen_ids": list(sub.seen_ids),
|
||||||
"error": sub.error,
|
"error": sub.error,
|
||||||
@@ -294,6 +312,7 @@ class SubscriptionManager:
|
|||||||
self._subs: dict[str, SubscriptionInfo] = {}
|
self._subs: dict[str, SubscriptionInfo] = {}
|
||||||
self._url_index: dict[str, str] = {} # normalized url -> id
|
self._url_index: dict[str, str] = {} # normalized url -> id
|
||||||
self._pending_urls: set[str] = set()
|
self._pending_urls: set[str] = set()
|
||||||
|
self._checks_in_flight: set[str] = set() # subscription ids being checked right now
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._loop_task: Optional[asyncio.Task] = None
|
self._loop_task: Optional[asyncio.Task] = None
|
||||||
self._load_all()
|
self._load_all()
|
||||||
@@ -469,6 +488,7 @@ class SubscriptionManager:
|
|||||||
ytdl_options_presets: Optional[list[str]] = None,
|
ytdl_options_presets: Optional[list[str]] = None,
|
||||||
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||||
title_regex: Any = None,
|
title_regex: Any = None,
|
||||||
|
skip_subscriber_only: Any = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
url = self._normalize_url(url)
|
url = self._normalize_url(url)
|
||||||
if not url:
|
if not url:
|
||||||
@@ -477,6 +497,14 @@ class SubscriptionManager:
|
|||||||
title_regex_stored = validate_title_regex(title_regex)
|
title_regex_stored = validate_title_regex(title_regex)
|
||||||
except re.error as exc:
|
except re.error as exc:
|
||||||
return {"status": "error", "msg": f"Invalid title_regex: {exc}"}
|
return {"status": "error", "msg": f"Invalid title_regex: {exc}"}
|
||||||
|
try:
|
||||||
|
skip_so = coerce_optional_bool(
|
||||||
|
skip_subscriber_only,
|
||||||
|
default=False,
|
||||||
|
field_name="skip_subscriber_only",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"status": "error", "msg": str(exc)}
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if url in self._url_index or url in self._pending_urls:
|
if url in self._url_index or url in self._pending_urls:
|
||||||
@@ -535,6 +563,7 @@ class SubscriptionManager:
|
|||||||
ytdl_options_presets=list(ytdl_options_presets or []),
|
ytdl_options_presets=list(ytdl_options_presets or []),
|
||||||
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
|
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
|
||||||
title_regex=title_regex_stored,
|
title_regex=title_regex_stored,
|
||||||
|
skip_subscriber_only=skip_so,
|
||||||
last_checked=time.time(),
|
last_checked=time.time(),
|
||||||
seen_ids=list(dict.fromkeys(all_ids)),
|
seen_ids=list(dict.fromkeys(all_ids)),
|
||||||
error=None,
|
error=None,
|
||||||
@@ -588,6 +617,18 @@ class SubscriptionManager:
|
|||||||
except re.error as exc:
|
except re.error as exc:
|
||||||
return {"status": "error", "msg": f"Invalid title_regex: {exc}"}
|
return {"status": "error", "msg": f"Invalid title_regex: {exc}"}
|
||||||
|
|
||||||
|
skip_so_set = False
|
||||||
|
validated_skip_so = False
|
||||||
|
if "skip_subscriber_only" in changes:
|
||||||
|
try:
|
||||||
|
validated_skip_so = coerce_optional_bool(
|
||||||
|
changes["skip_subscriber_only"],
|
||||||
|
field_name="skip_subscriber_only",
|
||||||
|
)
|
||||||
|
skip_so_set = True
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"status": "error", "msg": str(exc)}
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
sub = self._subs.get(sub_id)
|
sub = self._subs.get(sub_id)
|
||||||
if not sub:
|
if not sub:
|
||||||
@@ -603,6 +644,8 @@ class SubscriptionManager:
|
|||||||
sub.name = str(changes["name"])
|
sub.name = str(changes["name"])
|
||||||
if validated_tr is not None:
|
if validated_tr is not None:
|
||||||
sub.title_regex = validated_tr
|
sub.title_regex = validated_tr
|
||||||
|
if skip_so_set:
|
||||||
|
sub.skip_subscriber_only = validated_skip_so
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._save_locked()
|
self._save_locked()
|
||||||
@@ -635,6 +678,22 @@ class SubscriptionManager:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
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
|
sid = sub.id
|
||||||
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
||||||
log.info("Checking subscription: %s", sub.name)
|
log.info("Checking subscription: %s", sub.name)
|
||||||
@@ -658,7 +717,7 @@ class SubscriptionManager:
|
|||||||
entries = [ent for ent in entries if _is_media_entry(ent)]
|
entries = [ent for ent in entries if _is_media_entry(ent)]
|
||||||
|
|
||||||
etype = (info or {}).get("_type") or "video"
|
etype = (info or {}).get("_type") or "video"
|
||||||
if etype == "video" or not entries:
|
if etype == "video":
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
cur = self._subs.get(sid)
|
cur = self._subs.get(sid)
|
||||||
if cur:
|
if cur:
|
||||||
@@ -673,6 +732,22 @@ class SubscriptionManager:
|
|||||||
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
|
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
|
||||||
await self.notifier.subscription_updated(sub)
|
await self.notifier.subscription_updated(sub)
|
||||||
return
|
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:
|
async with self._lock:
|
||||||
cur = self._subs.get(sid)
|
cur = self._subs.get(sid)
|
||||||
@@ -695,6 +770,7 @@ class SubscriptionManager:
|
|||||||
dl_ytdl_presets = list(cur.ytdl_options_presets)
|
dl_ytdl_presets = list(cur.ytdl_options_presets)
|
||||||
dl_ytdl_overrides = dict(cur.ytdl_options_overrides)
|
dl_ytdl_overrides = dict(cur.ytdl_options_overrides)
|
||||||
dl_title_regex = cur.title_regex or ""
|
dl_title_regex = cur.title_regex or ""
|
||||||
|
dl_skip_subscriber_only = bool(cur.skip_subscriber_only)
|
||||||
|
|
||||||
new_entries: list[dict] = []
|
new_entries: list[dict] = []
|
||||||
for ent in entries:
|
for ent in entries:
|
||||||
@@ -727,6 +803,18 @@ class SubscriptionManager:
|
|||||||
continue
|
continue
|
||||||
queue_entries.append(ent)
|
queue_entries.append(ent)
|
||||||
|
|
||||||
|
subscriber_filtered_ids: list[str] = []
|
||||||
|
if dl_skip_subscriber_only:
|
||||||
|
kept_entries: list[dict] = []
|
||||||
|
for ent in queue_entries:
|
||||||
|
eid = _entry_id(ent)
|
||||||
|
if _is_subscriber_only_entry(ent):
|
||||||
|
if eid:
|
||||||
|
subscriber_filtered_ids.append(eid)
|
||||||
|
continue
|
||||||
|
kept_entries.append(ent)
|
||||||
|
queue_entries = kept_entries
|
||||||
|
|
||||||
queued_ids, queue_errors = await self._queue_subscription_entries(
|
queued_ids, queue_errors = await self._queue_subscription_entries(
|
||||||
queue_entries,
|
queue_entries,
|
||||||
download_type=dl_type,
|
download_type=dl_type,
|
||||||
@@ -745,15 +833,20 @@ class SubscriptionManager:
|
|||||||
ytdl_options_overrides=dl_ytdl_overrides,
|
ytdl_options_overrides=dl_ytdl_overrides,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
"Subscription check finished for %s: %d new, %d filtered, %d queued, %d failed",
|
"Subscription check finished for %s: %d new, %d filtered, %d subscriber_skipped, %d queued, %d failed",
|
||||||
sub.name,
|
sub.name,
|
||||||
len(new_entries),
|
len(new_entries),
|
||||||
len(filtered_ids),
|
len(filtered_ids),
|
||||||
|
len(subscriber_filtered_ids),
|
||||||
len(queued_ids),
|
len(queued_ids),
|
||||||
len(queue_errors),
|
len(queue_errors),
|
||||||
)
|
)
|
||||||
|
|
||||||
merged = list(dict.fromkeys(queued_ids + filtered_ids + seen_ids_snapshot))
|
merged = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
queued_ids + filtered_ids + subscriber_filtered_ids + seen_ids_snapshot
|
||||||
|
)
|
||||||
|
)
|
||||||
max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000))
|
max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000))
|
||||||
if len(merged) > max_seen:
|
if len(merged) > max_seen:
|
||||||
merged = merged[:max_seen]
|
merged = merged[:max_seen]
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import TestClient, TestServer
|
||||||
|
|
||||||
import main
|
import main
|
||||||
|
|
||||||
@@ -306,3 +309,41 @@ async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
|
|||||||
with pytest.raises(web.HTTPBadRequest):
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
await main.subscribe(req)
|
await main.subscribe(req)
|
||||||
main.submgr.add_subscription.assert_not_awaited()
|
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_URL, "")
|
||||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_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):
|
def test_public_host_url_already_slashed_unchanged(self):
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
@@ -107,6 +120,45 @@ 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_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):
|
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()
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import tempfile
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import time
|
||||||
|
|
||||||
from ytdl import DownloadQueue
|
from ytdl import DownloadInfo, DownloadQueue
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_merges_global_preset_and_override_options(dq_env):
|
async def test_add_merges_global_preset_and_override_options(dq_env):
|
||||||
notifier = AsyncMock()
|
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")
|
download = dq.pending.get("https://example.com/clip")
|
||||||
assert download.info.clip_start == 10.0
|
assert download.info.clip_start == 10.0
|
||||||
assert download.info.clip_end == 99.5
|
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 tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
|
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["schema_version"], 2)
|
||||||
self.assertEqual(payload["items"][0]["info"]["title"], "hello")
|
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):
|
def test_invalid_file_is_quarantined(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = os.path.join(tmp, "queue.json")
|
path = os.path.join(tmp, "queue.json")
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ sys.modules.setdefault("yt_dlp", fake_yt_dlp)
|
|||||||
sys.modules.setdefault("yt_dlp.networking", fake_networking)
|
sys.modules.setdefault("yt_dlp.networking", fake_networking)
|
||||||
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
||||||
|
|
||||||
from subscriptions import SubscriptionManager, extract_flat_playlist
|
from subscriptions import (
|
||||||
|
SubscriptionManager,
|
||||||
|
_is_subscriber_only_entry,
|
||||||
|
coerce_optional_bool,
|
||||||
|
extract_flat_playlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _Config:
|
class _Config:
|
||||||
@@ -75,6 +80,20 @@ def _create_legacy_shelf(path: str, record) -> None:
|
|||||||
shelf["sub-1"] = record
|
shelf["sub-1"] = record
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriberOnlyHelperTests(unittest.TestCase):
|
||||||
|
def test_is_subscriber_only_detects_availability(self):
|
||||||
|
self.assertTrue(_is_subscriber_only_entry({"availability": "subscriber_only"}))
|
||||||
|
self.assertFalse(_is_subscriber_only_entry({"availability": None}))
|
||||||
|
self.assertFalse(_is_subscriber_only_entry({}))
|
||||||
|
|
||||||
|
def test_coerce_optional_bool_defaults_and_fields(self):
|
||||||
|
self.assertFalse(coerce_optional_bool(None, default=False))
|
||||||
|
self.assertTrue(coerce_optional_bool(True))
|
||||||
|
self.assertFalse(coerce_optional_bool(False))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
coerce_optional_bool("maybe", field_name="skip_subscriber_only")
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||||
def test_load_imports_legacy_subscription_shelf(self):
|
def test_load_imports_legacy_subscription_shelf(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -386,6 +405,108 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
|
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
|
||||||
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
|
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
|
||||||
|
|
||||||
|
async def test_check_now_queues_subscriber_only_when_skip_disabled(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
queue = _Queue()
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"subscriptions.extract_flat_playlist",
|
||||||
|
side_effect=[
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "v2",
|
||||||
|
"title": "Members",
|
||||||
|
"webpage_url": "https://example.com/v2",
|
||||||
|
"availability": "subscriber_only",
|
||||||
|
},
|
||||||
|
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
result = await mgr.add_subscription(
|
||||||
|
"https://example.com/channel",
|
||||||
|
check_interval_minutes=60,
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
quality="best",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
auto_start=True,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
skip_subscriber_only=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(mgr.list_all()[0].skip_subscriber_only)
|
||||||
|
await mgr.check_now([result["subscription"]["id"]])
|
||||||
|
|
||||||
|
sub = mgr.list_all()[0]
|
||||||
|
self.assertIsNone(sub.error)
|
||||||
|
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
|
||||||
|
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
|
||||||
|
|
||||||
|
async def test_check_now_skips_subscriber_only_when_skip_enabled(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
queue = _Queue()
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"subscriptions.extract_flat_playlist",
|
||||||
|
side_effect=[
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "v2",
|
||||||
|
"title": "Members",
|
||||||
|
"webpage_url": "https://example.com/v2",
|
||||||
|
"availability": "subscriber_only",
|
||||||
|
},
|
||||||
|
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
result = await mgr.add_subscription(
|
||||||
|
"https://example.com/channel",
|
||||||
|
check_interval_minutes=60,
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
quality="best",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
auto_start=True,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
skip_subscriber_only=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(mgr.list_all()[0].skip_subscriber_only)
|
||||||
|
await mgr.check_now([result["subscription"]["id"]])
|
||||||
|
|
||||||
|
sub = mgr.list_all()[0]
|
||||||
|
self.assertIsNone(sub.error)
|
||||||
|
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
|
||||||
|
self.assertEqual(queue.entries, [])
|
||||||
|
|
||||||
async def test_update_subscription_parses_string_false_enabled(self):
|
async def test_update_subscription_parses_string_false_enabled(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
queue = _Queue()
|
queue = _Queue()
|
||||||
@@ -688,6 +809,72 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(upd["subscription"]["title_regex"], "foo|bar")
|
self.assertEqual(upd["subscription"]["title_regex"], "foo|bar")
|
||||||
self.assertEqual(mgr.list_all()[0].title_regex, "foo|bar")
|
self.assertEqual(mgr.list_all()[0].title_regex, "foo|bar")
|
||||||
|
|
||||||
|
async def test_update_subscription_skip_subscriber_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
queue = _Queue()
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||||
|
with patch(
|
||||||
|
"subscriptions.extract_flat_playlist",
|
||||||
|
return_value=(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await mgr.add_subscription(
|
||||||
|
"https://example.com/channel",
|
||||||
|
check_interval_minutes=60,
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
quality="best",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
auto_start=True,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
)
|
||||||
|
sub_id = result["subscription"]["id"]
|
||||||
|
self.assertFalse(mgr.list_all()[0].skip_subscriber_only)
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"skip_subscriber_only": True})
|
||||||
|
self.assertEqual(upd["status"], "ok")
|
||||||
|
self.assertTrue(upd["subscription"]["skip_subscriber_only"])
|
||||||
|
self.assertTrue(mgr.list_all()[0].skip_subscriber_only)
|
||||||
|
|
||||||
|
async def test_update_subscription_rejects_invalid_skip_subscriber_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
queue = _Queue()
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||||
|
with patch(
|
||||||
|
"subscriptions.extract_flat_playlist",
|
||||||
|
return_value=(
|
||||||
|
{"_type": "channel", "title": "Channel"},
|
||||||
|
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await mgr.add_subscription(
|
||||||
|
"https://example.com/channel",
|
||||||
|
check_interval_minutes=60,
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
quality="best",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
auto_start=True,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
)
|
||||||
|
sub_id = result["subscription"]["id"]
|
||||||
|
upd = await mgr.update_subscription(sub_id, {"skip_subscriber_only": "maybe"})
|
||||||
|
self.assertEqual(upd["status"], "error")
|
||||||
|
self.assertFalse(mgr.list_all()[0].skip_subscriber_only)
|
||||||
|
|
||||||
def test_persistence_includes_title_regex(self):
|
def test_persistence_includes_title_regex(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
json_path = os.path.join(tmp, "subscriptions.json")
|
json_path = os.path.join(tmp, "subscriptions.json")
|
||||||
@@ -728,6 +915,49 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
self.assertEqual(mgr.list_all()[0].title_regex, "EPISODE")
|
self.assertEqual(mgr.list_all()[0].title_regex, "EPISODE")
|
||||||
|
self.assertFalse(mgr.list_all()[0].skip_subscriber_only)
|
||||||
|
|
||||||
|
def test_persistence_includes_skip_subscriber_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
json_path = os.path.join(tmp, "subscriptions.json")
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"kind": "subscriptions",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "sub-1",
|
||||||
|
"name": "Channel",
|
||||||
|
"url": "https://example.com/channel",
|
||||||
|
"enabled": True,
|
||||||
|
"check_interval_minutes": 60,
|
||||||
|
"download_type": "video",
|
||||||
|
"codec": "auto",
|
||||||
|
"format": "any",
|
||||||
|
"quality": "best",
|
||||||
|
"folder": "",
|
||||||
|
"custom_name_prefix": "",
|
||||||
|
"auto_start": True,
|
||||||
|
"playlist_item_limit": 0,
|
||||||
|
"split_by_chapters": False,
|
||||||
|
"chapter_template": "",
|
||||||
|
"subtitle_language": "en",
|
||||||
|
"subtitle_mode": "prefer_manual",
|
||||||
|
"ytdl_options_presets": [],
|
||||||
|
"ytdl_options_overrides": {},
|
||||||
|
"title_regex": "",
|
||||||
|
"skip_subscriber_only": True,
|
||||||
|
"last_checked": None,
|
||||||
|
"seen_ids": [],
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||||
|
self.assertTrue(mgr.list_all()[0].skip_subscriber_only)
|
||||||
|
|
||||||
|
|
||||||
class ExtractFlatPlaylistTests(unittest.TestCase):
|
class ExtractFlatPlaylistTests(unittest.TestCase):
|
||||||
|
|||||||
+276
-18
@@ -24,6 +24,12 @@ from subscriptions import _entry_id
|
|||||||
|
|
||||||
log = logging.getLogger('ytdl')
|
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-
|
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
||||||
# sanitised when substituting playlist/channel titles into output templates so
|
# sanitised when substituting playlist/channel titles into output templates so
|
||||||
@@ -194,6 +200,8 @@ class DownloadInfo:
|
|||||||
ytdl_options_overrides=None,
|
ytdl_options_overrides=None,
|
||||||
clip_start=None,
|
clip_start=None,
|
||||||
clip_end=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.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}'
|
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.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
||||||
self.clip_start = clip_start
|
self.clip_start = clip_start
|
||||||
self.clip_end = clip_end
|
self.clip_end = clip_end
|
||||||
|
self.live_status = live_status
|
||||||
|
self.live_release_timestamp = live_release_timestamp
|
||||||
self.subtitle_files = []
|
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):
|
def __setstate__(self, state):
|
||||||
"""BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files."""
|
"""BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files."""
|
||||||
self.__dict__.update(state)
|
self.__dict__.update(state)
|
||||||
@@ -292,6 +316,10 @@ class DownloadInfo:
|
|||||||
self.clip_start = None
|
self.clip_start = None
|
||||||
if not hasattr(self, "clip_end"):
|
if not hasattr(self, "clip_end"):
|
||||||
self.clip_end = None
|
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 = (
|
_PERSISTED_DOWNLOAD_FIELDS = (
|
||||||
@@ -313,6 +341,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
|||||||
"ytdl_options_overrides",
|
"ytdl_options_overrides",
|
||||||
"clip_start",
|
"clip_start",
|
||||||
"clip_end",
|
"clip_end",
|
||||||
|
"live_status",
|
||||||
|
"live_release_timestamp",
|
||||||
"status",
|
"status",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"error",
|
"error",
|
||||||
@@ -568,7 +598,10 @@ class Download:
|
|||||||
self.info.filename = rel_name
|
self.info.filename = rel_name
|
||||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||||
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
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
|
# Handle chapter files
|
||||||
log.debug(f"Update status for {self.info.title}: {status}")
|
log.debug(f"Update status for {self.info.title}: {status}")
|
||||||
@@ -631,8 +664,8 @@ class PersistentQueue:
|
|||||||
def __init__(self, name, path):
|
def __init__(self, name, path):
|
||||||
self.identifier = name
|
self.identifier = name
|
||||||
pdir = os.path.dirname(path)
|
pdir = os.path.dirname(path)
|
||||||
if not os.path.isdir(pdir):
|
if pdir and not os.path.isdir(pdir):
|
||||||
os.mkdir(pdir)
|
os.makedirs(pdir, exist_ok=True)
|
||||||
self.legacy_path = path
|
self.legacy_path = path
|
||||||
self.path = f"{path}.json"
|
self.path = f"{path}.json"
|
||||||
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
|
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
|
||||||
@@ -757,11 +790,25 @@ class DownloadQueue:
|
|||||||
self.done.load()
|
self.done.load()
|
||||||
self._add_generation = 0
|
self._add_generation = 0
|
||||||
self._canceled_urls = set() # URLs canceled during current playlist add
|
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):
|
def cancel_add(self):
|
||||||
self._add_generation += 1
|
self._add_generation += 1
|
||||||
log.info('Playlist add operation canceled by user')
|
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):
|
async def __import_queue(self):
|
||||||
for k, v in self.queue.saved_items():
|
for k, v in self.queue.saved_items():
|
||||||
await self.__add_download(v, True)
|
await self.__add_download(v, True)
|
||||||
@@ -772,9 +819,165 @@ class DownloadQueue:
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
log.info("Initializing DownloadQueue")
|
log.info("Initializing DownloadQueue")
|
||||||
|
self._start_live_monitor()
|
||||||
asyncio.create_task(self.__import_queue())
|
asyncio.create_task(self.__import_queue())
|
||||||
asyncio.create_task(self.__import_pending())
|
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):
|
async def __start_download(self, download):
|
||||||
if download.canceled:
|
if download.canceled:
|
||||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
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.'}
|
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))
|
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
||||||
real_base_directory = os.path.realpath(base_directory)
|
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}"'}
|
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 os.path.isdir(dldirectory):
|
||||||
if not self.config.CREATE_CUSTOM_DIRS:
|
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')
|
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
||||||
ytdl_options['playlistend'] = playlist_item_limit
|
ytdl_options['playlistend'] = playlist_item_limit
|
||||||
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
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:
|
if auto_start is True:
|
||||||
self.queue.put(download)
|
if is_upcoming:
|
||||||
asyncio.create_task(self.__start_download(download))
|
self._schedule_upcoming_download(download)
|
||||||
|
else:
|
||||||
|
self.queue.put(download)
|
||||||
|
asyncio.create_task(self.__start_download(download))
|
||||||
else:
|
else:
|
||||||
self.pending.put(download)
|
self.pending.put(download)
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
@@ -952,6 +1171,8 @@ class DownloadQueue:
|
|||||||
_add_gen,
|
_add_gen,
|
||||||
)
|
)
|
||||||
elif etype == 'playlist' or etype == 'channel':
|
elif etype == 'playlist' or etype == 'channel':
|
||||||
|
if etype == 'playlist' and self.__is_channel_extraction(entry):
|
||||||
|
etype = 'channel'
|
||||||
log.debug(f'Processing as a {etype}')
|
log.debug(f'Processing as a {etype}')
|
||||||
entries = entry['entries']
|
entries = entry['entries']
|
||||||
# Convert generator to list if needed (for len() and slicing operations)
|
# Convert generator to list if needed (for len() and slicing operations)
|
||||||
@@ -971,7 +1192,15 @@ class DownloadQueue:
|
|||||||
if "id" not in etr:
|
if "id" not in etr:
|
||||||
etr["id"] = _entry_id(etr)
|
etr["id"] = _entry_id(etr)
|
||||||
etr["_type"] = "video"
|
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}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index)
|
||||||
etr[f"{etype}_count"] = total_entries
|
etr[f"{etype}_count"] = total_entries
|
||||||
etr[f"{etype}_autonumber"] = index
|
etr[f"{etype}_autonumber"] = index
|
||||||
@@ -1036,6 +1265,8 @@ class DownloadQueue:
|
|||||||
ytdl_options_overrides=ytdl_options_overrides,
|
ytdl_options_overrides=ytdl_options_overrides,
|
||||||
clip_start=clip_start,
|
clip_start=clip_start,
|
||||||
clip_end=clip_end,
|
clip_end=clip_end,
|
||||||
|
live_status=entry.get('live_status'),
|
||||||
|
live_release_timestamp=entry.get('release_timestamp'),
|
||||||
)
|
)
|
||||||
await self.__add_download(dl, auto_start)
|
await self.__add_download(dl, auto_start)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
@@ -1156,13 +1387,21 @@ class DownloadQueue:
|
|||||||
|
|
||||||
async def start_pending(self, ids):
|
async def start_pending(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.pending.exists(id):
|
if self.pending.exists(id):
|
||||||
log.warning(f'requested start for non-existent download {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
|
continue
|
||||||
dl = self.pending.get(id)
|
if self.queue.exists(id):
|
||||||
self.queue.put(dl)
|
dl = self.queue.get(id)
|
||||||
self.pending.delete(id)
|
if dl.info.status == 'scheduled':
|
||||||
asyncio.create_task(self.__start_download(dl))
|
self._force_start_scheduled(dl)
|
||||||
|
continue
|
||||||
|
log.warning(f'requested start for non-existent download {id}')
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
@@ -1177,6 +1416,8 @@ class DownloadQueue:
|
|||||||
log.warning(f'requested cancel for non-existent download {id}')
|
log.warning(f'requested cancel for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
dl = self.queue.get(id)
|
dl = self.queue.get(id)
|
||||||
|
if dl.info.status == 'scheduled':
|
||||||
|
self._unregister_scheduled(id)
|
||||||
if dl.started():
|
if dl.started():
|
||||||
dl.cancel()
|
dl.cancel()
|
||||||
else:
|
else:
|
||||||
@@ -1192,11 +1433,28 @@ class DownloadQueue:
|
|||||||
continue
|
continue
|
||||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||||
dl = self.done.get(id)
|
dl = self.done.get(id)
|
||||||
try:
|
dldirectory, calc_error = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||||
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
if calc_error is not None or not dldirectory:
|
||||||
os.remove(os.path.join(dldirectory, dl.info.filename))
|
log.warning(f'deleting files for download {id} skipped: could not resolve download directory')
|
||||||
except Exception as e:
|
else:
|
||||||
log.warning(f'deleting file for download {id} failed with error message {e!r}')
|
# 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)
|
self.done.delete(id)
|
||||||
await self.notifier.cleared(id)
|
await self.notifier.cleared(id)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|||||||
+55
-5
@@ -2,11 +2,54 @@
|
|||||||
|
|
||||||
PUID="${UID:-$PUID}"
|
PUID="${UID:-$PUID}"
|
||||||
PGID="${GID:-$PGID}"
|
PGID="${GID:-$PGID}"
|
||||||
|
AUDIO_DOWNLOAD_DIR="${AUDIO_DOWNLOAD_DIR:-$DOWNLOAD_DIR}"
|
||||||
|
|
||||||
echo "Setting umask to ${UMASK}"
|
echo "Setting umask to ${UMASK}"
|
||||||
umask ${UMASK}
|
umask ${UMASK}
|
||||||
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp 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}" "${STATE_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 [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
||||||
if [ "${PUID}" -eq 0 ]; then
|
if [ "${PUID}" -eq 0 ]; then
|
||||||
@@ -14,15 +57,22 @@ if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
|||||||
fi
|
fi
|
||||||
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
|
if [ "${CHOWN_DIRS:-true}" != "false" ]; 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}" "${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
|
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
|
||||||
|
|||||||
+26
-25
@@ -5,7 +5,7 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"build:watch": "ng build --watch",
|
"build:watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"lint": "ng lint"
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
@@ -21,43 +21,44 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@11.5.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.9",
|
"@angular/animations": "^22.0.6",
|
||||||
"@angular/common": "^21.2.9",
|
"@angular/common": "^22.0.6",
|
||||||
"@angular/compiler": "^21.2.9",
|
"@angular/compiler": "^22.0.6",
|
||||||
"@angular/core": "^21.2.9",
|
"@angular/core": "^22.0.6",
|
||||||
"@angular/forms": "^21.2.9",
|
"@angular/forms": "^22.0.6",
|
||||||
"@angular/platform-browser": "^21.2.9",
|
"@angular/platform-browser": "^22.0.6",
|
||||||
"@angular/platform-browser-dynamic": "^21.2.9",
|
"@angular/platform-browser-dynamic": "^22.0.6",
|
||||||
"@angular/service-worker": "^21.2.9",
|
"@angular/service-worker": "^22.0.6",
|
||||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
"@fortawesome/free-brands-svg-icons": "^7.3.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
"@fortawesome/free-regular-svg-icons": "^7.3.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "^21.8.0",
|
"@ng-select/ng-select": "^23.2.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"ngx-cookie-service": "^21.3.1",
|
"ngx-cookie-service": "^22.0.0",
|
||||||
"ngx-socket-io": "~4.10.0",
|
"ngx-socket-io": "~4.10.0",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"zone.js": "0.15.0"
|
"zone.js": "0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "21.1.0",
|
"@angular-eslint/builder": "22.0.0",
|
||||||
"@angular/build": "^21.2.7",
|
"@angular/build": "^22.0.5",
|
||||||
"@angular/cli": "^21.2.7",
|
"@angular/cli": "^22.0.5",
|
||||||
"@angular/compiler-cli": "^21.2.9",
|
"@angular/compiler-cli": "^22.0.6",
|
||||||
"@angular/localize": "^21.2.9",
|
"@angular/localize": "^22.0.6",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"angular-eslint": "21.1.0",
|
"angular-eslint": "22.0.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "8.47.0",
|
"typescript-eslint": "8.62.0",
|
||||||
"vitest": "^4.1.5"
|
"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 { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core';
|
||||||
import { provideServiceWorker } from '@angular/service-worker';
|
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 = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -12,6 +12,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
// or after 30 seconds (whichever comes first).
|
// or after 30 seconds (whichever comes first).
|
||||||
registrationStrategy: 'registerWhenStable:30000'
|
registrationStrategy: 'registerWhenStable:30000'
|
||||||
}),
|
}),
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withXhr(), withInterceptorsFromDi()),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
+79
-64
@@ -279,13 +279,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="input-group">
|
<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"
|
<select class="form-select"
|
||||||
name="format"
|
name="format"
|
||||||
[(ngModel)]="format"
|
[(ngModel)]="format"
|
||||||
(change)="formatChanged()"
|
(change)="formatChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Subtitle output format for captions mode">
|
|
||||||
@for (f of formatOptions; track f.id) {
|
@for (f of formatOptions; track f.id) {
|
||||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||||
}
|
}
|
||||||
@@ -294,7 +293,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="input-group">
|
<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"
|
<input class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
list="subtitleLanguageOptions"
|
list="subtitleLanguageOptions"
|
||||||
@@ -302,8 +301,7 @@
|
|||||||
[(ngModel)]="subtitleLanguage"
|
[(ngModel)]="subtitleLanguage"
|
||||||
(change)="subtitleLanguageChanged()"
|
(change)="subtitleLanguageChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
placeholder="e.g. en, es, zh-Hans"
|
placeholder="e.g. en, es, zh-Hans">
|
||||||
ngbTooltip="Subtitle language (you can type any language code)">
|
|
||||||
<datalist id="subtitleLanguageOptions">
|
<datalist id="subtitleLanguageOptions">
|
||||||
@for (lang of subtitleLanguages; track lang.id) {
|
@for (lang of subtitleLanguages; track lang.id) {
|
||||||
<option [value]="lang.id">{{ lang.text }}</option>
|
<option [value]="lang.id">{{ lang.text }}</option>
|
||||||
@@ -313,13 +311,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="input-group">
|
<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"
|
<select class="form-select"
|
||||||
name="subtitleMode"
|
name="subtitleMode"
|
||||||
[(ngModel)]="subtitleMode"
|
[(ngModel)]="subtitleMode"
|
||||||
(change)="subtitleModeChanged()"
|
(change)="subtitleModeChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
|
||||||
@for (mode of subtitleModes; track mode.id) {
|
@for (mode of subtitleModes; track mode.id) {
|
||||||
<option [ngValue]="mode.id">{{ mode.text }}</option>
|
<option [ngValue]="mode.id">{{ mode.text }}</option>
|
||||||
}
|
}
|
||||||
@@ -375,34 +372,29 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Download Folder</span>
|
<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>
|
||||||
@if (customDirs$ | async; as customDirs) {
|
<input type="text"
|
||||||
<ng-select [items]="customDirs"
|
class="form-control"
|
||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
[addTag]="allowCustomDir.bind(this)"
|
name="folder"
|
||||||
addTagText="Create directory"
|
|
||||||
bindLabel="folder"
|
|
||||||
[(ngModel)]="folder"
|
[(ngModel)]="folder"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[ngbTypeahead]="searchFolder"
|
||||||
[virtualScroll]="true"
|
[editable]="!!downloads.configuration['CREATE_CUSTOM_DIRS']"
|
||||||
[clearable]="true"
|
(focus)="folderFocus$.next($any($event.target).value)"
|
||||||
[loading]="downloads.loading"
|
(click)="folderClick$.next($any($event.target).value)"
|
||||||
[searchable]="true"
|
#folderTypeahead="ngbTypeahead"
|
||||||
[closeOnSelect]="true"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
name="customNamePrefix"
|
name="customNamePrefix"
|
||||||
[(ngModel)]="customNamePrefix"
|
[(ngModel)]="customNamePrefix"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Add a prefix to downloaded filenames">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -411,18 +403,16 @@
|
|||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
||||||
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
|
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Split video into separate files by chapters">
|
|
||||||
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
|
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (splitByChapters) {
|
@if (splitByChapters) {
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="input-group">
|
<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"
|
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
|
||||||
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Output template for chapter files">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -431,28 +421,26 @@
|
|||||||
@if (downloadType === 'video' || downloadType === 'audio') {
|
@if (downloadType === 'video' || downloadType === 'audio') {
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
name="clipStart"
|
name="clipStart"
|
||||||
[(ngModel)]="clipStart"
|
[(ngModel)]="clipStart"
|
||||||
(change)="clipStartChanged()"
|
(change)="clipStartChanged()"
|
||||||
placeholder="e.g. 2:26"
|
placeholder="e.g. 2:26"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Optional start time (seconds, M:SS, or H:MM:SS). Blank = from start or YouTube &t= in URL.">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
name="clipEnd"
|
name="clipEnd"
|
||||||
[(ngModel)]="clipEnd"
|
[(ngModel)]="clipEnd"
|
||||||
(change)="clipEndChanged()"
|
(change)="clipEndChanged()"
|
||||||
placeholder="e.g. 3:24"
|
placeholder="e.g. 3:24"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Optional end time. Blank = until end of media.">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -463,13 +451,12 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<select class="form-select"
|
||||||
name="autoStart"
|
name="autoStart"
|
||||||
[(ngModel)]="autoStart"
|
[(ngModel)]="autoStart"
|
||||||
(change)="autoStartChanged()"
|
(change)="autoStartChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Automatically start downloads when added">
|
|
||||||
<option [ngValue]="true">Yes</option>
|
<option [ngValue]="true">Yes</option>
|
||||||
<option [ngValue]="false">No</option>
|
<option [ngValue]="false">No</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -477,7 +464,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<input type="number"
|
||||||
min="0"
|
min="0"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@@ -485,13 +472,12 @@
|
|||||||
name="playlistItemLimit"
|
name="playlistItemLimit"
|
||||||
(keydown)="isNumber($event)"
|
(keydown)="isNumber($event)"
|
||||||
[(ngModel)]="playlistItemLimit"
|
[(ngModel)]="playlistItemLimit"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<input type="number"
|
||||||
min="1"
|
min="1"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@@ -499,20 +485,33 @@
|
|||||||
(keydown)="isNumber($event)"
|
(keydown)="isNumber($event)"
|
||||||
[(ngModel)]="checkIntervalMinutes"
|
[(ngModel)]="checkIntervalMinutes"
|
||||||
(ngModelChange)="checkIntervalChanged()"
|
(ngModelChange)="checkIntervalChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="How often to poll subscriptions for new videos">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
name="titleRegex"
|
name="titleRegex"
|
||||||
[(ngModel)]="titleRegex"
|
[(ngModel)]="titleRegex"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||||
placeholder="Optional regex"
|
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.">
|
</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" />
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,7 +521,7 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12" [class.col-md-6]="allowYtdlOptionsOverrides()">
|
<div class="col-12" [class.col-md-6]="allowYtdlOptionsOverrides()">
|
||||||
<div class="input-group">
|
<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
|
<ng-select
|
||||||
class="flex-grow-1"
|
class="flex-grow-1"
|
||||||
name="ytdlOptionsPresets"
|
name="ytdlOptionsPresets"
|
||||||
@@ -532,22 +531,20 @@
|
|||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
[(ngModel)]="ytdlOptionsPresets"
|
[(ngModel)]="ytdlOptionsPresets"
|
||||||
(ngModelChange)="ytdlOptionsPresetsChanged()"
|
(ngModelChange)="ytdlOptionsPresetsChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading" />
|
||||||
ngbTooltip="Choose one or more yt-dlp option presets configured on the server (applied in order)" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (allowYtdlOptionsOverrides()) {
|
@if (allowYtdlOptionsOverrides()) {
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<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"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder='e.g. {"writesubtitles": true}'
|
placeholder='e.g. {"writesubtitles": true}'
|
||||||
name="ytdlOptionsOverrides"
|
name="ytdlOptionsOverrides"
|
||||||
[(ngModel)]="ytdlOptionsOverrides"
|
[(ngModel)]="ytdlOptionsOverrides"
|
||||||
(change)="ytdlOptionsOverridesChanged()"
|
(change)="ytdlOptionsOverridesChanged()"
|
||||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||||
ngbTooltip="Optional per-download yt-dlp overrides as a JSON object">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -557,7 +554,7 @@
|
|||||||
<div class="settings-section-label">Tools</div>
|
<div class="settings-section-label">Tools</div>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<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"
|
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
||||||
(change)="onCookieFileSelect($event)"
|
(change)="onCookieFileSelect($event)"
|
||||||
[disabled]="cookieUploadInProgress || addInProgress">
|
[disabled]="cookieUploadInProgress || addInProgress">
|
||||||
@@ -565,8 +562,7 @@
|
|||||||
<label class="btn mb-0"
|
<label class="btn mb-0"
|
||||||
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
||||||
[class.disabled]="cookieUploadInProgress || addInProgress"
|
[class.disabled]="cookieUploadInProgress || addInProgress"
|
||||||
for="cookie-upload"
|
for="cookie-upload">
|
||||||
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
|
||||||
@if (cookieUploadInProgress) {
|
@if (cookieUploadInProgress) {
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -710,16 +706,31 @@
|
|||||||
</td>
|
</td>
|
||||||
<td title="{{ download.value.filename }}">
|
<td title="{{ download.value.filename }}">
|
||||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||||
<div>{{ download.value.title }} </div>
|
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||||
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
<span>{{ download.value.title }}</span>
|
||||||
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
@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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ download.value.speed | speed }}</td>
|
<td>{{ download.value.speed | speed }}</td>
|
||||||
<td>{{ download.value.eta | eta }}</td>
|
<td>{{ download.value.eta | eta }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<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]="'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>
|
<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>
|
||||||
@@ -845,6 +856,9 @@
|
|||||||
@if (entry[1].filename) {
|
@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>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -927,8 +941,7 @@
|
|||||||
</th>
|
</th>
|
||||||
<th scope="col">Name</th>
|
<th scope="col">Name</th>
|
||||||
<th scope="col">URL</th>
|
<th scope="col">URL</th>
|
||||||
<th scope="col" class="text-nowrap"
|
<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>
|
||||||
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">Interval (min)</th>
|
<th scope="col" class="text-nowrap">Interval (min)</th>
|
||||||
<th scope="col" class="text-nowrap">Last checked</th>
|
<th scope="col" class="text-nowrap">Last checked</th>
|
||||||
<th scope="col">Status</th>
|
<th scope="col">Status</th>
|
||||||
@@ -1065,3 +1078,5 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<app-toast-container />
|
||||||
|
|||||||
@@ -201,6 +201,12 @@ main
|
|||||||
color: var(--bs-secondary-color)
|
color: var(--bs-secondary-color)
|
||||||
margin-bottom: 0.4rem
|
margin-bottom: 0.4rem
|
||||||
|
|
||||||
|
.help-title
|
||||||
|
cursor: help
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
outline: none
|
||||||
|
|
||||||
.cookie-status
|
.cookie-status
|
||||||
font-size: 0.8rem
|
font-size: 0.8rem
|
||||||
margin-top: 0.35rem
|
margin-top: 0.35rem
|
||||||
|
|||||||
+50
-4
@@ -4,6 +4,7 @@ import { Subject, of } from 'rxjs';
|
|||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { DownloadsService } from './services/downloads.service';
|
import { DownloadsService } from './services/downloads.service';
|
||||||
import { SubscriptionsService } from './services/subscriptions.service';
|
import { SubscriptionsService } from './services/subscriptions.service';
|
||||||
|
import { ToastService } from './services/toast.service';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
|
|
||||||
class DownloadsServiceStub {
|
class DownloadsServiceStub {
|
||||||
@@ -182,6 +183,37 @@ describe('App', () => {
|
|||||||
expect(payload.ytdlOptionsOverrides).toBe('');
|
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', () => {
|
it('includes titleRegex in subscribe payload', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
@@ -190,8 +222,21 @@ describe('App', () => {
|
|||||||
app.titleRegex = 'EPISODE';
|
app.titleRegex = 'EPISODE';
|
||||||
app.addSubscription();
|
app.addSubscription();
|
||||||
expect(subs.subscribeCalls.length).toBe(1);
|
expect(subs.subscribeCalls.length).toBe(1);
|
||||||
const payload = subs.subscribeCalls[0] as { titleRegex: string };
|
const payload = subs.subscribeCalls[0] as { titleRegex: string; skipSubscriberOnly: boolean };
|
||||||
expect(payload.titleRegex).toBe('EPISODE');
|
expect(payload.titleRegex).toBe('EPISODE');
|
||||||
|
expect(payload.skipSubscriberOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes skipSubscriberOnly true when checked', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||||
|
app.addUrl = 'https://example.com/channel';
|
||||||
|
app.skipSubscriberOnly = true;
|
||||||
|
app.addSubscription();
|
||||||
|
expect(subs.subscribeCalls.length).toBe(1);
|
||||||
|
const payload = subs.subscribeCalls[0] as { skipSubscriberOnly: boolean };
|
||||||
|
expect(payload.skipSubscriberOnly).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits clip fields from subscribe payload', () => {
|
it('omits clip fields from subscribe payload', () => {
|
||||||
@@ -219,7 +264,8 @@ describe('App', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('blocks subscribe with invalid title regex', () => {
|
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 fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||||
@@ -227,7 +273,7 @@ describe('App', () => {
|
|||||||
app.titleRegex = '[';
|
app.titleRegex = '[';
|
||||||
app.addSubscription();
|
app.addSubscription();
|
||||||
expect(subs.subscribeCalls.length).toBe(0);
|
expect(subs.subscribeCalls.length).toBe(0);
|
||||||
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||||
alertSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+201
-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 { HttpClient } from '@angular/common/http';
|
||||||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
|
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 { FormsModule } from '@angular/forms';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
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 { 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 { 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 { ToastService } from './services/toast.service';
|
||||||
|
import { BatchUrlsService, BatchUrlFilter } from './services/batch-urls.service';
|
||||||
import { SubscriptionRow } from './interfaces/subscription';
|
import { SubscriptionRow } from './interfaces/subscription';
|
||||||
import { Themes } from './theme';
|
import { Themes } from './theme';
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +34,7 @@ import {
|
|||||||
State,
|
State,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
||||||
import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/';
|
import { SelectAllCheckboxComponent, ItemCheckboxComponent, ToastContainerComponent } from './components/';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -40,7 +43,6 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
KeyValuePipe,
|
KeyValuePipe,
|
||||||
AsyncPipe,
|
|
||||||
DatePipe,
|
DatePipe,
|
||||||
FontAwesomeModule,
|
FontAwesomeModule,
|
||||||
NgbModule,
|
NgbModule,
|
||||||
@@ -50,6 +52,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
|||||||
FileSizePipe,
|
FileSizePipe,
|
||||||
SelectAllCheckboxComponent,
|
SelectAllCheckboxComponent,
|
||||||
ItemCheckboxComponent,
|
ItemCheckboxComponent,
|
||||||
|
ToastContainerComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.sass',
|
styleUrl: './app.sass',
|
||||||
@@ -57,6 +60,9 @@ 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 toasts = inject(ToastService);
|
||||||
|
private batchUrls = inject(BatchUrlsService);
|
||||||
|
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);
|
||||||
@@ -93,6 +99,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
subscribeInProgress = false;
|
subscribeInProgress = false;
|
||||||
checkIntervalMinutes = 60;
|
checkIntervalMinutes = 60;
|
||||||
titleRegex = '';
|
titleRegex = '';
|
||||||
|
skipSubscriberOnly = false;
|
||||||
editingTitleRegexId: string | null = null;
|
editingTitleRegexId: string | null = null;
|
||||||
titleRegexEditDraft = '';
|
titleRegexEditDraft = '';
|
||||||
cachedSubs: [string, SubscriptionRow][] = [];
|
cachedSubs: [string, SubscriptionRow][] = [];
|
||||||
@@ -104,8 +111,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
cookieUploadInProgress = false;
|
cookieUploadInProgress = false;
|
||||||
themes: Theme[] = Themes;
|
themes: Theme[] = Themes;
|
||||||
activeTheme: Theme | undefined;
|
activeTheme: Theme | undefined;
|
||||||
customDirs$!: Observable<string[]>;
|
readonly folderTypeahead = viewChild<NgbTypeahead>('folderTypeahead');
|
||||||
showBatchPanel = false;
|
folderFocus$ = new Subject<string>();
|
||||||
|
folderClick$ = new Subject<string>();
|
||||||
|
showBatchPanel = false;
|
||||||
batchImportModalOpen = false;
|
batchImportModalOpen = false;
|
||||||
batchImportText = '';
|
batchImportText = '';
|
||||||
batchImportStatus = '';
|
batchImportStatus = '';
|
||||||
@@ -128,6 +137,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
lastCopiedErrorId: string | null = null;
|
lastCopiedErrorId: string | null = null;
|
||||||
private previousDownloadType = 'video';
|
private previousDownloadType = 'video';
|
||||||
private addRequestSub?: Subscription;
|
private addRequestSub?: Subscription;
|
||||||
|
private liveCountdownTimer?: ReturnType<typeof setInterval>;
|
||||||
private selectionsByType: Record<string, {
|
private selectionsByType: Record<string, {
|
||||||
codec: string;
|
codec: string;
|
||||||
format: string;
|
format: string;
|
||||||
@@ -184,6 +194,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
faUpload = faUpload;
|
faUpload = faUpload;
|
||||||
faPause = faPause;
|
faPause = faPause;
|
||||||
faPlay = faPlay;
|
faPlay = faPlay;
|
||||||
|
faShareNodes = faShareNodes;
|
||||||
subtitleLanguages = [
|
subtitleLanguages = [
|
||||||
{ id: 'en', text: 'English' },
|
{ id: 'en', text: 'English' },
|
||||||
{ id: 'ar', text: 'Arabic' },
|
{ id: 'ar', text: 'Arabic' },
|
||||||
@@ -280,6 +291,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// Subscribe to download updates
|
// Subscribe to download updates
|
||||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
|
this.syncLiveCountdownTimer();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
@@ -290,6 +302,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// Subscribe to real-time updates
|
// Subscribe to real-time updates
|
||||||
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
|
this.syncLiveCountdownTimer();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -307,7 +320,6 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.getConfiguration();
|
this.getConfiguration();
|
||||||
this.getYtdlOptionsUpdateTime();
|
this.getYtdlOptionsUpdateTime();
|
||||||
this.getYtdlOptionPresets();
|
this.getYtdlOptionPresets();
|
||||||
this.customDirs$ = this.getMatchingCustomDir();
|
|
||||||
this.setTheme(this.activeTheme!);
|
this.setTheme(this.activeTheme!);
|
||||||
|
|
||||||
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
||||||
@@ -326,18 +338,24 @@ 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() {
|
||||||
this.addRequestSub?.unsubscribe();
|
this.addRequestSub?.unsubscribe();
|
||||||
|
if (this.liveCountdownTimer) {
|
||||||
|
clearInterval(this.liveCountdownTimer);
|
||||||
|
}
|
||||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
// workaround to allow fetching of Map values in the order they were inserted
|
// workaround to allow fetching of Map values in the order they were inserted
|
||||||
// https://github.com/angular/angular/issues/31420
|
// https://github.com/angular/angular/issues/31420
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
asIsOrder() {
|
asIsOrder() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -374,34 +392,26 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
return this.downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] === true;
|
return this.downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
allowCustomDir(tag: string) {
|
searchFolder: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) => {
|
||||||
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
|
const debouncedText$ = text$.pipe(debounceTime(150), distinctUntilChanged());
|
||||||
return tag;
|
const clicksWithClosedPopup$ = this.folderClick$.pipe(
|
||||||
}
|
filter(() => !this.folderTypeahead()?.isPopupOpen()),
|
||||||
return false;
|
);
|
||||||
}
|
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() {
|
isAudioType() {
|
||||||
return this.downloadType === 'audio';
|
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() {
|
getYtdlOptionsUpdateTime() {
|
||||||
this.downloads.ytdlOptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
this.downloads.ytdlOptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -410,7 +420,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
const date = new Date(data['update_time'] * 1000);
|
const date = new Date(data['update_time'] * 1000);
|
||||||
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
||||||
}else{
|
}else{
|
||||||
alert("Error reload yt-dlp options: "+data['msg']);
|
this.toasts.error("Error reloading yt-dlp options: " + data['msg']);
|
||||||
}
|
}
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
}
|
}
|
||||||
@@ -485,11 +495,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed);
|
const parsed = JSON.parse(trimmed);
|
||||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Custom yt-dlp options must be valid JSON');
|
this.toasts.error('Custom yt-dlp options must be valid JSON');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -520,7 +530,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
||||||
const error = this.getStatusError(refreshRes);
|
const error = this.getStatusError(refreshRes);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Refresh subscriptions failed');
|
this.toasts.error(error || 'Refresh subscriptions failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
@@ -564,7 +574,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
const payload = this.buildAddPayload();
|
const payload = this.buildAddPayload();
|
||||||
if (!payload.url?.trim()) {
|
if (!payload.url?.trim()) {
|
||||||
alert('Please enter a URL');
|
this.toasts.error('Please enter a URL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tr = (this.titleRegex || '').trim();
|
const tr = (this.titleRegex || '').trim();
|
||||||
@@ -572,12 +582,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
void RegExp(tr);
|
void RegExp(tr);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Invalid subscription title filter (regex)');
|
this.toasts.error('Invalid subscription title filter (regex)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||||
@@ -593,6 +603,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
...subscribeBase,
|
...subscribeBase,
|
||||||
checkIntervalMinutes: this.checkIntervalMinutes,
|
checkIntervalMinutes: this.checkIntervalMinutes,
|
||||||
titleRegex: tr,
|
titleRegex: tr,
|
||||||
|
skipSubscriberOnly: this.skipSubscriberOnly,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
@@ -605,10 +616,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
next: (res) => {
|
next: (res) => {
|
||||||
const r = res as { status?: string; msg?: string };
|
const r = res as { status?: string; msg?: string };
|
||||||
if (r.status === 'error') {
|
if (r.status === 'error') {
|
||||||
alert(r.msg || 'Subscribe failed');
|
this.toasts.error(r.msg || 'Subscribe failed');
|
||||||
} else {
|
} else {
|
||||||
this.addUrl = '';
|
this.addUrl = '';
|
||||||
this.titleRegex = '';
|
this.titleRegex = '';
|
||||||
|
this.skipSubscriberOnly = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -632,14 +644,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
void RegExp(raw);
|
void RegExp(raw);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Invalid subscription title filter (regex)');
|
this.toasts.error('Invalid subscription title filter (regex)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
|
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Update subscription failed');
|
this.toasts.error(error || 'Update subscription failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cancelEditTitleRegex();
|
this.cancelEditTitleRegex();
|
||||||
@@ -650,7 +662,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Delete subscription failed');
|
this.toasts.error(error || 'Delete subscription failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedSubscriptionIds.delete(id);
|
this.selectedSubscriptionIds.delete(id);
|
||||||
@@ -666,7 +678,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.delete(ids).subscribe((res) => {
|
this.subscriptionsSvc.delete(ids).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Delete subscriptions failed');
|
this.toasts.error(error || 'Delete subscriptions failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedSubscriptionIds.clear();
|
this.selectedSubscriptionIds.clear();
|
||||||
@@ -692,7 +704,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
.subscribe((res) => {
|
.subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Subscription check failed');
|
this.toasts.error(error || 'Subscription check failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.refreshSubscriptionsWithAlert();
|
this.refreshSubscriptionsWithAlert();
|
||||||
@@ -739,7 +751,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
.subscribe((res) => {
|
.subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Subscription check failed');
|
this.toasts.error(error || 'Subscription check failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.refreshSubscriptionsWithAlert();
|
this.refreshSubscriptionsWithAlert();
|
||||||
@@ -762,7 +774,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
|
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
|
||||||
const error = this.getStatusError(res);
|
const error = this.getStatusError(res);
|
||||||
if (error) {
|
if (error) {
|
||||||
alert(error || 'Update subscription failed');
|
this.toasts.error(error || 'Update subscription failed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1059,20 +1071,19 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Validate chapter template if chapter splitting is enabled
|
// Validate chapter template if chapter splitting is enabled
|
||||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug('Downloading:', payload);
|
|
||||||
this.addInProgress = true;
|
this.addInProgress = true;
|
||||||
this.cancelRequested = false;
|
this.cancelRequested = false;
|
||||||
this.addRequestSub?.unsubscribe();
|
this.addRequestSub?.unsubscribe();
|
||||||
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
|
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
|
||||||
if (status.status === 'error' && !this.cancelRequested) {
|
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') {
|
} else if (status.status !== 'error') {
|
||||||
this.addUrl = '';
|
this.addUrl = '';
|
||||||
}
|
}
|
||||||
@@ -1105,6 +1116,26 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.downloads.startById([id]).subscribe();
|
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) {
|
retryDownload(key: string, download: Download) {
|
||||||
this.addDownload({
|
this.addDownload({
|
||||||
url: download.url,
|
url: download.url,
|
||||||
@@ -1158,19 +1189,39 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadSelectedFiles() {
|
// Chromium-based browsers silently drop programmatic downloads beyond ~10 when
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// triggered in a tight loop. Trigger in batches with a short pause in between so
|
||||||
this.downloads.done.forEach((dl, _) => {
|
// 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) {
|
if (dl.status === 'finished' && dl.checked) {
|
||||||
const link = document.createElement('a');
|
selected.push(dl);
|
||||||
link.href = this.buildDownloadLink(dl);
|
|
||||||
link.setAttribute('download', dl.filename);
|
|
||||||
link.setAttribute('target', '_self');
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
buildDownloadLink(download: Download) {
|
||||||
@@ -1186,6 +1237,80 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
return baseDir + encodeURIComponent(download.filename);
|
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) {
|
buildResultItemTooltip(download: Download) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (download.msg) {
|
if (download.msg) {
|
||||||
@@ -1271,7 +1396,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
.map(url => url.trim())
|
.map(url => url.trim())
|
||||||
.filter(url => url.length > 0);
|
.filter(url => url.length > 0);
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
alert('No valid URLs found.');
|
this.toasts.error('No valid URLs found.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.importInProgress = true;
|
this.importInProgress = true;
|
||||||
@@ -1336,66 +1461,17 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
|
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||||
exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
exportBatchUrls(filter: BatchUrlFilter): void {
|
||||||
let urls: string[];
|
this.batchUrls.export(filter);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
|
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||||
copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
copyBatchUrls(filter: BatchUrlFilter): void {
|
||||||
let urls: string[];
|
this.batchUrls.copy(filter);
|
||||||
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.'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchVersionInfo(): void {
|
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 baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`;
|
||||||
const versionUrl = `${baseUrl}version`;
|
const versionUrl = `${baseUrl}version`;
|
||||||
this.http.get<{ 'yt-dlp': string, version: string }>(versionUrl)
|
this.http.get<{ 'yt-dlp': string, version: string }>(versionUrl)
|
||||||
@@ -1451,7 +1527,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
const fail = (err?: unknown) => {
|
const fail = (err?: unknown) => {
|
||||||
console.error('Clipboard write failed:', err);
|
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) {
|
if (navigator.clipboard?.writeText) {
|
||||||
navigator.clipboard.writeText(text).then(done).catch(fail);
|
navigator.clipboard.writeText(text).then(done).catch(fail);
|
||||||
@@ -1487,7 +1563,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.hasCookies = true;
|
this.hasCookies = true;
|
||||||
} else {
|
} else {
|
||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
this.toasts.error(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||||
}
|
}
|
||||||
this.cookieUploadInProgress = false;
|
this.cookieUploadInProgress = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -1496,7 +1572,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
this.cookieUploadInProgress = false;
|
this.cookieUploadInProgress = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
alert('Error uploading cookies.');
|
this.toasts.error('Error uploading cookies.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1530,11 +1606,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
this.toasts.error(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.refreshCookieStatus();
|
this.refreshCookieStatus();
|
||||||
alert('Error deleting cookies.');
|
this.toasts.error('Error deleting cookies.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1558,7 +1634,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
speed += download.speed || 0;
|
speed += download.speed || 0;
|
||||||
} else if (download.status === 'preparing') {
|
} else if (download.status === 'preparing') {
|
||||||
active++;
|
active++;
|
||||||
} else if (download.status === 'pending') {
|
} else if (download.status === 'pending' || download.status === 'scheduled') {
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { SelectAllCheckboxComponent } from './master-checkbox.component';
|
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 { Checkable } from "../interfaces";
|
||||||
import { FormsModule } from "@angular/forms";
|
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>
|
<label class="form-check-label visually-hidden" for="{{id()}}-select-all">Select all</label>
|
||||||
</div>
|
</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
|
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 { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||||
import { Checkable } from '../interfaces';
|
import { Checkable } from '../interfaces';
|
||||||
import { FormsModule } from '@angular/forms';
|
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>
|
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
||||||
</div>
|
</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
|
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>;
|
ytdl_options_overrides?: Record<string, unknown>;
|
||||||
clip_start?: number;
|
clip_start?: number;
|
||||||
clip_end?: number;
|
clip_end?: number;
|
||||||
|
live_status?: string;
|
||||||
|
live_release_timestamp?: number;
|
||||||
status: string;
|
status: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface SubscriptionRow {
|
|||||||
quality: string;
|
quality: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
title_regex?: string;
|
title_regex?: string;
|
||||||
|
skip_subscriber_only?: boolean;
|
||||||
last_checked: number | null;
|
last_checked: number | null;
|
||||||
seen_count: number;
|
seen_count: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
@@ -11,6 +11,7 @@ import { AddDownloadPayload } from './downloads.service';
|
|||||||
export interface SubscribePayload extends AddDownloadPayload {
|
export interface SubscribePayload extends AddDownloadPayload {
|
||||||
checkIntervalMinutes: number;
|
checkIntervalMinutes: number;
|
||||||
titleRegex: string;
|
titleRegex: string;
|
||||||
|
skipSubscriberOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -99,6 +100,7 @@ export class SubscriptionsService {
|
|||||||
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||||
check_interval_minutes: payload.checkIntervalMinutes,
|
check_interval_minutes: payload.checkIntervalMinutes,
|
||||||
title_regex: payload.titleRegex,
|
title_regex: payload.titleRegex,
|
||||||
|
skip_subscriber_only: payload.skipSubscriberOnly,
|
||||||
})
|
})
|
||||||
.pipe(catchError((err) => this.handleHTTPError(err)));
|
.pipe(catchError((err) => this.handleHTTPError(err)));
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,12 @@ export class SubscriptionsService {
|
|||||||
|
|
||||||
update(
|
update(
|
||||||
id: string,
|
id: string,
|
||||||
changes: Partial<Pick<SubscriptionRow, 'enabled' | 'check_interval_minutes' | 'name' | 'title_regex'>>,
|
changes: Partial<
|
||||||
|
Pick<
|
||||||
|
SubscriptionRow,
|
||||||
|
'enabled' | 'check_interval_minutes' | 'name' | 'title_regex' | 'skip_subscriber_only'
|
||||||
|
>
|
||||||
|
>,
|
||||||
) {
|
) {
|
||||||
return this.http
|
return this.http
|
||||||
.post('subscriptions/update', { id, ...changes })
|
.post('subscriptions/update', { id, ...changes })
|
||||||
|
|||||||
@@ -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": [
|
"exclude": [
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.spec.ts"
|
||||||
]
|
],
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"extendedDiagnostics": {
|
||||||
|
"checks": {
|
||||||
|
"nullishCoalescingNotNullable": "suppress",
|
||||||
|
"optionalChainNotNullable": "suppress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user