Improve mpv playback configuration

This commit is contained in:
2026-06-14 16:48:23 -07:00
parent c9fe5ff272
commit e6e0b1d736
3 changed files with 155 additions and 12 deletions

View File

@@ -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`).

View File

@@ -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<Socket> {
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<Features> {
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<Socket> {
const retryDelayMs = 100;
const maxAttempts = 50;

View File

@@ -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;