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