backend: observe metadata/title changes

This commit is contained in:
2026-05-01 08:51:38 -07:00
parent 69742ce2d9
commit cfe5063828

View File

@@ -30,6 +30,11 @@ interface PendingCommand {
reject: (reason: any) => void; reject: (reason: any) => void;
} }
enum ObservedProperty {
MediaTitle = 1,
Metadata = 2,
}
enum UserEvent { enum UserEvent {
PlaylistUpdate = "playlist_update", PlaylistUpdate = "playlist_update",
NowPlayingUpdate = "now_playing_update", NowPlayingUpdate = "now_playing_update",
@@ -61,6 +66,8 @@ export class MediaPlayer {
private playbackErrors: Map<string, string> = new Map(); private playbackErrors: Map<string, string> = new Map();
private currentFile: string | null = null; private currentFile: string | null = null;
private lastLoadCandidate: string | null = null; private lastLoadCandidate: string | null = null;
private currentMediaTitle: string | null = null;
private currentPlaybackMetadata: Record<string, string> = {};
constructor() { constructor() {
this.socket = this.tryRespawnPlayerProcess(); this.socket = this.tryRespawnPlayerProcess();
@@ -160,11 +167,15 @@ export class MediaPlayer {
.then((response) => { .then((response) => {
// Enhance playlist items with metadata // Enhance playlist items with metadata
const playlist = response.data as PlaylistItem[]; const playlist = response.data as PlaylistItem[];
return playlist.map((item: PlaylistItem) => ({ return playlist.map((item: PlaylistItem) => {
...item, const enhancedItem = {
metadata: this.metadata.get(item.filename) || {}, ...item,
playbackError: this.playbackErrors.get(item.filename) 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 currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
const fetchMediaTitle = async (): Promise<string | null> => { const fetchMediaTitle = async (): Promise<string | null> => {
try { 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) { } catch (err) {
return null; return null;
} }
}; };
const mediaTitle = await fetchMediaTitle();
if (currentlyPlayingSong !== undefined) { 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 // Use media title if we don't have a title
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) { if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
return { return {
...currentlyPlayingSong, ...currentlyPlayingSong,
title: await fetchMediaTitle() || currentlyPlayingSong.filename title: mediaTitle || currentlyPlayingSong.filename
}; };
} }
return currentlyPlayingSong; return currentlyPlayingSong;
} }
const mediaTitle = await fetchMediaTitle() || "";
return { return {
id: 0, id: 0,
filename: mediaTitle, filename: mediaTitle || "",
title: mediaTitle title: mediaTitle || ""
}; };
} }
@@ -472,6 +491,9 @@ export class MediaPlayer {
console.error("MPV socket error:", error); console.error("MPV socket error:", error);
}); });
resolve(socket); resolve(socket);
this.registerMpvObservers().catch((error: unknown) => {
console.error("Failed to register mpv observers:", error);
});
}); });
socket.once("error", onError); 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) { private handleEvent(event: string, data: any) {
console.log("Event [" + event + "]: ", data); console.log("Event [" + event + "]: ", data);
@@ -522,6 +549,11 @@ export class MediaPlayer {
this.currentFile = file; this.currentFile = file;
this.playbackErrors.delete(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") { } else if (response.event === "end-file" && response.reason === "error") {
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file"; const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
const errorMessage = response.error || response["file-error"] || "Unknown playback error"; 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<string, string> {
if (data === null || typeof data !== "object" || Array.isArray(data)) {
return {};
}
const metadata: Record<string, string> = {};
const rawMetadata = data as Record<string, unknown>;
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<string>();
if (filename) {
this.addFallbackTitleCandidates(fallbackCandidates, filename);
}
if (this.currentFile) {
this.addFallbackTitleCandidates(fallbackCandidates, this.currentFile);
}
return fallbackCandidates.has(normalizedTitle);
}
private addFallbackTitleCandidates(candidates: Set<string>, 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<string>, 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.
}
}
} }