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
This commit is contained in:
2026-05-29 00:44:23 +00:00
parent 17d9426d0a
commit 0b3cd15589
7 changed files with 668 additions and 37 deletions
+29 -9
View File
@@ -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<MediaItem[]>([])
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<boolean> {
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)
+47 -16
View File
@@ -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<void> {
export async function fetchPlayers(): Promise<PlayerInfo[]> {
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<void> {
const normalizedPath = normalizeMediaPath(filePath)
const body: Record<string, unknown> = { 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<vo
}
}
/**
* Return player integration capabilities for the current OS.
*/
export async function getPlayerStatus(): Promise<PlayerStatus> {
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<boolean> {
export async function isMpcBeReachable(port?: number | null): Promise<boolean> {
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<boolean> {
}
}
/**
* 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<PlayerStatus> {
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.
+181 -2
View File
@@ -142,6 +142,66 @@
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Player</h2>
<p class="settings-section-desc">Choose which media player to launch files with.</p>
<div class="player-layout">
<div class="player-list">
<label
v-for="player in detectedPlayers"
:key="player.id"
class="player-radio-label"
>
<input
class="player-radio"
type="radio"
name="player-selection"
:checked="settings.playerId === player.id"
@change="setPlayer(player.id)"
/>
{{ player.name }}
</label>
</div>
<div class="player-options">
<template v-if="settings.playerId === 'custom'">
<div class="player-option-group">
<label class="player-option-label">Custom Command</label>
<input
class="player-option-input"
type="text"
placeholder='C:\Player\player.exe "%s"'
v-model="customCmd"
@change="setCustomCmd(customCmd)"
/>
<p class="player-option-hint">Use %s as placeholder for the file path.</p>
</div>
</template>
<template v-if="selectedPlayerFamily === 'mpc'">
<div class="player-option-group">
<label class="player-option-label" for="mpc-port">MPC Web UI Port</label>
<input
id="mpc-port"
class="player-option-input player-option-input--short"
type="number"
placeholder="13579"
:value="settings.playerMpcPort ?? ''"
@input="onMpcPortInput"
/>
<p class="player-option-hint">
Port for MPC-BE/HC web interface. Leave empty to disable remote control.
</p>
<p v-if="settings.playerMpcPort !== null" class="player-family-note">
Web remote control enabled
</p>
</div>
</template>
</div>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Preferred Format</h2>
<p class="settings-section-desc">Preferred format when multiple versions are available.</p>
@@ -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<PlayerInfo[]>([])
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;
}
</style>
+7 -1
View File
@@ -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<MediaHiveSettings>(loadSettings())
+2
View File
@@ -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):
+363
View File
@@ -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,
)
+39 -9
View File
@@ -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: