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
178 lines
7.6 KiB
JavaScript
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);
|
|
});
|