diora-web/e2e/sponsor.spec.js
mrwnslz 1194472572
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
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
2026-10-25 16:18:24 +01:00

178 lines
7.6 KiB
JavaScript

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