2026-11-07 10:34:25 -08:00
|
|
|
const test = require("node:test");
|
|
|
|
|
const assert = require("node:assert/strict");
|
|
|
|
|
const { MediaPlayer } = require("../build/MediaPlayer");
|
|
|
|
|
|
2026-11-07 10:48:45 -08:00
|
|
|
const makePlayer = (url = "https://example.test/watch?v=abc", playlist = [{ id: 42, filename: url }]) => {
|
2026-11-07 10:34:25 -08:00
|
|
|
const player = Object.create(MediaPlayer.prototype);
|
|
|
|
|
const starts = [];
|
2026-11-07 10:59:49 -08:00
|
|
|
const commands = [];
|
2026-11-07 10:34:25 -08:00
|
|
|
let stops = 0;
|
2026-11-07 10:48:45 -08:00
|
|
|
const state = {
|
2026-11-07 10:34:25 -08:00
|
|
|
eventSubscribers: [],
|
|
|
|
|
playbackErrors: new Map(),
|
2026-11-07 10:48:45 -08:00
|
|
|
currentFile: null,
|
|
|
|
|
lastLoadCandidate: null,
|
|
|
|
|
activeEntryId: null,
|
|
|
|
|
activeUrl: null,
|
2026-11-07 10:34:25 -08:00
|
|
|
currentMediaTitle: null,
|
|
|
|
|
currentPlaybackMetadata: {},
|
|
|
|
|
dataBuffer: "",
|
2026-11-07 10:48:45 -08:00
|
|
|
activeAttemptGeneration: 0,
|
|
|
|
|
fallbackSuppressedUntilStart: false,
|
|
|
|
|
fallbackCandidates: new Set(),
|
|
|
|
|
entryCandidates: new Map(),
|
|
|
|
|
pendingCandidateUrls: [],
|
2026-11-07 10:34:25 -08:00
|
|
|
browserUrl: null,
|
2026-11-07 10:48:45 -08:00
|
|
|
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;
|
2026-11-07 10:34:25 -08:00
|
|
|
},
|
2026-11-07 10:48:45 -08:00
|
|
|
stop: async () => {
|
|
|
|
|
stops += 1;
|
|
|
|
|
player.browserFallback.isRunning = false;
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-11-07 10:59:49 -08:00
|
|
|
player.writeCommand = async (command, args) => {
|
|
|
|
|
commands.push([command, args]);
|
2026-11-07 10:48:45 -08:00
|
|
|
if (command === "loadfile") return { error: "success" };
|
|
|
|
|
if (command === "get_property") return { data: playlist };
|
|
|
|
|
return { error: "success" };
|
|
|
|
|
};
|
|
|
|
|
player.fetchMetadataAndNotify = async () => undefined;
|
2026-11-07 10:59:49 -08:00
|
|
|
return { player, starts, commands, get stops() { return stops; }, set playlist(value) { playlist = value; } };
|
2026-11-07 10:34:25 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const begin = (player, entryId = 42) => player.receiveData(`${JSON.stringify({ event: "start-file", playlist_entry_id: entryId })}\n`);
|
2026-11-07 10:48:45 -08:00
|
|
|
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)); };
|
2026-11-07 10:34:25 -08:00
|
|
|
|
|
|
|
|
test("successful MPV completion does not start fallback", async () => {
|
|
|
|
|
const { player, starts } = makePlayer();
|
2026-11-07 10:48:45 -08:00
|
|
|
player.fallbackCandidates.add("https://example.test/watch?v=abc");
|
|
|
|
|
player.entryCandidates.set(42, "https://example.test/watch?v=abc");
|
2026-11-07 10:34:25 -08:00
|
|
|
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);
|
2026-11-07 10:48:45 -08:00
|
|
|
player.fallbackCandidates.add(url);
|
|
|
|
|
player.entryCandidates.set(42, url);
|
2026-11-07 10:34:25 -08:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-07 10:48:45 -08:00
|
|
|
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";
|
2026-11-07 10:59:49 -08:00
|
|
|
const { player, starts, commands } = makePlayer(a, [{ id: 42, filename: a }]);
|
2026-11-07 10:48:45 -08:00
|
|
|
player.fallbackCandidates.add(a);
|
|
|
|
|
player.entryCandidates.set(42, a);
|
|
|
|
|
begin(player, 42);
|
|
|
|
|
await player.append(b);
|
|
|
|
|
await settle();
|
2026-11-07 10:59:49 -08:00
|
|
|
assert.equal(commands.filter(([name]) => name === "loadfile").at(-1)[1][1], "append-play");
|
2026-11-07 10:48:45 -08:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-07 10:59:49 -08:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-07 10:48:45 -08:00
|
|
|
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: [] };
|
|
|
|
|
};
|
2026-11-07 10:51:47 -08:00
|
|
|
player.activeEntryId = null;
|
|
|
|
|
player.activeUrl = null;
|
|
|
|
|
player.currentFile = null;
|
2026-11-07 10:48:45 -08:00
|
|
|
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 () => {
|
2026-11-07 10:34:25 -08:00
|
|
|
const eof = makePlayer();
|
2026-11-07 10:48:45 -08:00
|
|
|
eof.player.fallbackCandidates.add("https://example.test/watch?v=abc");
|
|
|
|
|
eof.player.entryCandidates.set(42, "https://example.test/watch?v=abc");
|
|
|
|
|
begin(eof.player);
|
2026-11-07 10:34:25 -08:00
|
|
|
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();
|
2026-11-07 10:48:45 -08:00
|
|
|
send(player, { reason: "error" }, 42);
|
2026-11-07 10:34:25 -08:00
|
|
|
await settle();
|
|
|
|
|
assert.deepEqual(starts, [], `${action} must invalidate the callback`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stale = makePlayer();
|
2026-11-07 10:48:45 -08:00
|
|
|
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);
|
2026-11-07 10:34:25 -08:00
|
|
|
await settle();
|
|
|
|
|
assert.deepEqual(stale.starts, []);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("fallback startup failure leaves MPV state usable", async () => {
|
|
|
|
|
const { player, starts } = makePlayer();
|
2026-11-07 10:48:45 -08:00
|
|
|
const url = "https://example.test/watch?v=abc";
|
|
|
|
|
player.fallbackCandidates.add(url);
|
|
|
|
|
player.entryCandidates.set(42, url);
|
2026-11-07 10:34:25 -08:00
|
|
|
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, []);
|
2026-11-07 10:48:45 -08:00
|
|
|
assert.equal(player.playbackErrors.get(url), "Unknown playback error");
|
2026-11-07 10:34:25 -08:00
|
|
|
});
|