diff --git a/app/main.py b/app/main.py index 36620e4..e48ab43 100644 --- a/app/main.py +++ b/app/main.py @@ -973,6 +973,7 @@ async def subscribe(request): subtitle_mode=o['subtitle_mode'], ytdl_options_presets=o['ytdl_options_presets'], ytdl_options_overrides=o['ytdl_options_overrides'], + sponsorblock=o['sponsorblock'], title_regex=post.get('title_regex'), skip_subscriber_only=skip_subscriber_only, clip_start=sub_clip_start, diff --git a/app/subscriptions.py b/app/subscriptions.py index 1cf7764..5afe878 100644 --- a/app/subscriptions.py +++ b/app/subscriptions.py @@ -182,6 +182,7 @@ class SubscriptionInfo: auto_start: bool = True playlist_item_limit: int = 0 split_by_chapters: bool = False + sponsorblock: bool = False chapter_template: str = "" subtitle_language: str = "en" subtitle_mode: str = "prefer_manual" @@ -242,6 +243,7 @@ def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]: "auto_start": sub.auto_start, "playlist_item_limit": sub.playlist_item_limit, "split_by_chapters": sub.split_by_chapters, + "sponsorblock": sub.sponsorblock, "chapter_template": sub.chapter_template, "subtitle_language": sub.subtitle_language, "subtitle_mode": sub.subtitle_mode, @@ -487,6 +489,7 @@ class SubscriptionManager: ytdl_options_overrides: Optional[dict[str, Any]] = None, clip_start: Optional[float] = None, clip_end: Optional[float] = None, + sponsorblock: bool = False, ) -> tuple[list[str], list[str]]: queued_ids: list[str] = [] queue_errors: list[str] = [] @@ -519,6 +522,7 @@ class SubscriptionManager: ytdl_options_overrides, clip_start, clip_end, + sponsorblock=sponsorblock, ) if isinstance(result, dict) and result.get("status") == "error": msg = str(result.get("msg") or f"Queueing failed for {vurl}") @@ -606,6 +610,7 @@ class SubscriptionManager: subtitle_mode: str, ytdl_options_presets: Optional[list[str]] = None, ytdl_options_overrides: Optional[dict[str, Any]] = None, + sponsorblock: bool = False, title_regex: Any = None, skip_subscriber_only: Any = None, clip_start: Optional[float] = None, @@ -689,6 +694,7 @@ class SubscriptionManager: auto_start=bool(auto_start), playlist_item_limit=int(playlist_item_limit), split_by_chapters=bool(split_by_chapters), + sponsorblock=bool(sponsorblock), chapter_template=chapter_template or "", subtitle_language=subtitle_language, subtitle_mode=subtitle_mode, @@ -942,6 +948,7 @@ class SubscriptionManager: dl_plimit = cur.playlist_item_limit dl_autostart = cur.auto_start dl_split = cur.split_by_chapters + dl_sponsorblock = cur.sponsorblock dl_chapter = cur.chapter_template dl_sublang = cur.subtitle_language dl_submode = cur.subtitle_mode @@ -1010,6 +1017,7 @@ class SubscriptionManager: playlist_item_limit=dl_plimit, auto_start=dl_autostart, split_by_chapters=dl_split, + sponsorblock=dl_sponsorblock, chapter_template=dl_chapter or "", subtitle_language=dl_sublang, subtitle_mode=dl_submode, diff --git a/app/tests/test_api.py b/app/tests/test_api.py index ceb4124..0ed7ee0 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -366,6 +366,25 @@ async def test_subscribe_passes_clip_bounds(mock_dqueue, monkeypatch): 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 async def test_subscribe_without_clip_fields_stores_none(mock_dqueue, monkeypatch): monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"})) diff --git a/app/tests/test_subscriptions.py b/app/tests/test_subscriptions.py index 1934d36..cf72fba 100644 --- a/app/tests/test_subscriptions.py +++ b/app/tests/test_subscriptions.py @@ -479,6 +479,69 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(reloaded.get(sub_id).clip_start, 12.5) 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): with tempfile.TemporaryDirectory() as tmp: queue = _Queue() diff --git a/app/ytdl.py b/app/ytdl.py index 6991fb8..0368f5a 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -2008,6 +2008,7 @@ class DownloadQueue: ytdl_options_overrides=None, clip_start=None, clip_end=None, + sponsorblock=False, ): if ytdl_options_presets is None: ytdl_options_presets = [] @@ -2033,6 +2034,7 @@ class DownloadQueue: clip_end, already, None, + sponsorblock=sponsorblock, ) async def start_pending(self, ids): diff --git a/ui/src/app/services/subscriptions.service.spec.ts b/ui/src/app/services/subscriptions.service.spec.ts new file mode 100644 index 0000000..08c669c --- /dev/null +++ b/ui/src/app/services/subscriptions.service.spec.ts @@ -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> = {}; + + fromEvent(event: string) { + if (!this.subjects[event]) { + this.subjects[event] = new Subject(); + } + 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' }); + }); +}); diff --git a/ui/src/app/services/subscriptions.service.ts b/ui/src/app/services/subscriptions.service.ts index 3199db8..eb6ff67 100644 --- a/ui/src/app/services/subscriptions.service.ts +++ b/ui/src/app/services/subscriptions.service.ts @@ -92,6 +92,7 @@ export class SubscriptionsService { playlist_item_limit: payload.playlistItemLimit, auto_start: payload.autoStart, split_by_chapters: payload.splitByChapters, + sponsorblock: payload.sponsorblock, chapter_template: payload.chapterTemplate, subtitle_language: payload.subtitleLanguage, subtitle_mode: payload.subtitleMode,