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"; interface PendingCommand { resolve: (value: any) => void; reject: (reason: any) => void; } enum UserEvent { PlaylistUpdate = "playlist_update", NowPlayingUpdate = "now_playing_update", VolumeUpdate = "volume_update", FavoritesUpdate = "favorites_update", MetadataUpdate = "metadata_update", MPDUpdate = "mpd_update", } export class MediaPlayer { private playerProcess: ChildProcess; private socket: Socket; private eventSubscribers: WebSocket[] = []; private favoritesStore: FavoritesStore; private pendingCommands: Map = new Map(); private requestId: number = 1; private dataBuffer: string = ''; private metadata: Map = new Map(); constructor() { const socketFilename = Math.random().toString(36).substring(2, 10); const socketPath = `/tmp/mpv-${socketFilename}`; const enableVideo = process.env.ENABLE_VIDEO || false; console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")"); this.playerProcess = spawn("mpv", [ "--video=" + (enableVideo ? "auto" : "no"), "--fullscreen", "--no-terminal", "--idle=yes", "--input-ipc-server=" + socketPath ]); this.socket = new Socket(); this.playerProcess.on("spawn", () => { console.log(`Player process spawned, opening socket @ ${socketPath}`); setTimeout(() => { this.connectToSocket(socketPath); }, 500); }); this.favoritesStore = new FavoritesStore(); this.favoritesStore.onFavoritesChanged = (favorites) => { this.handleEvent(UserEvent.FavoritesUpdate, { favorites }); }; } 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) => ({ ...item, metadata: this.metadata.get(item.filename) || {} })); }); } public async getNowPlaying(): Promise { const playlist = await this.getPlaylist(); const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current); const fetchMediaTitle = async (): Promise => { return (await this.writeCommand("get_property", ["media-title"])).data; }; if (currentlyPlayingSong !== undefined) { // Use media title if we don't have a title if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) { return { ...currentlyPlayingSong, title: await fetchMediaTitle() }; } return currentlyPlayingSong; } const mediaTitle = await fetchMediaTitle(); return { id: 0, filename: mediaTitle, title: mediaTitle }; } public async getCurrentFile(): Promise { return this.writeCommand("get_property", ["stream-open-filename"]) .then((response) => { return response.data; }); } 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 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 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 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 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.favoritesStore.addFavorite(filename); } public async removeFavorite(filename: string) { return this.favoritesStore.removeFavorite(filename); } public async clearFavorites() { return this.favoritesStore.clearFavorites(); } private async loadFile(url: string, mode: string) { this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode])); 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; }); } private async writeCommand(command: string, args: any[]): Promise { return new Promise((resolve, reject) => { const id = this.requestId++; const commandObject = JSON.stringify({ command: [command, ...args], request_id: id }); this.pendingCommands.set(id, { resolve, reject }); this.socket.write(commandObject + '\n'); // 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) { this.socket.connect(path); this.socket.on("data", data => this.receiveData(data.toString())); } private handleEvent(event: string, data: any) { console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2)); // 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) { pending.resolve(response); this.pendingCommands.delete(response.request_id); } } else if (response.event) { this.handleEvent(UserEvent.MPDUpdate, response); } else { console.log(response); } } catch (error) { console.error('Error parsing JSON:', error); } } } } }