mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(youtube): add YouTube browser window and Whisper subtitle source (#273)
This commit is contained in:
1 parent
c68b73c8c1
commit
07b32626a6
101 files changed
+2269
-726
No files matched your search
@@ -0,0 +1,210 @@
|
||||
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<YoutubeBrowserPlaybackDeps> = {}) {
|
||||
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<void>((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<boolean>((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']);
|
||||
});
|
||||
Reference in new issue
Block a user