diff --git a/docs/youtube.md b/docs/youtube.md index b169387..cb1671f 100644 --- a/docs/youtube.md +++ b/docs/youtube.md @@ -39,6 +39,24 @@ sound**: each app finds the audio stream itself. that came through such a server (it downloaded the sound on the server) to the channel itself. +## Ad and outro: SponsorBlock + +What goes out of the sound — the advertisement and the closing words — is other people's +work: SponsorBlock (`sponsor.ajay.app`) lets listeners mark a video's parts by category. +`podcasts/sponsor.py` asks it once per episode (only `sponsor` and `outro`, only parts a +listener skips, never a part marked `full`) and keeps the answer on the episode +(`PodcastEpisode.sponsor`); `GET /podcasts/episodes//sponsor/` answers with it, +`{"ready": true, "v": 1, "spans": [[from, to], …]}`, empty for an episode that isn't a +video and with an `error` beside empty spans when SponsorBlock couldn't be asked. Nothing +else is fetched — no intro, self-promotion, interaction reminder or non-music section. + +An answer counts for a week, a failure for an hour (segments are added later; a failure was +probably the network). A player jumps over those parts and never runs them faster: the +setting is `skip_sponsor` of this device (on by default, in the play and speed menus), and +it does something only for YouTube episodes. Nothing of the sound leaves the device for +this — the video id does, to sponsor.ajay.app, when the server asks (never the sound, and +never for an episode that isn't a video). + ## Apps A client that sees a `watch?v=` address resolves it **itself, on the device, diff --git a/e2e/sponsor.spec.js b/e2e/sponsor.spec.js new file mode 100644 index 0000000..6eea235 --- /dev/null +++ b/e2e/sponsor.spec.js @@ -0,0 +1,178 @@ +// Skipping the ad and the outro of a YouTube episode (docs/youtube.md, podcasts/sponsor.py). +// +// The sound of a YouTube episode plays only in an app, so the desktop is imitated here: +// window.dioraDesktop.youtubeAudio answers with a stream of our own (a silent WAV, served +// with byte ranges so the player can jump in it). The browser host (podcasts-web.js) is the +// desktop's podcast host, so this is the code that runs there. +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const path = require('path'); +const { PYTHON, SERVER_ENV, TEST_USER } = require('./env'); + +const ROOT = path.join(__dirname, '..'); +const VID = 'aqz-KE-bpKQ'; +const WATCH = `https://www.youtube.com/watch?v=${VID}`; +const SPANS = [[1, 3], [6, 7.5]]; // ad, outro +const SECONDS = 9; +const STREAM = '/e2e-silence.wav'; + +// A silent 8 kHz mono WAV: long enough to reach both parts and past them. +function wav(seconds, rate = 8000) { + const n = seconds * rate; + const buf = Buffer.alloc(44 + n * 2); + buf.write('RIFF', 0); + buf.writeUInt32LE(36 + n * 2, 4); + buf.write('WAVE', 8); + buf.write('fmt ', 12); + buf.writeUInt32LE(16, 16); + buf.writeUInt16LE(1, 20); // PCM + buf.writeUInt16LE(1, 22); // mono + buf.writeUInt32LE(rate, 24); + buf.writeUInt32LE(rate * 2, 28); // byte rate + buf.writeUInt16LE(2, 32); // block align + buf.writeUInt16LE(16, 34); // bits + buf.write('data', 36); + buf.writeUInt32LE(n * 2, 40); + return buf; +} + +const BODY = wav(SECONDS); + +function seed() { + const py = ` +from django.contrib.auth import get_user_model +from django.utils import timezone +from podcasts.models import PodcastEpisode, PodcastFeed, PodcastQueue +import json +u = get_user_model().objects.get(username=${JSON.stringify(TEST_USER.username)}) +PodcastQueue.objects.filter(user=u).delete() +PodcastEpisode.objects.filter(feed__user=u).delete() +PodcastFeed.objects.filter(user=u).delete() +f = PodcastFeed.objects.create(user=u, rss_url='https://www.youtube.com/feeds/videos.xml?channel_id=UCe2etest', + title='Kanal') +ep = PodcastEpisode.objects.create(feed=f, guid='yt:video:${VID}', title='Eine Folge', + audio_url=${JSON.stringify(WATCH)}, pub_date=timezone.now(), + duration_seconds=${SECONDS}, + sponsor={'v': 1, 'spans': ${JSON.stringify(SPANS)}, + 'at': timezone.now().isoformat()}) +PodcastQueue.objects.create(user=u, episode=ep, position=0) +print('episode', ep.id) +`; + execFileSync(PYTHON, ['manage.py', 'shell', '-c', py], { cwd: ROOT, env: SERVER_ENV, stdio: 'inherit' }); +} + +test.beforeAll(seed); +test.use({ storageState: 'e2e/.auth/user.json' }); + +// The stream, with byte ranges: without them Chromium may refuse to jump in a file. +async function serveStream(page) { + await page.route(`**${STREAM}`, route => { + const range = route.request().headers()['range']; + const head = { 'Content-Type': 'audio/wav', 'Accept-Ranges': 'bytes' }; + if (!range) return route.fulfill({ status: 200, headers: head, body: BODY }); + const m = /bytes=(\d*)-(\d*)/.exec(range); + const from = m && m[1] ? Number(m[1]) : 0; + const to = m && m[2] ? Number(m[2]) : BODY.length - 1; + return route.fulfill({ + status: 206, + headers: { ...head, 'Content-Range': `bytes ${from}-${to}/${BODY.length}`, 'Content-Length': String(to - from + 1) }, + body: BODY.subarray(from, to + 1), + }); + }); +} + +// The desktop the host talks to, and a look at the player's own position over time. +async function imitateDesktop(page) { + await page.addInitScript(url => { + window.dioraDesktop = { + youtubeAudio: () => Promise.resolve({ url }), + deviceName: 'diora Desktop', + }; + window.__times = []; + const Orig = window.Audio; + window.Audio = function () { + const el = new Orig(...arguments); + window.__el = el; + el.addEventListener('timeupdate', () => window.__times.push(el.currentTime)); + el.addEventListener('playing', () => window.__times.push(el.currentTime)); + return el; + }; + window.Audio.prototype = Orig.prototype; + }, STREAM); +} + +// The episode as the host itself holds it (the queue from /podcasts/state/). +async function episode(page) { + return page.evaluate(() => { + const s = JSON.parse(window.DioraPodcastHost.podcastState()); + return (s.queue || []).find(q => /watch\?v=/.test(q.episode__audio_url || '')) || null; + }); +} + +function inPart(t) { + return SPANS.find(([from, to]) => t > from + 0.05 && t < to - 0.05) || null; +} + +test('the ad and the outro are jumped over', async ({ page }) => { + const asked = []; + page.on('request', r => { if (/\/podcasts\/episodes\/\d+\/sponsor\//.test(r.url())) asked.push(r.url()); }); + await serveStream(page); + await imitateDesktop(page); + await page.goto('/'); + await page.waitForFunction(() => window.DioraPodcastHost, null, { timeout: 15000 }); + await page.waitForFunction(() => { + const s = JSON.parse(window.DioraPodcastHost.podcastState()); + return (s.queue || []).some(q => /watch\?v=/.test(q.episode__audio_url || '')); + }, null, { timeout: 15000 }); + + const ep = await episode(page); + expect(ep, 'the seeded episode is in the queue').not.toBeNull(); + await page.evaluate(json => window.DioraPodcastHost.podcastPlayEpisode(json, 0), JSON.stringify(ep)); + + // Where the sound is over time, from here (a `timeupdate` comes only every 250 ms). + const times = await sample(page, 4.5); + + expect(asked.length, 'the server was asked for the parts').toBeGreaterThan(0); + const inside = times.filter(inPart); + expect(inside, `no part was played: ${inside.join(', ')} of ${times.length} positions`).toEqual([]); + expect(Math.max(...times), 'the sound kept going past the ad').toBeGreaterThan(3.2); +}); + +// The player's position, read every 100 ms until it passed `until` (in seconds). +async function sample(page, until, limit = 250) { + const times = []; + for (let i = 0; i < limit; i++) { + const t = await page.evaluate(() => (window.__el && window.__el.readyState > 0 + ? window.__el.currentTime : null)); + if (t !== null) times.push(t); + if (t !== null && t > until) break; + await page.waitForTimeout(100); + } + return times; +} + +test('with the setting off nothing is asked and nothing is jumped over', async ({ page }) => { + seed(); // the first test left the episode played (and out of the queue) + let asked = 0; + page.on('request', r => { if (/\/podcasts\/episodes\/\d+\/sponsor\//.test(r.url())) asked++; }); + await serveStream(page); + await imitateDesktop(page); + await page.goto('/'); + // This device's setting, before the host reads it (localStorage, PlaybackPrefs.kt). + await page.evaluate(() => localStorage.setItem('dpw_prefs_' + window.USER_ID, JSON.stringify({skip_sponsor: false}))); + await page.reload(); + await page.waitForFunction(() => window.DioraPodcastHost, null, { timeout: 15000 }); + await page.waitForFunction(() => { + const s = JSON.parse(window.DioraPodcastHost.podcastState()); + return (s.queue || []).some(q => /watch\?v=/.test(q.episode__audio_url || '')); + }, null, { timeout: 15000 }); + expect(await page.evaluate(() => JSON.parse(window.DioraPodcastHost.podcastPrefs()).skip_sponsor)).toBe(false); + + const ep = await episode(page); + await page.evaluate(json => window.DioraPodcastHost.podcastPlayEpisode(json, 0), JSON.stringify(ep)); + const times = await sample(page, 4.5); + + expect(asked, 'nothing was asked for').toBe(0); + const inside = times.filter(inPart); + expect(inside.length, `the ad was played (${inside.join(', ')})`).toBeGreaterThan(0); +}); diff --git a/podcasts/migrations/0026_podcastepisode_sponsor.py b/podcasts/migrations/0026_podcastepisode_sponsor.py new file mode 100644 index 0000000..cac71d6 --- /dev/null +++ b/podcasts/migrations/0026_podcastepisode_sponsor.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-25 15:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0025_podcast_radio'), + ] + + operations = [ + migrations.AddField( + model_name='podcastepisode', + name='sponsor', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 0ecdbda..c24859c 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -184,6 +184,9 @@ class PodcastEpisode(models.Model): # The pauses for skipping silence in the browser (silence.py), found on # first request: {"spans": [[start, end], …]} (+ "error"); null = not yet. silences = models.JSONField(null=True, blank=True) + # The ad and the outro of a YouTube episode (sponsor.py, SponsorBlock), asked for + # on first request: {"spans": [[start, end], …], "at": when, (+ "error")}. + sponsor = models.JSONField(null=True, blank=True) class Meta: unique_together = ('feed', 'guid') diff --git a/podcasts/sponsor.py b/podcasts/sponsor.py new file mode 100644 index 0000000..8f85bac --- /dev/null +++ b/podcasts/sponsor.py @@ -0,0 +1,144 @@ +"""Skip the ad and the outro of a YouTube episode: what SponsorBlock knows about it. + +A YouTube channel is a podcast here (docs/youtube.md): such an episode's audio_url is +``https://www.youtube.com/watch?v=<11-character id>``, and its sound belongs to the app — +the server never fetches it. What goes *out* of the sound is other people's work: +SponsorBlock (sponsor.ajay.app) lets listeners mark a video's parts by category, and the +two that hurt listening are the advertisement and the closing words. The server asks once +per episode and keeps the answer on it (PodcastEpisode.sponsor); the player then jumps over +those parts (podcasts-web.js for the browser and the desktop app, the app's own player for +Android). + +Only those two categories are asked for, and only parts a listener skips: nothing about +intros, self-promotion, interaction reminders or non-music sections, and no part marked +``full`` — the whole video sponsored means there would be no sound left. +""" +import logging +from datetime import datetime + +import requests + +from django.utils import timezone + +from . import youtube as youtube_mod + +log = logging.getLogger(__name__) + +VERSION = 1 +API = 'https://sponsor.ajay.app/api/skipSegments' +CATEGORIES = ('sponsor', 'outro') +TIMEOUT = (3, 8) # connect, read: a player waits for this, so keep it short +MIN_SPAN = 0.3 # s: a shorter part isn't worth a jump +STALE = 7 * 24 * 3600 # s: an answer is asked again this long after (segments get added) +STALE_ERROR = 3600 # s: a failed ask is tried again sooner (it may have been the network) + + +def video_id_of(audio_url): + """The video an episode is, judged by its address (the same reading as youtube.py).""" + return youtube_mod.video_id_in(audio_url or '') + + +def _spans(segments): + """[[from, to], …] in seconds: the parts of `segments` that go, in order, merged. + + What SponsorBlock answered for the categories asked for, as far as it is usable: a + segment has a start and an end, `skip` or `mute` means it goes (a jump is quieter than + muting and the player jumps anyway), and two parts that touch are one. + """ + found = [] + for seg in segments or []: + if not isinstance(seg, dict): + continue + if seg.get('category') not in CATEGORIES: + continue + if seg.get('actionType', 'skip') not in ('skip', 'mute'): + continue + span = seg.get('segment') + if not isinstance(span, (list, tuple)) or len(span) != 2: + continue + try: + start, end = float(span[0]), float(span[1]) + except (TypeError, ValueError): + continue + if not (start >= 0 and end > start) or end - start < MIN_SPAN or end != end: # NaN + continue + found.append([start, end]) + found.sort() + out = [] + for start, end in found: + if out and start <= out[-1][1] + 0.001: + out[-1][1] = max(out[-1][1], end) + else: + out.append([start, end]) + return [[round(a, 3), round(b, 3)] for a, b in out] + + +def fetch(video_id): + """[[from, to], …] for the video, from SponsorBlock. Raises what the asking raised.""" + r = requests.get(API, params={'videoID': video_id, 'categories': list(CATEGORIES)}, + timeout=TIMEOUT, headers={'User-Agent': 'diora podcast sponsorblock'}) + if r.status_code == 404: # nobody has marked this video: not an error + return [] + if r.status_code >= 400: + raise RuntimeError(f'HTTP {r.status_code}') + try: + data = r.json() + except ValueError: + raise RuntimeError('not JSON') + if not isinstance(data, list): + raise RuntimeError('unexpected answer') + return _spans(data) + + +def ask(video_id): + """The result to store on the episode: the parts that go, or why they weren't found.""" + try: + return {'v': VERSION, 'spans': fetch(video_id), 'at': timezone.now().isoformat()} + except Exception as e: # unreachable API, timeout, nonsense: remembered, retried later + log.warning('sponsor parts of video %s failed: %s', video_id, e) + return {'v': VERSION, 'spans': [], 'error': str(e)[:200] or type(e).__name__, + 'at': timezone.now().isoformat()} + + +def current(stored): + """Whether a stored result still counts (else the episode is asked again). + + A part that goes doesn't change; new ones appear, though, so an episode is asked again + after a week — and a failure after an hour, since it was probably the network. + """ + if not stored or stored.get('v') != VERSION or 'spans' not in stored: + return False + age = STALE_ERROR if stored.get('error') else STALE + at = stored.get('at') + if not at: + return False + try: + when = datetime.fromisoformat(at) + except (TypeError, ValueError): + return False + if timezone.is_naive(when): + when = timezone.make_aware(when) + return (timezone.now() - when).total_seconds() < age + + +def spans_of(episode): + """[[from, to], …]: what to leave out of this episode, asking SponsorBlock if need be. + + Nothing at all for an episode that isn't a video (SponsorBlock knows only YouTube), and + nothing when the asking fails. The episode is only handed back changed through the DB + (`update`), so a caller that keeps the row doesn't have to save it. + """ + from .models import PodcastEpisode + empty = {'ready': True, 'v': VERSION, 'spans': []} + video_id = video_id_of(episode.audio_url) + if not video_id: + return empty + stored = episode.sponsor + if not current(stored): + stored = ask(video_id) + PodcastEpisode.objects.filter(pk=episode.pk).update(sponsor=stored) + episode.sponsor = stored + out = dict(empty, spans=stored.get('spans', [])) + if stored.get('error'): + out['error'] = stored['error'] + return out diff --git a/podcasts/test_sponsor.py b/podcasts/test_sponsor.py new file mode 100644 index 0000000..69cb992 --- /dev/null +++ b/podcasts/test_sponsor.py @@ -0,0 +1,196 @@ +"""Skipping the ad and the outro of a YouTube episode (sponsor.py).""" +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone + +from . import sponsor +from .models import PodcastEpisode, PodcastFeed + +VIDEO = 'https://www.youtube.com/watch?v=aqz-KE-bpKQ' +VID = 'aqz-KE-bpKQ' + + +def seg(start, end, category='sponsor', action='skip'): + return {'UUID': f'{category}-{start}', 'category': category, 'actionType': action, + 'segment': [start, end], 'votes': 10, 'videoDuration': 600} + + +class SpanTests(TestCase): + """What is taken out of SponsorBlock's answer.""" + + def test_only_sponsor_and_outro(self): + self.assertEqual(sponsor._spans([ + seg(10, 20), seg(30, 40, 'outro'), + seg(50, 60, 'intro'), seg(70, 80, 'selfpromo'), + seg(90, 100, 'interaction'), seg(110, 120, 'music_offtopic'), + ]), [[10, 20], [30, 40]]) + + def test_full_and_poi_segments_are_left_alone(self): + # "full": the whole video is a sponsor — jumping would leave no sound at all. + self.assertEqual(sponsor._spans([seg(0, 600, 'sponsor', 'full')]), []) + + def test_mute_counts_as_skipped(self): + self.assertEqual(sponsor._spans([seg(10, 20, 'sponsor', 'mute')]), [[10, 20]]) + + def test_touching_parts_become_one(self): + self.assertEqual(sponsor._spans([seg(10, 20), seg(20, 30)]), [[10, 30]]) + self.assertEqual(sponsor._spans([seg(10, 20), seg(19.5, 30)]), [[10, 30]]) + + def test_order_does_not_matter(self): + self.assertEqual(sponsor._spans([seg(30, 40, 'outro'), seg(10, 20)]), [[10, 20], [30, 40]]) + + def test_nonsense_is_ignored(self): + self.assertEqual(sponsor._spans([ + seg(10, 20), 'nonsense', {}, {'category': 'sponsor'}, {'category': 'sponsor', 'segment': [1]}, + {'category': 'sponsor', 'segment': ['a', 'b']}, {'category': 'sponsor', 'segment': [20, 10]}, + {'category': 'sponsor', 'segment': [-5, 5]}, seg(30, 30.1), # too short to be worth it + ]), [[10, 20]]) + + def test_nothing_at_all(self): + self.assertEqual(sponsor._spans(None), []) + self.assertEqual(sponsor._spans([]), []) + + def test_the_video_id_of_an_episode(self): + self.assertEqual(sponsor.video_id_of(VIDEO), VID) + self.assertEqual(sponsor.video_id_of(f'https://example.org/podcasts/{VID}.mp3'), VID) + self.assertEqual(sponsor.video_id_of('https://example.org/folge.mp3'), '') + + +class FetchTests(TestCase): + def setUp(self): + self.answers = [] + + def get(url, **kw): + self.asked = (url, kw.get('params'), kw.get('timeout')) + return self.answers.pop(0) + + self.get = get + + def _answer(self, status=200, body=None): + class R: + status_code = status + + def json(_self): + return body + r = R() + r.status_code = status + return r + + def test_a_video_nobody_marked_is_not_an_error(self): + self.answers = [self._answer(404)] + with patch('podcasts.sponsor.requests.get', self.get): + self.assertEqual(sponsor.fetch(VID), []) + url, params, timeout = self.asked + self.assertEqual(url, sponsor.API) + self.assertEqual(params['videoID'], VID) + self.assertEqual(params['categories'], ['sponsor', 'outro']) + self.assertEqual(timeout, sponsor.TIMEOUT) + + def test_what_the_api_answers_is_read(self): + self.answers = [self._answer(200, [seg(60, 90, 'outro'), seg(10, 20)])] + with patch('podcasts.sponsor.requests.get', self.get): + self.assertEqual(sponsor.fetch(VID), [[10, 20], [60, 90]]) + + def test_a_broken_answer_is_an_error(self): + for answer in (self._answer(500), self._answer(200, {'error': 'nope'})): + self.answers = [answer] + with patch('podcasts.sponsor.requests.get', self.get): + with self.assertRaises(RuntimeError): + sponsor.fetch(VID) + + def test_ask_remembers_an_error_instead_of_raising(self): + def boom(url, **kw): + raise OSError('no route to host') + with patch('podcasts.sponsor.requests.get', boom): + got = sponsor.ask(VID) + self.assertEqual(got['spans'], []) + self.assertEqual(got['v'], sponsor.VERSION) + self.assertIn('no route', got['error']) + self.assertTrue(got['at']) + + def test_a_stored_answer_is_used_until_it_is_old(self): + now = timezone.now().isoformat() + fresh = {'v': sponsor.VERSION, 'spans': [[1, 2]], 'at': now} + self.assertTrue(sponsor.current(fresh)) + self.assertFalse(sponsor.current(None)) + self.assertFalse(sponsor.current({})) + self.assertFalse(sponsor.current({'v': sponsor.VERSION - 1, 'spans': [], 'at': now})) + self.assertFalse(sponsor.current({'v': sponsor.VERSION, 'spans': []})) # no "at" + + def test_a_failure_is_asked_again_sooner_than_an_answer(self): + def ago(seconds): + return (timezone.now() - timedelta(seconds=seconds)).isoformat() + old = {'v': sponsor.VERSION, 'spans': [[1, 2]], 'at': ago(sponsor.STALE + 60)} + self.assertFalse(sponsor.current(old)) + self.assertTrue(sponsor.current(dict(old, at=ago(sponsor.STALE - 60)))) + failed = {'v': sponsor.VERSION, 'spans': [], 'error': 'HTTP 500', 'at': ago(sponsor.STALE_ERROR + 60)} + self.assertFalse(sponsor.current(failed)) + + +class EpisodeSponsorViewTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(self.user) + self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://www.youtube.com/feeds/videos.xml?channel_id=UCx', + title='Kanal') + self.video = PodcastEpisode.objects.create( + feed=self.feed, guid=f'yt:video:{VID}', title='Eine Folge', audio_url=VIDEO, + pub_date=timezone.now()) + self.other = PodcastEpisode.objects.create( + feed=self.feed, guid='e1', title='Eine Folge', audio_url='https://example.org/e1.mp3', + pub_date=timezone.now()) + self.answers = [] + + def _get(self, url, **kw): + return self.answers.pop(0) + + def _answer(self, body): + class R: + status_code = 200 + + def json(_self): + return body + return R() + + def url(self, ep): + return f'/podcasts/episodes/{ep.id}/sponsor/' + + def test_a_video_is_asked_once_and_the_answer_is_kept(self): + self.answers = [self._answer([seg(10, 20), seg(60, 90, 'outro')])] + with patch('podcasts.sponsor.requests.get', self._get): + d = self.client.get(self.url(self.video)).json() + self.assertEqual(d, {'ready': True, 'v': sponsor.VERSION, 'spans': [[10, 20], [60, 90]]}) + self.assertEqual(PodcastEpisode.objects.get(pk=self.video.pk).sponsor['spans'], [[10, 20], [60, 90]]) + # Kept: the second call doesn't ask again. + with patch('podcasts.sponsor.requests.get', self._get): + d = self.client.get(self.url(self.video)).json() + self.assertEqual(d['spans'], [[10, 20], [60, 90]]) + + def test_a_failed_ask_answers_empty_and_is_remembered(self): + def boom(url, **kw): + raise OSError('timed out') + with patch('podcasts.sponsor.requests.get', boom): + d = self.client.get(self.url(self.video)).json() + self.assertEqual(d['spans'], []) + self.assertEqual(d['error'], 'timed out') + self.assertIn('error', PodcastEpisode.objects.get(pk=self.video.pk).sponsor) + + def test_an_episode_that_is_no_video_is_never_asked_for(self): + with patch('podcasts.sponsor.requests.get', self._get): + d = self.client.get(self.url(self.other)).json() + self.assertEqual(d, {'ready': True, 'v': sponsor.VERSION, 'spans': []}) + self.assertIsNone(PodcastEpisode.objects.get(pk=self.other.pk).sponsor) + + def test_a_foreign_episode_is_not_found(self): + other = User.objects.create_user(username='bob', password='pw12345678') + feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/f', title='F') + ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url=VIDEO, + pub_date=timezone.now()) + self.assertEqual(self.client.get(self.url(ep)).status_code, 404) + + def test_asking_without_a_login_is_refused(self): + self.client.logout() + self.assertEqual(self.client.get(self.url(self.video)).status_code, 401) diff --git a/podcasts/urls.py b/podcasts/urls.py index 933f93e..aaaaa25 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ path('episodes//', views.episode_detail, name='podcast_episode_detail'), path('episodes//transcript/', views.episode_transcript, name='podcast_episode_transcript'), path('episodes//silences/', views.episode_silences, name='podcast_episode_silences'), + path('episodes//sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'), path('episodes//favorite/', views.set_favorite, name='podcast_set_favorite'), path('stats/', views.podcast_stats, name='podcast_stats'), path('queue/', views.queue_get, name='podcast_queue_get'), diff --git a/podcasts/views.py b/podcasts/views.py index 29e9d70..b856077 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -27,6 +27,7 @@ from django.views.decorators.http import require_http_methods from . import chapters as chapters_mod from . import silence as silence_mod +from . import sponsor as sponsor_mod from . import feedext from . import transcripts as transcripts_mod from . import youtube as youtube_mod @@ -1481,6 +1482,22 @@ def episode_silences(request, pk): **({'error': ep.silences['error']} if ep.silences.get('error') else {})}) +@csrf_exempt +@require_http_methods(['GET']) +def episode_sponsor(request, pk): + """What to leave out of the episode's sound: the ad and the outro, from SponsorBlock + (sponsor.py). {"ready": true, "v": 1, "spans": [[from, to], …]} — asked for on the + first request and then kept on the episode; empty for an episode that isn't a video, + and an "error" beside empty spans when SponsorBlock couldn't be asked.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).only( + 'id', 'sponsor', 'audio_url').first() + if ep is None: + return JsonResponse({'error': 'not found'}, status=404) + return JsonResponse(sponsor_mod.spans_of(ep)) + + @csrf_exempt @require_http_methods(['GET']) def episode_transcript(request, pk): diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index 35438c2..2b0b7ea 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -482,7 +482,9 @@ smart_played: [0, 15, 30, 45, 60, 120, 300, 600], }; const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30, - sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6, skip_silence: false}, load('prefs', {})); + sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6, skip_silence: false, + // Ad and outro of a YouTube episode (sponsor.py): on, since that is what it is for. + skip_sponsor: true}, load('prefs', {})); // Skip lengths and "counts as played": the account's where it has them, else this browser's. function eff(name) { @@ -504,6 +506,8 @@ prefs.remove_played = !!value; } else if (name === 'skip_silence') { prefs.skip_silence = !!value; // the server finds the pauses, we jump over them (below) + } else if (name === 'skip_sponsor') { + prefs.skip_sponsor = !!value; // SponsorBlock knows the ad and the outro (below) } else if (name === 'sleep_from' || name === 'sleep_to') { if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return; prefs[name] = value; @@ -517,7 +521,7 @@ function prefsJson() { return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'), remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}, - skip_silence: !!prefs.skip_silence, + skip_silence: !!prefs.skip_silence, skip_sponsor: !!prefs.skip_sponsor, sleep_from: sleepHour('sleep_from', 22), sleep_to: sleepHour('sleep_to', 6)}; } @@ -1338,6 +1342,70 @@ document.addEventListener('visibilitychange', silenceWatch); audio.addEventListener('timeupdate', () => { if (!silenceTimer && !audio.paused) silenceWatch(); }); + // ---- skip the ad and the outro (SponsorBlock) ----------------------------------------- + // A YouTube episode's sound is the video's, and its ad and closing words are marked by + // other listeners (sponsor.py on the server, docs/youtube.md). Only YouTube episodes: + // SponsorBlock knows nothing else. They are simply jumped over — such a part is long, + // and a jump is quieter than running it through faster or muting it. + const SPONSOR_LEAD = 0.15; // s: jump this early, a timer is a little late + let sponsorTimer = null; + let sponsorDone = -1; // the end of the part last jumped over (not entered again) + + const sponsor = {id: null, spans: null, asked: 0}; + + // [[from, to], …] in seconds: what to leave out of this episode, once the server said. + function sponsorSpans() { + const id = curId(); + if (id == null || !prefs.skip_sponsor || !cur || !YOUTUBE.test(cur.episode__audio_url || '')) return null; + if (sponsor.id !== id) { sponsor.id = id; sponsor.spans = null; sponsor.asked = 0; sponsorDone = -1; } + if (!sponsor.spans && Date.now() - sponsor.asked > 20000) { + sponsor.asked = Date.now(); + fetch(`/podcasts/episodes/${id}/sponsor/`, {credentials: 'same-origin', cache: 'no-store'}) + .then(r => (r.ok ? r.json() : null)) + .then(d => { if (d && d.v === 1 && sponsor.id === id) sponsor.spans = d.spans || []; }) + .catch(() => {}); + } + return sponsor.spans; + } + + function sponsorSchedule(ms) { + clearTimeout(sponsorTimer); + sponsorTimer = setTimeout(sponsorTick, Math.max(20, Math.min(ms, 2000))); + } + + function sponsorTick() { + sponsorTimer = null; + if (!loaded || audio.paused || audio.seeking) return; + const spans = sponsorSpans(); + if (!spans) { if (spans === null) sponsorSchedule(1000); return; } // still asking the server + const t = audio.currentTime; + for (const [from, to] of spans) { // in order + if (to <= t || to === sponsorDone) continue; + if (from <= t) { // inside a part that goes + sponsorDone = to; + try { audio.currentTime = to; } catch (e) {} + sponsorSchedule(100); + return; + } + sponsorSchedule((from - t - SPONSOR_LEAD) / (audio.playbackRate || 1) * 1000 + 30); + return; + } + } + + function sponsorWatch() { + clearTimeout(sponsorTimer); + sponsorTimer = null; + if (loaded && !audio.paused) sponsorTick(); + } + audio.addEventListener('playing', sponsorWatch); + audio.addEventListener('pause', sponsorWatch); + audio.addEventListener('ended', sponsorWatch); + audio.addEventListener('emptied', sponsorWatch); + audio.addEventListener('seeked', () => { sponsorDone = -1; sponsorWatch(); }); + audio.addEventListener('ratechange', () => { if (sponsorTimer) sponsorWatch(); }); // the jump is timed by rate + document.addEventListener('visibilitychange', sponsorWatch); + audio.addEventListener('timeupdate', () => { if (!sponsorTimer && !audio.paused) sponsorWatch(); }); + // ---- radio and podcasts take turns --------------------------------------------------- function stopRadio() { diff --git a/static/js/sw.js b/static/js/sw.js index a125705..14e6fac 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v154'; +const CACHE = 'diora-v155'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 71aa62d..d0b4ddb 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 7f37d4e 2026-10-25: web-native/static/native-podcast.js +// Copied from diora-android e287f48 2026-10-25: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -666,7 +666,8 @@ keepSkippedItem(), deleteDequeueItem(), ]), - group('▶', 'Abspielen', ['Sprungweiten', 'speed_presets' in s && 'Tempo', 'skip_silence' in prefs && 'Stille', '„gehört“', + group('▶', 'Abspielen', ['Sprungweiten', 'speed_presets' in s && 'Tempo', 'skip_silence' in prefs && 'Stille', + 'skip_sponsor' in prefs && 'Werbung', '„gehört“', 'times_by_speed' in s && 'Zeiten', 'headset_next' in prefs && 'Kopfhörer', 'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [ skipItem('skip_back', 'Zurückspringen', SKIPS), @@ -674,6 +675,7 @@ 'speed_presets' in s && {icon: '»', label: 'Tempo-Voreinstellungen', hint: presets().map(speedLabel).join(' · '), action: () => presetsEditor()}, silenceItem(), + sponsorItem(), playedItem(), timesItem(), headsetItem(), @@ -931,6 +933,28 @@ action: () => N.podcastSetPref('skip_silence', !on)}; } + // „Werbung und Schluss überspringen“ next to „Stille überspringen“ in the speed menu: one + // setting for this device, no per-podcast choice (only YouTube episodes have it at all). + function sponsorSpeedItems() { + const prefs = devicePrefs(); + if (!N.podcastSetPref || !('skip_sponsor' in prefs)) return []; + const device = !!prefs.skip_sponsor; + return [{icon: device ? '☑' : '☐', label: 'Werbung und Schluss überspringen', + hint: (device ? 'an' : 'aus') + ' · YouTube-Folgen (SponsorBlock)', + action: () => { N.podcastSetPref('skip_sponsor', !device); + toast(device ? 'Werbung und Schluss werden gespielt' : 'Werbung und Schluss werden übersprungen'); }}]; + } + + // Ad and outro of a YouTube episode (server: sponsor.py, SponsorBlock). Only where the + // player does it at all — the app knows the setting once PlaybackPrefs has it. + function sponsorItem() { + if (!N.podcastSetPref || !('skip_sponsor' in devicePrefs())) return null; + const on = !!devicePrefs().skip_sponsor; + return {icon: on ? '☑' : '☐', label: 'Werbung und Schluss überspringen', + hint: on ? 'an: in YouTube-Folgen' : 'aus', + action: () => N.podcastSetPref('skip_sponsor', !on)}; + } + function devicePrefs() { return parse(N.podcastPrefs ? N.podcastPrefs() : '{}', {}); } @@ -4948,13 +4972,13 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi label: speedLabel(s), action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)), })).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : []) - .concat(presetsEditItem(), silenceSpeedItems())); + .concat(presetsEditItem(), silenceSpeedItems(), sponsorSpeedItems())); if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; } openSheet('Geschwindigkeit', [ {icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)}, {icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene', action: () => pick(true)}, - ].concat(presetsEditItem(), silenceSpeedItems())); + ].concat(presetsEditItem(), silenceSpeedItems(), sponsorSpeedItems())); } // „Stille überspringen“ where AntennaPod has it: in the speed menu. For all podcasts (this