Compare commits

..

4 Commits

Author SHA1 Message Date
Alex Shnitman 5d96a581b9 allow filtering out members-only videos in subscriptions (closes #971) 2026-04-28 22:02:05 +03:00
Alex Shnitman 4f83174d05 implement time-clipped downloads (closes #969, replaces #907) 2026-04-26 23:07:50 +03:00
Alex Shnitman 91ee8312bf title filter for subscriptions (closes #968) 2026-04-26 22:51:48 +03:00
dependabot[bot] d89a5ddbe5 Bump aquasecurity/trivy-action in the github-actions group
Bumps the github-actions group with 1 update: [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action).


Updates `aquasecurity/trivy-action` from 0.35.0 to 0.36.0
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 16:12:39 +00:00
16 changed files with 1233 additions and 15 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Run backend tests - name: Run backend tests
run: uv run pytest app/tests/ run: uv run pytest app/tests/
- name: Run Trivy filesystem scan - name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@0.35.0 uses: aquasecurity/trivy-action@v0.36.0
with: with:
scan-type: fs scan-type: fs
scan-ref: . scan-ref: .
+167 -2
View File
@@ -14,10 +14,11 @@ import logging
import json import json
import pathlib import pathlib
import re import re
from urllib.parse import parse_qs, 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')
@@ -260,6 +261,115 @@ def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict:
return value 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]: def _parse_ytdl_options_presets(post: dict) -> list[str]:
"""Normalize preset names from add/subscribe body; supports list or legacy singular string.""" """Normalize preset names from add/subscribe body; supports list or legacy singular string."""
raw = post.get('ytdl_options_presets') raw = post.get('ytdl_options_presets')
@@ -542,6 +652,39 @@ def parse_download_options(post: dict) -> dict:
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from 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 { return {
'url': url, 'url': url,
'download_type': download_type, 'download_type': download_type,
@@ -558,6 +701,8 @@ def parse_download_options(post: dict) -> dict:
'subtitle_mode': subtitle_mode, 'subtitle_mode': subtitle_mode,
'ytdl_options_presets': ytdl_options_presets, 'ytdl_options_presets': ytdl_options_presets,
'ytdl_options_overrides': ytdl_options_overrides, '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['subtitle_mode'],
o['ytdl_options_presets'], o['ytdl_options_presets'],
o['ytdl_options_overrides'], o['ytdl_options_overrides'],
o['clip_start'],
o['clip_end'],
) )
return web.Response(text=serializer.encode(status)) 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 raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
if cic < 1: if cic < 1:
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 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( result = await submgr.add_subscription(
o['url'], o['url'],
@@ -645,6 +803,8 @@ async def subscribe(request):
subtitle_mode=o['subtitle_mode'], subtitle_mode=o['subtitle_mode'],
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'),
skip_subscriber_only=skip_subscriber_only,
) )
return web.Response(text=serializer.encode(result)) return web.Response(text=serializer.encode(result))
@@ -660,7 +820,12 @@ async def subscriptions_update(request):
sub_id = post.get('id') sub_id = post.get('id')
if not sub_id: if not sub_id:
raise web.HTTPBadRequest(reason='missing subscription 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: if not changes:
raise web.HTTPBadRequest(reason='no valid fields to update') raise web.HTTPBadRequest(reason='no valid fields to update')
log.info("Subscription update requested for %s: %s", sub_id, sorted(changes.keys())) log.info("Subscription update requested for %s: %s", sub_id, sorted(changes.keys()))
+122 -5
View File
@@ -6,6 +6,7 @@ import asyncio
import copy import copy
import logging import logging
import os import os
import re
import time import time
import types import types
import uuid import uuid
@@ -126,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
@@ -147,6 +163,8 @@ class SubscriptionInfo:
subtitle_mode: str = "prefer_manual" subtitle_mode: str = "prefer_manual"
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 = ""
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
@@ -167,6 +185,8 @@ class SubscriptionInfo:
"format": self.format, "format": self.format,
"quality": self.quality, "quality": self.quality,
"folder": self.folder, "folder": self.folder,
"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,
@@ -194,6 +214,8 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
"subtitle_mode": sub.subtitle_mode, "subtitle_mode": sub.subtitle_mode,
"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,
"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,
@@ -231,6 +253,22 @@ def _subscription_from_record(record: Any) -> Optional[SubscriptionInfo]:
return None 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: def _coerce_bool(value: Any) -> bool:
"""Accept JSON booleans and common string forms used by API clients.""" """Accept JSON booleans and common string forms used by API clients."""
if isinstance(value, bool): if isinstance(value, bool):
@@ -448,10 +486,24 @@ class SubscriptionManager:
subtitle_mode: str, subtitle_mode: str,
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,
skip_subscriber_only: Any = None,
) -> dict: ) -> dict:
url = self._normalize_url(url) url = self._normalize_url(url)
if not url: if not url:
return {"status": "error", "msg": "Missing 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: 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:
@@ -509,6 +561,8 @@ class SubscriptionManager:
subtitle_mode=subtitle_mode, subtitle_mode=subtitle_mode,
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,
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,
@@ -555,6 +609,25 @@ class SubscriptionManager:
return {"status": "ok"} return {"status": "ok"}
async def update_subscription(self, sub_id: str, changes: dict) -> dict: 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: async with self._lock:
sub = self._subs.get(sub_id) sub = self._subs.get(sub_id)
if not sub: if not sub:
@@ -568,6 +641,10 @@ class SubscriptionManager:
sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"])) sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"]))
if "name" in changes and changes["name"]: if "name" in changes and changes["name"]:
sub.name = str(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: try:
self._save_locked() self._save_locked()
@@ -659,9 +736,10 @@ class SubscriptionManager:
dl_submode = cur.subtitle_mode dl_submode = cur.subtitle_mode
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_skip_subscriber_only = bool(cur.skip_subscriber_only)
new_entries: list[dict] = [] new_entries: list[dict] = []
new_ids: list[str] = []
for ent in entries: for ent in entries:
eid = _entry_id(ent) eid = _entry_id(ent)
if not eid: if not eid:
@@ -669,10 +747,43 @@ class SubscriptionManager:
if eid in seen and ent.get("live_status") != "is_live": if eid in seen and ent.get("live_status") != "is_live":
continue continue
new_entries.append(ent) 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( queued_ids, queue_errors = await self._queue_subscription_entries(
new_entries, queue_entries,
download_type=dl_type, download_type=dl_type,
codec=dl_codec, codec=dl_codec,
format=dl_format, format=dl_format,
@@ -689,14 +800,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 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(subscriber_filtered_ids),
len(queued_ids), len(queued_ids),
len(queue_errors), 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)) 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]
+27
View File
@@ -279,3 +279,30 @@ async def test_add_legacy_format_migrated(mock_dqueue):
call = mock_dqueue.add.await_args call = mock_dqueue.add.await_args
assert call is not None assert call is not None
assert call.args[1] == "audio" 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()
+35
View File
@@ -351,3 +351,38 @@ async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
assert result["status"] == "ok" assert result["status"] == "ok"
assert captured_params[0]["extract_flat"] is True assert captured_params[0]["extract_flat"] is True
assert captured_params[0]["noplaylist"] 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
+74
View File
@@ -205,6 +205,80 @@ class ParseDownloadOptionsTests(unittest.TestCase):
finally: finally:
main.config.YTDL_OPTIONS_PRESETS = previous 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+508 -1
View File
@@ -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()
@@ -453,6 +574,392 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
await mgr.update_subscription(sub_id, {"enabled": "maybe"}) 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): class ExtractFlatPlaylistTests(unittest.TestCase):
def test_descends_one_level_when_root_entries_are_nested_collections(self): def test_descends_one_level_when_root_entries_are_nested_collections(self):
responses = iter( responses = iter(
+37 -1
View File
@@ -192,6 +192,8 @@ class DownloadInfo:
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
ytdl_options_presets=None, ytdl_options_presets=None,
ytdl_options_overrides=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.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}'
@@ -216,6 +218,8 @@ class DownloadInfo:
self.subtitle_mode = subtitle_mode self.subtitle_mode = subtitle_mode
self.ytdl_options_presets = list(ytdl_options_presets or []) self.ytdl_options_presets = list(ytdl_options_presets or [])
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_end = clip_end
self.subtitle_files = [] self.subtitle_files = []
def __setstate__(self, state): def __setstate__(self, state):
@@ -284,6 +288,10 @@ class DownloadInfo:
self.subtitle_files = [] self.subtitle_files = []
if not hasattr(self, "chapter_files"): if not hasattr(self, "chapter_files"):
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 = ( _PERSISTED_DOWNLOAD_FIELDS = (
@@ -303,6 +311,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"subtitle_mode", "subtitle_mode",
"ytdl_options_presets", "ytdl_options_presets",
"ytdl_options_overrides", "ytdl_options_overrides",
"clip_start",
"clip_end",
"status", "status",
"timestamp", "timestamp",
"error", "error",
@@ -473,6 +483,16 @@ class Download:
'force_keyframes': False '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]) ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'}) self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
log.info(f"Finished download for: {self.info.title}") log.info(f"Finished download for: {self.info.title}")
@@ -890,6 +910,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen=None, _add_gen=None,
): ):
@@ -924,6 +946,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen, _add_gen,
) )
@@ -975,6 +999,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen, _add_gen,
) )
@@ -1008,6 +1034,8 @@ class DownloadQueue:
subtitle_mode=subtitle_mode, subtitle_mode=subtitle_mode,
ytdl_options_presets=ytdl_options_presets, ytdl_options_presets=ytdl_options_presets,
ytdl_options_overrides=ytdl_options_overrides, ytdl_options_overrides=ytdl_options_overrides,
clip_start=clip_start,
clip_end=clip_end,
) )
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
@@ -1030,6 +1058,8 @@ class DownloadQueue:
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
ytdl_options_presets=None, ytdl_options_presets=None,
ytdl_options_overrides=None, ytdl_options_overrides=None,
clip_start=None,
clip_end=None,
already=None, already=None,
_add_gen=None, _add_gen=None,
): ):
@@ -1038,7 +1068,7 @@ class DownloadQueue:
log.info( log.info(
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} ' 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'{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: if already is None:
_add_gen = self._add_generation _add_gen = self._add_generation
@@ -1072,6 +1102,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen, _add_gen,
) )
@@ -1093,6 +1125,8 @@ class DownloadQueue:
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
ytdl_options_presets=None, ytdl_options_presets=None,
ytdl_options_overrides=None, ytdl_options_overrides=None,
clip_start=None,
clip_end=None,
): ):
if ytdl_options_presets is None: if ytdl_options_presets is None:
ytdl_options_presets = [] ytdl_options_presets = []
@@ -1114,6 +1148,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
None, None,
) )
+77
View File
@@ -428,6 +428,34 @@
} }
</div> </div>
</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> </div>
<!-- Behavior --> <!-- Behavior -->
@@ -475,6 +503,27 @@
ngbTooltip="How often to poll subscriptions for new videos"> ngbTooltip="How often to poll subscriptions for new videos">
</div> </div>
</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> </div>
<!-- yt-dlp --> <!-- yt-dlp -->
@@ -887,6 +936,8 @@
</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"
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>
@@ -905,6 +956,32 @@
</td> </td>
<td>{{ entry[1].name }}</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 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>{{ entry[1].check_interval_minutes }}</td>
<td class="text-nowrap"> <td class="text-nowrap">
@if (entry[1].last_checked !== null) { @if (entry[1].last_checked !== null) {
+69 -1
View File
@@ -63,8 +63,10 @@ class DownloadsServiceStub {
class SubscriptionsServiceStub { class SubscriptionsServiceStub {
subscriptions = new Map(); subscriptions = new Map();
subscriptionsChanged = new Subject<void>(); subscriptionsChanged = new Subject<void>();
subscribeCalls: unknown[] = [];
subscribe() { subscribe(payload: unknown) {
this.subscribeCalls.push(payload);
return of({ status: 'ok' as const }); return of({ status: 'ok' as const });
} }
@@ -72,6 +74,10 @@ class SubscriptionsServiceStub {
return of({}); return of({});
} }
update() {
return of({ status: 'ok' as const });
}
refreshList() { refreshList() {
return of([]); return of([]);
} }
@@ -175,4 +181,66 @@ describe('App', () => {
expect(payload.ytdlOptionsOverrides).toBe(''); 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();
});
}); });
+70 -1
View File
@@ -81,6 +81,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
playlistItemLimit!: number; playlistItemLimit!: number;
splitByChapters: boolean; splitByChapters: boolean;
chapterTemplate: string; chapterTemplate: string;
clipStart = '';
clipEnd = '';
subtitleLanguage: string; subtitleLanguage: string;
subtitleMode: string; subtitleMode: string;
ytdlOptionsPresets: string[] = []; ytdlOptionsPresets: string[] = [];
@@ -90,6 +92,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
cancelRequested = false; cancelRequested = false;
subscribeInProgress = false; subscribeInProgress = false;
checkIntervalMinutes = 60; checkIntervalMinutes = 60;
titleRegex = '';
skipSubscriberOnly = false;
editingTitleRegexId: string | null = null;
titleRegexEditDraft = '';
cachedSubs: [string, SubscriptionRow][] = []; cachedSubs: [string, SubscriptionRow][] = [];
selectedSubscriptionIds = new Set<string>(); selectedSubscriptionIds = new Set<string>();
checkingSubscriptionIds = new Set<string>(); checkingSubscriptionIds = new Set<string>();
@@ -239,6 +245,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
// Will be set from backend configuration, use empty string as placeholder // Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; 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.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
this.ytdlOptionsPresets = this.loadYtdlOptionsPresetsFromCookie(); this.ytdlOptionsPresets = this.loadYtdlOptionsPresetsFromCookie();
@@ -560,6 +568,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert('Please enter a URL'); alert('Please enter a URL');
return; 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)')) { if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)'); alert('Chapter template must include %(section_number)');
return; return;
@@ -567,11 +584,17 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) { if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return; 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.subscribeInProgress = true;
this.subscriptionsSvc this.subscriptionsSvc
.subscribe({ .subscribe({
...payload, ...subscribeBase,
checkIntervalMinutes: this.checkIntervalMinutes, checkIntervalMinutes: this.checkIntervalMinutes,
titleRegex: tr,
skipSubscriberOnly: this.skipSubscriberOnly,
}) })
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
@@ -587,11 +610,45 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert(r.msg || 'Subscribe failed'); alert(r.msg || 'Subscribe failed');
} else { } else {
this.addUrl = ''; 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) { deleteSubscription(id: string) {
this.subscriptionsSvc.delete([id]).subscribe((res) => { this.subscriptionsSvc.delete([id]).subscribe((res) => {
const error = this.getStatusError(res); const error = this.getStatusError(res);
@@ -761,6 +818,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: this.settingsCookieExpiryDays }); 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() { subtitleLanguageChanged() {
this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: this.settingsCookieExpiryDays }); this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: this.settingsCookieExpiryDays });
this.saveSelection(this.downloadType); this.saveSelection(this.downloadType);
@@ -987,6 +1052,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
ytdlOptionsOverrides: allowYtdlOptionsOverrides ytdlOptionsOverrides: allowYtdlOptionsOverrides
? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides) ? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides)
: '', : '',
clipStart: overrides.clipStart ?? this.clipStart,
clipEnd: overrides.clipEnd ?? this.clipEnd,
}; };
} }
@@ -1060,6 +1127,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
? [...download.ytdl_options_presets] ? [...download.ytdl_options_presets]
: [], : [],
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '', 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(); this.downloads.delById('done', [key]).subscribe();
} }
+2
View File
@@ -16,6 +16,8 @@ export interface Download {
subtitle_mode?: string; subtitle_mode?: string;
ytdl_options_presets?: string[]; ytdl_options_presets?: string[];
ytdl_options_overrides?: Record<string, unknown>; ytdl_options_overrides?: Record<string, unknown>;
clip_start?: number;
clip_end?: number;
status: string; status: string;
msg: string; msg: string;
percent: number; percent: number;
+2
View File
@@ -9,6 +9,8 @@ export interface SubscriptionRow {
format: string; format: string;
quality: string; quality: string;
folder: string; folder: 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;
@@ -41,6 +41,8 @@ function basePayload(): AddDownloadPayload {
subtitleMode: 'prefer_manual', subtitleMode: 'prefer_manual',
ytdlOptionsPresets: [], ytdlOptionsPresets: [],
ytdlOptionsOverrides: '', ytdlOptionsOverrides: '',
clipStart: '',
clipEnd: '',
}; };
} }
@@ -88,6 +90,24 @@ describe('DownloadsService', () => {
req.flush({ status: 'ok' }); 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', () => { it('getPresets() fetches configured preset names', () => {
service.getPresets().subscribe((result) => { service.getPresets().subscribe((result) => {
expect(result).toEqual({ presets: ['Preset A'] }); expect(result).toEqual({ presets: ['Preset A'] });
+9 -2
View File
@@ -22,6 +22,8 @@ export interface AddDownloadPayload {
subtitleMode: string; subtitleMode: string;
ytdlOptionsPresets: string[]; ytdlOptionsPresets: string[];
ytdlOptionsOverrides: string; ytdlOptionsOverrides: string;
clipStart?: string;
clipEnd?: string;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -129,7 +131,7 @@ export class DownloadsService {
} }
public add(payload: AddDownloadPayload) { public add(payload: AddDownloadPayload) {
return this.http.post<Status>('add', { const body: Record<string, unknown> = {
url: payload.url, url: payload.url,
download_type: payload.downloadType, download_type: payload.downloadType,
codec: payload.codec, codec: payload.codec,
@@ -145,7 +147,12 @@ export class DownloadsService {
subtitle_mode: payload.subtitleMode, subtitle_mode: payload.subtitleMode,
ytdl_options_presets: payload.ytdlOptionsPresets, ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides, 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) catchError(this.handleHTTPError)
); );
} }
+13 -1
View File
@@ -10,6 +10,8 @@ import { AddDownloadPayload } from './downloads.service';
export interface SubscribePayload extends AddDownloadPayload { export interface SubscribePayload extends AddDownloadPayload {
checkIntervalMinutes: number; checkIntervalMinutes: number;
titleRegex: string;
skipSubscriberOnly: boolean;
} }
@Injectable({ @Injectable({
@@ -97,6 +99,8 @@ export class SubscriptionsService {
ytdl_options_presets: payload.ytdlOptionsPresets, ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides, ytdl_options_overrides: payload.ytdlOptionsOverrides,
check_interval_minutes: payload.checkIntervalMinutes, check_interval_minutes: payload.checkIntervalMinutes,
title_regex: payload.titleRegex,
skip_subscriber_only: payload.skipSubscriberOnly,
}) })
.pipe(catchError((err) => this.handleHTTPError(err))); .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))); 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 return this.http
.post('subscriptions/update', { id, ...changes }) .post('subscriptions/update', { id, ...changes })
.pipe(catchError((err) => this.handleHTTPError(err))); .pipe(catchError((err) => this.handleHTTPError(err)));