Merge PR #1028: first-class SponsorBlock toggle

Adds a "Remove sponsor segments" switch to the shared options panel,
building the same SponsorBlock + ModifyChapters pair the CLI's
--sponsorblock-remove sponsor does, and carries the flag through
subscriptions so the panel's control applies to both forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Shnitman
2026-08-18 15:47:02 +02:00
14 changed files with 318 additions and 1 deletions
+4
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))
@@ -970,6 +973,7 @@ 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'],
sponsorblock=o['sponsorblock'],
title_regex=post.get('title_regex'), title_regex=post.get('title_regex'),
skip_subscriber_only=skip_subscriber_only, skip_subscriber_only=skip_subscriber_only,
clip_start=sub_clip_start, clip_start=sub_clip_start,
+8
View File
@@ -182,6 +182,7 @@ class SubscriptionInfo:
auto_start: bool = True auto_start: bool = True
playlist_item_limit: int = 0 playlist_item_limit: int = 0
split_by_chapters: bool = False split_by_chapters: bool = False
sponsorblock: bool = False
chapter_template: str = "" chapter_template: str = ""
subtitle_language: str = "en" subtitle_language: str = "en"
subtitle_mode: str = "prefer_manual" subtitle_mode: str = "prefer_manual"
@@ -242,6 +243,7 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
"auto_start": sub.auto_start, "auto_start": sub.auto_start,
"playlist_item_limit": sub.playlist_item_limit, "playlist_item_limit": sub.playlist_item_limit,
"split_by_chapters": sub.split_by_chapters, "split_by_chapters": sub.split_by_chapters,
"sponsorblock": sub.sponsorblock,
"chapter_template": sub.chapter_template, "chapter_template": sub.chapter_template,
"subtitle_language": sub.subtitle_language, "subtitle_language": sub.subtitle_language,
"subtitle_mode": sub.subtitle_mode, "subtitle_mode": sub.subtitle_mode,
@@ -487,6 +489,7 @@ class SubscriptionManager:
ytdl_options_overrides: Optional[dict[str, Any]] = None, ytdl_options_overrides: Optional[dict[str, Any]] = None,
clip_start: Optional[float] = None, clip_start: Optional[float] = None,
clip_end: Optional[float] = None, clip_end: Optional[float] = None,
sponsorblock: bool = False,
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
queued_ids: list[str] = [] queued_ids: list[str] = []
queue_errors: list[str] = [] queue_errors: list[str] = []
@@ -519,6 +522,7 @@ class SubscriptionManager:
ytdl_options_overrides, ytdl_options_overrides,
clip_start, clip_start,
clip_end, clip_end,
sponsorblock=sponsorblock,
) )
if isinstance(result, dict) and result.get("status") == "error": if isinstance(result, dict) and result.get("status") == "error":
msg = str(result.get("msg") or f"Queueing failed for {vurl}") msg = str(result.get("msg") or f"Queueing failed for {vurl}")
@@ -606,6 +610,7 @@ 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,
sponsorblock: bool = False,
title_regex: Any = None, title_regex: Any = None,
skip_subscriber_only: Any = None, skip_subscriber_only: Any = None,
clip_start: Optional[float] = None, clip_start: Optional[float] = None,
@@ -689,6 +694,7 @@ class SubscriptionManager:
auto_start=bool(auto_start), auto_start=bool(auto_start),
playlist_item_limit=int(playlist_item_limit), playlist_item_limit=int(playlist_item_limit),
split_by_chapters=bool(split_by_chapters), split_by_chapters=bool(split_by_chapters),
sponsorblock=bool(sponsorblock),
chapter_template=chapter_template or "", chapter_template=chapter_template or "",
subtitle_language=subtitle_language, subtitle_language=subtitle_language,
subtitle_mode=subtitle_mode, subtitle_mode=subtitle_mode,
@@ -942,6 +948,7 @@ class SubscriptionManager:
dl_plimit = cur.playlist_item_limit dl_plimit = cur.playlist_item_limit
dl_autostart = cur.auto_start dl_autostart = cur.auto_start
dl_split = cur.split_by_chapters dl_split = cur.split_by_chapters
dl_sponsorblock = cur.sponsorblock
dl_chapter = cur.chapter_template dl_chapter = cur.chapter_template
dl_sublang = cur.subtitle_language dl_sublang = cur.subtitle_language
dl_submode = cur.subtitle_mode dl_submode = cur.subtitle_mode
@@ -1010,6 +1017,7 @@ class SubscriptionManager:
playlist_item_limit=dl_plimit, playlist_item_limit=dl_plimit,
auto_start=dl_autostart, auto_start=dl_autostart,
split_by_chapters=dl_split, split_by_chapters=dl_split,
sponsorblock=dl_sponsorblock,
chapter_template=dl_chapter or "", chapter_template=dl_chapter or "",
subtitle_language=dl_sublang, subtitle_language=dl_sublang,
subtitle_mode=dl_submode, subtitle_mode=dl_submode,
+19
View File
@@ -366,6 +366,25 @@ async def test_subscribe_passes_clip_bounds(mock_dqueue, monkeypatch):
assert kwargs["clip_end"] == pytest.approx(204.0) assert kwargs["clip_end"] == pytest.approx(204.0)
@pytest.mark.asyncio
async def test_subscribe_passes_sponsorblock(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request(
{**_valid_video_add_body(), "check_interval_minutes": 60, "sponsorblock": True}
)
resp = await main.subscribe(req)
assert resp.status == 200
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is True
@pytest.mark.asyncio
async def test_subscribe_defaults_sponsorblock_off(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
await main.subscribe(req)
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subscribe_without_clip_fields_stores_none(mock_dqueue, monkeypatch): async def test_subscribe_without_clip_fields_stores_none(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"})) monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
+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
@@ -479,6 +479,69 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(reloaded.get(sub_id).clip_start, 12.5) self.assertEqual(reloaded.get(sub_id).clip_start, 12.5)
self.assertIsNone(reloaded.get(sub_id).clip_end) self.assertIsNone(reloaded.get(sub_id).clip_end)
async def test_check_now_applies_subscription_sponsorblock(self):
"""Subscriptions download unattended, so the sponsor-segment removal has
to reach every entry the subscription queues, not just manual adds."""
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": "Two", "webpage_url": "https://example.com/v2"},
{"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",
sponsorblock=True,
)
sub_id = result["subscription"]["id"]
self.assertTrue(mgr.get(sub_id).sponsorblock)
await mgr.check_now([sub_id])
self.assertEqual(len(queue.entries), 1)
_entry, _args, kwargs = queue.entries[0]
self.assertIs(kwargs["sponsorblock"], True)
async def test_sponsorblock_survives_reload_and_defaults_to_false(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = _Config(tmp)
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
sub_id = await self._add_one_subscription(mgr)
# Records written before the field existed simply take the default.
self.assertFalse(mgr.get(sub_id).sponsorblock)
mgr.get(sub_id).sponsorblock = True
async with mgr._lock:
mgr._save_locked()
reloaded = SubscriptionManager(cfg, _Queue(), _Notifier())
self.assertTrue(reloaded.get(sub_id).sponsorblock)
async def test_check_now_queues_subscriber_only_when_skip_disabled(self): async def test_check_now_queues_subscriber_only_when_skip_disabled(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
queue = _Queue() queue = _Queue()
+63
View File
@@ -679,6 +679,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()
+36 -1
View File
@@ -483,6 +483,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}'
@@ -502,6 +503,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
@@ -571,6 +573,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"):
@@ -615,6 +619,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",
@@ -857,6 +862,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
@@ -1654,6 +1680,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."}
@@ -1697,6 +1724,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):
@@ -1764,6 +1792,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):
@@ -1804,6 +1833,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'}
@@ -1884,13 +1914,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
@@ -1953,6 +1984,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):
@@ -1989,6 +2021,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(
@@ -2010,6 +2043,7 @@ class DownloadQueue:
ytdl_options_overrides=None, ytdl_options_overrides=None,
clip_start=None, clip_start=None,
clip_end=None, clip_end=None,
sponsorblock=False,
): ):
if ytdl_options_presets is None: if ytdl_options_presets is None:
ytdl_options_presets = [] ytdl_options_presets = []
@@ -2035,6 +2069,7 @@ class DownloadQueue:
clip_end, clip_end,
already, already,
None, None,
sponsorblock=sponsorblock,
) )
async def start_pending(self, ids): async def start_pending(self, ids):
+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,
@@ -0,0 +1,77 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Subject } from 'rxjs';
import { SubscriptionsService, SubscribePayload } from './subscriptions.service';
import { MeTubeSocket } from './metube-socket.service';
class MeTubeSocketStub {
private subjects: Record<string, Subject<string>> = {};
fromEvent(event: string) {
if (!this.subjects[event]) {
this.subjects[event] = new Subject<string>();
}
return this.subjects[event].asObservable();
}
}
function basePayload(): SubscribePayload {
return {
url: 'https://example.com/channel',
downloadType: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
folder: '',
customNamePrefix: '',
playlistItemLimit: 0,
autoStart: true,
splitByChapters: false,
sponsorblock: false,
chapterTemplate: '',
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
ytdlOptionsPresets: [],
ytdlOptionsOverrides: '',
clipStart: '',
clipEnd: '',
checkIntervalMinutes: 60,
titleRegex: '',
skipSubscriberOnly: false,
};
}
describe('SubscriptionsService', () => {
let httpMock: HttpTestingController;
let service: SubscriptionsService;
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
SubscriptionsService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MeTubeSocket, useValue: new MeTubeSocketStub() },
],
}).compileComponents();
service = TestBed.inject(SubscriptionsService);
httpMock = TestBed.inject(HttpTestingController);
});
it('subscribe() carries the sponsorblock flag', () => {
service.subscribe({ ...basePayload(), sponsorblock: true }).subscribe();
const req = httpMock.expectOne('subscribe');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: true }));
req.flush({ status: 'ok' });
});
it('subscribe() sends the flag off by default', () => {
service.subscribe(basePayload()).subscribe();
const req = httpMock.expectOne('subscribe');
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: false }));
req.flush({ status: 'ok' });
});
});
@@ -92,6 +92,7 @@ export class SubscriptionsService {
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,