Add Chromium fallback for failed playback

This commit is contained in:
2026-11-07 10:49:48 -08:00
parent 57b7d4cb82
commit 3d7104b6b7
7 changed files with 448 additions and 15 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"
},
+146
View File
@@ -0,0 +1,146 @@
import { ChildProcess, spawn } from "child_process";
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 || "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);
}
}
}
+115 -15
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,16 @@ export class MediaPlayer {
private lastLoadCandidate: string | null = null;
private currentMediaTitle: string | null = null;
private currentPlaybackMetadata: Record<string, string> = {};
private loadGeneration = 0;
private fallbackCandidates: Map<string, number> = new Map();
private activeEntryId: number | 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 +282,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 +325,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 +333,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 +348,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 +356,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 +364,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 +372,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 +380,17 @@ export class MediaPlayer {
}
public async append(url: string) {
await this.cancelBrowserFallback();
await this.loadFile(url, "append-play");
}
public async replace(url: string) {
await this.loadFile(url, "replace");
await this.cancelBrowserFallback();
await this.loadFile(url, "replace", true, [], false);
}
public async initiateScreenSharing(url: string) {
await this.cancelBrowserFallback();
console.log(`Initiating screen sharing with file: ${url}`);
this.metadata.set(url, {
@@ -407,18 +431,26 @@ 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();
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 +502,10 @@ 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) {
const generation = ++this.loadGeneration;
if (fallbackEligible) this.fallbackCandidates.set(url, generation);
else this.fallbackCandidates.delete(url);
this.lastLoadCandidate = url;
this.playbackErrors.delete(url);
const loadPromise = this.modify(
@@ -675,25 +710,30 @@ 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);
}
if (this.browserUrl) void this.cancelBrowserFallback();
const entryId = typeof response.playlist_entry_id === "number"
? response.playlist_entry_id
: null;
const pendingCandidate = this.lastLoadCandidate && this.fallbackCandidates.has(this.lastLoadCandidate)
? this.lastLoadCandidate
: null;
const file = response.file || pendingCandidate;
this.activeEntryId = typeof response.playlist_entry_id === "number"
? response.playlist_entry_id
: null;
this.currentFile = file || null;
if (file) this.playbackErrors.delete(file);
if (entryId !== null && !response.file) void this.refreshCurrentFile(entryId);
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") {
const file = response.file || this.currentFile || this.lastLoadCandidate;
if (file) this.fallbackCandidates.delete(file);
}
this.handleEvent(UserEvent.MPDUpdate, response);
@@ -707,6 +747,66 @@ 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 || entryId !== this.activeEntryId) return;
let file = response.file as string | undefined;
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
file = playlist.find((item) => item.id === entryId)?.filename || file;
} catch {
// MPV can be transitioning already; use the active load URL if available.
}
file = file || this.currentFile || this.lastLoadCandidate || "Unknown 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 });
const candidateGeneration = this.fallbackCandidates.get(file);
this.fallbackCandidates.delete(file);
if (entryId === this.activeEntryId && candidateGeneration === this.loadGeneration) {
void this.startBrowserFallback(file, candidateGeneration);
}
}
private async refreshCurrentFile(entryId: number) {
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
if (this.activeEntryId !== entryId) return;
const file = playlist.find((item) => item.id === entryId)?.filename || null;
this.currentFile = 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 startBrowserFallback(url: string, generation: number) {
try {
const started = await this.browserFallback.start(url, () =>
generation === this.loadGeneration && this.currentFile === url,
);
if (started && this.browserFallback.isRunning && generation === this.loadGeneration && this.currentFile === url) {
this.browserUrl = url;
this.handleEvent(UserEvent.NowPlayingUpdate, {});
}
} catch (error) {
console.error(`Failed to start Chromium fallback for ${url}:`, error);
}
}
private async cancelBrowserFallback() {
this.loadGeneration++;
this.fallbackCandidates.clear();
this.activeEntryId = null;
this.browserUrl = null;
await this.browserFallback.stop();
}
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();
@@ -0,0 +1,78 @@
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("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,103 @@
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") => {
const player = Object.create(MediaPlayer.prototype);
const starts = [];
let stops = 0;
Object.assign(player, {
eventSubscribers: [],
playbackErrors: new Map(),
currentFile: url,
lastLoadCandidate: url,
activeEntryId: 42,
currentMediaTitle: null,
currentPlaybackMetadata: {},
dataBuffer: "",
loadGeneration: 4,
fallbackCandidates: new Map([[url, 4]]),
browserUrl: null,
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;
},
},
writeCommand: async () => ({ data: [{ id: 42, filename: url }] }),
});
return { player, starts, get stops() { return stops; } };
};
const begin = (player, entryId = 42) => player.receiveData(`${JSON.stringify({ event: "start-file", playlist_entry_id: entryId })}\n`);
const send = (player, event) => player.receiveData(`${JSON.stringify({ event: "end-file", file: player.currentFile, playlist_entry_id: player.activeEntryId, ...event })}\n`);
const settle = () => new Promise((resolve) => setImmediate(resolve));
test("successful MPV completion does not start fallback", async () => {
const { player, starts } = makePlayer();
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);
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("EOF and user stop, cancel, replacement, or stale queue events do not fallback", async () => {
const eof = makePlayer();
send(eof.player, { reason: "eof" });
for (const action of ["stop", "cancel", "replacement", "shutdown"]) {
const replacementUrl = "https://example.test/replacement";
const { player, starts } = makePlayer(replacementUrl);
player.writeCommand = async () => ({});
player.fetchMetadataAndNotify = async () => undefined;
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();
begin(player);
send(player, { reason: "error" });
await settle();
assert.deepEqual(starts, [], `${action} must invalidate the callback`);
}
const stale = makePlayer();
stale.player.currentFile = "https://example.test/next";
stale.player.activeEntryId = 43;
send(stale.player, { reason: "error", playlist_entry_id: 42 });
await settle();
assert.deepEqual(stale.starts, []);
});
test("fallback startup failure leaves MPV state usable", async () => {
const { player, starts } = makePlayer();
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(player.currentFile), "Unknown playback error");
});
+2
View File
@@ -0,0 +1,2 @@
require("./ChromiumFallback.test.cjs");
require("./MediaPlayerFallback.test.cjs");