/* * server.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 express from "express"; import expressWs from "express-ws"; import path from "path"; import fs from "fs"; import os from "os"; import { MediaPlayer } from "./MediaPlayer"; import { searchInvidious, fetchThumbnail } from "./InvidiousAPI"; import { PlaylistItem } from './types'; import { PassThrough } from "stream"; import { AddressInfo } from "net"; const app = express(); app.use(express.json()); expressWs(app); const apiRouter = express.Router(); const mediaPlayer = new MediaPlayer(); // Create a shared stream that both endpoints can access let activeScreenshareStream: PassThrough | null = null; let activeScreenshareMimeType: string | null = null; const withErrorHandling = (func: (req: any, res: any) => Promise) => { return async (req: any, res: any) => { try { await func(req, res); } catch (error: any) { res.status(500).send(JSON.stringify({ success: false, error: error.message })); } }; }; apiRouter.get("/playlist", withErrorHandling(async (req, res) => { const playlist = await mediaPlayer.getPlaylist(); res.send(playlist); })); apiRouter.post("/playlist", withErrorHandling(async (req, res) => { const { url } = req.body as { url: string }; await mediaPlayer.append(url); res.send(JSON.stringify({ success: true })); })); apiRouter.delete("/playlist/:index", withErrorHandling(async (req, res) => { const { index } = req.params as { index: string }; await mediaPlayer.deletePlaylistItem(parseInt(index)); res.send(JSON.stringify({ success: true })); })); apiRouter.post("/playlist/replace", withErrorHandling(async (req, res) => { const { url } = req.body as { url: string }; await mediaPlayer.replace(url); res.send(JSON.stringify({ success: true })); })); apiRouter.post("/play", withErrorHandling(async (req, res) => { await mediaPlayer.play(); res.send(JSON.stringify({ success: true })); })); apiRouter.post("/pause", withErrorHandling(async (req, res) => { await mediaPlayer.pause(); res.send(JSON.stringify({ success: true })); })); apiRouter.post("/stop", withErrorHandling(async (req, res) => { await mediaPlayer.stop(); res.send(JSON.stringify({ success: true })); })); apiRouter.post("/skip", withErrorHandling(async (req, res) => { await mediaPlayer.skip(); res.send(JSON.stringify({ success: true })); })); apiRouter.post("/skip/:index", withErrorHandling(async (req, res) => { const { index } = req.params as { index: string }; await mediaPlayer.skipTo(parseInt(index)); res.send(JSON.stringify({ success: true })); })); apiRouter.post("/previous", withErrorHandling(async (req, res) => { await mediaPlayer.previous(); res.send(JSON.stringify({ success: true })); })); apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => { const playingItem = await mediaPlayer.getNowPlaying(); const currentFile = await mediaPlayer.getCurrentFile(); const pauseState = await mediaPlayer.getPauseState(); const volume = await mediaPlayer.getVolume(); const idle = await mediaPlayer.getIdle(); res.send(JSON.stringify({ success: true, playingItem: playingItem, isPaused: pauseState, volume: volume, isIdle: idle, currentFile: currentFile })); })); apiRouter.post("/volume", withErrorHandling(async (req, res) => { const { volume } = req.body as { volume: number }; await mediaPlayer.setVolume(volume); res.send(JSON.stringify({ success: true })); })); apiRouter.ws("/events", (ws, req) => { console.log("Events client connected"); mediaPlayer.subscribe(ws); ws.on("close", () => { console.log("Events client disconnected"); mediaPlayer.unsubscribe(ws); }); }); // This is effectively a "private" endpoint that only the MPV instance accesses. We're // using the fact that QueueCube/MPV is based all around streaming URLs, so the active // screenshare stream manifests as just another URL to play. apiRouter.get("/screenshareStream", withErrorHandling(async (req, res) => { res.setHeader("Content-Type", activeScreenshareMimeType || "video/mp4"); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); res.setHeader("Connection", "keep-alive"); res.setHeader("Transfer-Encoding", "chunked"); if (!activeScreenshareStream) { res.status(503).send("No active screen sharing session"); return; } // Handle client disconnection req.on('close', () => { console.log("Screenshare viewer disconnected"); }); // Configure stream for low latency activeScreenshareStream.setMaxListeners(0); // Pipe with immediate flush activeScreenshareStream.pipe(res, { end: false }); })); apiRouter.ws("/screenshare", (ws, req) => { const mimeType = req.query.mimeType as string; console.log("Screen sharing client connected with mimeType: " + mimeType); ws.binaryType = "arraybuffer"; let firstChunk = false; // Configure WebSocket for low latency ws.setMaxListeners(0); ws.binaryType = "arraybuffer"; ws.on('message', (data: any) => { const buffer = data instanceof Buffer ? data : Buffer.from(data); if (!firstChunk) { firstChunk = true; const port = (server.address() as AddressInfo).port; const url = `http://localhost:${port}/api/screenshareStream`; console.log(`Starting screen share stream at ${url}`); // Create new shared stream with immediate flush activeScreenshareStream = new PassThrough({ highWaterMark: 1024 * 1024, // 1MB buffer allowHalfOpen: false }); activeScreenshareStream.write(buffer); mediaPlayer.initiateScreenSharing(url); } else if (activeScreenshareStream) { // Write with immediate flush activeScreenshareStream.write(buffer, () => { activeScreenshareStream?.cork(); activeScreenshareStream?.uncork(); }); } }); ws.on('close', () => { console.log("Screen sharing client disconnected"); if (activeScreenshareStream) { activeScreenshareStream.end(); activeScreenshareStream = null; } mediaPlayer.stop(); }); }); apiRouter.get("/search", withErrorHandling(async (req, res) => { const query = req.query.q as string; if (!query) { res.status(400) .send(JSON.stringify({ success: false, error: "Query parameter 'q' is required" })); return; } const results = await searchInvidious(query); res.send(JSON.stringify({ success: true, results })); })); apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => { const thumbnailUrl = req.query.url as string; if (!thumbnailUrl) { res.status(400) .send(JSON.stringify({ success: false, error: "URL parameter is required" })); return; } try { const { data, contentType } = await fetchThumbnail(thumbnailUrl); res.set('Content-Type', contentType); data.pipe(res); } catch (error) { console.error('Failed to proxy thumbnail:', error); res.status(500) .send(JSON.stringify({ success: false, error: 'Failed to fetch thumbnail' })); } })); apiRouter.get("/favorites", withErrorHandling(async (req, res) => { const favorites = await mediaPlayer.getFavorites(); res.send(JSON.stringify(favorites)); })); apiRouter.post("/favorites", withErrorHandling(async (req, res) => { const { filename } = req.body as { filename: string }; console.log("Adding favorite: " + filename); await mediaPlayer.addFavorite(filename); res.send(JSON.stringify({ success: true })); })); apiRouter.delete("/favorites/:filename", withErrorHandling(async (req, res) => { const { filename } = req.params as { filename: string }; await mediaPlayer.removeFavorite(filename); res.send(JSON.stringify({ success: true })); })); apiRouter.delete("/favorites", withErrorHandling(async (req, res) => { await mediaPlayer.clearFavorites(); res.send(JSON.stringify({ success: true })); })); apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) => { const { filename } = req.params as { filename: string }; const { title } = req.body as { title: string }; if (!title) { res.status(400).send(JSON.stringify({ success: false, error: "Title is required" })); return; } await mediaPlayer.updateFavoriteTitle(filename, title); res.send(JSON.stringify({ success: true })); })); // Serve static files for React app (after building) app.use(express.static(path.join(__dirname, "../dist/frontend"))); // Mount API routes under /api app.use("/api", apiRouter); // Serve React app for all other routes (client-side routing) app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "../dist/frontend/index.html")); }); const port = process.env.PORT || 3000; const server = app.listen(port, () => { console.log(`Server is running on port ${port}`); // Start zeroconf service advertisement mediaPlayer.startZeroconfService(Number(port)); }); // Add graceful shutdown handling const shutdown = async () => { console.log('Received shutdown signal. Closing server...'); // Stop zeroconf service mediaPlayer.stopZeroconfService(); server.close(() => { console.log('Server closed'); process.exit(0); }); // Force termination after some timeout (10sec) setTimeout(() => { console.log('Forcing server shutdown'); process.exit(1); }, 10000); }; // Handle various shutdown signals process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);