Add Chromium fallback for failed playback
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 { 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();
|
||||
|
||||
|
||||
@@ -373,6 +373,9 @@ const server = app.listen(port, () => {
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
require("./ChromiumFallback.test.cjs");
|
||||
require("./MediaPlayerFallback.test.cjs");
|
||||
Reference in New Issue
Block a user