import { ChildProcess, spawn } from "child_process"; import { Socket } from "net"; import { WebSocket } from "ws"; import { getLinkPreview } from "link-preview-js"; interface PendingCommand { resolve: (value: any) => void; reject: (reason: any) => void; } interface LinkMetadata { title?: string; description?: string; siteName?: string; } export class MediaPlayer { private playerProcess: ChildProcess; private socket: Socket; private eventSubscribers: WebSocket[] = []; 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}`; console.log("Starting player process"); this.playerProcess = spawn("mpv", [ "--no-video", "--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); }); } public async getPlaylist(): Promise { return this.writeCommand("get_property", ["playlist"]) .then((response) => { // Enhance playlist items with metadata const playlist = response.data; return playlist.map((item: any) => ({ ...item, metadata: this.metadata.get(item.filename) || {} })); }); } public async getNowPlaying(): Promise { return this.writeCommand("get_property", ["media-title"]) .then((response) => { return response.data; }); } 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) { const result = await this.modify(() => this.writeCommand("loadfile", [url, "append-play"])); // Asynchronously fetch the metadata for this after we update the playlist this.fetchMetadataAndNotify(url).catch(error => { console.warn(`Failed to fetch metadata for ${url}:`, error); }); return result; } public async play() { return this.modify(() => this.writeCommand("set_property", ["pause", false])); } public async pause() { return this.modify(() => this.writeCommand("set_property", ["pause", true])); } public async skip() { return this.modify(() => this.writeCommand("playlist-next", [])); } public async skipTo(index: number) { return this.modify(() => this.writeCommand("playlist-play-index", [index])); } public async previous() { return this.modify(() => this.writeCommand("playlist-prev", [])); } public async deletePlaylistItem(index: number) { return this.modify(() => this.writeCommand("playlist-remove", [index])); } public async setVolume(volume: number) { return this.modify(() => 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); } private async modify(func: () => Promise): Promise { return func() .then((result) => { // Notify all subscribers this.handleEvent("user_modify", {}); 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 { const metadata = await getLinkPreview(url); this.metadata.set(url, { title: (metadata as any)?.title, description: (metadata as any)?.description, siteName: (metadata as any)?.siteName, }); // Notify clients that metadata has been updated this.handleEvent("metadata_update", { 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("MPV 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(response.event, response); } else { console.log(response); } } catch (error) { console.error('Error parsing JSON:', error); } } } } }