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:
+29
-9
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user