Compare commits

...
5 Commits
7 changed files with 661 additions and 17 deletions
+1
View File
@@ -4,6 +4,7 @@
"main": "build/server.js",
"scripts": {
"build": "tsc -b",
"test": "npm run build && node test/run.cjs",
"dev": "concurrently \"tsc -w -p src\" \"nodemon build/server.js\"",
"start": "node build/index.js"
},
+164
View File
@@ -0,0 +1,164 @@
import { ChildProcess, spawn } from "child_process";
import { accessSync, constants } from "fs";
import { mkdtemp, rm } from "fs/promises";
import os from "os";
import path from "path";
export interface ChromiumProcess {
process: ChildProcess;
profilePath: string;
generation: number;
}
export class ChromiumFallback {
private active: ChromiumProcess | null = null;
private generation = 0;
constructor(
private readonly onExit: (url: string) => void = () => undefined,
private readonly executable = process.env.QUEUECUBE_CHROME_BIN || ChromiumFallback.findExecutable(),
) {}
private static findExecutable(): string {
const directories = (process.env.PATH || "").split(path.delimiter);
for (const name of ["chromium", "chromium-browser", "google-chrome"]) {
for (const directory of directories) {
if (!directory) continue;
const candidate = path.join(directory, name);
try {
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// Keep checking the remaining supported executable names.
}
}
}
return "chromium";
}
public get isRunning(): boolean {
return this.active !== null;
}
public async start(url: string, isCurrent: () => boolean): Promise<boolean> {
await this.stop();
const generation = ++this.generation;
const profilePath = await mkdtemp(path.join(os.tmpdir(), "queuecube-chrome-"));
if (generation !== this.generation || !isCurrent()) {
await this.removeProfile(profilePath);
return false;
}
let child: ChildProcess;
try {
child = spawn(this.executable, [
"--kiosk",
"--no-first-run",
"--no-default-browser-check",
`--user-data-dir=${profilePath}`,
"--",
url,
], { detached: true, stdio: "ignore" });
} catch (error) {
await this.removeProfile(profilePath);
throw error;
}
const active = { process: child, profilePath, generation };
this.active = active;
child.once("spawn", () => {
if (this.active !== active || generation !== this.generation) {
this.signalOwnedGroup(child, "SIGTERM");
}
});
return new Promise<boolean>((resolve, reject) => {
let settled = false;
const cleanup = () => {
if (this.active === active) {
this.active = null;
// Chromium can exit before its helper processes. Reap only the
// process group created for this fallback instance.
this.signalOwnedGroup(child, "SIGTERM");
this.onExit(url);
}
void this.removeProfile(profilePath);
};
child.once("error", (error) => {
cleanup();
if (!settled) {
settled = true;
reject(error);
}
});
child.once("exit", () => {
cleanup();
if (!settled) {
settled = true;
resolve(false);
}
});
child.once("spawn", () => {
if (settled) return;
settled = true;
if (generation !== this.generation || !isCurrent()) {
void this.stopActive(active);
resolve(false);
return;
}
resolve(true);
});
});
}
public async stop(): Promise<void> {
this.generation++;
const active = this.active;
if (active) await this.stopActive(active);
}
private async stopActive(active: ChromiumProcess): Promise<void> {
if (this.active === active) this.active = null;
const child = active.process;
if (child.exitCode === null && child.signalCode === null) {
if (child.pid) this.signalOwnedGroup(child, "SIGTERM");
await Promise.race([
new Promise<void>((resolve) => child.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, 2000)),
]);
if (child.exitCode === null && child.signalCode === null) {
if (child.pid) this.signalOwnedGroup(child, "SIGKILL");
await Promise.race([
new Promise<void>((resolve) => child.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
]);
}
}
await this.removeProfile(active.profilePath);
}
private async removeProfile(profilePath: string): Promise<void> {
try {
await rm(profilePath, { recursive: true, force: true });
} catch (error) {
console.error(`Failed to remove Chromium profile ${profilePath}:`, error);
}
}
private signalOwnedGroup(child: ChildProcess, signal: NodeJS.Signals): void {
if (!child.pid) return;
try {
// detached=true gives this instance its own process group. Signal only
// that group so Chromium helpers cannot outlive the owned browser.
process.kill(-child.pid, signal);
} catch (error: any) {
if (error.code !== "ESRCH") child.kill(signal);
}
}
}
+196 -17
View File
@@ -24,6 +24,7 @@ import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore";
import { Bonjour } from "bonjour-service";
import os from 'os';
import { ChromiumFallback } from "./ChromiumFallback";
interface PendingCommand {
resolve: (value: any) => void;
@@ -68,6 +69,21 @@ export class MediaPlayer {
private lastLoadCandidate: string | null = null;
private currentMediaTitle: string | null = null;
private currentPlaybackMetadata: Record<string, string> = {};
private activeAttemptGeneration = 0;
private fallbackCandidates: Set<string> = new Set();
private entryCandidates: Map<number, string> = new Map();
private pendingCandidateUrls: string[] = [];
private activeEntryId: number | null = null;
private activeUrl: string | null = null;
private fallbackSuppressedUntilStart = false;
private fallbackPendingUrl: string | null = null;
private browserUrl: string | null = null;
private browserFallback = new ChromiumFallback((url) => {
if (this.browserUrl === url) {
this.browserUrl = null;
this.handleEvent(UserEvent.NowPlayingUpdate, {});
}
});
constructor() {
this.socket = this.tryRespawnPlayerProcess();
@@ -271,6 +287,10 @@ export class MediaPlayer {
}
public async getNowPlaying(): Promise<PlaylistItem> {
if (this.browserUrl) {
return { id: -1, filename: this.browserUrl, title: this.browserUrl, playing: true, current: true };
}
const playlist = await this.getPlaylist();
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
const fetchMediaTitle = async (): Promise<string | null> => {
@@ -310,6 +330,7 @@ export class MediaPlayer {
}
public async getCurrentFile(): Promise<string | null> {
if (this.browserUrl) return this.browserUrl;
return this.writeCommand("get_property", ["stream-open-filename"])
.then((response) => {
return response.data;
@@ -317,6 +338,7 @@ export class MediaPlayer {
}
public async getPauseState(): Promise<boolean> {
if (this.browserUrl) return false;
return this.writeCommand("get_property", ["pause"])
.then((response) => {
return response.data;
@@ -331,6 +353,7 @@ export class MediaPlayer {
}
public async getTimePosition(): Promise<number | null> {
if (this.browserUrl) return null;
return this.writeCommand("get_property", ["time-pos"])
.then((response) => {
return response.data;
@@ -338,6 +361,7 @@ export class MediaPlayer {
}
public async getDuration(): Promise<number | null> {
if (this.browserUrl) return null;
return this.writeCommand("get_property", ["duration"])
.then((response) => {
return response.data;
@@ -345,6 +369,7 @@ export class MediaPlayer {
}
public async getSeekable(): Promise<boolean | null> {
if (this.browserUrl) return false;
return this.writeCommand("get_property", ["seekable"])
.then((response) => {
return response.data;
@@ -352,6 +377,7 @@ export class MediaPlayer {
}
public async getIdle(): Promise<boolean> {
if (this.browserUrl) return false;
return this.writeCommand("get_property", ["idle"])
.then((response) => {
return response.data;
@@ -359,14 +385,21 @@ export class MediaPlayer {
}
public async append(url: string) {
await this.loadFile(url, "append-play");
const mode = this.browserUrl || this.fallbackPendingUrl || this.browserFallback.isRunning
? "append"
: "append-play";
await this.loadFile(url, mode);
}
public async replace(url: string) {
await this.loadFile(url, "replace");
await this.cancelBrowserFallback();
this.clearFallbackCandidates();
await this.loadFile(url, "replace", true, [], false);
}
public async initiateScreenSharing(url: string) {
await this.cancelBrowserFallback();
this.clearFallbackCandidates();
console.log(`Initiating screen sharing with file: ${url}`);
this.metadata.set(url, {
@@ -407,18 +440,36 @@ export class MediaPlayer {
}
public async stop() {
await this.cancelBrowserFallback();
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
}
public async shutdown() {
await this.cancelBrowserFallback();
}
public async skip() {
await this.cancelBrowserFallback();
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
}
public async skipTo(index: number) {
await this.cancelBrowserFallback();
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
const target = playlist[index];
if (target && this.fallbackCandidates.has(target.filename)) {
this.entryCandidates.set(target.id, target.filename);
this.removePendingCandidate(target.filename);
}
} catch {
// Existing entry-to-URL mappings remain available if MPV is transitioning.
}
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
}
public async previous() {
await this.cancelBrowserFallback();
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
}
@@ -470,7 +521,11 @@ export class MediaPlayer {
};
}
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = [], fallbackEligible: boolean = true) {
if (fallbackEligible) {
this.fallbackCandidates.add(url);
this.pendingCandidateUrls.push(url);
}
this.lastLoadCandidate = url;
this.playbackErrors.delete(url);
const loadPromise = this.modify(
@@ -484,7 +539,18 @@ export class MediaPlayer {
});
}
await loadPromise;
const result = await loadPromise;
if (result?.error !== "success") {
if (fallbackEligible) {
this.removePendingCandidate(url);
if (!this.pendingCandidateUrls.includes(url) &&
![...this.entryCandidates.values()].includes(url)) {
this.fallbackCandidates.delete(url);
}
}
return;
}
if (fallbackEligible) void this.rememberPlaylistEntry(url);
}
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
@@ -674,26 +740,35 @@ export class MediaPlayer {
}
} else if (response.event) {
if (response.event === "start-file") {
// Clear any previous error for the file that is starting
const file = response.file || this.lastLoadCandidate;
if (file) {
this.currentFile = file;
this.playbackErrors.delete(file);
// A new MPV entry supersedes pending browser startup and playback state.
const hadBrowser = !!this.browserUrl || this.browserFallback.isRunning;
this.activeAttemptGeneration++;
this.fallbackSuppressedUntilStart = false;
this.fallbackPendingUrl = null;
this.browserUrl = null;
if (hadBrowser) void this.browserFallback.stop();
const entryId = typeof response.playlist_entry_id === "number"
? response.playlist_entry_id
: null;
let file = response.file || (entryId === null ? null : this.entryCandidates.get(entryId)) || null;
if (!file && this.pendingCandidateUrls.length > 0) {
file = this.pendingCandidateUrls.shift() || null;
if (entryId !== null && file) this.entryCandidates.set(entryId, file);
}
this.activeEntryId = entryId;
this.activeUrl = file;
this.currentFile = file || null;
if (file) this.playbackErrors.delete(file);
if (entryId !== null && !file) void this.refreshCurrentFile(entryId, this.activeAttemptGeneration);
this.currentMediaTitle = null;
this.currentPlaybackMetadata = {};
} else if (response.event === "property-change") {
this.handlePropertyChange(response);
} else if (response.event === "end-file" && response.reason === "error") {
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
this.playbackErrors.set(file, errorMessage);
this.handleEvent(UserEvent.PlaybackError, {
filename: file,
error: errorMessage
});
void this.handlePlaybackFailure(response);
} else if (response.event === "end-file") {
// Keep entry eligibility for a later replay of this queue item.
}
this.handleEvent(UserEvent.MPDUpdate, response);
@@ -707,6 +782,110 @@ export class MediaPlayer {
}
}
private async handlePlaybackFailure(response: any) {
const entryId = typeof response.playlist_entry_id === "number"
? response.playlist_entry_id
: this.activeEntryId;
if (entryId === null) return;
const attemptGeneration = this.activeAttemptGeneration;
const file = this.entryCandidates.get(entryId) || response.file ||
(entryId === this.activeEntryId ? this.activeUrl : null);
if (!file) return;
if (this.activeEntryId === null && !this.fallbackSuppressedUntilStart) {
// MPV can clear its current entry before delivering end-file. Restore
// this event's known entry from its playlist_entry_id for mirroring.
this.activeEntryId = entryId;
this.activeUrl = file;
this.currentFile = file;
}
const errorMessage = response.file_error || response.error || response["file-error"] || "Unknown playback error";
this.playbackErrors.set(file, errorMessage);
this.handleEvent(UserEvent.PlaybackError, { filename: file, error: errorMessage });
// end-file identifies the failed playlist entry even if MPV has already
// cleared its current entry or advanced before any asynchronous query.
// A user transition increments the attempt generation and prevents stale
// failures from opening a browser.
if (this.fallbackCandidates.has(file) &&
(entryId === this.activeEntryId || (this.activeEntryId === null && !this.fallbackSuppressedUntilStart)) &&
attemptGeneration === this.activeAttemptGeneration) {
this.fallbackPendingUrl = file;
void this.startBrowserFallback(file, attemptGeneration, entryId);
}
}
private async refreshCurrentFile(entryId: number, attemptGeneration: number) {
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
if (this.activeEntryId !== entryId || this.activeAttemptGeneration !== attemptGeneration) return;
const file = playlist.find((item) => item.id === entryId)?.filename || null;
this.currentFile = file;
this.activeUrl = file;
if (file && this.fallbackCandidates.has(file)) this.entryCandidates.set(entryId, file);
if (this.browserUrl && this.browserUrl !== file) void this.cancelBrowserFallback();
} catch {
// The player may be transitioning; a later start-file event will refresh it.
}
}
private async rememberPlaylistEntry(url: string) {
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
const entry = playlist.filter((item) => item.filename === url && !this.entryCandidates.has(item.id)).at(-1);
if (entry) {
this.entryCandidates.set(entry.id, url);
this.removePendingCandidate(url);
}
} catch {
// The pending candidate is associated when MPV emits start-file instead.
}
}
private async startBrowserFallback(url: string, generation: number, entryId: number) {
try {
const started = await this.browserFallback.start(url, () =>
generation === this.activeAttemptGeneration &&
this.activeEntryId === entryId &&
this.activeUrl === url,
);
if (started && this.browserFallback.isRunning &&
generation === this.activeAttemptGeneration &&
this.activeEntryId === entryId && this.activeUrl === url) {
this.browserUrl = url;
this.handleEvent(UserEvent.NowPlayingUpdate, {});
}
} catch (error) {
console.error(`Failed to start Chromium fallback for ${url}:`, error);
} finally {
if (generation === this.activeAttemptGeneration && this.fallbackPendingUrl === url) {
this.fallbackPendingUrl = null;
}
}
}
private async cancelBrowserFallback() {
this.activeAttemptGeneration++;
this.fallbackSuppressedUntilStart = true;
this.activeEntryId = null;
this.activeUrl = null;
this.currentFile = null;
this.fallbackPendingUrl = null;
this.browserUrl = null;
await this.browserFallback.stop();
}
private clearFallbackCandidates() {
this.fallbackCandidates.clear();
this.entryCandidates.clear();
this.pendingCandidateUrls = [];
}
private removePendingCandidate(url: string) {
const index = this.pendingCandidateUrls.indexOf(url);
if (index >= 0) this.pendingCandidateUrls.splice(index, 1);
}
private handlePropertyChange(response: any) {
const previousTitle = this.getCurrentDynamicTitle();
+3
View File
@@ -372,6 +372,9 @@ const server = app.listen(port, () => {
// Add graceful shutdown handling
const shutdown = async () => {
console.log('Received shutdown signal. Closing server...');
// Invalidate pending playback failures and close only QueueCube's owned browser.
await mediaPlayer.shutdown();
// Stop zeroconf service
mediaPlayer.stopZeroconfService();
+104
View File
@@ -0,0 +1,104 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { ChromiumFallback } = require("../build/ChromiumFallback");
const waitFor = async (predicate) => {
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
if (await predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("timed out waiting for subprocess fixture");
};
test("uses isolated profile and safe argv; stop kills only its process and cleans profile", async () => {
const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "queuecube-test-"));
const argsFile = path.join(fixture, "argv.json");
const executable = path.join(fixture, "fake-chrome.js");
await fs.writeFile(executable, `#!/usr/bin/env node\nconst fs=require('fs'); fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2))); setInterval(()=>{},1000);\n`);
await fs.chmod(executable, 0o700);
const browser = new ChromiumFallback(() => undefined, executable);
const url = "https://example.test/?x=1; touch /tmp/should-not-exist";
try {
assert.equal(await browser.start(url, () => true), true);
await waitFor(async () => { try { await fs.access(argsFile); return true; } catch { return false; } });
const args = JSON.parse(await fs.readFile(argsFile, "utf8"));
assert.equal(args.at(-1), url);
assert.ok(args.some((arg) => arg.startsWith("--user-data-dir=")));
assert.ok(args.includes("--kiosk"));
assert.ok(!args.some((arg) => /no-sandbox|disable-web-security|ignore-certificate/i.test(arg)));
const profilePath = args.find((arg) => arg.startsWith("--user-data-dir=")).split("=", 2)[1];
await browser.stop();
assert.equal(browser.isRunning, false);
await assert.rejects(fs.access(profilePath));
} finally {
await browser.stop();
await fs.rm(fixture, { recursive: true, force: true });
}
});
test("discovers chromium-browser when that is the installed executable", async () => {
const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "queuecube-browser-path-test-"));
const argsFile = path.join(fixture, "argv.json");
const executable = path.join(fixture, "chromium-browser");
const previousPath = process.env.PATH;
const previousOverride = process.env.QUEUECUBE_CHROME_BIN;
await fs.writeFile(executable, `#!/usr/bin/env node\nconst fs=require('fs'); fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2))); setInterval(()=>{},1000);\n`);
await fs.chmod(executable, 0o700);
await fs.symlink(process.execPath, path.join(fixture, "node"));
try {
process.env.PATH = fixture;
delete process.env.QUEUECUBE_CHROME_BIN;
const browser = new ChromiumFallback();
assert.equal(await browser.start("https://example.test/browser-discovery", () => true), true);
await waitFor(async () => { try { await fs.access(argsFile); return true; } catch { return false; } });
assert.equal(JSON.parse(await fs.readFile(argsFile, "utf8")).at(-1), "https://example.test/browser-discovery");
await browser.stop();
} finally {
process.env.PATH = previousPath;
if (previousOverride === undefined) delete process.env.QUEUECUBE_CHROME_BIN;
else process.env.QUEUECUBE_CHROME_BIN = previousOverride;
await fs.rm(fixture, { recursive: true, force: true });
}
});
test("canceled startup cleans its profile without spawning", async () => {
const browser = new ChromiumFallback(() => undefined, "/bin/false");
assert.equal(await browser.start("https://example.test/", () => false), false);
assert.equal(browser.isRunning, false);
});
test("cancellation during process startup stops the late process and removes its profile", async () => {
const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "queuecube-race-test-"));
const executable = path.join(fixture, "fake-chrome.js");
await fs.writeFile(executable, "#!/usr/bin/env node\nsetInterval(()=>{},1000);\n");
await fs.chmod(executable, 0o700);
const browser = new ChromiumFallback(() => undefined, executable);
const cleanedProfiles = [];
const removeProfile = browser.removeProfile.bind(browser);
browser.removeProfile = async (profile) => { cleanedProfiles.push(profile); await removeProfile(profile); };
let checks = 0;
try {
assert.equal(await browser.start("https://example.test/race", () => ++checks === 1), false);
assert.equal(checks, 2, "the request became stale after process startup");
await waitFor(async () => {
if (!cleanedProfiles.length) return false;
try { await fs.access(cleanedProfiles[0]); return false; } catch { return true; }
});
assert.equal(browser.isRunning, false);
} finally {
await browser.stop();
await fs.rm(fixture, { recursive: true, force: true });
}
});
test("missing browser executable reports startup failure and clears ownership", async () => {
const browser = new ChromiumFallback(() => undefined, "/definitely/missing/queuecube-chrome");
await assert.rejects(browser.start("https://example.test/", () => true), /ENOENT/);
assert.equal(browser.isRunning, false);
});
@@ -0,0 +1,191 @@
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");
});
+2
View File
@@ -0,0 +1,2 @@
require("./ChromiumFallback.test.cjs");
require("./MediaPlayerFallback.test.cjs");