diff --git a/web/backend/src/MediaPlayer.ts b/web/backend/src/MediaPlayer.ts index 7b4c749..96386a3 100644 --- a/web/backend/src/MediaPlayer.ts +++ b/web/backend/src/MediaPlayer.ts @@ -69,9 +69,13 @@ export class MediaPlayer { private lastLoadCandidate: string | null = null; private currentMediaTitle: string | null = null; private currentPlaybackMetadata: Record = {}; - private loadGeneration = 0; - private fallbackCandidates: Map = new Map(); + private activeAttemptGeneration = 0; + private fallbackCandidates: Set = new Set(); + private entryCandidates: Map = 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(event: UserEvent, func: () => Promise): Promise { @@ -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(); diff --git a/web/backend/test/MediaPlayerFallback.test.cjs b/web/backend/test/MediaPlayerFallback.test.cjs index beee1fe..921b1c5 100644 --- a/web/backend/test/MediaPlayerFallback.test.cjs +++ b/web/backend/test/MediaPlayerFallback.test.cjs @@ -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"); });