diff --git a/app/main.py b/app/main.py index e566bab..889ee7e 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ 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 @@ -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,8 @@ 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') result = await submgr.add_subscription( o['url'], diff --git a/app/tests/test_api.py b/app/tests/test_api.py index e7fb8ae..9776856 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -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() diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py index 71632c8..20bf863 100644 --- a/app/tests/test_download_queue.py +++ b/app/tests/test_download_queue.py @@ -351,3 +351,38 @@ async def test_extract_info_metube_extract_keys_win_over_preset(dq_env): 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 diff --git a/app/tests/test_main_helpers.py b/app/tests/test_main_helpers.py index 5b9fb2c..d39e54b 100644 --- a/app/tests/test_main_helpers.py +++ b/app/tests/test_main_helpers.py @@ -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() diff --git a/app/ytdl.py b/app/ytdl.py index d6df963..d928c0f 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -192,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}' @@ -216,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): @@ -284,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 = ( @@ -303,6 +311,8 @@ _PERSISTED_DOWNLOAD_FIELDS = ( "subtitle_mode", "ytdl_options_presets", "ytdl_options_overrides", + "clip_start", + "clip_end", "status", "timestamp", "error", @@ -473,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}") @@ -890,6 +910,8 @@ class DownloadQueue: subtitle_mode, ytdl_options_presets, ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen=None, ): @@ -924,6 +946,8 @@ class DownloadQueue: subtitle_mode, ytdl_options_presets, ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen, ) @@ -975,6 +999,8 @@ class DownloadQueue: subtitle_mode, ytdl_options_presets, ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen, ) @@ -1008,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'} @@ -1030,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, ): @@ -1038,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 @@ -1072,6 +1102,8 @@ class DownloadQueue: subtitle_mode, ytdl_options_presets, ytdl_options_overrides, + clip_start, + clip_end, already, _add_gen, ) @@ -1093,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 = [] @@ -1114,6 +1148,8 @@ class DownloadQueue: subtitle_mode, ytdl_options_presets, ytdl_options_overrides, + clip_start, + clip_end, already, None, ) diff --git a/ui/src/app/app.html b/ui/src/app/app.html index cf4986c..06d6476 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -428,6 +428,34 @@ } + @if (downloadType === 'video' || downloadType === 'audio') { +