From 0b3cd15589d882f754ae4c88105b865961b54ee7 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 29 May 2026 00:44:23 +0000 Subject: [PATCH] feat: configurable media player selection with auto-detection Backend: - Add mediahive/players.py with cross-platform player detection: - Windows: MPC-BE, MPC-HC, VLC, PotPlayer, mpv (registry + known paths) - macOS: IINA, VLC (/Applications bundle scanning) - Linux: VLC, SMPlayer (PATH via which) - Add GET /api/players endpoint returning detected players - Extend POST /api/roots/{id}/play to accept player_id and player_custom_cmd - Make MPC-BE web UI port configurable via ?port= query param Frontend: - Add player selection to settings (localStorage, per-client) - Two-column layout: player list (9.5em) + contextual options panel - Contextual options: Custom Command input, MPC Web UI Port input - Empty MPC port disables all Web UI polling/connection attempts - MPC-BE polling only runs when MPC family player is selected and port is set --- frontend/src/App.vue | 38 ++- frontend/src/api.ts | 63 ++-- frontend/src/components/Header.vue | 183 +++++++++++- frontend/src/composables/useSettings.ts | 8 +- mediahive/models/protocol.py | 2 + mediahive/players.py | 363 ++++++++++++++++++++++++ mediahive/server.py | 48 +++- 7 files changed, 668 insertions(+), 37 deletions(-) create mode 100644 mediahive/players.py diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a1386c5..6537ab1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -190,6 +190,7 @@ import { getPlayerStatus, fetchRoots, } from "./api" +import { useSettings } from "./composables/useSettings" import { useKeyboardNavigation } from "./composables/useKeyboardNavigation" import { useMediaWebSocket } from "./composables/useMediaWebSocket" import Header from "./components/Header.vue" @@ -304,6 +305,8 @@ onUnmounted(() => { stopRootsPolling() }) +const settings = useSettings() + const searchResults = ref([]) const isSearching = ref(false) const mpcBeConnected = ref(false) @@ -314,6 +317,15 @@ const MPC_BE_OPENING_GRACE_MS = 4000 const mpcBeOpeningUntil = ref(0) let mpcBePollTimer: number | null = null +function isMpcFamilySelected(): boolean { + // Empty port means Web UI is disabled + if (settings.playerMpcPort === null) return false + // If default is selected, we opportunistically try MPC-BE (backward compat) + // If a specific player is selected, only poll for MPC family + if (!settings.playerId || settings.playerId === "default") return true + return settings.playerId === "mpc-be" || settings.playerId === "mpc-hc" +} + function isMpcBeGamepadCaptured() { return mpcBeConnected.value || Date.now() < mpcBeOpeningUntil.value } @@ -330,8 +342,12 @@ async function refreshResumePositions() { } async function refreshPlayerStatus() { + if (!isMpcFamilySelected()) { + mpcBeConnected.value = false + return + } try { - const status = await getPlayerStatus() + const status = await getPlayerStatus(settings.playerMpcPort) mpcBeConnected.value = status.remote } catch { mpcBeConnected.value = false @@ -347,7 +363,7 @@ function hasResumePosition(filePath: string | null) { function startMpcBePolling() { if (mpcBePollTimer !== null) return mpcBePollTimer = window.setInterval(async () => { - const reachable = await isMpcBeReachable() + const reachable = await isMpcBeReachable(settings.playerMpcPort) const wasConnected = mpcBeConnected.value mpcBeConnected.value = reachable if (!reachable) { @@ -361,7 +377,7 @@ function startMpcBePolling() { async function tryConnectMpcBe(attempts = 8, delayMs = 400): Promise { for (let i = 0; i < attempts; i++) { - const reachable = await isMpcBeReachable() + const reachable = await isMpcBeReachable(settings.playerMpcPort) if (reachable) return true if (i < attempts - 1) { await new Promise((resolve) => window.setTimeout(resolve, delayMs)) @@ -1218,13 +1234,17 @@ async function handlePlay(filePath: string) { console.error("Cannot play: unknown root for path", filePath) return } - mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS + if (isMpcFamilySelected()) { + mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS + } try { - await playMedia(rootId, filePath) - const connected = await tryConnectMpcBe() - if (connected) { - mpcBeConnected.value = true - startMpcBePolling() + await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd) + if (isMpcFamilySelected()) { + const connected = await tryConnectMpcBe() + if (connected) { + mpcBeConnected.value = true + startMpcBePolling() + } } } catch (e) { console.error("Failed to play media:", e) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6980a58..4cbc21d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,13 @@ export interface PlayerStatus { remote: boolean } +export interface PlayerInfo { + id: string + name: string + family: string + path: string | null +} + export interface RootStatus { root_id: string name: string @@ -139,15 +146,35 @@ export async function replaceRoots( } /** - * Play a media file with the system's default player + * Fetch detected media players from the backend. */ -export async function playMedia(rootId: string, filePath: string): Promise { +export async function fetchPlayers(): Promise { + const response = await fetch("/api/players") + if (!response.ok) { + throw new Error(`Failed to load players: ${response.statusText}`) + } + const data = await response.json() + return data.players || [] +} + +/** + * Play a media file with the selected player. + */ +export async function playMedia( + rootId: string, + filePath: string, + playerId?: string | null, + playerCustomCmd?: string | null, +): Promise { const normalizedPath = normalizeMediaPath(filePath) + const body: Record = { file_path: normalizedPath } + if (playerId) body.player_id = playerId + if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd try { const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/play`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ file_path: normalizedPath }), + body: JSON.stringify(body), }) if (!response.ok) { const error = await response.json() @@ -180,23 +207,14 @@ export async function openFolder(rootId: string, folderPath: string): Promise { - const response = await fetch("/api/player/status") - if (!response.ok) { - throw new Error(`Failed to load player status: ${response.statusText}`) - } - return response.json() -} - /** * Return whether MPC-BE local web control is currently reachable. + * @param port - Optional custom port (default 13579) */ -export async function isMpcBeReachable(): Promise { +export async function isMpcBeReachable(port?: number | null): Promise { try { - const response = await fetch("/api/mpcbe/status") + const url = port ? `/api/mpcbe/status?port=${port}` : "/api/mpcbe/status" + const response = await fetch(url) if (!response.ok) return false const data = await response.json().catch(() => ({})) return Boolean(data.reachable) @@ -205,6 +223,19 @@ export async function isMpcBeReachable(): Promise { } } +/** + * Return player integration capabilities for the current OS. + * @param port - Optional custom MPC-BE port (default 13579) + */ +export async function getPlayerStatus(port?: number | null): Promise { + const url = port ? `/api/player/status?port=${port}` : "/api/player/status" + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to load player status: ${response.statusText}`) + } + return response.json() +} + /** * Convert a cover path to a displayable URL. * Uses FastAPI server for async file serving. diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index e050dd4..2a10d6d 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -142,6 +142,66 @@ +
+

Player

+

Choose which media player to launch files with.

+ +
+
+ +
+ +
+ + + +
+
+
+

Preferred Format

Preferred format when multiple versions are available.

@@ -242,7 +302,8 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue" import { useRouter, useRoute } from "vue-router" import { navAttrs } from "../composables/useKeyboardNavigation" import logoUrl from "../assets/mediahive.webp" -import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from "../api" +import { fetchRoots, replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api" +import type { PlayerInfo } from "../api" import HexKeyboard from "./HexKeyboard.vue" import { useSettings, @@ -252,6 +313,14 @@ import { const settings = useSettings() +const detectedPlayers = ref([]) +const customCmd = ref(settings.playerCustomCmd || "") + +const selectedPlayerFamily = computed(() => { + const p = detectedPlayers.value.find((p) => p.id === settings.playerId) + return p?.family ?? "default" +}) + interface RootEntry { root_id: string name: string @@ -309,6 +378,33 @@ function setPreferredHdr(value: HdrPreference) { settings.preferredHdr = value } +function setPlayer(id: string) { + settings.playerId = id +} + +function setCustomCmd(cmd: string) { + settings.playerCustomCmd = cmd.trim() || null +} + +function onMpcPortInput(e: Event) { + const target = e.target as HTMLInputElement + const value = target.value.trim() + if (value === "") { + settings.playerMpcPort = null + } else { + const num = parseInt(value, 10) + settings.playerMpcPort = isNaN(num) || num <= 0 ? null : num + } +} + +async function refreshPlayers() { + try { + detectedPlayers.value = await fetchPlayers() + } catch (e) { + console.error("Failed to fetch players:", e) + } +} + async function refreshRoots() { try { const data = await fetchRoots() @@ -360,7 +456,10 @@ async function addRoot() { } watch(showSettings, (visible) => { - if (visible) void refreshRoots() + if (visible) { + void refreshRoots() + void refreshPlayers() + } }) // Check if we're on a detail page @@ -710,4 +809,84 @@ onUnmounted(() => { cursor: pointer; accent-color: var(--accent, #3b82f6); } + +.player-layout { + display: grid; + grid-template-columns: 9.5em 1fr; + gap: 1.25em; + align-items: start; +} + +.player-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.player-radio-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--text-primary); + font-size: 0.85rem; +} + +.player-radio { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: var(--accent, #3b82f6); +} + +.player-options { + display: flex; + flex-direction: column; + gap: 14px; +} + +.player-option-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.player-option-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-primary); +} + +.player-option-input { + width: 100%; + max-width: 400px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + color: var(--text-primary); + font-size: 0.85rem; + outline: none; +} + +.player-option-input:focus { + border-color: var(--accent, #3b82f6); +} + +.player-option-input--short { + width: 120px; + max-width: none; +} + +.player-option-hint { + margin: 2px 0 0; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.player-family-note { + margin: 4px 0 0; + font-size: 0.8rem; + color: #22c55e; +} diff --git a/frontend/src/composables/useSettings.ts b/frontend/src/composables/useSettings.ts index a01faef..7c2da8d 100644 --- a/frontend/src/composables/useSettings.ts +++ b/frontend/src/composables/useSettings.ts @@ -7,6 +7,9 @@ export type HdrPreference = "none" | "hdr10plus" | "dovi" export interface MediaHiveSettings { preferredResolution: ResolutionPreference preferredHdr: HdrPreference + playerId: string | null + playerCustomCmd: string | null + playerMpcPort: number | null } const STORAGE_KEY = "MediaHive" @@ -48,12 +51,15 @@ function loadSettings(): MediaHiveSettings { ? parsed.preferredResolution : "rmax", preferredHdr: isHdrPreference(parsed.preferredHdr) ? parsed.preferredHdr : "none", + playerId: typeof parsed.playerId === "string" ? parsed.playerId : "default", + playerCustomCmd: typeof parsed.playerCustomCmd === "string" ? parsed.playerCustomCmd : null, + playerMpcPort: typeof parsed.playerMpcPort === "number" ? parsed.playerMpcPort : null, } } } catch { // ignore parse errors } - return { preferredResolution: "rmax", preferredHdr: "none" } + return { preferredResolution: "rmax", preferredHdr: "none", playerId: "default", playerCustomCmd: null, playerMpcPort: null } } const settings = reactive(loadSettings()) diff --git a/mediahive/models/protocol.py b/mediahive/models/protocol.py index b7b3aae..fca541b 100644 --- a/mediahive/models/protocol.py +++ b/mediahive/models/protocol.py @@ -54,6 +54,8 @@ class PlayMediaRequest(msgspec.Struct): """POST /api/roots/{root_id}/play body.""" file_path: str = "" + player_id: str | None = None + player_custom_cmd: str | None = None class OpenFolderRequest(msgspec.Struct): diff --git a/mediahive/players.py b/mediahive/players.py new file mode 100644 index 0000000..e698d25 --- /dev/null +++ b/mediahive/players.py @@ -0,0 +1,363 @@ +"""Media player detection and launching. + +Windows: registry + known install paths. +macOS: /Applications + PATH. +Linux: PATH (which) only. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import msgspec + + +class PlayerInfo(msgspec.Struct): + """Descriptor for a detected media player.""" + + id: str + name: str + family: str + path: str | None = None + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _which(cmd: str) -> str | None: + """Find a command in PATH.""" + return shutil.which(cmd) + + +# --------------------------------------------------------------------------- +# Windows detection +# --------------------------------------------------------------------------- + + +def _winreg_lookup(key_path: str, value_name: str = "") -> str | None: + """Read a string value from the Windows registry.""" + try: + import winreg + + # key_path like r"HKLM\Software\MPC-BE Team\MPC-BE" + parts = key_path.split("\\", 1) + hive_name = parts[0].upper() + subpath = parts[1] if len(parts) > 1 else "" + hive = { + "HKLM": winreg.HKEY_LOCAL_MACHINE, + "HKCU": winreg.HKEY_CURRENT_USER, + "HKCR": winreg.HKEY_CLASSES_ROOT, + }.get(hive_name) + if hive is None: + return None + with winreg.OpenKey(hive, subpath) as key: + val, _ = winreg.QueryValueEx(key, value_name or None) + if isinstance(val, str): + return val + except OSError: + pass + return None + + +def _expand_command_path(cmd: str) -> Path | None: + r"""Extract the executable path from a shell\open\command string. + + Handles quoted paths like ``"C:\Program Files\Player\player.exe" "%1"`` + and unquoted like ``C:\Program Files\Player\player.exe "%1"``. + """ + cmd = cmd.strip() + if cmd.startswith('"'): + end = cmd.find('"', 1) + if end != -1: + exe = cmd[1:end] + return Path(exe) if Path(exe).exists() else None + # Space-separated, take first token + parts = cmd.split() + if parts: + candidate = Path(parts[0]) + if candidate.exists(): + return candidate + return None + + +def _detect_default_player() -> PlayerInfo | None: + """Detect which program is associated with .mkv files.""" + try: + import winreg + + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r".mkv") as key: + progid, _ = winreg.QueryValueEx(key, None) + if not progid: + return None + with winreg.OpenKey( + winreg.HKEY_CLASSES_ROOT, f"{progid}\\shell\\open\\command" + ) as key: + cmd, _ = winreg.QueryValueEx(key, None) + exe_path = _expand_command_path(cmd) if cmd else None + name = progid.replace(".", " ").title() + return PlayerInfo( + id="default-associated", + name=f"Default ({name})", + family="default", + path=str(exe_path) if exe_path else None, + ) + except OSError: + return None + + +def _detect_mpc_be() -> PlayerInfo | None: + candidates = [ + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + / "MPC-BE" + / "mpc-be64.exe", + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + / "MPC-BE" + / "mpc-be.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) + / "MPC-BE" + / "mpc-be.exe", + ] + # Registry path used by MPC-BE installer + reg = _winreg_lookup(r"HKLM\Software\MPC-BE Team\MPC-BE", "ExePath") + if reg: + candidates.insert(0, Path(reg)) + + for path in candidates: + if path.exists(): + return PlayerInfo(id="mpc-be", name="MPC-BE", family="mpc", path=str(path)) + return None + + +def _detect_mpc_hc() -> PlayerInfo | None: + candidates = [ + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + / "MPC-HC" + / "mpc-hc64.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) + / "MPC-HC" + / "mpc-hc.exe", + ] + for path in candidates: + if path.exists(): + return PlayerInfo(id="mpc-hc", name="MPC-HC", family="mpc", path=str(path)) + return None + + +def _detect_vlc() -> PlayerInfo | None: + candidates = [ + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + / "VideoLAN" + / "VLC" + / "vlc.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) + / "VideoLAN" + / "VLC" + / "vlc.exe", + ] + for path in candidates: + if path.exists(): + return PlayerInfo( + id="vlc", name="VLC media player", family="vlc", path=str(path) + ) + return None + + +def _detect_potplayer() -> PlayerInfo | None: + candidates = [ + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + / "DAUM" + / "PotPlayer" + / "PotPlayer64.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) + / "DAUM" + / "PotPlayer" + / "PotPlayer.exe", + ] + for path in candidates: + if path.exists(): + return PlayerInfo( + id="potplayer", name="PotPlayer", family="potplayer", path=str(path) + ) + return None + + +def _detect_mpv() -> PlayerInfo | None: + # Check PATH first + mpv_in_path = shutil.which("mpv") + if mpv_in_path: + return PlayerInfo(id="mpv", name="mpv", family="mpv", path=mpv_in_path) + candidates = [ + Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + / "mpv" + / "mpv.exe", + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "mpv" / "mpv.exe", + ] + for path in candidates: + if path.exists(): + return PlayerInfo(id="mpv", name="mpv", family="mpv", path=str(path)) + return None + + +# --------------------------------------------------------------------------- +# macOS detection +# --------------------------------------------------------------------------- + + +def _detect_macos_app( + bundle_name: str, display_name: str, family: str +) -> PlayerInfo | None: + """Detect an app in /Applications or ~/Applications.""" + for apps_dir in (Path("/Applications"), Path.home() / "Applications"): + app_path = apps_dir / f"{bundle_name}.app" + if app_path.exists(): + # Find the actual executable inside the bundle + macos_dir = app_path / "Contents" / "MacOS" + if macos_dir.exists(): + # Often the executable name matches the bundle name + exe = macos_dir / bundle_name + if exe.exists(): + return PlayerInfo( + id=family, name=display_name, family=family, path=str(exe) + ) + # Fallback: any executable in MacOS dir + for child in macos_dir.iterdir(): + if child.is_file() and os.access(child, os.X_OK): + return PlayerInfo( + id=family, name=display_name, family=family, path=str(child) + ) + return None + + +def _detect_iina() -> PlayerInfo | None: + return _detect_macos_app("IINA", "IINA", "mpv") + + +def _detect_vlc_macos() -> PlayerInfo | None: + return _detect_macos_app("VLC", "VLC media player", "vlc") + + +# --------------------------------------------------------------------------- +# Linux detection +# --------------------------------------------------------------------------- + + +def _detect_vlc_linux() -> PlayerInfo | None: + path = _which("vlc") + if path: + return PlayerInfo(id="vlc", name="VLC media player", family="vlc", path=path) + return None + + +def _detect_smplayer() -> PlayerInfo | None: + path = _which("smplayer") + if path: + return PlayerInfo(id="smplayer", name="SMPlayer", family="mpv", path=path) + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def detect_players() -> list[PlayerInfo]: + """Return a list of detected players plus default and custom entries.""" + detected: list[PlayerInfo] = [] + + if sys.platform == "win32": + # Specific players + for detector in ( + _detect_mpc_be, + _detect_mpc_hc, + _detect_vlc, + _detect_potplayer, + _detect_mpv, + ): + player = detector() + if player: + detected.append(player) + + # Default associated player (optional, for info) + default_assoc = _detect_default_player() + if default_assoc and default_assoc.path: + # Only add if it's a different executable than one we already found + existing_paths = {p.path.lower() for p in detected if p.path} + if default_assoc.path.lower() not in existing_paths: + detected.append(default_assoc) + + elif sys.platform == "darwin": + for detector in (_detect_iina, _detect_vlc_macos): + player = detector() + if player: + detected.append(player) + + else: + # Linux / other Unix + for detector in (_detect_vlc_linux, _detect_smplayer): + player = detector() + if player: + detected.append(player) + + # Always include the abstract "default" and "custom" options + result: list[PlayerInfo] = [ + PlayerInfo(id="default", name="System Default", family="default"), + ] + result.extend(detected) + result.append(PlayerInfo(id="custom", name="Custom…", family="custom")) + return result + + +def launch_player( + player_id: str, + file_path: Path, + player_path: str | None = None, + custom_cmd: str | None = None, +) -> None: + """Launch a media file with the specified player. + + Args: + player_id: One of "default", "custom", or a detected player id. + file_path: Absolute path to the media file. + player_path: Executable path for detected players (from detection). + custom_cmd: Raw command string for "custom" player (with %s). + + """ + if player_id == "default" or not player_id: + if sys.platform == "win32": + os.startfile(str(file_path)) + return + opener = "open" if sys.platform == "darwin" else "xdg-open" + subprocess.Popen( + [opener, str(file_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return + + if player_id == "custom": + if not custom_cmd: + raise ValueError("Custom player command is empty") + cmd_str = custom_cmd.replace("%s", str(file_path)) + if "%s" not in custom_cmd: + cmd_str = f'{custom_cmd} "{file_path}"' + # Use shell=True for custom commands so arguments are parsed naturally + subprocess.Popen( + cmd_str, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + return + + if not player_path: + raise ValueError(f"Player path not provided for {player_id}") + + # Detected player: pass file path as the single argument + subprocess.Popen( + [player_path, str(file_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/mediahive/server.py b/mediahive/server.py index 1532076..0855598 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -37,11 +37,12 @@ from mediahive.models.protocol import ( PlayMediaRequest, RootsRequest, ) +from mediahive.players import detect_players, launch_player from mediahive.root_registry import Supervisor logger = logging.getLogger("mediahive.server") -MPC_BE_BASE_URL = "http://127.0.0.1:13579" +MPC_BE_DEFAULT_PORT = 13579 # Suppress console windows when spawning subprocesses on Windows _POPEN_KWARGS: dict = ( @@ -484,9 +485,16 @@ async def ws_endpoint(ws: WebSocket, root_id: str) -> None: # --- Media actions --- +@app.get("/api/players") +async def list_players(): + """Return detected media players available on this system.""" + players = detect_players() + return {"players": [msgspec.structs.asdict(p) for p in players]} + + @app.post("/api/roots/{root_id}/play") async def play_media(root_id: str, request: Request): - """Open a media file with the system's default player.""" + """Open a media file with the selected player.""" ctx = _get_context(root_id) req = msgspec.json.decode(await request.body(), type=PlayMediaRequest) file_path = ctx.root_path / req.file_path @@ -494,8 +502,25 @@ async def play_media(root_id: str, request: Request): if not file_path.exists(): raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}") + # Resolve player path if a specific detected player was chosen + player_path: str | None = None + if req.player_id and req.player_id not in ("default", "custom"): + for p in detect_players(): + if p.id == req.player_id: + player_path = p.path + break + if not player_path: + raise HTTPException( + status_code=400, detail=f"Player not found: {req.player_id}" + ) + try: - _open_with_default_app(file_path) + launch_player( + req.player_id or "default", + file_path, + player_path=player_path, + custom_cmd=req.player_custom_cmd, + ) return {"status": "ok"} except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e: raise HTTPException(status_code=500, detail=f"Failed to play media: {e}") @@ -546,24 +571,29 @@ async def root_playback_resume_positions(root_id: str): # --- MPC-BE / Player status --- +def _mpcbe_url(port: int | None = None) -> str: + """Build MPC-BE base URL from optional custom port.""" + return f"http://127.0.0.1:{port or MPC_BE_DEFAULT_PORT}" + + @app.get("/api/mpcbe/status") -async def mpcbe_status(): +async def mpcbe_status(port: int | None = None): """Check whether MPC-BE web interface is reachable.""" - return {"reachable": _mpcbe_request("/")} + return {"reachable": _mpcbe_request("/", port=port)} @app.get("/api/player/status") -async def player_status(): +async def player_status(port: int | None = None): """Return whether remote player control is currently available.""" - return {"remote": _mpcbe_request("/")} + return {"remote": _mpcbe_request("/", port=port)} -def _mpcbe_request(path: str, timeout: float = 0.75) -> bool: +def _mpcbe_request(path: str, timeout: float = 0.75, port: int | None = None) -> bool: """Call MPC-BE's local web interface and return True on HTTP success.""" if sys.platform != "win32": return False - url = f"{MPC_BE_BASE_URL}{path}" + url = f"{_mpcbe_url(port)}{path}" req = urllib.request.Request(url=url, method="GET") try: with urllib.request.urlopen(req, timeout=timeout) as resp: