const test = require("node:test"); const assert = require("node:assert/strict"); const { MediaPlayer } = require("../build/MediaPlayer"); const makePlayer = (url = "https://example.test/watch?v=abc", playlist = [{ id: 42, filename: url }]) => { const player = Object.create(MediaPlayer.prototype); const starts = []; const commands = []; let stops = 0; const state = { eventSubscribers: [], playbackErrors: new Map(), currentFile: null, lastLoadCandidate: null, activeEntryId: null, activeUrl: null, currentMediaTitle: null, currentPlaybackMetadata: {}, dataBuffer: "", activeAttemptGeneration: 0, fallbackSuppressedUntilStart: false, fallbackCandidates: new Set(), entryCandidates: new Map(), pendingCandidateUrls: [], browserUrl: null, metadata: new Map(), }; Object.assign(player, state); player.browserFallback = { isRunning: false, start: async (requestedUrl, isCurrent) => { starts.push(requestedUrl); if (!isCurrent()) return false; player.browserFallback.isRunning = true; return true; }, stop: async () => { stops += 1; player.browserFallback.isRunning = false; }, }; player.writeCommand = async (command, args) => { commands.push([command, args]); if (command === "loadfile") return { error: "success" }; if (command === "get_property") return { data: playlist }; return { error: "success" }; }; player.fetchMetadataAndNotify = async () => undefined; return { player, starts, commands, get stops() { return stops; }, set playlist(value) { playlist = value; } }; }; const begin = (player, entryId = 42) => player.receiveData(`${JSON.stringify({ event: "start-file", playlist_entry_id: entryId })}\n`); const send = (player, event, entryId = player.activeEntryId) => player.receiveData(`${JSON.stringify({ event: "end-file", playlist_entry_id: entryId, ...event, })}\n`); const settle = async () => { await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); }; test("successful MPV completion does not start fallback", async () => { const { player, starts } = makePlayer(); player.fallbackCandidates.add("https://example.test/watch?v=abc"); player.entryCandidates.set(42, "https://example.test/watch?v=abc"); begin(player); send(player, { reason: "eof" }); await settle(); assert.deepEqual(starts, []); }); test("actual MPV error opens the exact original URL and mirrors indeterminate state", async () => { const url = "https://example.test/watch?v=x&next=1;touch%20/tmp/nope"; const { player, starts } = makePlayer(url); player.fallbackCandidates.add(url); player.entryCandidates.set(42, url); begin(player); send(player, { reason: "error", file_error: "demux failed" }); await settle(); assert.deepEqual(starts, [url]); assert.equal(player.browserUrl, url); assert.equal((await player.getNowPlaying()).filename, url); assert.equal(await player.getPauseState(), false); assert.equal(await player.getIdle(), false); assert.equal(await player.getTimePosition(), null); assert.equal(await player.getDuration(), null); assert.equal(await player.getSeekable(), false); }); test("queue append leaves active A eligible when queued B is added", async () => { const a = "https://example.test/a"; const b = "https://example.test/b"; const { player, starts, commands } = makePlayer(a, [{ id: 42, filename: a }]); player.fallbackCandidates.add(a); player.entryCandidates.set(42, a); begin(player, 42); await player.append(b); await settle(); assert.equal(commands.filter(([name]) => name === "loadfile").at(-1)[1][1], "append-play"); send(player, { reason: "error", file_error: "failed A" }, 42); await settle(); assert.deepEqual(starts, [a]); assert.equal(player.browserUrl, a); assert.equal(player.fallbackCandidates.has(b), true); }); test("queue append during fallback startup keeps browser A open without starting B", async () => { const a = "https://example.test/a"; const b = "https://example.test/b"; const { player, starts, commands } = makePlayer(a, [{ id: 42, filename: a }]); player.fallbackCandidates.add(a); player.entryCandidates.set(42, a); begin(player, 42); send(player, { reason: "error", file_error: "failed A" }, 42); await player.append(b); await settle(); const load = commands.filter(([name]) => name === "loadfile").at(-1); assert.equal(load[1][1], "append"); assert.deepEqual(starts, [a]); assert.equal(player.browserUrl, a); }); test("append then skipTo keeps the selected entry eligible for its actual MPV error", async () => { const url = "https://example.test/shared-original?x=1&y=2"; const { player, starts } = makePlayer(url, [{ id: 42, filename: url }]); await player.append(url); await settle(); await player.skipTo(0); begin(player, 42); send(player, { reason: "error", file_error: "load failed" }, 42); await settle(); assert.deepEqual(starts, [url]); }); test("failure is correlated from MPV end-file entry id without querying a cleared playlist", async () => { const url = "https://example.test/fails"; const { player, starts } = makePlayer(url); player.fallbackCandidates.add(url); player.entryCandidates.set(42, url); begin(player, 42); player.writeCommand = async (command) => { assert.notEqual(command, "get_property", "failure correlation must use the emitted entry id"); return { data: [] }; }; player.activeEntryId = null; player.activeUrl = null; player.currentFile = null; send(player, { reason: "error", file_error: "network unavailable" }, 42); await settle(); assert.deepEqual(starts, [url]); assert.equal(player.playbackErrors.get(url), "network unavailable"); }); test("EOF, user stop, cancel, replacement, or stale queue events do not fallback", async () => { const eof = makePlayer(); eof.player.fallbackCandidates.add("https://example.test/watch?v=abc"); eof.player.entryCandidates.set(42, "https://example.test/watch?v=abc"); begin(eof.player); send(eof.player, { reason: "eof" }); for (const action of ["stop", "cancel", "replacement", "shutdown"]) { const replacementUrl = "https://example.test/replacement"; const { player, starts } = makePlayer(replacementUrl); if (action === "shutdown") await player.shutdown(); else if (action === "stop") await player.stop(); else if (action === "replacement") await player.replace(replacementUrl); else await player.cancelBrowserFallback(); send(player, { reason: "error" }, 42); await settle(); assert.deepEqual(starts, [], `${action} must invalidate the callback`); } const stale = makePlayer(); stale.player.fallbackCandidates.add("https://example.test/watch?v=abc"); stale.player.entryCandidates.set(42, "https://example.test/watch?v=abc"); begin(stale.player, 42); begin(stale.player, 43); send(stale.player, { reason: "error" }, 42); await settle(); assert.deepEqual(stale.starts, []); }); test("fallback startup failure leaves MPV state usable", async () => { const { player, starts } = makePlayer(); const url = "https://example.test/watch?v=abc"; player.fallbackCandidates.add(url); player.entryCandidates.set(42, url); player.browserFallback.start = async () => { throw new Error("chromium missing"); }; begin(player); send(player, { reason: "error" }); await settle(); assert.equal(player.browserUrl, null); assert.deepEqual(starts, []); assert.equal(player.playbackErrors.get(url), "Unknown playback error"); });