diff --git a/app/main.py b/app/main.py index a94cbd6..36620e4 100644 --- a/app/main.py +++ b/app/main.py @@ -725,6 +725,7 @@ def parse_download_options(post: dict) -> dict: playlist_item_limit = post.get('playlist_item_limit') auto_start = post.get('auto_start') split_by_chapters = post.get('split_by_chapters') + sponsorblock = bool(post.get('sponsorblock')) chapter_template = post.get('chapter_template') subtitle_language = post.get('subtitle_language') subtitle_mode = post.get('subtitle_mode') @@ -845,6 +846,7 @@ def parse_download_options(post: dict) -> dict: 'playlist_item_limit': playlist_item_limit, 'auto_start': auto_start, 'split_by_chapters': split_by_chapters, + 'sponsorblock': sponsorblock, 'chapter_template': chapter_template, 'subtitle_language': subtitle_language, 'subtitle_mode': subtitle_mode, @@ -890,6 +892,7 @@ async def add(request): o['ytdl_options_overrides'], o['clip_start'], o['clip_end'], + sponsorblock=o['sponsorblock'], ) return web.Response(text=serializer.encode(status)) diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py index 8c85be2..66b5a0d 100644 --- a/app/tests/test_download_queue.py +++ b/app/tests/test_download_queue.py @@ -476,6 +476,32 @@ async def test_retry_keeps_overrides_while_still_allowed(dq_env): assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True} +@pytest.mark.asyncio +async def test_retry_carries_the_sponsorblock_flag(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + url = "https://example.com/watch?v=1" + dq.done.put( + Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url, sponsorblock=True)) + ) + + def fake_extract(self, extracted_url, *_args, **_kwargs): + return { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": extracted_url, + "webpage_url": extracted_url, + } + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \ + patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()): + result = await dq.retry(url) + + assert result["status"] == "ok" + assert dq.queue.get(url).info.sponsorblock is True + + @pytest.mark.asyncio async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env): notifier = AsyncMock() diff --git a/app/tests/test_ytdl_utils.py b/app/tests/test_ytdl_utils.py index f207e64..f10299b 100644 --- a/app/tests/test_ytdl_utils.py +++ b/app/tests/test_ytdl_utils.py @@ -678,6 +678,69 @@ class DownloadResultTests(unittest.TestCase): ) +def _capture_ytdl_params(download: Download) -> dict: + """Run ``_download`` far enough to capture the params it builds.""" + fake_ydl = MagicMock() + fake_ydl.download.return_value = 0 + download.status_queue = types.SimpleNamespace(put=lambda _: None) + + with patch('ytdl.install_socket_guard'), \ + patch.object(Download, '_make_youtube_dl', return_value=fake_ydl) as make: + download._download() + + params, = make.call_args.args + return params + + +class SponsorBlockPostprocessorTests(unittest.TestCase): + def test_no_sponsorblock_postprocessors_when_disabled(self): + download = _make_test_download() + + params = _capture_ytdl_params(download) + + keys = [pp['key'] for pp in params.get('postprocessors', [])] + self.assertNotIn('SponsorBlock', keys) + self.assertNotIn('ModifyChapters', keys) + + def test_sponsorblock_pair_matches_the_cli(self): + download = _make_test_download() + download.info.sponsorblock = True + + params = _capture_ytdl_params(download) + + self.assertEqual( + params['postprocessors'], + [ + { + 'key': 'SponsorBlock', + 'categories': ['sponsor'], + 'when': 'after_filter', + }, + { + 'key': 'ModifyChapters', + 'remove_sponsor_segments': ['sponsor'], + 'force_keyframes': False, + }, + ], + ) + + def test_segment_removal_runs_before_the_chapter_split(self): + # yt-dlp runs same-stage postprocessors in list order, so ModifyChapters + # has to rewrite the chapter list before FFmpegSplitChapters cuts the + # file up -- the order the CLI builds for + # --sponsorblock-remove sponsor --split-chapters. + download = _make_test_download() + download.info.sponsorblock = True + download.info.split_by_chapters = True + download.info.chapter_template = '%(section_number)s.%(ext)s' + + params = _capture_ytdl_params(download) + + keys = [pp['key'] for pp in params['postprocessors']] + self.assertEqual(keys, ['SponsorBlock', 'ModifyChapters', 'FFmpegSplitChapters']) + self.assertEqual(params['outtmpl']['chapter'], '%(section_number)s.%(ext)s') + + class ProgressThrottleTests(unittest.TestCase): def test_downloading_ticks_are_throttled(self): dl = _make_test_download() diff --git a/app/ytdl.py b/app/ytdl.py index 11ac146..6991fb8 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -453,6 +453,7 @@ class DownloadInfo: clip_end=None, live_status=None, live_release_timestamp=None, + sponsorblock=False, ): 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}' @@ -472,6 +473,7 @@ class DownloadInfo: self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None self.playlist_item_limit = playlist_item_limit self.split_by_chapters = split_by_chapters + self.sponsorblock = sponsorblock self.chapter_template = chapter_template self.subtitle_language = subtitle_language self.subtitle_mode = subtitle_mode @@ -541,6 +543,8 @@ class DownloadInfo: self.playlist_item_limit = 0 if not hasattr(self, "split_by_chapters"): self.split_by_chapters = False + if not hasattr(self, "sponsorblock"): + self.sponsorblock = False if not hasattr(self, "chapter_template"): self.chapter_template = "" if not hasattr(self, "subtitle_language"): @@ -585,6 +589,7 @@ _PERSISTED_DOWNLOAD_FIELDS = ( "custom_name_prefix", "playlist_item_limit", "split_by_chapters", + "sponsorblock", "chapter_template", "subtitle_language", "subtitle_mode", @@ -822,6 +827,27 @@ class Download: # this logger, so a user-supplied one must not replace it. ytdl_params['logger'] = ytdl_logger + # SponsorBlock: mark sponsor segments and cut them out, the same + # postprocessor pair the CLI's --sponsorblock-remove sponsor builds. + # This has to stay above the chapter-splitting block: yt-dlp runs + # same-stage postprocessors in list order, and ModifyChapters must + # rewrite the chapter list before FFmpegSplitChapters cuts the file + # up, or the chapter files keep the sponsor segments and the + # removal desyncs the remaining chapter timings. + if getattr(self.info, 'sponsorblock', False): + if 'postprocessors' not in ytdl_params: + ytdl_params['postprocessors'] = [] + ytdl_params['postprocessors'].append({ + 'key': 'SponsorBlock', + 'categories': ['sponsor'], + 'when': 'after_filter', + }) + ytdl_params['postprocessors'].append({ + 'key': 'ModifyChapters', + 'remove_sponsor_segments': ['sponsor'], + 'force_keyframes': False, + }) + # Add chapter splitting options if enabled if self.info.split_by_chapters: ytdl_params['outtmpl']['chapter'] = self.info.chapter_template @@ -1619,6 +1645,7 @@ class DownloadQueue: already, _add_gen=None, retry_entry=None, + sponsorblock=False, ): if not entry: return {'status': 'error', 'msg': "Invalid/empty data was given."} @@ -1662,6 +1689,7 @@ class DownloadQueue: already, _add_gen, retry_entry, + sponsorblock=sponsorblock, ) elif etype == 'playlist' or etype == 'channel': if etype == 'playlist' and self.__is_channel_extraction(entry): @@ -1729,6 +1757,7 @@ class DownloadQueue: clip_end, already, _add_gen, + sponsorblock=sponsorblock, ) ) if any(res['status'] == 'error' for res in results): @@ -1769,6 +1798,7 @@ class DownloadQueue: clip_end=clip_end, live_status=entry.get('live_status'), live_release_timestamp=entry.get('release_timestamp'), + sponsorblock=sponsorblock, ) await self.__add_download(dl, auto_start) return {'status': 'ok'} @@ -1849,13 +1879,14 @@ class DownloadQueue: already=None, _add_gen=None, retry_entry=None, + sponsorblock=False, ): if ytdl_options_presets is None: ytdl_options_presets = [] 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=} {clip_start=} {clip_end=}' + f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=} {sponsorblock=}' ) if already is None: _add_gen = self._add_generation @@ -1918,6 +1949,7 @@ class DownloadQueue: already, _add_gen, retry_entry, + sponsorblock=sponsorblock, ) async def retry(self, id): @@ -1954,6 +1986,7 @@ class DownloadQueue: info.clip_start, info.clip_end, retry_entry=info.entry, + sponsorblock=info.sponsorblock, ) async def add_entry( diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 0f705cb..f9b6dbb 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -399,6 +399,16 @@
+
+
+ + +
+