Preserve fallback eligibility across queue transitions
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user