From cfe5063828538e5a627854a61ebdd4268be70f49 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 1 May 2026 08:51:38 -0700 Subject: [PATCH] backend: observe metadata/title changes --- web/backend/src/MediaPlayer.ts | 191 +++++++++++++++++++++++++++++++-- 1 file changed, 181 insertions(+), 10 deletions(-) diff --git a/web/backend/src/MediaPlayer.ts b/web/backend/src/MediaPlayer.ts index 7d9a242..3dd28d8 100644 --- a/web/backend/src/MediaPlayer.ts +++ b/web/backend/src/MediaPlayer.ts @@ -30,6 +30,11 @@ interface PendingCommand { reject: (reason: any) => void; } +enum ObservedProperty { + MediaTitle = 1, + Metadata = 2, +} + enum UserEvent { PlaylistUpdate = "playlist_update", NowPlayingUpdate = "now_playing_update", @@ -61,6 +66,8 @@ export class MediaPlayer { private playbackErrors: Map = new Map(); private currentFile: string | null = null; private lastLoadCandidate: string | null = null; + private currentMediaTitle: string | null = null; + private currentPlaybackMetadata: Record = {}; constructor() { this.socket = this.tryRespawnPlayerProcess(); @@ -160,11 +167,15 @@ export class MediaPlayer { .then((response) => { // Enhance playlist items with metadata const playlist = response.data as PlaylistItem[]; - return playlist.map((item: PlaylistItem) => ({ - ...item, - metadata: this.metadata.get(item.filename) || {}, - playbackError: this.playbackErrors.get(item.filename) - })); + return playlist.map((item: PlaylistItem) => { + const enhancedItem = { + ...item, + metadata: this.metadata.get(item.filename) || {}, + playbackError: this.playbackErrors.get(item.filename) + }; + + return item.current ? this.withCurrentDynamicTitle(enhancedItem) : enhancedItem; + }); }); } @@ -173,29 +184,37 @@ export class MediaPlayer { const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current); const fetchMediaTitle = async (): Promise => { try { - return (await this.writeCommand("get_property", ["media-title"])).data; + const mediaTitle = (await this.writeCommand("get_property", ["media-title"])).data; + this.currentMediaTitle = typeof mediaTitle === "string" ? mediaTitle : null; + return this.currentMediaTitle; } catch (err) { return null; } }; + const mediaTitle = await fetchMediaTitle(); + if (currentlyPlayingSong !== undefined) { + const dynamicTitle = this.getCurrentDynamicTitle(currentlyPlayingSong); + if (dynamicTitle) { + return this.withCurrentDynamicTitle(currentlyPlayingSong, dynamicTitle); + } + // Use media title if we don't have a title if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) { return { ...currentlyPlayingSong, - title: await fetchMediaTitle() || currentlyPlayingSong.filename + title: mediaTitle || currentlyPlayingSong.filename }; } return currentlyPlayingSong; } - const mediaTitle = await fetchMediaTitle() || ""; return { id: 0, - filename: mediaTitle, - title: mediaTitle + filename: mediaTitle || "", + title: mediaTitle || "" }; } @@ -472,6 +491,9 @@ export class MediaPlayer { console.error("MPV socket error:", error); }); resolve(socket); + this.registerMpvObservers().catch((error: unknown) => { + console.error("Failed to register mpv observers:", error); + }); }); socket.once("error", onError); @@ -482,6 +504,11 @@ export class MediaPlayer { }); } + private async registerMpvObservers() { + await this.writeCommand("observe_property", [ObservedProperty.MediaTitle, "media-title"]); + await this.writeCommand("observe_property", [ObservedProperty.Metadata, "metadata"]); + } + private handleEvent(event: string, data: any) { console.log("Event [" + event + "]: ", data); @@ -522,6 +549,11 @@ export class MediaPlayer { this.currentFile = file; this.playbackErrors.delete(file); } + + 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"; @@ -543,4 +575,143 @@ export class MediaPlayer { } } } + + private handlePropertyChange(response: any) { + const previousTitle = this.getCurrentDynamicTitle(); + + if (response.id === ObservedProperty.MediaTitle || response.name === "media-title") { + this.currentMediaTitle = typeof response.data === "string" ? response.data : null; + } else if (response.id === ObservedProperty.Metadata || response.name === "metadata") { + this.currentPlaybackMetadata = this.normalizeMpvMetadata(response.data); + } + + const currentTitle = this.getCurrentDynamicTitle(); + if (currentTitle !== previousTitle) { + this.handleEvent(UserEvent.MetadataUpdate, { + url: this.currentFile, + title: currentTitle, + metadata: this.currentPlaybackMetadata + }); + } + } + + private withCurrentDynamicTitle(item: PlaylistItem, dynamicTitle: string | null = this.getCurrentDynamicTitle(item)): PlaylistItem { + if (!dynamicTitle) { + return item; + } + + return { + ...item, + title: dynamicTitle, + metadata: { + ...item.metadata, + title: dynamicTitle + } + }; + } + + private getCurrentDynamicTitle(item?: PlaylistItem): string | null { + if (this.currentMediaTitle && !this.isFallbackMediaTitle(this.currentMediaTitle, item?.filename)) { + return this.currentMediaTitle.trim(); + } + + return this.extractTitleFromMpvMetadata(); + } + + private extractTitleFromMpvMetadata(): string | null { + const titleKeys = new Set(["title", "icy-title", "icy_title", "streamtitle", "stream-title"]); + + for (const key of Object.keys(this.currentPlaybackMetadata)) { + const normalizedKey = key.toLowerCase(); + if (!titleKeys.has(normalizedKey)) { + continue; + } + + const title = this.parseStreamTitle(this.currentPlaybackMetadata[key]); + if (title) { + return title; + } + } + + return null; + } + + private parseStreamTitle(value: string | undefined): string | null { + const trimmedValue = value?.trim(); + if (!trimmedValue) { + return null; + } + + const streamTitleMatch = trimmedValue.match(/StreamTitle='([^']*)'/i); + return (streamTitleMatch?.[1] || trimmedValue).trim() || null; + } + + private normalizeMpvMetadata(data: unknown): Record { + if (data === null || typeof data !== "object" || Array.isArray(data)) { + return {}; + } + + const metadata: Record = {}; + const rawMetadata = data as Record; + + for (const key of Object.keys(rawMetadata)) { + const value = rawMetadata[key]; + if (value !== null && value !== undefined) { + metadata[key] = String(value); + } + } + + return metadata; + } + + private isFallbackMediaTitle(title: string, filename?: string): boolean { + const normalizedTitle = title.trim(); + if (!normalizedTitle) { + return true; + } + + const fallbackCandidates = new Set(); + if (filename) { + this.addFallbackTitleCandidates(fallbackCandidates, filename); + } + + if (this.currentFile) { + this.addFallbackTitleCandidates(fallbackCandidates, this.currentFile); + } + + return fallbackCandidates.has(normalizedTitle); + } + + private addFallbackTitleCandidates(candidates: Set, value: string) { + candidates.add(value); + + try { + candidates.add(decodeURIComponent(value)); + } catch { + // Keep the original value if it is not URI-encoded. + } + + try { + const url = new URL(value); + this.addPathTitleCandidates(candidates, url.pathname); + } catch { + this.addPathTitleCandidates(candidates, value); + } + } + + private addPathTitleCandidates(candidates: Set, path: string) { + const pathSegments = path.split("/").filter(Boolean); + const lastPathSegment = pathSegments[pathSegments.length - 1]; + if (!lastPathSegment) { + return; + } + + candidates.add(lastPathSegment); + + try { + candidates.add(decodeURIComponent(lastPathSegment)); + } catch { + // Keep the original segment if it is not URI-encoded. + } + } }