mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
Compare commits
8 Commits
2026.04.16
...
2026.04.28
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d96a581b9 | |||
| 4f83174d05 | |||
| 91ee8312bf | |||
| d89a5ddbe5 | |||
| abb9492d21 | |||
| 23de9824f0 | |||
| 0ea934c08f | |||
| e9f979b349 |
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Run backend tests
|
||||
run: uv run pytest app/tests/
|
||||
- name: Run Trivy filesystem scan
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
|
||||
@@ -104,6 +104,8 @@ MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves
|
||||
|
||||
When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options.
|
||||
|
||||
In JSON presets and overrides, setting an option to **`null`** clears that option for that download (for example, `"download_archive": null` overrides a global archive path so the archive is not used). This follows yt-dlp’s usual meaning of `None` for that option.
|
||||
|
||||
### Option format
|
||||
|
||||
yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
|
||||
@@ -204,6 +206,8 @@ When a download starts, the final set of yt-dlp options is built in this order:
|
||||
2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys).
|
||||
3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys).
|
||||
|
||||
MeTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
|
||||
|
||||
**Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written.
|
||||
|
||||
### Configuration cookbooks
|
||||
|
||||
+167
-2
@@ -14,10 +14,11 @@ import logging
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
from watchfiles import DefaultFilter, Change, awatch
|
||||
|
||||
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
|
||||
|
||||
log = logging.getLogger('main')
|
||||
@@ -260,6 +261,115 @@ def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
_YOUTUBE_T_COMPACT_RE = re.compile(
|
||||
r'^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)(?:s)?)?$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_youtube_t_compact(value: str) -> float | None:
|
||||
"""Parse YouTube-style ``t`` values: ``885``, ``885s``, ``14m45s``, ``1h2m3s``."""
|
||||
v = value.strip()
|
||||
if not v:
|
||||
return None
|
||||
if re.fullmatch(r'-?\d+(\.\d+)?', v):
|
||||
sec = float(v)
|
||||
return sec if sec >= 0 else None
|
||||
m = _YOUTUBE_T_COMPACT_RE.match(v)
|
||||
if m and any(m.groups()):
|
||||
hours = int(m.group(1) or 0)
|
||||
minutes = int(m.group(2) or 0)
|
||||
seconds = int(m.group(3) or 0)
|
||||
total = hours * 3600 + minutes * 60 + seconds
|
||||
return float(total) if total >= 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_clock_timestamp(s: str) -> float:
|
||||
"""Parse ``MM:SS``, ``H:MM:SS``, or single segment as seconds (with optional decimals)."""
|
||||
part = s.strip()
|
||||
if not part:
|
||||
raise ValueError('empty timestamp')
|
||||
segments = part.split(':')
|
||||
if len(segments) > 3:
|
||||
raise ValueError('too many segments')
|
||||
try:
|
||||
nums = [float(x) for x in segments]
|
||||
except ValueError as exc:
|
||||
raise ValueError('invalid number') from exc
|
||||
if any(x < 0 for x in nums):
|
||||
raise ValueError('negative segment')
|
||||
if len(segments) == 1:
|
||||
return nums[0]
|
||||
if len(segments) == 2:
|
||||
return nums[0] * 60 + nums[1]
|
||||
return nums[0] * 3600 + nums[1] * 60 + nums[2]
|
||||
|
||||
|
||||
def _parse_clip_timestamp_value(value) -> float:
|
||||
"""Coerce a clip boundary from JSON to seconds (non-negative)."""
|
||||
if isinstance(value, bool):
|
||||
raise web.HTTPBadRequest(reason='clip timestamp must be a number or string')
|
||||
if isinstance(value, (int, float)):
|
||||
if value < 0:
|
||||
raise web.HTTPBadRequest(reason='clip timestamp must be non-negative')
|
||||
return float(value)
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
raise web.HTTPBadRequest(reason='clip timestamp cannot be empty')
|
||||
if ':' in s:
|
||||
try:
|
||||
return _parse_clock_timestamp(s)
|
||||
except ValueError as exc:
|
||||
raise web.HTTPBadRequest(reason='invalid clip timestamp format') from exc
|
||||
compact = _parse_youtube_t_compact(s)
|
||||
if compact is not None:
|
||||
return compact
|
||||
raise web.HTTPBadRequest(reason='invalid clip timestamp format')
|
||||
|
||||
|
||||
def _optional_clip_field(raw) -> float | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str) and not raw.strip():
|
||||
return None
|
||||
return _parse_clip_timestamp_value(raw)
|
||||
|
||||
|
||||
def _clip_field_provided_in_post(raw) -> bool:
|
||||
if raw is None:
|
||||
return False
|
||||
if isinstance(raw, str) and not raw.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _extract_t_query_from_url(url: str) -> tuple[str, float | None]:
|
||||
"""If ``t=`` is present and parseable, return URL without ``t`` and start seconds."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
params = parse_qs(parsed.query)
|
||||
except Exception:
|
||||
return url, None
|
||||
t_values = params.get('t')
|
||||
if not t_values:
|
||||
return url, None
|
||||
start = _parse_youtube_t_compact(t_values[0])
|
||||
if start is None:
|
||||
return url, None
|
||||
filtered = {k: v for k, v in params.items() if k != 't'}
|
||||
new_query = urlencode(filtered, doseq=True)
|
||||
cleaned = urlunparse((
|
||||
parsed.scheme,
|
||||
parsed.netloc,
|
||||
parsed.path,
|
||||
parsed.params,
|
||||
new_query,
|
||||
parsed.fragment,
|
||||
))
|
||||
return cleaned, float(start)
|
||||
|
||||
|
||||
def _parse_ytdl_options_presets(post: dict) -> list[str]:
|
||||
"""Normalize preset names from add/subscribe body; supports list or legacy singular string."""
|
||||
raw = post.get('ytdl_options_presets')
|
||||
@@ -542,6 +652,39 @@ def parse_download_options(post: dict) -> dict:
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from exc
|
||||
|
||||
clip_start_raw = post.get('clip_start')
|
||||
clip_end_raw = post.get('clip_end')
|
||||
clip_start: float | None
|
||||
clip_end: float | None
|
||||
if download_type in ('captions', 'thumbnail'):
|
||||
if _clip_field_provided_in_post(clip_start_raw) or _clip_field_provided_in_post(clip_end_raw):
|
||||
raise web.HTTPBadRequest(
|
||||
reason='clip_start and clip_end are only supported for video and audio downloads',
|
||||
)
|
||||
clip_start = None
|
||||
clip_end = None
|
||||
else:
|
||||
cleaned_url, url_t = _extract_t_query_from_url(url)
|
||||
if url_t is not None:
|
||||
url = cleaned_url
|
||||
explicit_start = _optional_clip_field(clip_start_raw)
|
||||
explicit_end = _optional_clip_field(clip_end_raw)
|
||||
explicit_start_provided = _clip_field_provided_in_post(clip_start_raw)
|
||||
explicit_end_provided = _clip_field_provided_in_post(clip_end_raw)
|
||||
if explicit_start_provided:
|
||||
clip_start = explicit_start
|
||||
elif explicit_end_provided:
|
||||
clip_start = 0.0
|
||||
elif url_t is not None:
|
||||
clip_start = url_t
|
||||
else:
|
||||
clip_start = None
|
||||
clip_end = explicit_end
|
||||
if clip_end is not None and clip_start is None:
|
||||
clip_start = 0.0
|
||||
if clip_start is not None and clip_end is not None and clip_end <= clip_start:
|
||||
raise web.HTTPBadRequest(reason='clip_end must be greater than clip_start')
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
'download_type': download_type,
|
||||
@@ -558,6 +701,8 @@ def parse_download_options(post: dict) -> dict:
|
||||
'subtitle_mode': subtitle_mode,
|
||||
'ytdl_options_presets': ytdl_options_presets,
|
||||
'ytdl_options_overrides': ytdl_options_overrides,
|
||||
'clip_start': clip_start,
|
||||
'clip_end': clip_end,
|
||||
}
|
||||
|
||||
|
||||
@@ -594,6 +739,8 @@ async def add(request):
|
||||
o['subtitle_mode'],
|
||||
o['ytdl_options_presets'],
|
||||
o['ytdl_options_overrides'],
|
||||
o['clip_start'],
|
||||
o['clip_end'],
|
||||
)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@@ -627,6 +774,17 @@ async def subscribe(request):
|
||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
|
||||
if cic < 1:
|
||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1')
|
||||
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')
|
||||
|
||||
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(
|
||||
o['url'],
|
||||
@@ -645,6 +803,8 @@ async def subscribe(request):
|
||||
subtitle_mode=o['subtitle_mode'],
|
||||
ytdl_options_presets=o['ytdl_options_presets'],
|
||||
ytdl_options_overrides=o['ytdl_options_overrides'],
|
||||
title_regex=post.get('title_regex'),
|
||||
skip_subscriber_only=skip_subscriber_only,
|
||||
)
|
||||
return web.Response(text=serializer.encode(result))
|
||||
|
||||
@@ -660,7 +820,12 @@ async def subscriptions_update(request):
|
||||
sub_id = post.get('id')
|
||||
if not sub_id:
|
||||
raise web.HTTPBadRequest(reason='missing subscription id')
|
||||
changes = {k: v for k, v in post.items() if k != 'id' and k in ('enabled', 'check_interval_minutes', 'name')}
|
||||
changes = {
|
||||
k: v
|
||||
for k, v in post.items()
|
||||
if k != 'id'
|
||||
and k in ('enabled', 'check_interval_minutes', 'name', 'title_regex', 'skip_subscriber_only')
|
||||
}
|
||||
if not changes:
|
||||
raise web.HTTPBadRequest(reason='no valid fields to update')
|
||||
log.info("Subscription update requested for %s: %s", sub_id, sorted(changes.keys()))
|
||||
|
||||
+122
-5
@@ -6,6 +6,7 @@ import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import types
|
||||
import uuid
|
||||
@@ -126,6 +127,21 @@ def _entry_id(entry: dict) -> Optional[str]:
|
||||
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
|
||||
class SubscriptionInfo:
|
||||
id: str
|
||||
@@ -147,6 +163,8 @@ class SubscriptionInfo:
|
||||
subtitle_mode: str = "prefer_manual"
|
||||
ytdl_options_presets: list[str] = field(default_factory=list)
|
||||
ytdl_options_overrides: dict[str, Any] = field(default_factory=dict)
|
||||
title_regex: str = ""
|
||||
skip_subscriber_only: bool = False
|
||||
last_checked: Optional[float] = None
|
||||
seen_ids: list[str] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
@@ -167,6 +185,8 @@ class SubscriptionInfo:
|
||||
"format": self.format,
|
||||
"quality": self.quality,
|
||||
"folder": self.folder,
|
||||
"title_regex": self.title_regex,
|
||||
"skip_subscriber_only": self.skip_subscriber_only,
|
||||
"last_checked": self.last_checked,
|
||||
"seen_count": len(self.seen_ids),
|
||||
"error": self.error,
|
||||
@@ -194,6 +214,8 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
|
||||
"subtitle_mode": sub.subtitle_mode,
|
||||
"ytdl_options_presets": list(sub.ytdl_options_presets),
|
||||
"ytdl_options_overrides": sub.ytdl_options_overrides,
|
||||
"title_regex": sub.title_regex,
|
||||
"skip_subscriber_only": sub.skip_subscriber_only,
|
||||
"last_checked": sub.last_checked,
|
||||
"seen_ids": list(sub.seen_ids),
|
||||
"error": sub.error,
|
||||
@@ -231,6 +253,22 @@ def _subscription_from_record(record: Any) -> Optional[SubscriptionInfo]:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_title_regex_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def validate_title_regex(value: Any) -> str:
|
||||
"""Return stored title regex string; non-empty values must compile (re.error on failure)."""
|
||||
s = _normalize_title_regex_value(value)
|
||||
if s:
|
||||
re.compile(s)
|
||||
return s
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> bool:
|
||||
"""Accept JSON booleans and common string forms used by API clients."""
|
||||
if isinstance(value, bool):
|
||||
@@ -448,10 +486,24 @@ class SubscriptionManager:
|
||||
subtitle_mode: str,
|
||||
ytdl_options_presets: Optional[list[str]] = None,
|
||||
ytdl_options_overrides: Optional[dict[str, Any]] = None,
|
||||
title_regex: Any = None,
|
||||
skip_subscriber_only: Any = None,
|
||||
) -> dict:
|
||||
url = self._normalize_url(url)
|
||||
if not url:
|
||||
return {"status": "error", "msg": "Missing URL"}
|
||||
try:
|
||||
title_regex_stored = validate_title_regex(title_regex)
|
||||
except re.error as 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:
|
||||
if url in self._url_index or url in self._pending_urls:
|
||||
@@ -509,6 +561,8 @@ class SubscriptionManager:
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=list(ytdl_options_presets or []),
|
||||
ytdl_options_overrides=dict(ytdl_options_overrides or {}),
|
||||
title_regex=title_regex_stored,
|
||||
skip_subscriber_only=skip_so,
|
||||
last_checked=time.time(),
|
||||
seen_ids=list(dict.fromkeys(all_ids)),
|
||||
error=None,
|
||||
@@ -555,6 +609,25 @@ class SubscriptionManager:
|
||||
return {"status": "ok"}
|
||||
|
||||
async def update_subscription(self, sub_id: str, changes: dict) -> dict:
|
||||
validated_tr: Optional[str] = None
|
||||
if "title_regex" in changes:
|
||||
try:
|
||||
validated_tr = validate_title_regex(changes["title_regex"])
|
||||
except re.error as 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:
|
||||
sub = self._subs.get(sub_id)
|
||||
if not sub:
|
||||
@@ -568,6 +641,10 @@ class SubscriptionManager:
|
||||
sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"]))
|
||||
if "name" in changes and changes["name"]:
|
||||
sub.name = str(changes["name"])
|
||||
if validated_tr is not None:
|
||||
sub.title_regex = validated_tr
|
||||
if skip_so_set:
|
||||
sub.skip_subscriber_only = validated_skip_so
|
||||
|
||||
try:
|
||||
self._save_locked()
|
||||
@@ -659,9 +736,10 @@ class SubscriptionManager:
|
||||
dl_submode = cur.subtitle_mode
|
||||
dl_ytdl_presets = list(cur.ytdl_options_presets)
|
||||
dl_ytdl_overrides = dict(cur.ytdl_options_overrides)
|
||||
dl_title_regex = cur.title_regex or ""
|
||||
dl_skip_subscriber_only = bool(cur.skip_subscriber_only)
|
||||
|
||||
new_entries: list[dict] = []
|
||||
new_ids: list[str] = []
|
||||
for ent in entries:
|
||||
eid = _entry_id(ent)
|
||||
if not eid:
|
||||
@@ -669,10 +747,43 @@ class SubscriptionManager:
|
||||
if eid in seen and ent.get("live_status") != "is_live":
|
||||
continue
|
||||
new_entries.append(ent)
|
||||
new_ids.append(eid)
|
||||
|
||||
pattern_re: Optional[re.Pattern[str]] = None
|
||||
if dl_title_regex:
|
||||
try:
|
||||
pattern_re = re.compile(dl_title_regex)
|
||||
except re.error:
|
||||
log.warning(
|
||||
"Invalid stored title_regex on subscription %s, ignoring filter",
|
||||
sub.name,
|
||||
)
|
||||
|
||||
queue_entries: list[dict] = []
|
||||
filtered_ids: list[str] = []
|
||||
for ent in new_entries:
|
||||
eid = _entry_id(ent)
|
||||
if pattern_re is not None:
|
||||
title = str(ent.get("title") or "")
|
||||
if not pattern_re.search(title):
|
||||
if eid:
|
||||
filtered_ids.append(eid)
|
||||
continue
|
||||
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(
|
||||
new_entries,
|
||||
queue_entries,
|
||||
download_type=dl_type,
|
||||
codec=dl_codec,
|
||||
format=dl_format,
|
||||
@@ -689,14 +800,20 @@ class SubscriptionManager:
|
||||
ytdl_options_overrides=dl_ytdl_overrides,
|
||||
)
|
||||
log.info(
|
||||
"Subscription check finished for %s: %d new, %d queued, %d failed",
|
||||
"Subscription check finished for %s: %d new, %d filtered, %d subscriber_skipped, %d queued, %d failed",
|
||||
sub.name,
|
||||
len(new_entries),
|
||||
len(filtered_ids),
|
||||
len(subscriber_filtered_ids),
|
||||
len(queued_ids),
|
||||
len(queue_errors),
|
||||
)
|
||||
|
||||
merged = list(dict.fromkeys(queued_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))
|
||||
if len(merged) > max_seen:
|
||||
merged = merged[:max_seen]
|
||||
|
||||
@@ -279,3 +279,30 @@ async def test_add_legacy_format_migrated(mock_dqueue):
|
||||
call = mock_dqueue.add.await_args
|
||||
assert call is not None
|
||||
assert call.args[1] == "audio"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_passes_clip_bounds_to_queue(mock_dqueue):
|
||||
req = _json_request(
|
||||
_valid_video_add_body(clip_start="2:26", clip_end="3:24"),
|
||||
)
|
||||
resp = await main.add(req)
|
||||
assert resp.status == 200
|
||||
call = mock_dqueue.add.await_args
|
||||
assert call is not None
|
||||
assert call.args[15] == pytest.approx(146.0)
|
||||
assert call.args[16] == pytest.approx(204.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
|
||||
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock())
|
||||
req = _json_request(
|
||||
{
|
||||
**_valid_video_add_body(clip_start="10"),
|
||||
"check_interval_minutes": 60,
|
||||
}
|
||||
)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.subscribe(req)
|
||||
main.submgr.add_subscription.assert_not_awaited()
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_get_returns_tuple_of_lists(dq_env):
|
||||
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url):
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -86,7 +86,7 @@ async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
||||
async def test_cancel_removes_from_pending(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url):
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -114,11 +114,54 @@ async def test_cancel_removes_from_pending(dq_env):
|
||||
notifier.canceled.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_before_start_marks_download_canceled(dq_env):
|
||||
"""Regression test for the race condition where cancel() arrives after the
|
||||
download has been placed in the queue and ``__start_download`` has been
|
||||
scheduled via ``asyncio.create_task`` but has not yet executed. Without the
|
||||
fix, the pending task would run ``download.start()`` despite the user
|
||||
cancelling, because its ``download.canceled`` guard was never flipped."""
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
url = "https://example.com/race"
|
||||
start_mock = AsyncMock()
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||
await dq.add(
|
||||
url,
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=True,
|
||||
)
|
||||
assert dq.queue.exists(url)
|
||||
download = dq.queue.get(url)
|
||||
assert download.canceled is False
|
||||
await dq.cancel([url])
|
||||
assert not dq.queue.exists(url)
|
||||
assert download.canceled is True
|
||||
notifier.canceled.assert_awaited_with(url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_pending_moves_to_queue(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url):
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
@@ -187,7 +230,7 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
|
||||
"Preset B": {"writesubtitles": False, "ratelimit": 1000},
|
||||
}
|
||||
|
||||
def fake_extract(self, url):
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid2",
|
||||
@@ -219,3 +262,127 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
|
||||
assert queued.ytdl_opts["ratelimit"] == 1000
|
||||
assert queued.ytdl_opts["proxy"] == "http://override"
|
||||
assert queued.ytdl_opts["embed_thumbnail"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_info_preset_null_download_archive_overrides_global(dq_env):
|
||||
"""Preset download_archive:null must apply during extract_info (global archive otherwise wins first)."""
|
||||
dq_env.YTDL_OPTIONS = {"download_archive": "/tmp/archive.txt"}
|
||||
dq_env.YTDL_OPTIONS_PRESETS = {"NoArchive": {"download_archive": None}}
|
||||
|
||||
captured_params: list = []
|
||||
|
||||
class FakeYoutubeDL:
|
||||
def __init__(self, params=None):
|
||||
captured_params.append(params)
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid-archive",
|
||||
"title": "Archive Test",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
|
||||
result = await dq.add(
|
||||
"https://example.com/archive-test",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
ytdl_options_presets=["NoArchive"],
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert len(captured_params) == 1
|
||||
extract_params = captured_params[0]
|
||||
assert extract_params.get("download_archive") is None
|
||||
assert extract_params["extract_flat"] is True
|
||||
assert extract_params["noplaylist"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
|
||||
"""MeTube's flat-extract settings must not be overridden by presets."""
|
||||
dq_env.YTDL_OPTIONS = {}
|
||||
dq_env.YTDL_OPTIONS_PRESETS = {
|
||||
"TryOverride": {"extract_flat": False, "noplaylist": False},
|
||||
}
|
||||
|
||||
captured_params: list = []
|
||||
|
||||
class FakeYoutubeDL:
|
||||
def __init__(self, params=None):
|
||||
captured_params.append(params)
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid-flat",
|
||||
"title": "Flat Test",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
|
||||
result = await dq.add(
|
||||
"https://example.com/flat-test",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
ytdl_options_presets=["TryOverride"],
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert captured_params[0]["extract_flat"] is True
|
||||
assert captured_params[0]["noplaylist"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
result = await dq.add(
|
||||
"https://example.com/clip",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
clip_start=10.0,
|
||||
clip_end=99.5,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
download = dq.pending.get("https://example.com/clip")
|
||||
assert download.info.clip_start == 10.0
|
||||
assert download.info.clip_end == 99.5
|
||||
|
||||
@@ -205,6 +205,80 @@ class ParseDownloadOptionsTests(unittest.TestCase):
|
||||
finally:
|
||||
main.config.YTDL_OPTIONS_PRESETS = previous
|
||||
|
||||
def test_clip_start_end_seconds_and_clock(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "2:26",
|
||||
"clip_end": "3:24",
|
||||
})
|
||||
self.assertEqual(parsed["clip_start"], 146.0)
|
||||
self.assertEqual(parsed["clip_end"], 204.0)
|
||||
|
||||
def test_clip_url_t_param_strips_query_and_sets_start(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=855s",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 855.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_explicit_start_wins_over_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=100",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "50",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 50.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_end_only_sets_start_zero_and_strips_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=999",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_end": "60",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 0.0)
|
||||
self.assertEqual(parsed["clip_end"], 60.0)
|
||||
|
||||
def test_clip_rejects_end_before_start(self):
|
||||
with self.assertRaises(main.web.HTTPBadRequest):
|
||||
main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "100",
|
||||
"clip_end": "50",
|
||||
})
|
||||
|
||||
def test_clip_rejected_for_captions(self):
|
||||
with self.assertRaises(main.web.HTTPBadRequest):
|
||||
main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"download_type": "captions",
|
||||
"codec": "auto",
|
||||
"format": "srt",
|
||||
"quality": "best",
|
||||
"clip_start": "1",
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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.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:
|
||||
@@ -75,6 +80,20 @@ def _create_legacy_shelf(path: str, record) -> None:
|
||||
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):
|
||||
def test_load_imports_legacy_subscription_shelf(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -386,6 +405,108 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
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_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):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
@@ -453,6 +574,392 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
await mgr.update_subscription(sub_id, {"enabled": "maybe"})
|
||||
|
||||
async def test_add_subscription_rejects_invalid_title_regex(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
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",
|
||||
title_regex="[",
|
||||
)
|
||||
self.assertEqual(result["status"], "error")
|
||||
self.assertIn("title_regex", result["msg"].lower())
|
||||
self.assertEqual(mgr.list_all(), [])
|
||||
|
||||
async def test_add_subscription_stores_and_exposes_title_regex(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",
|
||||
title_regex="EPISODE",
|
||||
)
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["subscription"]["title_regex"], "EPISODE")
|
||||
self.assertEqual(mgr.list_all()[0].title_regex, "EPISODE")
|
||||
|
||||
async def test_check_now_title_regex_queues_only_matches_and_marks_unmatched_seen(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": "Old", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[
|
||||
{
|
||||
"id": "v2",
|
||||
"title": "Minecraft | EPISODE 1",
|
||||
"webpage_url": "https://example.com/v2",
|
||||
},
|
||||
{
|
||||
"id": "v3",
|
||||
"title": "Unrelated IRL",
|
||||
"webpage_url": "https://example.com/v3",
|
||||
},
|
||||
{
|
||||
"id": "v1",
|
||||
"title": "Old",
|
||||
"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",
|
||||
title_regex="EPISODE",
|
||||
)
|
||||
await mgr.check_now([result["subscription"]["id"]])
|
||||
self.assertEqual([e["webpage_url"] for e, _, _ in queue.entries], ["https://example.com/v2"])
|
||||
sub = mgr.list_all()[0]
|
||||
self.assertEqual(sub.seen_ids[:3], ["v2", "v3", "v1"])
|
||||
|
||||
async def test_check_now_title_regex_queue_failure_keeps_matched_id_unseen(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": "Old", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[
|
||||
{
|
||||
"id": "v2",
|
||||
"title": "Show | EPISODE 1",
|
||||
"webpage_url": "https://example.com/v2",
|
||||
},
|
||||
{
|
||||
"id": "v3",
|
||||
"title": "Other",
|
||||
"webpage_url": "https://example.com/v3",
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
):
|
||||
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",
|
||||
title_regex="EPISODE",
|
||||
)
|
||||
queue.fail = True
|
||||
await mgr.check_now([result["subscription"]["id"]])
|
||||
sub = mgr.list_all()[0]
|
||||
self.assertEqual(sub.error, "queue failed")
|
||||
self.assertEqual(set(sub.seen_ids), {"v1", "v3"})
|
||||
self.assertNotIn("v2", sub.seen_ids)
|
||||
|
||||
async def test_update_subscription_rejects_invalid_title_regex(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, {"title_regex": "("})
|
||||
self.assertEqual(upd["status"], "error")
|
||||
self.assertEqual(mgr.list_all()[0].title_regex, "")
|
||||
|
||||
async def test_update_subscription_persists_valid_title_regex(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, {"title_regex": "foo|bar"})
|
||||
self.assertEqual(upd["status"], "ok")
|
||||
self.assertEqual(upd["subscription"]["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):
|
||||
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": "EPISODE",
|
||||
"last_checked": None,
|
||||
"seen_ids": [],
|
||||
"error": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
f,
|
||||
)
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
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):
|
||||
def test_descends_one_level_when_root_entries_are_nested_collections(self):
|
||||
responses = iter(
|
||||
|
||||
+67
-13
@@ -9,6 +9,7 @@ from collections import OrderedDict
|
||||
import time
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
from functools import partial
|
||||
import logging
|
||||
import re
|
||||
import types
|
||||
@@ -191,6 +192,8 @@ class DownloadInfo:
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=None,
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=None,
|
||||
clip_end=None,
|
||||
):
|
||||
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}'
|
||||
@@ -215,6 +218,8 @@ class DownloadInfo:
|
||||
self.subtitle_mode = subtitle_mode
|
||||
self.ytdl_options_presets = list(ytdl_options_presets or [])
|
||||
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
||||
self.clip_start = clip_start
|
||||
self.clip_end = clip_end
|
||||
self.subtitle_files = []
|
||||
|
||||
def __setstate__(self, state):
|
||||
@@ -283,6 +288,10 @@ class DownloadInfo:
|
||||
self.subtitle_files = []
|
||||
if not hasattr(self, "chapter_files"):
|
||||
self.chapter_files = []
|
||||
if not hasattr(self, "clip_start"):
|
||||
self.clip_start = None
|
||||
if not hasattr(self, "clip_end"):
|
||||
self.clip_end = None
|
||||
|
||||
|
||||
_PERSISTED_DOWNLOAD_FIELDS = (
|
||||
@@ -302,6 +311,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
||||
"subtitle_mode",
|
||||
"ytdl_options_presets",
|
||||
"ytdl_options_overrides",
|
||||
"clip_start",
|
||||
"clip_end",
|
||||
"status",
|
||||
"timestamp",
|
||||
"error",
|
||||
@@ -472,6 +483,16 @@ class Download:
|
||||
'force_keyframes': False
|
||||
})
|
||||
|
||||
clip_start = getattr(self.info, 'clip_start', None)
|
||||
clip_end = getattr(self.info, 'clip_end', None)
|
||||
if clip_start is not None or clip_end is not None:
|
||||
start = float(clip_start) if clip_start is not None else 0.0
|
||||
end = float(clip_end) if clip_end is not None else float('inf')
|
||||
ytdl_params['download_ranges'] = yt_dlp.utils.download_range_func(
|
||||
None,
|
||||
[(start, end)],
|
||||
)
|
||||
|
||||
ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
|
||||
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
|
||||
log.info(f"Finished download for: {self.info.title}")
|
||||
@@ -796,9 +817,19 @@ class DownloadQueue:
|
||||
log.debug(f'Auto-clearing completed download: {url}')
|
||||
await self.clear([url])
|
||||
|
||||
def __extract_info(self, url):
|
||||
def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
"""Merge global options, presets (in order), and per-download overrides."""
|
||||
opts = dict(self.config.YTDL_OPTIONS)
|
||||
for preset_name in ytdl_options_presets or []:
|
||||
opts.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
|
||||
opts.update(ytdl_options_overrides or {})
|
||||
return opts
|
||||
|
||||
def __extract_info(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
return yt_dlp.YoutubeDL(params={
|
||||
user_opts = self._build_ytdl_options(ytdl_options_presets, ytdl_options_overrides)
|
||||
params = {
|
||||
**user_opts,
|
||||
'quiet': not debug_logging,
|
||||
'verbose': debug_logging,
|
||||
'no_color': True,
|
||||
@@ -806,9 +837,11 @@ class DownloadQueue:
|
||||
'ignore_no_formats_error': True,
|
||||
'noplaylist': True,
|
||||
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
|
||||
**self.config.YTDL_OPTIONS,
|
||||
**({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}),
|
||||
}).extract_info(url, download=False)
|
||||
}
|
||||
imp = user_opts.get('impersonate')
|
||||
if imp is not None:
|
||||
params['impersonate'] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(imp)
|
||||
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
|
||||
|
||||
def __calc_download_path(self, download_type, folder):
|
||||
base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR
|
||||
@@ -844,10 +877,10 @@ class DownloadQueue:
|
||||
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
||||
sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()}
|
||||
output = _resolve_outtmpl_fields(output, sanitized, ('channel',))
|
||||
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
||||
for preset_name in getattr(dl, 'ytdl_options_presets', None) or []:
|
||||
ytdl_options.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
|
||||
ytdl_options.update(getattr(dl, 'ytdl_options_overrides', {}) or {})
|
||||
ytdl_options = self._build_ytdl_options(
|
||||
getattr(dl, 'ytdl_options_presets', None),
|
||||
getattr(dl, 'ytdl_options_overrides', {}) or {},
|
||||
)
|
||||
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
|
||||
if playlist_item_limit > 0:
|
||||
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
||||
@@ -877,6 +910,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen=None,
|
||||
):
|
||||
@@ -911,6 +946,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
@@ -962,6 +999,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
@@ -995,6 +1034,8 @@ class DownloadQueue:
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=ytdl_options_presets,
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
@@ -1017,6 +1058,8 @@ class DownloadQueue:
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=None,
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=None,
|
||||
clip_end=None,
|
||||
already=None,
|
||||
_add_gen=None,
|
||||
):
|
||||
@@ -1025,7 +1068,7 @@ class DownloadQueue:
|
||||
log.info(
|
||||
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} '
|
||||
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
|
||||
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=}'
|
||||
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=}'
|
||||
)
|
||||
if already is None:
|
||||
_add_gen = self._add_generation
|
||||
@@ -1037,7 +1080,10 @@ class DownloadQueue:
|
||||
else:
|
||||
already.add(url)
|
||||
try:
|
||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
||||
entry = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
partial(self.__extract_info, url, ytdl_options_presets, ytdl_options_overrides),
|
||||
)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
return {'status': 'error', 'msg': str(exc)}
|
||||
return await self.__add_entry(
|
||||
@@ -1056,6 +1102,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
@@ -1077,6 +1125,8 @@ class DownloadQueue:
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=None,
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=None,
|
||||
clip_end=None,
|
||||
):
|
||||
if ytdl_options_presets is None:
|
||||
ytdl_options_presets = []
|
||||
@@ -1098,6 +1148,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
None,
|
||||
)
|
||||
@@ -1124,9 +1176,11 @@ class DownloadQueue:
|
||||
if not self.queue.exists(id):
|
||||
log.warning(f'requested cancel for non-existent download {id}')
|
||||
continue
|
||||
if self.queue.get(id).started():
|
||||
self.queue.get(id).cancel()
|
||||
dl = self.queue.get(id)
|
||||
if dl.started():
|
||||
dl.cancel()
|
||||
else:
|
||||
dl.canceled = True
|
||||
self.queue.delete(id)
|
||||
await self.notifier.canceled(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
+1
-1
@@ -58,6 +58,6 @@
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.47.0",
|
||||
"vitest": "^4.1.4"
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+266
-249
File diff suppressed because it is too large
Load Diff
@@ -428,6 +428,34 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (downloadType === 'video' || downloadType === 'audio') {
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Clip start</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="clipStart"
|
||||
[(ngModel)]="clipStart"
|
||||
(change)="clipStartChanged()"
|
||||
placeholder="e.g. 2:26"
|
||||
[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 class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Clip end</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="clipEnd"
|
||||
[(ngModel)]="clipEnd"
|
||||
(change)="clipEndChanged()"
|
||||
placeholder="e.g. 3:24"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Optional end time. Blank = until end of media.">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Behavior -->
|
||||
@@ -475,6 +503,27 @@
|
||||
ngbTooltip="How often to poll subscriptions for new videos">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subscription Title Filter</span>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="titleRegex"
|
||||
[(ngModel)]="titleRegex"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
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"
|
||||
ngbTooltip="When enabled, subscription checks skip videos marked members-only by yt-dlp (channel Join). Ignored for one-off downloads." />
|
||||
<label class="form-check-label" for="checkbox-skip-subscriber-only">Skip members-only subscription videos</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- yt-dlp -->
|
||||
@@ -608,6 +657,19 @@
|
||||
@if (batchImportStatus) {
|
||||
<small>{{ batchImportStatus }}</small>
|
||||
}
|
||||
@if (batchImportTotal > 0) {
|
||||
<div class="progress mt-2" style="height: 20px;">
|
||||
<div class="progress-bar"
|
||||
[class.bg-danger]="batchImportFailures > 0"
|
||||
role="progressbar"
|
||||
[style.width.%]="(batchImportCount / batchImportTotal) * 100"
|
||||
[attr.aria-valuenow]="batchImportCount"
|
||||
[attr.aria-valuemin]="0"
|
||||
[attr.aria-valuemax]="batchImportTotal">
|
||||
{{ batchImportCount }} / {{ batchImportTotal }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -874,6 +936,8 @@
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col" class="text-nowrap"
|
||||
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">Last checked</th>
|
||||
<th scope="col">Status</th>
|
||||
@@ -892,6 +956,32 @@
|
||||
</td>
|
||||
<td>{{ entry[1].name }}</td>
|
||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||
<td>
|
||||
@if (editingTitleRegexId === entry[0]) {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm flex-grow-1"
|
||||
[name]="'subTitleRegex' + entry[0]"
|
||||
[(ngModel)]="titleRegexEditDraft"
|
||||
[disabled]="downloads.loading" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="saveTitleRegex(entry[0])"
|
||||
[disabled]="downloads.loading">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
(click)="cancelEditTitleRegex()"
|
||||
[disabled]="downloads.loading">Cancel</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<span class="text-muted small text-break"
|
||||
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0"
|
||||
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
|
||||
[disabled]="downloads.loading"
|
||||
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ entry[1].check_interval_minutes }}</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].last_checked !== null) {
|
||||
|
||||
+69
-1
@@ -63,8 +63,10 @@ class DownloadsServiceStub {
|
||||
class SubscriptionsServiceStub {
|
||||
subscriptions = new Map();
|
||||
subscriptionsChanged = new Subject<void>();
|
||||
subscribeCalls: unknown[] = [];
|
||||
|
||||
subscribe() {
|
||||
subscribe(payload: unknown) {
|
||||
this.subscribeCalls.push(payload);
|
||||
return of({ status: 'ok' as const });
|
||||
}
|
||||
|
||||
@@ -72,6 +74,10 @@ class SubscriptionsServiceStub {
|
||||
return of({});
|
||||
}
|
||||
|
||||
update() {
|
||||
return of({ status: 'ok' as const });
|
||||
}
|
||||
|
||||
refreshList() {
|
||||
return of([]);
|
||||
}
|
||||
@@ -175,4 +181,66 @@ describe('App', () => {
|
||||
|
||||
expect(payload.ytdlOptionsOverrides).toBe('');
|
||||
});
|
||||
|
||||
it('includes titleRegex in subscribe payload', () => {
|
||||
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.titleRegex = 'EPISODE';
|
||||
app.addSubscription();
|
||||
expect(subs.subscribeCalls.length).toBe(1);
|
||||
const payload = subs.subscribeCalls[0] as { titleRegex: string; skipSubscriberOnly: boolean };
|
||||
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', () => {
|
||||
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.clipStart = '1:00';
|
||||
app.clipEnd = '2:00';
|
||||
app.addSubscription();
|
||||
expect(subs.subscribeCalls.length).toBe(1);
|
||||
const payload = subs.subscribeCalls[0] as Record<string, unknown>;
|
||||
expect('clipStart' in payload).toBe(false);
|
||||
expect('clipEnd' in payload).toBe(false);
|
||||
});
|
||||
|
||||
it('buildAddPayload includes clip times', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
app.clipStart = '0:10';
|
||||
app.clipEnd = '1:20';
|
||||
const payload = app['buildAddPayload']();
|
||||
expect(payload.clipStart).toBe('0:10');
|
||||
expect(payload.clipEnd).toBe('1:20');
|
||||
});
|
||||
|
||||
it('blocks subscribe with invalid title regex', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
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.titleRegex = '[';
|
||||
app.addSubscription();
|
||||
expect(subs.subscribeCalls.length).toBe(0);
|
||||
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+133
-40
@@ -1,7 +1,7 @@
|
||||
import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Observable, Subscription, map, distinctUntilChanged, finalize } from 'rxjs';
|
||||
import { Observable, Subject, Subscription, from, map, distinctUntilChanged, finalize, mergeMap, takeUntil, tap } from 'rxjs';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
@@ -81,6 +81,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
playlistItemLimit!: number;
|
||||
splitByChapters: boolean;
|
||||
chapterTemplate: string;
|
||||
clipStart = '';
|
||||
clipEnd = '';
|
||||
subtitleLanguage: string;
|
||||
subtitleMode: string;
|
||||
ytdlOptionsPresets: string[] = [];
|
||||
@@ -90,6 +92,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
cancelRequested = false;
|
||||
subscribeInProgress = false;
|
||||
checkIntervalMinutes = 60;
|
||||
titleRegex = '';
|
||||
skipSubscriberOnly = false;
|
||||
editingTitleRegexId: string | null = null;
|
||||
titleRegexEditDraft = '';
|
||||
cachedSubs: [string, SubscriptionRow][] = [];
|
||||
selectedSubscriptionIds = new Set<string>();
|
||||
checkingSubscriptionIds = new Set<string>();
|
||||
@@ -104,8 +110,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
batchImportModalOpen = false;
|
||||
batchImportText = '';
|
||||
batchImportStatus = '';
|
||||
batchImportCount = 0;
|
||||
batchImportTotal = 0;
|
||||
batchImportFailures = 0;
|
||||
importInProgress = false;
|
||||
cancelImportFlag = false;
|
||||
private batchImportCancel$ = new Subject<void>();
|
||||
// Maximum number of /add requests to have in-flight at once during a batch
|
||||
// import. Keeps the server from being hit with hundreds of simultaneous
|
||||
// yt-dlp metadata extractions when a user pastes a huge URL list.
|
||||
private static readonly BATCH_IMPORT_CONCURRENCY = 4;
|
||||
ytDlpOptionsUpdateTime: string | null = null;
|
||||
ytDlpVersion: string | null = null;
|
||||
metubeVersion: string | null = null;
|
||||
@@ -232,6 +245,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
||||
// Will be set from backend configuration, use empty string as placeholder
|
||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||
this.clipStart = this.cookieService.get('metube_clip_start') || '';
|
||||
this.clipEnd = this.cookieService.get('metube_clip_end') || '';
|
||||
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
|
||||
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
|
||||
this.ytdlOptionsPresets = this.loadYtdlOptionsPresetsFromCookie();
|
||||
@@ -553,6 +568,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
alert('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
const tr = (this.titleRegex || '').trim();
|
||||
if (tr) {
|
||||
try {
|
||||
void RegExp(tr);
|
||||
} catch {
|
||||
alert('Invalid subscription title filter (regex)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
||||
alert('Chapter template must include %(section_number)');
|
||||
return;
|
||||
@@ -560,11 +584,17 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||
return;
|
||||
}
|
||||
// Subscriptions do not support clip ranges (backend rejects clip fields).
|
||||
const { clipStart: _clipStart, clipEnd: _clipEnd, ...subscribeBase } = payload;
|
||||
void _clipStart;
|
||||
void _clipEnd;
|
||||
this.subscribeInProgress = true;
|
||||
this.subscriptionsSvc
|
||||
.subscribe({
|
||||
...payload,
|
||||
...subscribeBase,
|
||||
checkIntervalMinutes: this.checkIntervalMinutes,
|
||||
titleRegex: tr,
|
||||
skipSubscriberOnly: this.skipSubscriberOnly,
|
||||
})
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
@@ -580,11 +610,45 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
alert(r.msg || 'Subscribe failed');
|
||||
} else {
|
||||
this.addUrl = '';
|
||||
this.titleRegex = '';
|
||||
this.skipSubscriberOnly = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beginEditTitleRegex(id: string, current: string | undefined) {
|
||||
this.editingTitleRegexId = id;
|
||||
this.titleRegexEditDraft = current ?? '';
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
cancelEditTitleRegex() {
|
||||
this.editingTitleRegexId = null;
|
||||
this.titleRegexEditDraft = '';
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
saveTitleRegex(id: string) {
|
||||
const raw = (this.titleRegexEditDraft || '').trim();
|
||||
if (raw) {
|
||||
try {
|
||||
void RegExp(raw);
|
||||
} catch {
|
||||
alert('Invalid subscription title filter (regex)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Update subscription failed');
|
||||
return;
|
||||
}
|
||||
this.cancelEditTitleRegex();
|
||||
});
|
||||
}
|
||||
|
||||
deleteSubscription(id: string) {
|
||||
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
@@ -754,6 +818,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
clipStartChanged() {
|
||||
this.cookieService.set('metube_clip_start', this.clipStart, { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
clipEndChanged() {
|
||||
this.cookieService.set('metube_clip_end', this.clipEnd, { expires: this.settingsCookieExpiryDays });
|
||||
}
|
||||
|
||||
subtitleLanguageChanged() {
|
||||
this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: this.settingsCookieExpiryDays });
|
||||
this.saveSelection(this.downloadType);
|
||||
@@ -980,6 +1052,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
ytdlOptionsOverrides: allowYtdlOptionsOverrides
|
||||
? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides)
|
||||
: '',
|
||||
clipStart: overrides.clipStart ?? this.clipStart,
|
||||
clipEnd: overrides.clipEnd ?? this.clipEnd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1053,6 +1127,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
? [...download.ytdl_options_presets]
|
||||
: [],
|
||||
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
|
||||
clipStart: download.clip_start != null ? String(download.clip_start) : '',
|
||||
clipEnd: download.clip_end != null ? String(download.clip_end) : '',
|
||||
});
|
||||
this.downloads.delById('done', [key]).subscribe();
|
||||
}
|
||||
@@ -1173,8 +1249,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.batchImportModalOpen = true;
|
||||
this.batchImportText = '';
|
||||
this.batchImportStatus = '';
|
||||
this.batchImportCount = 0;
|
||||
this.batchImportTotal = 0;
|
||||
this.batchImportFailures = 0;
|
||||
this.importInProgress = false;
|
||||
this.cancelImportFlag = false;
|
||||
setTimeout(() => {
|
||||
const textarea = document.getElementById('batch-import-textarea');
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
@@ -1200,48 +1278,63 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.importInProgress = true;
|
||||
this.cancelImportFlag = false;
|
||||
this.batchImportStatus = `Starting to import ${urls.length} URLs...`;
|
||||
let index = 0;
|
||||
const delayBetween = 1000;
|
||||
const processNext = () => {
|
||||
if (this.cancelImportFlag) {
|
||||
this.batchImportStatus = `Import cancelled after ${index} of ${urls.length} URLs.`;
|
||||
this.importInProgress = false;
|
||||
return;
|
||||
}
|
||||
if (index >= urls.length) {
|
||||
this.batchImportStatus = `Finished importing ${urls.length} URLs.`;
|
||||
this.importInProgress = false;
|
||||
return;
|
||||
}
|
||||
const url = urls[index];
|
||||
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
||||
// Pass current selection options to backend
|
||||
this.downloads.add(this.buildAddPayload({ url }))
|
||||
.subscribe({
|
||||
next: (status: Status) => {
|
||||
this.batchImportCount = 0;
|
||||
this.batchImportFailures = 0;
|
||||
this.batchImportTotal = urls.length;
|
||||
this.updateBatchImportStatus();
|
||||
|
||||
from(urls).pipe(
|
||||
mergeMap(
|
||||
url => this.downloads.add(this.buildAddPayload({ url })).pipe(
|
||||
// downloads.add() already catches HTTP errors and emits a single
|
||||
// Status value, so `tap` (not `finalize`) is the right place to
|
||||
// count. This avoids incrementing the counter when an in-flight
|
||||
// request is aborted by cancellation.
|
||||
tap((status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
alert(`Error adding URL ${url}: ${status.msg}`);
|
||||
this.batchImportFailures++;
|
||||
console.error(`Error adding URL ${url}: ${status.msg}`);
|
||||
}
|
||||
index++;
|
||||
setTimeout(processNext, delayBetween);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error importing URL ${url}:`, err);
|
||||
index++;
|
||||
setTimeout(processNext, delayBetween);
|
||||
}
|
||||
});
|
||||
};
|
||||
processNext();
|
||||
this.batchImportCount++;
|
||||
this.updateBatchImportStatus();
|
||||
this.cdr.markForCheck();
|
||||
}),
|
||||
),
|
||||
App.BATCH_IMPORT_CONCURRENCY,
|
||||
),
|
||||
takeUntil(this.batchImportCancel$),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => {
|
||||
this.importInProgress = false;
|
||||
this.updateBatchImportStatus(true);
|
||||
this.cdr.markForCheck();
|
||||
}),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
// Cancel the batch import process
|
||||
private updateBatchImportStatus(done = false): void {
|
||||
const parts: string[] = [];
|
||||
if (done) {
|
||||
const processed = this.batchImportCount;
|
||||
if (processed < this.batchImportTotal) {
|
||||
parts.push(`Import cancelled after ${processed} of ${this.batchImportTotal} URLs.`);
|
||||
} else {
|
||||
parts.push(`Finished importing ${this.batchImportTotal} URLs.`);
|
||||
}
|
||||
} else {
|
||||
parts.push(`Importing ${this.batchImportCount} of ${this.batchImportTotal} URLs...`);
|
||||
}
|
||||
if (this.batchImportFailures > 0) {
|
||||
parts.push(`${this.batchImportFailures} failed.`);
|
||||
}
|
||||
this.batchImportStatus = parts.join(' ');
|
||||
}
|
||||
|
||||
// Cancel the batch import process: aborts in-flight and pending requests
|
||||
// immediately via the cancellation Subject wired into the pipeline.
|
||||
cancelBatchImport(): void {
|
||||
if (this.importInProgress) {
|
||||
this.cancelImportFlag = true;
|
||||
this.batchImportStatus += ' Cancelling...';
|
||||
this.batchImportCancel$.next();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Download {
|
||||
subtitle_mode?: string;
|
||||
ytdl_options_presets?: string[];
|
||||
ytdl_options_overrides?: Record<string, unknown>;
|
||||
clip_start?: number;
|
||||
clip_end?: number;
|
||||
status: string;
|
||||
msg: string;
|
||||
percent: number;
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface SubscriptionRow {
|
||||
format: string;
|
||||
quality: string;
|
||||
folder: string;
|
||||
title_regex?: string;
|
||||
skip_subscriber_only?: boolean;
|
||||
last_checked: number | null;
|
||||
seen_count: number;
|
||||
error: string | null;
|
||||
|
||||
@@ -41,6 +41,8 @@ function basePayload(): AddDownloadPayload {
|
||||
subtitleMode: 'prefer_manual',
|
||||
ytdlOptionsPresets: [],
|
||||
ytdlOptionsOverrides: '',
|
||||
clipStart: '',
|
||||
clipEnd: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +90,24 @@ describe('DownloadsService', () => {
|
||||
req.flush({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('add() sends clip_start and clip_end when set', () => {
|
||||
service
|
||||
.add({
|
||||
...basePayload(),
|
||||
clipStart: '1:00',
|
||||
clipEnd: '2:00',
|
||||
})
|
||||
.subscribe();
|
||||
const req = httpMock.expectOne('add');
|
||||
expect(req.request.body).toEqual(
|
||||
expect.objectContaining({
|
||||
clip_start: '1:00',
|
||||
clip_end: '2:00',
|
||||
}),
|
||||
);
|
||||
req.flush({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('getPresets() fetches configured preset names', () => {
|
||||
service.getPresets().subscribe((result) => {
|
||||
expect(result).toEqual({ presets: ['Preset A'] });
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface AddDownloadPayload {
|
||||
subtitleMode: string;
|
||||
ytdlOptionsPresets: string[];
|
||||
ytdlOptionsOverrides: string;
|
||||
clipStart?: string;
|
||||
clipEnd?: string;
|
||||
}
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -129,7 +131,7 @@ export class DownloadsService {
|
||||
}
|
||||
|
||||
public add(payload: AddDownloadPayload) {
|
||||
return this.http.post<Status>('add', {
|
||||
const body: Record<string, unknown> = {
|
||||
url: payload.url,
|
||||
download_type: payload.downloadType,
|
||||
codec: payload.codec,
|
||||
@@ -145,7 +147,12 @@ export class DownloadsService {
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
ytdl_options_presets: payload.ytdlOptionsPresets,
|
||||
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||
}).pipe(
|
||||
};
|
||||
const cs = payload.clipStart?.trim();
|
||||
const ce = payload.clipEnd?.trim();
|
||||
if (cs) body['clip_start'] = cs;
|
||||
if (ce) body['clip_end'] = ce;
|
||||
return this.http.post<Status>('add', body).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { AddDownloadPayload } from './downloads.service';
|
||||
|
||||
export interface SubscribePayload extends AddDownloadPayload {
|
||||
checkIntervalMinutes: number;
|
||||
titleRegex: string;
|
||||
skipSubscriberOnly: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
@@ -97,6 +99,8 @@ export class SubscriptionsService {
|
||||
ytdl_options_presets: payload.ytdlOptionsPresets,
|
||||
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||
check_interval_minutes: payload.checkIntervalMinutes,
|
||||
title_regex: payload.titleRegex,
|
||||
skip_subscriber_only: payload.skipSubscriberOnly,
|
||||
})
|
||||
.pipe(catchError((err) => this.handleHTTPError(err)));
|
||||
}
|
||||
@@ -105,7 +109,15 @@ export class SubscriptionsService {
|
||||
return this.http.post('subscriptions/delete', { ids }).pipe(catchError((err) => this.handleHTTPError(err)));
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<Pick<SubscriptionRow, 'enabled' | 'check_interval_minutes' | 'name'>>) {
|
||||
update(
|
||||
id: string,
|
||||
changes: Partial<
|
||||
Pick<
|
||||
SubscriptionRow,
|
||||
'enabled' | 'check_interval_minutes' | 'name' | 'title_regex' | 'skip_subscriber_only'
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return this.http
|
||||
.post('subscriptions/update', { id, ...changes })
|
||||
.pipe(catchError((err) => this.handleHTTPError(err)));
|
||||
|
||||
Reference in New Issue
Block a user