import assert from 'node:assert/strict'; import test from 'node:test'; import { createYoutubeBrowserPlaybackRuntime, parseYoutubeBrowserVideoRequest, type YoutubeBrowserPlaybackDeps, } from './youtube-browser-playback'; const VIDEO_A = 'https://www.youtube.com/watch?v=aaaaaaaaaaa'; const VIDEO_B = 'https://www.youtube.com/watch?v=bbbbbbbbbbb'; const VIDEO_C = 'https://www.youtube.com/watch?v=ccccccccccc'; function createHarness(overrides: Partial = {}) { const calls: string[] = []; const pendingFlows: Array<() => void> = []; let mpvPlaying = false; const runtime = createYoutubeBrowserPlaybackRuntime({ isMpvPlaying: () => mpvPlaying, ensureMpvReady: async () => true, runPlaybackFlow: (url) => { calls.push(`flow:${url}`); return new Promise((resolve) => pendingFlows.push(resolve)); }, appendToMpvPlaylist: (url) => calls.push(`append:${url}`), notifyFailure: (message) => calls.push(`notify:${message}`), logWarn: () => {}, onStartupFailed: () => calls.push('startup-failed'), ...overrides, }); return { runtime, calls, setMpvPlaying: (next: boolean) => { mpvPlaying = next; }, finishFlows: async () => { for (const resolve of pendingFlows.splice(0)) resolve(); await new Promise((resolve) => setImmediate(resolve)); }, }; } test('parseYoutubeBrowserVideoRequest accepts play and queue requests only', () => { assert.deepEqual(parseYoutubeBrowserVideoRequest({ action: 'queue', url: VIDEO_A }), { action: 'queue', url: VIDEO_A, }); assert.equal(parseYoutubeBrowserVideoRequest({ action: 'delete', url: VIDEO_A }), null); assert.equal(parseYoutubeBrowserVideoRequest({ action: 'play' }), null); assert.equal(parseYoutubeBrowserVideoRequest(null), null); }); test('play runs the playback flow with a single-video url', async () => { const { runtime, calls } = createHarness(); const result = await runtime.openVideo({ action: 'play', url: `${VIDEO_A}&list=WL&index=2`, }); assert.deepEqual(result, { ok: true, message: 'Opening in mpv' }); assert.deepEqual(calls, [`flow:${VIDEO_A}`]); }); test('queue appends to mpv while something is playing and plays otherwise', async () => { const { runtime, calls, setMpvPlaying } = createHarness(); await runtime.openVideo({ action: 'queue', url: VIDEO_A }); runtime.handleMediaPathChange(VIDEO_A); setMpvPlaying(true); const queued = await runtime.openVideo({ action: 'queue', url: VIDEO_B }); assert.deepEqual(queued, { ok: true, message: 'Queued in mpv' }); assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`]); }); test('queue requests while the first video starts append behind it in order', async () => { const { runtime, calls } = createHarness(); await runtime.openVideo({ action: 'play', url: VIDEO_A }); assert.equal(runtime.isStartingPlayback(), true); assert.deepEqual(await runtime.openVideo({ action: 'queue', url: VIDEO_B }), { ok: true, message: 'Queued in mpv', }); await runtime.openVideo({ action: 'queue', url: VIDEO_C }); assert.deepEqual(calls, [`flow:${VIDEO_A}`]); runtime.handleMediaPathChange(VIDEO_A); await new Promise((resolve) => setImmediate(resolve)); assert.equal(runtime.isStartingPlayback(), false); assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `append:${VIDEO_C}`]); }); test('queue requests play instead when the first video fails to start', async () => { let failFlow = true; const calls: string[] = []; const { runtime } = createHarness({ runPlaybackFlow: async (url) => { calls.push(`flow:${url}`); if (failFlow) { failFlow = false; throw new Error('no formats'); } }, // The fallback play for the queued video must already count as starting. onStartupFailed: () => calls.push(`startup-failed:${runtime.isStartingPlayback()}`), }); await runtime.openVideo({ action: 'play', url: VIDEO_A }); await runtime.openVideo({ action: 'queue', url: VIDEO_B }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(calls, [`flow:${VIDEO_A}`, 'startup-failed:true', `flow:${VIDEO_B}`]); }); test('repeat play clicks while mpv is starting open the video once', async () => { let releaseMpv: (ready: boolean) => void = () => {}; const { runtime, calls } = createHarness({ ensureMpvReady: () => new Promise((resolve) => { releaseMpv = resolve; }), }); const first = runtime.openVideo({ action: 'play', url: VIDEO_A }); const second = await runtime.openVideo({ action: 'play', url: VIDEO_A }); releaseMpv(true); await first; assert.deepEqual(second, { ok: true, message: 'Already opening in mpv' }); assert.deepEqual(calls, [`flow:${VIDEO_A}`]); }); test('rejects non-video links and reports when mpv cannot start', async () => { const { runtime, calls } = createHarness({ ensureMpvReady: async () => false }); assert.equal( (await runtime.openVideo({ action: 'play', url: 'https://www.youtube.com/' })).ok, false, ); assert.deepEqual(await runtime.openVideo({ action: 'play', url: VIDEO_A }), { ok: false, message: 'Could not start mpv.', }); assert.deepEqual(calls, ['startup-failed']); }); test('advancing onto a queued video runs the playback flow for it', async () => { const { runtime, calls, setMpvPlaying, finishFlows } = createHarness(); await runtime.openVideo({ action: 'play', url: VIDEO_A }); runtime.handleMediaPathChange(VIDEO_A); await finishFlows(); setMpvPlaying(true); await runtime.openVideo({ action: 'queue', url: VIDEO_B }); runtime.handleMediaPathChange(''); runtime.handleMediaPathChange(VIDEO_B); runtime.handleMediaPathChange(VIDEO_B); assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]); }); test('path changes are ignored while a flow is loading its own video', async () => { const { runtime, calls } = createHarness(); await runtime.openVideo({ action: 'play', url: VIDEO_A }); await runtime.openVideo({ action: 'play', url: VIDEO_B }); runtime.handleMediaPathChange(VIDEO_A); runtime.handleMediaPathChange(VIDEO_B); assert.deepEqual(calls, [`flow:${VIDEO_A}`, `flow:${VIDEO_B}`]); }); test('a stuck flow does not block queued videos from getting theirs', async () => { const { runtime, calls, setMpvPlaying } = createHarness(); await runtime.openVideo({ action: 'play', url: VIDEO_A }); runtime.handleMediaPathChange(VIDEO_A); setMpvPlaying(true); await runtime.openVideo({ action: 'queue', url: VIDEO_B }); runtime.handleMediaPathChange(VIDEO_B); assert.deepEqual(calls, [`flow:${VIDEO_A}`, `append:${VIDEO_B}`, `flow:${VIDEO_B}`]); }); test('a queued video gets the flow only the first time mpv reaches it', async () => { const { runtime, calls, setMpvPlaying, finishFlows } = createHarness(); setMpvPlaying(true); await runtime.openVideo({ action: 'queue', url: VIDEO_B }); runtime.handleMediaPathChange(VIDEO_B); await finishFlows(); // A later launcher-started play of the same video runs its own flow. runtime.handleMediaPathChange(VIDEO_A); runtime.handleMediaPathChange(VIDEO_B); assert.deepEqual(calls, [`append:${VIDEO_B}`, `flow:${VIDEO_B}`]); }); test('videos not sent from the browser never trigger the flow', async () => { const { runtime, calls } = createHarness(); runtime.handleMediaPathChange(VIDEO_A); assert.deepEqual(calls, []); }); test('mpv disconnect forgets queued videos', async () => { const { runtime, calls, setMpvPlaying, finishFlows } = createHarness(); setMpvPlaying(true); await runtime.openVideo({ action: 'queue', url: VIDEO_B }); runtime.handleMpvDisconnected(); runtime.handleMediaPathChange(VIDEO_B); await finishFlows(); assert.deepEqual(calls, [`append:${VIDEO_B}`]); }); test('flow failures are reported', async () => { const { runtime, calls } = createHarness({ runPlaybackFlow: async () => { throw new Error('mpv went away'); }, }); await runtime.openVideo({ action: 'play', url: VIDEO_A }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(calls, ['notify:YouTube playback failed: mpv went away', 'startup-failed']); });