Add Chromium fallback for failed playback
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
"main": "build/server.js",
|
"main": "build/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
|
"test": "npm run build && node test/run.cjs",
|
||||||
"dev": "concurrently \"tsc -w -p src\" \"nodemon build/server.js\"",
|
"dev": "concurrently \"tsc -w -p src\" \"nodemon build/server.js\"",
|
||||||
"start": "node build/index.js"
|
"start": "node build/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -24,6 +24,7 @@ import { PlaylistItem, LinkMetadata } from './types';
|
|||||||
import { FavoritesStore } from "./FavoritesStore";
|
import { FavoritesStore } from "./FavoritesStore";
|
||||||
import { Bonjour } from "bonjour-service";
|
import { Bonjour } from "bonjour-service";
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import { ChromiumFallback } from "./ChromiumFallback";
|
||||||
|
|
||||||
interface PendingCommand {
|
interface PendingCommand {
|
||||||
resolve: (value: any) => void;
|
resolve: (value: any) => void;
|
||||||
@@ -68,6 +69,16 @@ export class MediaPlayer {
|
|||||||
private lastLoadCandidate: string | null = null;
|
private lastLoadCandidate: string | null = null;
|
||||||
private currentMediaTitle: string | null = null;
|
private currentMediaTitle: string | null = null;
|
||||||
private currentPlaybackMetadata: Record<string, string> = {};
|
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() {
|
constructor() {
|
||||||
this.socket = this.tryRespawnPlayerProcess();
|
this.socket = this.tryRespawnPlayerProcess();
|
||||||
@@ -271,6 +282,10 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getNowPlaying(): Promise<PlaylistItem> {
|
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 playlist = await this.getPlaylist();
|
||||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||||
@@ -310,6 +325,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getCurrentFile(): Promise<string | null> {
|
public async getCurrentFile(): Promise<string | null> {
|
||||||
|
if (this.browserUrl) return this.browserUrl;
|
||||||
return this.writeCommand("get_property", ["stream-open-filename"])
|
return this.writeCommand("get_property", ["stream-open-filename"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -317,6 +333,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getPauseState(): Promise<boolean> {
|
public async getPauseState(): Promise<boolean> {
|
||||||
|
if (this.browserUrl) return false;
|
||||||
return this.writeCommand("get_property", ["pause"])
|
return this.writeCommand("get_property", ["pause"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -331,6 +348,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getTimePosition(): Promise<number | null> {
|
public async getTimePosition(): Promise<number | null> {
|
||||||
|
if (this.browserUrl) return null;
|
||||||
return this.writeCommand("get_property", ["time-pos"])
|
return this.writeCommand("get_property", ["time-pos"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -338,6 +356,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getDuration(): Promise<number | null> {
|
public async getDuration(): Promise<number | null> {
|
||||||
|
if (this.browserUrl) return null;
|
||||||
return this.writeCommand("get_property", ["duration"])
|
return this.writeCommand("get_property", ["duration"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -345,6 +364,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSeekable(): Promise<boolean | null> {
|
public async getSeekable(): Promise<boolean | null> {
|
||||||
|
if (this.browserUrl) return false;
|
||||||
return this.writeCommand("get_property", ["seekable"])
|
return this.writeCommand("get_property", ["seekable"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -352,6 +372,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getIdle(): Promise<boolean> {
|
public async getIdle(): Promise<boolean> {
|
||||||
|
if (this.browserUrl) return false;
|
||||||
return this.writeCommand("get_property", ["idle"])
|
return this.writeCommand("get_property", ["idle"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -359,14 +380,17 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async append(url: string) {
|
public async append(url: string) {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
await this.loadFile(url, "append-play");
|
await this.loadFile(url, "append-play");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async replace(url: string) {
|
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) {
|
public async initiateScreenSharing(url: string) {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
console.log(`Initiating screen sharing with file: ${url}`);
|
console.log(`Initiating screen sharing with file: ${url}`);
|
||||||
|
|
||||||
this.metadata.set(url, {
|
this.metadata.set(url, {
|
||||||
@@ -407,18 +431,26 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async shutdown() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
|
}
|
||||||
|
|
||||||
public async skip() {
|
public async skip() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async skipTo(index: number) {
|
public async skipTo(index: number) {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async previous() {
|
public async previous() {
|
||||||
|
await this.cancelBrowserFallback();
|
||||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
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.lastLoadCandidate = url;
|
||||||
this.playbackErrors.delete(url);
|
this.playbackErrors.delete(url);
|
||||||
const loadPromise = this.modify(
|
const loadPromise = this.modify(
|
||||||
@@ -675,25 +710,30 @@ export class MediaPlayer {
|
|||||||
} else if (response.event) {
|
} else if (response.event) {
|
||||||
if (response.event === "start-file") {
|
if (response.event === "start-file") {
|
||||||
// Clear any previous error for the file that is starting
|
// Clear any previous error for the file that is starting
|
||||||
const file = response.file || this.lastLoadCandidate;
|
if (this.browserUrl) void this.cancelBrowserFallback();
|
||||||
if (file) {
|
const entryId = typeof response.playlist_entry_id === "number"
|
||||||
this.currentFile = file;
|
? response.playlist_entry_id
|
||||||
this.playbackErrors.delete(file);
|
: 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.currentMediaTitle = null;
|
||||||
this.currentPlaybackMetadata = {};
|
this.currentPlaybackMetadata = {};
|
||||||
} else if (response.event === "property-change") {
|
} else if (response.event === "property-change") {
|
||||||
this.handlePropertyChange(response);
|
this.handlePropertyChange(response);
|
||||||
} else if (response.event === "end-file" && response.reason === "error") {
|
} else if (response.event === "end-file" && response.reason === "error") {
|
||||||
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
|
void this.handlePlaybackFailure(response);
|
||||||
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
|
} else if (response.event === "end-file") {
|
||||||
|
const file = response.file || this.currentFile || this.lastLoadCandidate;
|
||||||
this.playbackErrors.set(file, errorMessage);
|
if (file) this.fallbackCandidates.delete(file);
|
||||||
this.handleEvent(UserEvent.PlaybackError, {
|
|
||||||
filename: file,
|
|
||||||
error: errorMessage
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handleEvent(UserEvent.MPDUpdate, response);
|
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) {
|
private handlePropertyChange(response: any) {
|
||||||
const previousTitle = this.getCurrentDynamicTitle();
|
const previousTitle = this.getCurrentDynamicTitle();
|
||||||
|
|
||||||
|
|||||||
@@ -372,6 +372,9 @@ const server = app.listen(port, () => {
|
|||||||
// Add graceful shutdown handling
|
// Add graceful shutdown handling
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
console.log('Received shutdown signal. Closing server...');
|
console.log('Received shutdown signal. Closing server...');
|
||||||
|
|
||||||
|
// Invalidate pending playback failures and close only QueueCube's owned browser.
|
||||||
|
await mediaPlayer.shutdown();
|
||||||
|
|
||||||
// Stop zeroconf service
|
// Stop zeroconf service
|
||||||
mediaPlayer.stopZeroconfService();
|
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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
require("./ChromiumFallback.test.cjs");
|
||||||
|
require("./MediaPlayerFallback.test.cjs");
|
||||||
Reference in New Issue
Block a user