Podcasts: Werbung und Schluss in YouTube-Folgen überspringen (SponsorBlock)
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:
parent
5b1325609f
commit
1194472572
11 changed files with 674 additions and 7 deletions
|
|
@ -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
178
e2e/sponsor.spec.js
Normal 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);
|
||||
});
|
||||
18
podcasts/migrations/0026_podcastepisode_sponsor.py
Normal file
18
podcasts/migrations/0026_podcastepisode_sponsor.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
144
podcasts/sponsor.py
Normal 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
196
podcasts/test_sponsor.py
Normal 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)
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue