From e6e0b1d736569cca6707d4c51570de2e9ab0fa0b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 14 Jun 2026 16:48:23 -0700 Subject: [PATCH] Improve mpv playback configuration --- README.md | 17 +++- web/backend/src/MediaPlayer.ts | 140 ++++++++++++++++++++++++++++++--- web/backend/src/server.ts | 10 ++- 3 files changed, 155 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 279f310..22b20d9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,22 @@ Once running, you should be able to access the UI via http://localhost:8080. QueueCube supports video as well. Just set the environment variable `ENABLE_VIDEO=1`. +When video is enabled, QueueCube starts a managed `mpv` process. The process +fullscreens by default; set `MPV_FULLSCREEN=0` to leave sizing and placement to +your window manager. These optional environment variables are also passed to +`mpv`: + +| Environment variable | mpv option | +| --- | --- | +| `MPV_WAYLAND_APP_ID` | `--wayland-app-id=...` | +| `MPV_TITLE` | `--title=...` | +| `MPV_BORDER` | `--border=...` | +| `MPV_ONTOP=1` | `--ontop` | +| `MPV_YTDL_FORMAT` | `--ytdl-format=...` | + +For less common options, `MPV_EXTRA_ARGS` can contain additional shell-style +arguments that will be appended to the generated `mpv` command line. + #### Video + Docker When running in a Docker container, there are a few extra steps needed to make this work. @@ -68,4 +84,3 @@ privileged: true (or `--privileged`). On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`). - diff --git a/web/backend/src/MediaPlayer.ts b/web/backend/src/MediaPlayer.ts index ec5cf1f..e899ad6 100644 --- a/web/backend/src/MediaPlayer.ts +++ b/web/backend/src/MediaPlayer.ts @@ -117,15 +117,86 @@ export class MediaPlayer { } } + private envEnabled(name: string, defaultValue: boolean = false): boolean { + const value = process.env[name]; + if (value === undefined) { + return defaultValue; + } + + switch (value.trim().toLowerCase()) { + case "1": + case "true": + case "yes": + case "on": + return true; + case "": + case "0": + case "false": + case "no": + case "off": + return false; + default: + return defaultValue; + } + } + + private addMpvEnvOption(args: string[], envName: string, optionName: string) { + const value = process.env[envName]; + if (value) { + args.push(`${optionName}=${value}`); + } + } + + private parseMpvExtraArgs(): string[] { + const value = process.env.MPV_EXTRA_ARGS; + if (!value) { + return []; + } + + const args: string[] = []; + let current = ""; + let quote: string | null = null; + let escaped = false; + + for (const char of value) { + if (escaped) { + current += char; + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + } else if (char === "\"" || char === "'") { + quote = char; + } else if (/\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; + } + 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 enableVideo = this.envEnabled("ENABLE_VIDEO"); 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, @@ -133,10 +204,24 @@ export class MediaPlayer { "--msg-level=all=v" ]; + if (this.envEnabled("MPV_FULLSCREEN", true)) { + playerArgs.push("--fullscreen"); + } + + this.addMpvEnvOption(playerArgs, "MPV_WAYLAND_APP_ID", "--wayland-app-id"); + this.addMpvEnvOption(playerArgs, "MPV_TITLE", "--title"); + this.addMpvEnvOption(playerArgs, "MPV_BORDER", "--border"); + + if (this.envEnabled("MPV_ONTOP")) { + playerArgs.push("--ontop"); + } + if (ytdlFormat) { playerArgs.push("--ytdl-format=" + ytdlFormat); } + playerArgs.push(...this.parseMpvExtraArgs()); + console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")"); this.playerProcess = spawn("mpv", playerArgs); @@ -381,9 +466,9 @@ export class MediaPlayer { public async getFeatures(): Promise { return { - video: !!process.env.ENABLE_VIDEO, - screenshare: !!process.env.ENABLE_SCREENSHARE, - browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK + video: this.envEnabled("ENABLE_VIDEO"), + screenshare: this.envEnabled("ENABLE_SCREENSHARE"), + browserPlayback: this.envEnabled("ENABLE_BROWSER_PLAYBACK") }; } @@ -392,9 +477,9 @@ export class MediaPlayer { this.playbackErrors.delete(url); this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')])); - if (fetchMetadata) { + if (fetchMetadata && this.shouldFetchLinkPreview(url)) { this.fetchMetadataAndNotify(url).catch(error => { - console.warn(`Failed to fetch metadata for ${url}:`, error); + console.warn(`Failed to fetch metadata for ${this.loggableUrl(url)}:`, error); }); } } @@ -446,7 +531,7 @@ export class MediaPlayer { private async fetchMetadataAndNotify(url: string) { try { - console.log("Fetching metadata for " + url); + console.log("Fetching metadata for " + this.loggableUrl(url)); const metadata = await getLinkPreview(url); this.metadata.set(url, { title: (metadata as any)?.title, @@ -454,7 +539,7 @@ export class MediaPlayer { siteName: (metadata as any)?.siteName, }); - console.log("Metadata fetched for " + url); + console.log("Metadata fetched for " + this.loggableUrl(url)); console.log(this.metadata.get(url)); // Notify clients that metadata has been updated @@ -467,6 +552,43 @@ export class MediaPlayer { } } + private shouldFetchLinkPreview(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol === "file:") { + return false; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return false; + } + + const pathname = parsed.pathname.toLowerCase(); + if (/\.(ts|m3u8|mpd|mp4|m4v|mkv|webm|avi|mov|mpeg|mpg|mp3|aac|flac|wav|ogg|opus)$/.test(pathname)) { + return false; + } + + if (/\/download\/?$/.test(pathname)) { + return false; + } + + return true; + } catch { + return false; + } + } + + private loggableUrl(url: string): string { + try { + const parsed = new URL(url); + const segments = parsed.pathname.split("/").filter(Boolean); + const basename = segments.length > 0 ? segments[segments.length - 1] : ""; + return `${parsed.protocol}//${parsed.host}/${basename ? ".../" + basename : ""}${parsed.search ? "?..." : ""}`; + } catch { + return url; + } + } + private connectToSocket(path: string): Promise { const retryDelayMs = 100; const maxAttempts = 50; diff --git a/web/backend/src/server.ts b/web/backend/src/server.ts index e0def02..2d456c9 100644 --- a/web/backend/src/server.ts +++ b/web/backend/src/server.ts @@ -31,6 +31,12 @@ const app = express(); app.use(express.json()); expressWs(app); +const frontendDistPath = [ + path.join(__dirname, "../dist/frontend"), + path.join(__dirname, "../../frontend/dist"), +].find((candidate) => fs.existsSync(path.join(candidate, "index.html"))) + || path.join(__dirname, "../dist/frontend"); + const apiRouter = express.Router(); const mediaPlayer = new MediaPlayer(); @@ -300,14 +306,14 @@ apiRouter.get("/features", withErrorHandling(async (req, res) => { })); // Serve static files for React app (after building) -app.use(express.static(path.join(__dirname, "../dist/frontend"))); +app.use(express.static(frontendDistPath)); // 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")); + res.sendFile(path.join(frontendDistPath, "index.html")); }); const port = process.env.PORT || 3000;