backend: observe metadata/title changes
This commit is contained in:
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user