feat: first-class SponsorBlock toggle

A "Remove sponsor segments" switch next to "Split by chapters" queues
the download with the same postprocessor pair the CLI's
--sponsorblock-remove sponsor builds (SponsorBlock + ModifyChapters).
The flag persists as a cookie like the other form options, survives in
the queue records, and is carried into retries.

The pair is registered above the chapter-splitting block: yt-dlp runs
same-stage postprocessors in list order, so ModifyChapters has to
rewrite the chapter list before FFmpegSplitChapters cuts the file up,
matching what the CLI builds for --sponsorblock-remove sponsor
--split-chapters. With both toggles on the other way around the chapter
files keep the sponsor segments and the removal desyncs the remaining
chapter timings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tjelite1986
2026-07-16 22:19:41 +02:00
parent ac46fff6d9
commit 8c2990e68a
9 changed files with 147 additions and 1 deletions
+3
View File
@@ -725,6 +725,7 @@ def parse_download_options(post: dict) -> dict:
playlist_item_limit = post.get('playlist_item_limit') playlist_item_limit = post.get('playlist_item_limit')
auto_start = post.get('auto_start') auto_start = post.get('auto_start')
split_by_chapters = post.get('split_by_chapters') split_by_chapters = post.get('split_by_chapters')
sponsorblock = bool(post.get('sponsorblock'))
chapter_template = post.get('chapter_template') chapter_template = post.get('chapter_template')
subtitle_language = post.get('subtitle_language') subtitle_language = post.get('subtitle_language')
subtitle_mode = post.get('subtitle_mode') subtitle_mode = post.get('subtitle_mode')
@@ -845,6 +846,7 @@ def parse_download_options(post: dict) -> dict:
'playlist_item_limit': playlist_item_limit, 'playlist_item_limit': playlist_item_limit,
'auto_start': auto_start, 'auto_start': auto_start,
'split_by_chapters': split_by_chapters, 'split_by_chapters': split_by_chapters,
'sponsorblock': sponsorblock,
'chapter_template': chapter_template, 'chapter_template': chapter_template,
'subtitle_language': subtitle_language, 'subtitle_language': subtitle_language,
'subtitle_mode': subtitle_mode, 'subtitle_mode': subtitle_mode,
@@ -890,6 +892,7 @@ async def add(request):
o['ytdl_options_overrides'], o['ytdl_options_overrides'],
o['clip_start'], o['clip_start'],
o['clip_end'], o['clip_end'],
sponsorblock=o['sponsorblock'],
) )
return web.Response(text=serializer.encode(status)) return web.Response(text=serializer.encode(status))
+26
View File
@@ -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} 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 @pytest.mark.asyncio
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env): async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
+63
View File
@@ -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): class ProgressThrottleTests(unittest.TestCase):
def test_downloading_ticks_are_throttled(self): def test_downloading_ticks_are_throttled(self):
dl = _make_test_download() dl = _make_test_download()
+34 -1
View File
@@ -453,6 +453,7 @@ class DownloadInfo:
clip_end=None, clip_end=None,
live_status=None, live_status=None,
live_release_timestamp=None, live_release_timestamp=None,
sponsorblock=False,
): ):
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}'
@@ -472,6 +473,7 @@ class DownloadInfo:
self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
self.playlist_item_limit = playlist_item_limit self.playlist_item_limit = playlist_item_limit
self.split_by_chapters = split_by_chapters self.split_by_chapters = split_by_chapters
self.sponsorblock = sponsorblock
self.chapter_template = chapter_template self.chapter_template = chapter_template
self.subtitle_language = subtitle_language self.subtitle_language = subtitle_language
self.subtitle_mode = subtitle_mode self.subtitle_mode = subtitle_mode
@@ -541,6 +543,8 @@ class DownloadInfo:
self.playlist_item_limit = 0 self.playlist_item_limit = 0
if not hasattr(self, "split_by_chapters"): if not hasattr(self, "split_by_chapters"):
self.split_by_chapters = False self.split_by_chapters = False
if not hasattr(self, "sponsorblock"):
self.sponsorblock = False
if not hasattr(self, "chapter_template"): if not hasattr(self, "chapter_template"):
self.chapter_template = "" self.chapter_template = ""
if not hasattr(self, "subtitle_language"): if not hasattr(self, "subtitle_language"):
@@ -585,6 +589,7 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"custom_name_prefix", "custom_name_prefix",
"playlist_item_limit", "playlist_item_limit",
"split_by_chapters", "split_by_chapters",
"sponsorblock",
"chapter_template", "chapter_template",
"subtitle_language", "subtitle_language",
"subtitle_mode", "subtitle_mode",
@@ -822,6 +827,27 @@ class Download:
# this logger, so a user-supplied one must not replace it. # this logger, so a user-supplied one must not replace it.
ytdl_params['logger'] = ytdl_logger 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 # Add chapter splitting options if enabled
if self.info.split_by_chapters: if self.info.split_by_chapters:
ytdl_params['outtmpl']['chapter'] = self.info.chapter_template ytdl_params['outtmpl']['chapter'] = self.info.chapter_template
@@ -1619,6 +1645,7 @@ class DownloadQueue:
already, already,
_add_gen=None, _add_gen=None,
retry_entry=None, retry_entry=None,
sponsorblock=False,
): ):
if not entry: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@@ -1662,6 +1689,7 @@ class DownloadQueue:
already, already,
_add_gen, _add_gen,
retry_entry, retry_entry,
sponsorblock=sponsorblock,
) )
elif etype == 'playlist' or etype == 'channel': elif etype == 'playlist' or etype == 'channel':
if etype == 'playlist' and self.__is_channel_extraction(entry): if etype == 'playlist' and self.__is_channel_extraction(entry):
@@ -1729,6 +1757,7 @@ class DownloadQueue:
clip_end, clip_end,
already, already,
_add_gen, _add_gen,
sponsorblock=sponsorblock,
) )
) )
if any(res['status'] == 'error' for res in results): if any(res['status'] == 'error' for res in results):
@@ -1769,6 +1798,7 @@ class DownloadQueue:
clip_end=clip_end, clip_end=clip_end,
live_status=entry.get('live_status'), live_status=entry.get('live_status'),
live_release_timestamp=entry.get('release_timestamp'), live_release_timestamp=entry.get('release_timestamp'),
sponsorblock=sponsorblock,
) )
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
@@ -1849,13 +1879,14 @@ class DownloadQueue:
already=None, already=None,
_add_gen=None, _add_gen=None,
retry_entry=None, retry_entry=None,
sponsorblock=False,
): ):
if ytdl_options_presets is None: if ytdl_options_presets is None:
ytdl_options_presets = [] ytdl_options_presets = []
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=} {clip_start=} {clip_end=}' f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=} {sponsorblock=}'
) )
if already is None: if already is None:
_add_gen = self._add_generation _add_gen = self._add_generation
@@ -1918,6 +1949,7 @@ class DownloadQueue:
already, already,
_add_gen, _add_gen,
retry_entry, retry_entry,
sponsorblock=sponsorblock,
) )
async def retry(self, id): async def retry(self, id):
@@ -1954,6 +1986,7 @@ class DownloadQueue:
info.clip_start, info.clip_start,
info.clip_end, info.clip_end,
retry_entry=info.entry, retry_entry=info.entry,
sponsorblock=info.sponsorblock,
) )
async def add_entry( async def add_entry(
+10
View File
@@ -399,6 +399,16 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="row g-2 align-items-center"> <div class="row g-2 align-items-center">
<div class="col-auto">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-sponsorblock"
name="sponsorblock" [(ngModel)]="sponsorblock" (change)="sponsorblockChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<label class="form-check-label" for="checkbox-sponsorblock"
ngbPopover="Cut out sponsor segments using SponsorBlock's crowd-sourced markers (YouTube only)."
triggers="hover" container="body">Remove sponsor segments</label>
</div>
</div>
<div class="col-auto"> <div class="col-auto">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters" <input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
+7
View File
@@ -86,6 +86,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
autoStart: boolean; autoStart: boolean;
playlistItemLimit!: number; playlistItemLimit!: number;
splitByChapters: boolean; splitByChapters: boolean;
sponsorblock: boolean;
chapterTemplate: string; chapterTemplate: string;
clipStart = ''; clipStart = '';
clipEnd = ''; clipEnd = '';
@@ -259,6 +260,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.quality = this.cookieService.get('metube_quality') || 'best'; this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
this.sponsorblock = this.cookieService.get('metube_sponsorblock') === '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.clipStart = this.cookieService.get('metube_clip_start') || '';
@@ -855,6 +857,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays }); this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
} }
sponsorblockChanged() {
this.cookieService.set('metube_sponsorblock', this.sponsorblock ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
}
splitByChaptersChanged() { splitByChaptersChanged() {
this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays }); this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
} }
@@ -1111,6 +1117,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit, playlistItemLimit: overrides.playlistItemLimit ?? this.playlistItemLimit,
autoStart: overrides.autoStart ?? this.autoStart, autoStart: overrides.autoStart ?? this.autoStart,
splitByChapters: overrides.splitByChapters ?? this.splitByChapters, splitByChapters: overrides.splitByChapters ?? this.splitByChapters,
sponsorblock: overrides.sponsorblock ?? this.sponsorblock,
chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate, chapterTemplate: overrides.chapterTemplate ?? this.chapterTemplate,
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage, subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: overrides.subtitleMode ?? this.subtitleMode, subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
+1
View File
@@ -11,6 +11,7 @@ export interface Download {
custom_name_prefix: string; custom_name_prefix: string;
playlist_item_limit: number; playlist_item_limit: number;
split_by_chapters?: boolean; split_by_chapters?: boolean;
sponsorblock?: boolean;
chapter_template?: string; chapter_template?: string;
subtitle_language?: string; subtitle_language?: string;
subtitle_mode?: string; subtitle_mode?: string;
@@ -36,6 +36,7 @@ function basePayload(): AddDownloadPayload {
playlistItemLimit: 0, playlistItemLimit: 0,
autoStart: true, autoStart: true,
splitByChapters: false, splitByChapters: false,
sponsorblock: false,
chapterTemplate: '', chapterTemplate: '',
subtitleLanguage: 'en', subtitleLanguage: 'en',
subtitleMode: 'prefer_manual', subtitleMode: 'prefer_manual',
+2
View File
@@ -17,6 +17,7 @@ export interface AddDownloadPayload {
playlistItemLimit: number; playlistItemLimit: number;
autoStart: boolean; autoStart: boolean;
splitByChapters: boolean; splitByChapters: boolean;
sponsorblock: boolean;
chapterTemplate: string; chapterTemplate: string;
subtitleLanguage: string; subtitleLanguage: string;
subtitleMode: string; subtitleMode: string;
@@ -148,6 +149,7 @@ export class DownloadsService {
playlist_item_limit: payload.playlistItemLimit, playlist_item_limit: payload.playlistItemLimit,
auto_start: payload.autoStart, auto_start: payload.autoStart,
split_by_chapters: payload.splitByChapters, split_by_chapters: payload.splitByChapters,
sponsorblock: payload.sponsorblock,
chapter_template: payload.chapterTemplate, chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage, subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode, subtitle_mode: payload.subtitleMode,