Podcasts: Werbung und Schluss in YouTube-Folgen überspringen (SponsorBlock)
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m56s
Test / e2e (push) Failing after 1m33s

Die Folgen eines YouTube-Kanals werden gehört wie Podcasts; unterbrochen wird man
darin von der Werbung und vom Schluss. Beides markieren andere Hörer bei
SponsorBlock: podcasts/sponsor.py fragt je Folge einmal danach (nur sponsor und
outro, nur Teile, die übersprungen werden, kein „full“) und legt die Antwort an
der Folge ab (PodcastEpisode.sponsor); GET /podcasts/episodes/<id>/sponsor/
antwortet damit. Eine Antwort gilt eine Woche, ein Fehlschlag eine Stunde.

Der Player springt über solche Teile (podcasts-web.js für Browser und Desktop,
in der App später der eigene Player); der Schalter heißt skip_sponsor, steht bei
„Stille überspringen“ im Abspiel- und Tempomenü, ist an und wirkt nur bei
YouTube-Folgen (default on). e2e/sponsor.spec.js spielt es in Chromium durch;
sw v155
This commit is contained in:
mrwnslz 2026-10-25 16:18:24 +01:00
parent 5b1325609f
commit 1194472572
11 changed files with 674 additions and 7 deletions

View file

@ -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/<id>/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,

178
e2e/sponsor.spec.js Normal file
View file

@ -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);
});

View file

@ -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),
),
]

View file

@ -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')

144
podcasts/sponsor.py Normal file
View file

@ -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

196
podcasts/test_sponsor.py Normal file
View file

@ -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)

View file

@ -29,6 +29,7 @@ urlpatterns = [
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'),
path('episodes/<int:pk>/sponsor/', views.episode_sponsor, name='podcast_episode_sponsor'),
path('episodes/<int:pk>/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'),

View file

@ -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):

View file

@ -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() {

View file

@ -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',

View file

@ -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