Files
QueueCube/backend/src/MediaPlayer.ts

295 lines
9.7 KiB
TypeScript
Raw Normal View History

2025-02-15 00:37:32 -08:00
import { ChildProcess, spawn } from "child_process";
import { Socket } from "net";
import { WebSocket } from "ws";
import { getLinkPreview } from "link-preview-js";
2025-02-23 13:46:31 -08:00
import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore";
2025-02-23 16:37:39 -08:00
2025-02-15 00:37:32 -08:00
interface PendingCommand {
resolve: (value: any) => void;
reject: (reason: any) => void;
}
2025-02-23 16:37:39 -08:00
enum UserEvent {
PlaylistUpdate = "playlist_update",
NowPlayingUpdate = "now_playing_update",
VolumeUpdate = "volume_update",
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
}
2025-02-15 00:37:32 -08:00
export class MediaPlayer {
private playerProcess: ChildProcess;
private socket: Socket;
private eventSubscribers: WebSocket[] = [];
2025-02-23 13:46:31 -08:00
private favoritesStore: FavoritesStore;
2025-02-15 00:37:32 -08:00
private pendingCommands: Map<number, PendingCommand> = new Map();
private requestId: number = 1;
private dataBuffer: string = '';
private metadata: Map<string, LinkMetadata> = new Map();
2025-02-15 00:37:32 -08:00
constructor() {
2025-02-15 02:19:14 -08:00
const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`;
2025-02-15 00:37:32 -08:00
console.log("Starting player process");
this.playerProcess = spawn("mpv", [
"--no-video",
"--no-terminal",
"--idle=yes",
2025-02-15 02:19:14 -08:00
"--input-ipc-server=" + socketPath
2025-02-15 00:37:32 -08:00
]);
this.socket = new Socket();
this.playerProcess.on("spawn", () => {
2025-02-15 02:19:14 -08:00
console.log(`Player process spawned, opening socket @ ${socketPath}`);
2025-02-15 00:37:32 -08:00
setTimeout(() => {
2025-02-15 02:19:14 -08:00
this.connectToSocket(socketPath);
2025-02-15 12:15:41 -08:00
}, 500);
2025-02-15 00:37:32 -08:00
});
2025-02-23 13:46:31 -08:00
this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => {
2025-02-23 16:37:39 -08:00
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
2025-02-23 13:46:31 -08:00
};
2025-02-15 00:37:32 -08:00
}
2025-02-15 22:15:59 -08:00
public async getPlaylist(): Promise<PlaylistItem[]> {
2025-02-15 00:37:32 -08:00
return this.writeCommand("get_property", ["playlist"])
.then((response) => {
// Enhance playlist items with metadata
2025-02-15 22:15:59 -08:00
const playlist = response.data as PlaylistItem[];
return playlist.map((item: PlaylistItem) => ({
...item,
metadata: this.metadata.get(item.filename) || {}
}));
2025-02-15 00:37:32 -08:00
});
}
2025-02-15 22:15:59 -08:00
public async getNowPlaying(): Promise<PlaylistItem> {
const playlist = await this.getPlaylist();
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
const fetchMediaTitle = async (): Promise<string> => {
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
};
2025-02-15 00:37:32 -08:00
}
2025-02-15 02:37:17 -08:00
public async getCurrentFile(): Promise<string> {
return this.writeCommand("get_property", ["stream-open-filename"])
.then((response) => {
return response.data;
});
}
2025-02-15 00:37:32 -08:00
public async getPauseState(): Promise<boolean> {
return this.writeCommand("get_property", ["pause"])
.then((response) => {
return response.data;
});
}
public async getVolume(): Promise<number> {
return this.writeCommand("get_property", ["volume"])
.then((response) => {
return response.data;
});
}
public async getIdle(): Promise<boolean> {
return this.writeCommand("get_property", ["idle"])
.then((response) => {
return response.data;
});
}
public async append(url: string) {
2025-02-23 13:46:31 -08:00
await this.loadFile(url, "append-play");
}
public async replace(url: string) {
await this.loadFile(url, "replace");
2025-02-15 00:37:32 -08:00
}
public async play() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
2025-02-15 00:37:32 -08:00
}
public async pause() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
2025-02-15 02:37:17 -08:00
}
public async skip() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
2025-02-15 02:37:17 -08:00
}
2025-02-15 16:28:47 -08:00
public async skipTo(index: number) {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
2025-02-15 16:28:47 -08:00
}
2025-02-15 02:37:17 -08:00
public async previous() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
2025-02-15 00:37:32 -08:00
}
public async deletePlaylistItem(index: number) {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
2025-02-15 02:37:17 -08:00
}
public async setVolume(volume: number) {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
2025-02-15 00:37:32 -08:00
}
public subscribe(ws: WebSocket) {
this.eventSubscribers.push(ws);
}
public unsubscribe(ws: WebSocket) {
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
}
2025-02-15 02:37:17 -08:00
2025-02-23 13:46:31 -08:00
public async getFavorites(): Promise<PlaylistItem[]> {
return this.favoritesStore.getFavorites();
}
public async addFavorite(item: PlaylistItem) {
return this.favoritesStore.addFavorite(item);
}
public async removeFavorite(filename: string) {
return this.favoritesStore.removeFavorite(filename);
2025-02-23 13:46:31 -08:00
}
public async clearFavorites() {
return this.favoritesStore.clearFavorites();
}
private async loadFile(url: string, mode: string) {
2025-02-23 16:37:39 -08:00
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode]));
2025-02-23 13:46:31 -08:00
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, error);
});
}
2025-02-23 16:37:39 -08:00
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
2025-02-15 02:37:17 -08:00
return func()
.then((result) => {
// Notify all subscribers
2025-02-23 16:37:39 -08:00
this.handleEvent(event, {});
2025-02-15 02:37:17 -08:00
return result;
});
}
2025-02-15 00:37:32 -08:00
private async writeCommand(command: string, args: any[]): Promise<any> {
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 {
2025-02-23 13:46:31 -08:00
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,
});
2025-02-23 13:46:31 -08:00
console.log("Metadata fetched for " + url);
console.log(this.metadata.get(url));
// Notify clients that metadata has been updated
2025-02-23 16:37:39 -08:00
this.handleEvent(UserEvent.MetadataUpdate, {
url,
metadata: this.metadata.get(url)
});
} catch (error) {
throw error;
}
}
2025-02-15 02:19:14 -08:00
private connectToSocket(path: string) {
this.socket.connect(path);
2025-02-15 00:37:32 -08:00
this.socket.on("data", data => this.receiveData(data.toString()));
}
private handleEvent(event: string, data: any) {
2025-02-23 13:46:31 -08:00
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2));
2025-02-15 00:37:32 -08:00
// 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) {
2025-02-23 16:37:39 -08:00
this.handleEvent(UserEvent.MPDUpdate, response);
2025-02-15 00:37:32 -08:00
} else {
console.log(response);
}
} catch (error) {
console.error('Error parsing JSON:', error);
}
}
}
}
}