/* * MediaPlayer.ts * Copyleft 2025 James Magahern * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published * by the Free Software Foundation, either version 3 of the License, * or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import { ChildProcess, spawn } from "child_process"; import { Socket } from "net"; import { WebSocket } from "ws"; import { getLinkPreview } from "link-preview-js"; import { PlaylistItem, LinkMetadata } from './types'; import { FavoritesStore } from "./FavoritesStore"; import { Bonjour } from "bonjour-service"; import os from 'os'; interface PendingCommand { resolve: (value: any) => void; reject: (reason: any) => void; } enum ObservedProperty { MediaTitle = 1, Metadata = 2, } enum UserEvent { PlaylistUpdate = "playlist_update", NowPlayingUpdate = "now_playing_update", VolumeUpdate = "volume_update", FavoritesUpdate = "favorites_update", MetadataUpdate = "metadata_update", MPDUpdate = "mpd_update", PlaybackError = "playback_error", } export interface Features { video: boolean; screenshare: boolean; browserPlayback: boolean; } export class MediaPlayer { private playerProcess: ChildProcess | null = null; private socket: Promise; private eventSubscribers: WebSocket[] = []; private favoritesStore: FavoritesStore; private pendingCommands: Map = new Map(); private requestId: number = 1; private dataBuffer: string = ''; private metadata: Map = new Map(); private bonjourInstance: Bonjour | null = null; 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(); this.favoritesStore = new FavoritesStore(); this.favoritesStore.onFavoritesChanged = (favorites) => { this.handleEvent(UserEvent.FavoritesUpdate, { favorites }); }; this.getFeatures().then(features => { console.log("Features: ", features); }); } public startZeroconfService(port: number) { if (this.bonjourInstance) { console.log("Zeroconf service already running"); return; } this.bonjourInstance = new Bonjour(); const service = this.bonjourInstance.publish({ name: `QueueCube Media Server (${os.hostname()})`, type: 'queuecube', port: port, txt: { version: '1.0.0', features: 'playlist,favorites,screenshare' } }); service.on('up', () => { console.log(`Zeroconf service advertised: ${service.name} on port ${port}`); }); service.on('error', (err: Error) => { console.error('Zeroconf service error:', err); }); } public stopZeroconfService() { if (this.bonjourInstance) { this.bonjourInstance.destroy(); this.bonjourInstance = null; console.log("Zeroconf service stopped"); } } private tryRespawnPlayerProcess(): Promise { const socketFilename = Math.random().toString(36).substring(2, 10); const socketPath = `/tmp/mpv-${socketFilename}`; const enableVideo = process.env.ENABLE_VIDEO || false; const ytdlFormat = process.env.MPV_YTDL_FORMAT; const logfilePath = `/tmp/mpv-logfile.txt`; const playerArgs = [ "--video=" + (enableVideo ? "auto" : "no"), "--fullscreen", "--no-terminal", "--idle=yes", "--input-ipc-server=" + socketPath, "--log-file=" + logfilePath, "--msg-level=all=v" ]; if (ytdlFormat) { playerArgs.push("--ytdl-format=" + ytdlFormat); } console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")"); this.playerProcess = spawn("mpv", playerArgs); let socketReady!: (s: Socket) => void; let socketFailed!: (reason?: unknown) => void; let socketPromise = new Promise((resolve, reject) => { socketReady = resolve; socketFailed = reject; }); this.playerProcess.on("spawn", () => { console.log(`Player process spawned, opening socket @ ${socketPath}`); this.connectToSocket(socketPath) .then(socket => socketReady(socket)) .catch((error: unknown) => { console.error(`Failed to connect to mpv socket @ ${socketPath}:`, error); console.log("Continuing without mpv player..."); socketFailed(error); }); }); this.playerProcess.on("error", (error: unknown) => { console.error("Player process error:", error); console.log("Continuing without mpv player..."); socketFailed(error); }); return socketPromise; } public async getPlaylist(): Promise { return this.writeCommand("get_property", ["playlist"]) .then((response) => { // Enhance playlist items with metadata const playlist = response.data as PlaylistItem[]; 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; }); }); } public async getNowPlaying(): Promise { const playlist = await this.getPlaylist(); const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current); const fetchMediaTitle = async (): Promise => { try { 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: mediaTitle || currentlyPlayingSong.filename }; } return currentlyPlayingSong; } return { id: 0, filename: mediaTitle || "", title: mediaTitle || "" }; } public async getCurrentFile(): Promise { return this.writeCommand("get_property", ["stream-open-filename"]) .then((response) => { return response.data; }, (reject) => { return null; }); } public async getPauseState(): Promise { return this.writeCommand("get_property", ["pause"]) .then((response) => { return response.data; }); } public async getVolume(): Promise { return this.writeCommand("get_property", ["volume"]) .then((response) => { return response.data; }); } public async getTimePosition(): Promise { return this.writeCommand("get_property", ["time-pos"]) .then((response) => { return response.data; }, (rejected) => { return null; }); } public async getDuration(): Promise { return this.writeCommand("get_property", ["duration"]) .then((response) => { return response.data; }, (rejected) => { return null; }); } public async getSeekable(): Promise { return this.writeCommand("get_property", ["seekable"]) .then((response) => { return response.data; }, (rejected) => { return null; }); } public async getIdle(): Promise { return this.writeCommand("get_property", ["idle"]) .then((response) => { return response.data; }); } public async append(url: string) { await this.loadFile(url, "append-play"); } public async replace(url: string) { await this.loadFile(url, "replace"); } public async initiateScreenSharing(url: string) { console.log(`Initiating screen sharing with file: ${url}`); this.metadata.set(url, { title: "Screen Sharing", description: "Screen Sharing", siteName: "Screen Sharing", }); // Special options for mpv to better handle screen sharing (AI recommended...) await this.loadFile(url, "replace", false, [ "demuxer-lavf-o=fflags=+nobuffer+discardcorrupt", // Reduce buffering and discard corrupt frames "demuxer-lavf-o=analyzeduration=100000", // Reduce analyze duration "demuxer-lavf-o=probesize=1000000", // Reduce probe size "untimed=yes", // Ignore timing info "cache=no", // Disable cache "force-seekable=yes", // Force seekable "no-cache=yes", // Disable cache "demuxer-max-bytes=500K", // Limit demuxer buffer "demuxer-readahead-secs=0.1", // Reduce readahead "hr-seek=no", // Disable high-res seeking "video-sync=display-resample", // Better sync mode "video-latency-hacks=yes", // Enable latency hacks "audio-sync=yes", // Enable audio sync "audio-buffer=0.1", // Reduce audio buffer "audio-channels=stereo", // Force stereo audio "audio-samplerate=44100", // Match sample rate "audio-format=s16", // Use 16-bit audio ]); // Make sure it's playing setTimeout(() => this.play(), 100); } public async play() { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false])); } public async pause() { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true])); } public async stop() { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", [])); } public async skip() { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", [])); } public async skipTo(index: number) { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index])); } public async previous() { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", [])); } public async deletePlaylistItem(index: number) { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index])); } public async setVolume(volume: number) { return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume])); } public async seek(time: number) { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"])); } public subscribe(ws: WebSocket) { this.eventSubscribers.push(ws); } public unsubscribe(ws: WebSocket) { this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws); } public async getFavorites(): Promise { return this.favoritesStore.getFavorites(); } public async addFavorite(filename: string) { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename)); } public async removeFavorite(filename: string) { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename)); } public async clearFavorites() { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites()); } public async updateFavoriteTitle(filename: string, title: string) { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title)); } public async getFeatures(): Promise { return { video: !!process.env.ENABLE_VIDEO, screenshare: !!process.env.ENABLE_SCREENSHARE, browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK }; } private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) { this.lastLoadCandidate = url; this.playbackErrors.delete(url); this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')])); if (fetchMetadata) { this.fetchMetadataAndNotify(url).catch(error => { console.warn(`Failed to fetch metadata for ${url}:`, error); }); } } private async modify(event: UserEvent, func: () => Promise): Promise { return func() .then((result) => { // Notify all subscribers this.handleEvent(event, {}); return result; }, (reject) => { console.log("Error modifying playlist: " + reject); return reject; }); } private async writeCommand(command: string, args: any[]): Promise { // Wait for socket to become available. let socket = await this.socket; return new Promise((resolve, reject) => { const id = this.requestId++; const commandObject = JSON.stringify({ command: [command, ...args], request_id: id }); try { this.pendingCommands.set(id, { resolve, reject }); socket.write(commandObject + '\n'); } catch (e: any) { console.error(`Error writing to socket: ${e}. Trying to respawn.`) this.socket = this.tryRespawnPlayerProcess(); } // Add timeout to prevent hanging promises setTimeout(() => { if (this.pendingCommands.has(id)) { const pending = this.pendingCommands.get(id); if (pending) { pending.reject(new Error('Command timed out')); this.pendingCommands.delete(id); } } }, 5000); }); } private async fetchMetadataAndNotify(url: string) { try { console.log("Fetching metadata for " + url); const metadata = await getLinkPreview(url); this.metadata.set(url, { title: (metadata as any)?.title, description: (metadata as any)?.description, siteName: (metadata as any)?.siteName, }); console.log("Metadata fetched for " + url); console.log(this.metadata.get(url)); // Notify clients that metadata has been updated this.handleEvent(UserEvent.MetadataUpdate, { url, metadata: this.metadata.get(url) }); } catch (error) { throw error; } } private connectToSocket(path: string): Promise { const retryDelayMs = 100; const maxAttempts = 50; return new Promise((resolve, reject) => { type SocketError = Error & { code?: string }; const attemptConnection = (attempt: number) => { const socket = new Socket(); const onError = (error: SocketError) => { socket.removeAllListeners(); socket.destroy(); const shouldRetry = (error.code === "ENOENT" || error.code === "ECONNREFUSED") && attempt < maxAttempts; if (shouldRetry) { setTimeout(() => attemptConnection(attempt + 1), retryDelayMs); return; } reject(error); }; socket.once("connect", () => { socket.removeListener("error", onError); socket.on("data", (data: unknown) => this.receiveData(String(data))); socket.on("error", (error: unknown) => { 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); socket.connect(path); }; attemptConnection(1); }); } 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); // Notify all subscribers this.eventSubscribers.forEach(subscriber => { subscriber.send(JSON.stringify({ event, data })); }); } private receiveData(data: string) { this.dataBuffer += data; const lines = this.dataBuffer.split('\n'); // Keep last incomplete line in the buffer this.dataBuffer = lines.pop() || ''; for (const line of lines) { if (line.trim().length > 0) { try { const response = JSON.parse(line); if (response.request_id) { const pending = this.pendingCommands.get(response.request_id); if (pending) { if (response.error == "success") { pending.resolve(response); } else { pending.reject(response.error); } this.pendingCommands.delete(response.request_id); } } else if (response.event) { if (response.event === "start-file") { // Clear any previous error for the file that is starting const file = response.file || this.lastLoadCandidate; if (file) { 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"; this.playbackErrors.set(file, errorMessage); this.handleEvent(UserEvent.PlaybackError, { filename: file, error: errorMessage }); } this.handleEvent(UserEvent.MPDUpdate, response); } else { console.log(response); } } catch (error) { console.error('Error parsing JSON:', error); } } } } 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. } } }