Preserve fallback eligibility across queue transitions

This commit is contained in:
2026-11-07 10:50:06 -08:00
parent 3d7104b6b7
commit f80fd99211
2 changed files with 197 additions and 73 deletions
+98 -40
View File
@@ -69,9 +69,13 @@ 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 activeAttemptGeneration = 0;
private fallbackCandidates: Set<string> = new Set();
private entryCandidates: Map<number, string> = new Map();
private pendingCandidateUrls: string[] = [];
private activeEntryId: number | null = null;
private activeUrl: string | null = null;
private fallbackSuppressedUntilStart = false;
private browserUrl: string | null = null;
private browserFallback = new ChromiumFallback((url) => {
if (this.browserUrl === url) {
@@ -380,17 +384,18 @@ export class MediaPlayer {
}
public async append(url: string) {
await this.cancelBrowserFallback();
await this.loadFile(url, "append-play");
}
public async replace(url: string) {
await this.cancelBrowserFallback();
this.clearFallbackCandidates();
await this.loadFile(url, "replace", true, [], false);
}
public async initiateScreenSharing(url: string) {
await this.cancelBrowserFallback();
this.clearFallbackCandidates();
console.log(`Initiating screen sharing with file: ${url}`);
this.metadata.set(url, {
@@ -446,6 +451,16 @@ export class MediaPlayer {
public async skipTo(index: number) {
await this.cancelBrowserFallback();
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
const target = playlist[index];
if (target && this.fallbackCandidates.has(target.filename)) {
this.entryCandidates.set(target.id, target.filename);
this.removePendingCandidate(target.filename);
}
} catch {
// Existing entry-to-URL mappings remain available if MPV is transitioning.
}
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
}
@@ -503,9 +518,10 @@ export class MediaPlayer {
}
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);
if (fallbackEligible) {
this.fallbackCandidates.add(url);
this.pendingCandidateUrls.push(url);
}
this.lastLoadCandidate = url;
this.playbackErrors.delete(url);
const loadPromise = this.modify(
@@ -519,7 +535,15 @@ export class MediaPlayer {
});
}
await loadPromise;
const result = await loadPromise;
if (result?.error !== "success") {
if (fallbackEligible) {
this.removePendingCandidate(url);
if (![...this.entryCandidates.values()].includes(url)) this.fallbackCandidates.delete(url);
}
return;
}
if (fallbackEligible) void this.rememberPlaylistEntry(url);
}
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
@@ -709,21 +733,25 @@ export class MediaPlayer {
}
} else if (response.event) {
if (response.event === "start-file") {
// Clear any previous error for the file that is starting
if (this.browserUrl) void this.cancelBrowserFallback();
// A new MPV entry supersedes pending browser startup and playback state.
const hadBrowser = !!this.browserUrl || this.browserFallback.isRunning;
this.activeAttemptGeneration++;
this.fallbackSuppressedUntilStart = false;
this.browserUrl = null;
if (hadBrowser) void this.browserFallback.stop();
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;
let file = response.file || (entryId === null ? null : this.entryCandidates.get(entryId)) || null;
if (!file && this.pendingCandidateUrls.length > 0) {
file = this.pendingCandidateUrls.shift() || null;
if (entryId !== null && file) this.entryCandidates.set(entryId, file);
}
this.activeEntryId = entryId;
this.activeUrl = file;
this.currentFile = file || null;
if (file) this.playbackErrors.delete(file);
if (entryId !== null && !response.file) void this.refreshCurrentFile(entryId);
if (entryId !== null && !file) void this.refreshCurrentFile(entryId, this.activeAttemptGeneration);
this.currentMediaTitle = null;
this.currentPlaybackMetadata = {};
@@ -732,8 +760,7 @@ export class MediaPlayer {
} else if (response.event === "end-file" && response.reason === "error") {
void this.handlePlaybackFailure(response);
} else if (response.event === "end-file") {
const file = response.file || this.currentFile || this.lastLoadCandidate;
if (file) this.fallbackCandidates.delete(file);
// Keep entry eligibility for a later replay of this queue item.
}
this.handleEvent(UserEvent.MPDUpdate, response);
@@ -751,46 +778,64 @@ export class MediaPlayer {
const entryId = typeof response.playlist_entry_id === "number"
? response.playlist_entry_id
: this.activeEntryId;
if (entryId === null || entryId !== this.activeEntryId) return;
if (entryId === null) 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 attemptGeneration = this.activeAttemptGeneration;
const file = this.entryCandidates.get(entryId) || response.file ||
(entryId === this.activeEntryId ? this.activeUrl : null);
if (!file) return;
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);
// end-file identifies the failed playlist entry even if MPV has already
// cleared its current entry or advanced before any asynchronous query.
// A user transition increments the attempt generation and prevents stale
// failures from opening a browser.
if (this.fallbackCandidates.has(file) &&
(entryId === this.activeEntryId || (this.activeEntryId === null && !this.fallbackSuppressedUntilStart)) &&
attemptGeneration === this.activeAttemptGeneration) {
void this.startBrowserFallback(file, attemptGeneration, entryId);
}
}
private async refreshCurrentFile(entryId: number) {
private async refreshCurrentFile(entryId: number, attemptGeneration: number) {
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
if (this.activeEntryId !== entryId) return;
if (this.activeEntryId !== entryId || this.activeAttemptGeneration !== attemptGeneration) return;
const file = playlist.find((item) => item.id === entryId)?.filename || null;
this.currentFile = file;
this.activeUrl = file;
if (file && this.fallbackCandidates.has(file)) this.entryCandidates.set(entryId, file);
if (this.browserUrl && this.browserUrl !== file) void this.cancelBrowserFallback();
} catch {
// The player may be transitioning; a later start-file event will refresh it.
}
}
private async startBrowserFallback(url: string, generation: number) {
private async rememberPlaylistEntry(url: string) {
try {
const playlist = (await this.writeCommand("get_property", ["playlist"])).data as PlaylistItem[];
const entry = playlist.filter((item) => item.filename === url && !this.entryCandidates.has(item.id)).at(-1);
if (entry) {
this.entryCandidates.set(entry.id, url);
this.removePendingCandidate(url);
}
} catch {
// The pending candidate is associated when MPV emits start-file instead.
}
}
private async startBrowserFallback(url: string, generation: number, entryId: number) {
try {
const started = await this.browserFallback.start(url, () =>
generation === this.loadGeneration && this.currentFile === url,
generation === this.activeAttemptGeneration &&
this.activeEntryId === entryId &&
this.activeUrl === url,
);
if (started && this.browserFallback.isRunning && generation === this.loadGeneration && this.currentFile === url) {
if (started && this.browserFallback.isRunning &&
generation === this.activeAttemptGeneration &&
this.activeEntryId === entryId && this.activeUrl === url) {
this.browserUrl = url;
this.handleEvent(UserEvent.NowPlayingUpdate, {});
}
@@ -800,13 +845,26 @@ export class MediaPlayer {
}
private async cancelBrowserFallback() {
this.loadGeneration++;
this.fallbackCandidates.clear();
this.activeAttemptGeneration++;
this.fallbackSuppressedUntilStart = true;
this.activeEntryId = null;
this.activeUrl = null;
this.currentFile = null;
this.browserUrl = null;
await this.browserFallback.stop();
}
private clearFallbackCandidates() {
this.fallbackCandidates.clear();
this.entryCandidates.clear();
this.pendingCandidateUrls = [];
}
private removePendingCandidate(url: string) {
const index = this.pendingCandidateUrls.indexOf(url);
if (index >= 0) this.pendingCandidateUrls.splice(index, 1);
}
private handlePropertyChange(response: any) {
const previousTitle = this.getCurrentDynamicTitle();
+99 -33
View File
@@ -2,46 +2,61 @@ 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 makePlayer = (url = "https://example.test/watch?v=abc", playlist = [{ id: 42, filename: url }]) => {
const player = Object.create(MediaPlayer.prototype);
const starts = [];
let stops = 0;
Object.assign(player, {
const state = {
eventSubscribers: [],
playbackErrors: new Map(),
currentFile: url,
lastLoadCandidate: url,
activeEntryId: 42,
currentFile: null,
lastLoadCandidate: null,
activeEntryId: null,
activeUrl: null,
currentMediaTitle: null,
currentPlaybackMetadata: {},
dataBuffer: "",
loadGeneration: 4,
fallbackCandidates: new Map([[url, 4]]),
activeAttemptGeneration: 0,
fallbackSuppressedUntilStart: false,
fallbackCandidates: new Set(),
entryCandidates: new Map(),
pendingCandidateUrls: [],
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;
},
metadata: new Map(),
};
Object.assign(player, state);
player.browserFallback = {
isRunning: false,
start: async (requestedUrl, isCurrent) => {
starts.push(requestedUrl);
if (!isCurrent()) return false;
player.browserFallback.isRunning = true;
return true;
},
writeCommand: async () => ({ data: [{ id: 42, filename: url }] }),
});
return { player, starts, get stops() { return stops; } };
stop: async () => {
stops += 1;
player.browserFallback.isRunning = false;
},
};
player.writeCommand = async (command) => {
if (command === "loadfile") return { error: "success" };
if (command === "get_property") return { data: playlist };
return { error: "success" };
};
player.fetchMetadataAndNotify = async () => undefined;
return { player, starts, get stops() { return stops; }, set playlist(value) { playlist = value; } };
};
const begin = (player, entryId = 42) => player.receiveData(`${JSON.stringify({ event: "start-file", playlist_entry_id: entryId })}\n`);
const send = (player, event) => player.receiveData(`${JSON.stringify({ event: "end-file", file: player.currentFile, playlist_entry_id: player.activeEntryId, ...event })}\n`);
const settle = () => new Promise((resolve) => setImmediate(resolve));
const send = (player, event, entryId = player.activeEntryId) => player.receiveData(`${JSON.stringify({
event: "end-file", playlist_entry_id: entryId, ...event,
})}\n`);
const settle = async () => { await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); };
test("successful MPV completion does not start fallback", async () => {
const { player, starts } = makePlayer();
player.fallbackCandidates.add("https://example.test/watch?v=abc");
player.entryCandidates.set(42, "https://example.test/watch?v=abc");
begin(player);
send(player, { reason: "eof" });
await settle();
@@ -51,6 +66,8 @@ test("successful MPV completion does not start fallback", async () => {
test("actual MPV error opens the exact original URL and mirrors indeterminate state", async () => {
const url = "https://example.test/watch?v=x&next=1;touch%20/tmp/nope";
const { player, starts } = makePlayer(url);
player.fallbackCandidates.add(url);
player.entryCandidates.set(42, url);
begin(player);
send(player, { reason: "error", file_error: "demux failed" });
await settle();
@@ -64,40 +81,89 @@ test("actual MPV error opens the exact original URL and mirrors indeterminate st
assert.equal(await player.getSeekable(), false);
});
test("EOF and user stop, cancel, replacement, or stale queue events do not fallback", async () => {
test("queue append leaves active A eligible when queued B is added", async () => {
const a = "https://example.test/a";
const b = "https://example.test/b";
const { player, starts } = makePlayer(a, [{ id: 42, filename: a }]);
player.fallbackCandidates.add(a);
player.entryCandidates.set(42, a);
begin(player, 42);
await player.append(b);
await settle();
send(player, { reason: "error", file_error: "failed A" }, 42);
await settle();
assert.deepEqual(starts, [a]);
assert.equal(player.browserUrl, a);
assert.equal(player.fallbackCandidates.has(b), true);
});
test("append then skipTo keeps the selected entry eligible for its actual MPV error", async () => {
const url = "https://example.test/shared-original?x=1&y=2";
const { player, starts } = makePlayer(url, [{ id: 42, filename: url }]);
await player.append(url);
await settle();
await player.skipTo(0);
begin(player, 42);
send(player, { reason: "error", file_error: "load failed" }, 42);
await settle();
assert.deepEqual(starts, [url]);
});
test("failure is correlated from MPV end-file entry id without querying a cleared playlist", async () => {
const url = "https://example.test/fails";
const { player, starts } = makePlayer(url);
player.fallbackCandidates.add(url);
player.entryCandidates.set(42, url);
begin(player, 42);
player.writeCommand = async (command) => {
assert.notEqual(command, "get_property", "failure correlation must use the emitted entry id");
return { data: [] };
};
send(player, { reason: "error", file_error: "network unavailable" }, 42);
await settle();
assert.deepEqual(starts, [url]);
assert.equal(player.playbackErrors.get(url), "network unavailable");
});
test("EOF, user stop, cancel, replacement, or stale queue events do not fallback", async () => {
const eof = makePlayer();
eof.player.fallbackCandidates.add("https://example.test/watch?v=abc");
eof.player.entryCandidates.set(42, "https://example.test/watch?v=abc");
begin(eof.player);
send(eof.player, { reason: "eof" });
for (const action of ["stop", "cancel", "replacement", "shutdown"]) {
const replacementUrl = "https://example.test/replacement";
const { player, starts } = makePlayer(replacementUrl);
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" });
send(player, { reason: "error" }, 42);
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 });
stale.player.fallbackCandidates.add("https://example.test/watch?v=abc");
stale.player.entryCandidates.set(42, "https://example.test/watch?v=abc");
begin(stale.player, 42);
begin(stale.player, 43);
send(stale.player, { reason: "error" }, 42);
await settle();
assert.deepEqual(stale.starts, []);
});
test("fallback startup failure leaves MPV state usable", async () => {
const { player, starts } = makePlayer();
const url = "https://example.test/watch?v=abc";
player.fallbackCandidates.add(url);
player.entryCandidates.set(42, url);
player.browserFallback.start = async () => { throw new Error("chromium missing"); };
begin(player);
send(player, { reason: "error" });
await settle();
assert.equal(player.browserUrl, null);
assert.deepEqual(starts, []);
assert.equal(player.playbackErrors.get(player.currentFile), "Unknown playback error");
assert.equal(player.playbackErrors.get(url), "Unknown playback error");
});