diff --git a/web/backend/package.json b/web/backend/package.json index c132da1..3e22847 100644 --- a/web/backend/package.json +++ b/web/backend/package.json @@ -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" }, diff --git a/web/backend/src/ChromiumFallback.ts b/web/backend/src/ChromiumFallback.ts new file mode 100644 index 0000000..d97bf16 --- /dev/null +++ b/web/backend/src/ChromiumFallback.ts @@ -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 { + 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((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 { + this.generation++; + const active = this.active; + if (active) await this.stopActive(active); + } + + private async stopActive(active: ChromiumProcess): Promise { + 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((resolve) => child.once("exit", () => resolve())), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); + + if (child.exitCode === null && child.signalCode === null) { + if (child.pid) this.signalOwnedGroup(child, "SIGKILL"); + await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve())), + new Promise((resolve) => setTimeout(resolve, 1000)), + ]); + } + } + + await this.removeProfile(active.profilePath); + } + + private async removeProfile(profilePath: string): Promise { + 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); + } + } +} diff --git a/web/backend/src/MediaPlayer.ts b/web/backend/src/MediaPlayer.ts index 34216e2..7b4c749 100644 --- a/web/backend/src/MediaPlayer.ts +++ b/web/backend/src/MediaPlayer.ts @@ -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 = {}; + private loadGeneration = 0; + private fallbackCandidates: Map = 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 { + 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 => { @@ -310,6 +325,7 @@ export class MediaPlayer { } public async getCurrentFile(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/web/backend/src/server.ts b/web/backend/src/server.ts index ab07920..0e3a153 100644 --- a/web/backend/src/server.ts +++ b/web/backend/src/server.ts @@ -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(); diff --git a/web/backend/test/ChromiumFallback.test.cjs b/web/backend/test/ChromiumFallback.test.cjs new file mode 100644 index 0000000..82191ce --- /dev/null +++ b/web/backend/test/ChromiumFallback.test.cjs @@ -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); +}); diff --git a/web/backend/test/MediaPlayerFallback.test.cjs b/web/backend/test/MediaPlayerFallback.test.cjs new file mode 100644 index 0000000..beee1fe --- /dev/null +++ b/web/backend/test/MediaPlayerFallback.test.cjs @@ -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"); +}); diff --git a/web/backend/test/run.cjs b/web/backend/test/run.cjs new file mode 100644 index 0000000..ed3c139 --- /dev/null +++ b/web/backend/test/run.cjs @@ -0,0 +1,2 @@ +require("./ChromiumFallback.test.cjs"); +require("./MediaPlayerFallback.test.cjs");