14 Commits
26 changed files with 1917 additions and 910 deletions
+4 -4
View File
@@ -10,20 +10,20 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/api/health` | Lightweight health check. | | `GET` | `/api/health` | Lightweight health check. |
| `GET` | `/api/config` | Returns the current root configuration. | | `GET` | `/api/config` | Returns the current root configuration. |
| `GET` | `/api/roots` | List all active roots with status. | | `PUT` | `/api/config/roots` | Atomically replace the full root set. |
| `PUT` | `/api/roots` | Atomically replace the full root set. |
| `POST` | `/api/play/{root_id}` | Opens a media file with the system player. | | `POST` | `/api/play/{root_id}` | Opens a media file with the system player. |
| `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. | | `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. |
| `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`. | | `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `<root>/.mediahive`. |
| `GET` | `/api/meta/playback-state` | Returns merged resume positions across all roots. |
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. | | `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. | | `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. | | `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
| `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. | | `GET` | `/api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serves typed assets from `<root>/.mediahive`. |
| `WS` | `/api/ws/{root_id}` | Streams live index updates and task progress for one root. | | `WS` | `/api/ws` | Streams roots, index updates, and task progress for all roots. |
## Notes ## Notes
- `PUT /api/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set. - `PUT /api/config/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
- `POST /api/play/{root_id}` and `POST /api/open-folder/{root_id}` expect JSON request bodies with `file_path` / `folder_path` relative to the root. - `POST /api/play/{root_id}` and `POST /api/open-folder/{root_id}` expect JSON request bodies with `file_path` / `folder_path` relative to the root.
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected. - `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected.
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`. - `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
-94
View File
@@ -1,94 +0,0 @@
# Multi-Root Implementation Notes
## Overview
MediaHive now supports multiple independent media roots. Each root is a filesystem directory with its own index, scanner, and WebSocket stream. The frontend merges per-root state into a single reactive view.
## Architecture
### Root Identity
- **Root ID**: friendly root name derived from configured path basename.
- **Name/ID collision handling**: suffixes `2`, `3`, … are appended to keep each root ID unique.
- **Path normalization**: lower-case Windows drive letter, strip trailing slashes, forward slashes only (`as_posix()`).
### Per-Root Runtime (`RootContext`)
Each active root gets an isolated `RootContext` managed by the `Supervisor`:
- `root_id`, `root_path` — stable identifiers
- `IndexStore` — owns snapshot at `<root>/.mediahive/index.json`
- `RootScanner` — per-root scanning instance (replaced legacy global scanner)
- `asyncio.Queue` + consumer task — bridges scanner events to WebSocket
- `status`: `idle` | `loading` | `ready` | `scanning` | `error`
### Supervisor
- Holds `dict[str, RootContext]` keyed by `root_id`.
- `replace_roots(new_roots)` atomically swaps the active set:
1. Validate & canonicalize paths.
2. Derive unique friendly `root_id` for each.
3. Prepare new `RootContext`s (load snapshots).
4. Swap dict atomically.
5. Stop removed contexts in background with bounded timeout.
- Exposes merged read helpers (`merged_index`, `all_statuses`).
### Item IDs
`root_id` is stored separately on each item.
- `Movie.id` uses a slug built from the movie title and year, for example `spider-man-no-way-home-2021`.
- `Series.id` uses a slug built from the series title, for example `lost`.
- Legacy snapshot migrations are handled by `scripts/indexmigr.py`, not during app startup.
## API
| Endpoint | Description |
|----------|-------------|
| `GET /api/roots` | List all roots (name, path, root_id, status) |
| `PUT /api/roots` | Atomically replace full root map `{name: path}` |
| `WS /api/ws/{root_id}` | Per-root WebSocket (init/upsert/remove/task + status/task events) |
| `GET /api/media/{root_id}/{path:path}` | Serve media file scoped to root |
| `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` | Serve `.mediahive/{asset_type}` assets (`movies`, `series`, `people`) |
| `POST /api/play/{root_id}` | Play file within root |
| `POST /api/open-folder/{root_id}` | Open folder within root |
| `GET /api/meta/{root_id}/{meta_key}` | Per-root metadata (for example `playback-state`) |
| `POST /api/ui/pick-folder` | Native OS folder picker (returns path) |
> **Removed legacy endpoints**: `/api/change-folder`, `/api/index`, `/api/scan`, `/api/status`, `/api/playback/resume-positions`. No backwards compatibility is maintained.
## macOS Startup Safety
The server **must not** touch the filesystem during startup, because macOS may show permission dialogs that block the event loop and prevent the HTTP server from accepting requests.
- `lifespan()` creates a background task (`_activate_all_roots()`) and immediately yields.
- All filesystem validation (`exists()`, `is_dir()`, `resolve()`) runs in a thread pool via `asyncio.to_thread()`.
- CLI entry points (`__main__.py`, `winmain.py`, `hivescan/__main__.py`) pass raw paths via the `MEDIAHIVE_ROOTS` environment variable; they do **not** validate paths before starting the server.
## POSIX Path Enforcement
All stored and transmitted paths use forward slashes exclusively:
- `_normalize_path()` always returns POSIX paths.
- Config stores `p.as_posix()`.
- URLs use `/` separators.
- `Path(root_path) / relative_path` works correctly on Windows because `Path` accepts POSIX separators.
## Config Migration
- Old `media_folder` string is auto-migrated to `roots: {basename: path}` on load.
- `roots` is persisted back to TOML config.
## Scanner
- Legacy global module-level scanner API was removed from `hivescan/scanner.py`.
- `RootScanner` is the only scanning interface.
- Each `RootScanner` owns its own `showreel_queue`, `scan_task`, `rescan_worker_task`, and `_seen_mtimes`.
## Frontend
- `useMediaWebSocket.ts` manages one WebSocket per active root.
- `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`.
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
- Playback URLs are root-qualified (`/api/media/{root_id}/...`).
- Metadata cache assets use typed root paths (`/api/assets/{root_id}/{asset_type}/...`) rather than exposing `.mediahive` in URLs.
+23 -62
View File
@@ -37,6 +37,7 @@
<Header <Header
:current-view="headerCurrentView" :current-view="headerCurrentView"
:search-query="searchQuery" :search-query="searchQuery"
:roots="headerRoots"
:mpc-be-connected="mpcBeConnected" :mpc-be-connected="mpcBeConnected"
:nav-row="1" :nav-row="1"
:position="headerPosition" :position="headerPosition"
@@ -70,7 +71,7 @@
<!-- Browse/Search page (left panel) --> <!-- Browse/Search page (left panel) -->
<main <main
ref="browsePanelRef" ref="browsePanelRef"
class="main-content page-slider-panel" class="main-content page-slider-panel scrollbar-hidden"
data-nav-scope="browse" data-nav-scope="browse"
@scroll.passive="handlePanelScroll('browse')" @scroll.passive="handlePanelScroll('browse')"
> >
@@ -165,7 +166,7 @@
<!-- Detail page (right panel) --> <!-- Detail page (right panel) -->
<main <main
ref="detailPanelRef" ref="detailPanelRef"
class="main-content page-slider-panel page-slider-detail-panel" class="main-content page-slider-panel page-slider-detail-panel scrollbar-hidden"
data-nav-scope="detail" data-nav-scope="detail"
@scroll.passive="handlePanelScroll('detail')" @scroll.passive="handlePanelScroll('detail')"
> >
@@ -206,9 +207,7 @@ import {
openFolder, openFolder,
isMpcBeReachable, isMpcBeReachable,
fetchResumePositions, fetchResumePositions,
normalizeMediaPath,
getPlayerStatus, getPlayerStatus,
fetchRoots,
} from "./api" } from "./api"
import { useSettings } from "./composables/useSettings" import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation" import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
@@ -268,7 +267,7 @@ const {
error, error,
connected: wsConnected, connected: wsConnected,
tasks, tasks,
setActiveRoots, roots: rootStatuses,
} = useMediaWebSocket() } = useMediaWebSocket()
type RootTaskInfo = TaskInfo & { root_id: string } type RootTaskInfo = TaskInfo & { root_id: string }
@@ -287,14 +286,9 @@ interface ProgressRootState {
const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values())) const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
// Poll for active roots and connect WS to them
const rootStatuses = ref<
Map<string, { name: string; path: string; status: string; snapshotLoaded: boolean }>
>(new Map())
function getRootName(rootId: string | null | undefined): string | null { function getRootName(rootId: string | null | undefined): string | null {
if (!rootId) return null if (!rootId) return null
return rootStatuses.value.get(rootId)?.name || null return rootStatuses.value.get(rootId)?.root_id || null
} }
function normalizePosixPath(value: string): string { function normalizePosixPath(value: string): string {
@@ -427,10 +421,14 @@ const hasLibraryItems = computed(() => {
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0 return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
}) })
const hasAnySnapshotLoaded = computed(() => const hasAnySnapshotLoaded = computed(() =>
Array.from(rootStatuses.value.values()).some((root) => root.snapshotLoaded), Array.from(rootStatuses.value.values()).some((root) => root.snapshot_loaded),
) )
const isInitialScanMode = computed(() => !hasLibraryItems.value && !hasAnySnapshotLoaded.value) const isInitialScanMode = computed(() => !hasLibraryItems.value && !hasAnySnapshotLoaded.value)
const headerRoots = computed(() =>
Array.from(rootStatuses.value.values()).sort((a, b) => a.root_id.localeCompare(b.root_id)),
)
const showProgressPanel = computed(() => { const showProgressPanel = computed(() => {
if (isInitialScanMode.value) { if (isInitialScanMode.value) {
return !wsConnected.value || progressRoots.value.length > 0 return !wsConnected.value || progressRoots.value.length > 0
@@ -480,51 +478,7 @@ watch(
{ deep: false }, { deep: false },
) )
async function refreshRoots() {
try {
const roots = await fetchRoots()
const newMap = new Map<
string,
{ name: string; path: string; status: string; snapshotLoaded: boolean }
>()
const activeIds: string[] = []
for (const r of roots) {
newMap.set(r.root_id, {
name: r.root_id,
path: r.path,
status: r.status,
snapshotLoaded: Boolean(r.snapshot_loaded),
})
if (r.status === "ready" || r.status === "scanning") {
activeIds.push(r.root_id)
}
}
rootStatuses.value = newMap
setActiveRoots(activeIds)
} catch (e) {
console.error("Failed to fetch roots:", e)
}
}
let rootsPollTimer: number | null = null
function startRootsPolling() {
if (rootsPollTimer !== null) return
void refreshRoots()
rootsPollTimer = window.setInterval(refreshRoots, 5000)
}
function stopRootsPolling() {
if (rootsPollTimer !== null) {
window.clearInterval(rootsPollTimer)
rootsPollTimer = null
}
}
onMounted(() => {
startRootsPolling()
})
onUnmounted(() => { onUnmounted(() => {
stopRootsPolling()
if (libraryUpdateToastTimer !== null) { if (libraryUpdateToastTimer !== null) {
window.clearTimeout(libraryUpdateToastTimer) window.clearTimeout(libraryUpdateToastTimer)
libraryUpdateToastTimer = null libraryUpdateToastTimer = null
@@ -592,10 +546,9 @@ async function refreshPlayerStatus() {
} }
} }
function hasResumePosition(filePath: string | null) { function hasResumePosition(mediaId: string | null) {
if (!filePath) return false if (!mediaId) return false
const normalizedPath = normalizeMediaPath(filePath) return Number(resumePositions.value[mediaId] || 0) > 0
return Number(resumePositions.value[normalizedPath] || 0) > 0
} }
function startMpcBePolling() { function startMpcBePolling() {
@@ -1641,6 +1594,7 @@ function findRootIdForPath(filePath: string): string | null {
} }
async function handlePlay(filePath: string) { async function handlePlay(filePath: string) {
const actionStart = performance.now()
const rootId = findRootIdForPath(filePath) const rootId = findRootIdForPath(filePath)
if (!rootId) { if (!rootId) {
console.error("Cannot play: unknown root for path", filePath) console.error("Cannot play: unknown root for path", filePath)
@@ -1650,7 +1604,10 @@ async function handlePlay(filePath: string) {
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS
} }
try { try {
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd) await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd, {
actionStartedAt: actionStart,
source: "App.handlePlay",
})
if (isMpcFamilySelected()) { if (isMpcFamilySelected()) {
const connected = await tryConnectMpcBe() const connected = await tryConnectMpcBe()
if (connected) { if (connected) {
@@ -1664,13 +1621,17 @@ async function handlePlay(filePath: string) {
} }
async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) { async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
const actionStart = performance.now()
const rootId = explicitRootId || findRootIdForPath(folderPath) const rootId = explicitRootId || findRootIdForPath(folderPath)
if (!rootId) { if (!rootId) {
console.error("Cannot open folder: unknown root for path", folderPath) console.error("Cannot open folder: unknown root for path", folderPath)
return return
} }
try { try {
await openFolder(rootId, folderPath) await openFolder(rootId, folderPath, {
actionStartedAt: actionStart,
source: "App.handleOpenFolder",
})
} catch (e) { } catch (e) {
console.error("Failed to open folder:", e) console.error("Failed to open folder:", e)
} }
+113 -41
View File
@@ -9,18 +9,43 @@ export interface PlayerInfo {
path: string | null path: string | null
} }
export interface RootStatus { export interface RootEntry {
root_id: string root_id: string
path: string path: string
status: string
error: string | null
snapshot_loaded: boolean
movies: number
series: number
} }
export interface RootsResponse { interface ActionTimingContext {
roots: RootStatus[] actionStartedAt?: number
source?: string
}
function nowMs(): number {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now()
}
return Date.now()
}
function makeTraceId(action: string): string {
const suffix = Math.random().toString(16).slice(2, 8)
return `${action}-${Date.now().toString(36)}-${suffix}`
}
function logActionTiming(
action: string,
traceId: string,
status: number,
actionToFetchMs: number,
fetchMs: number,
totalMs: number,
serverTiming: string | null,
source?: string,
) {
const sourceTag = source ? ` source=${source}` : ""
const serverTag = serverTiming ? ` serverTiming=${serverTiming}` : ""
console.info(
`[timing:${action}] trace=${traceId}${sourceTag} status=${status} actionToFetch=${actionToFetchMs.toFixed(1)}ms fetch=${fetchMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms${serverTag}`,
)
} }
export function normalizeMediaPath(input: string): string { export function normalizeMediaPath(input: string): string {
@@ -123,37 +148,27 @@ function splitAssetTypePath(assetPath: string): { assetType: string; relativePat
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") } return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
} }
/**
* Fetch active roots and their statuses
*/
export async function fetchRoots(): Promise<RootStatus[]> {
const response = await fetch("/api/roots")
if (!response.ok) {
throw new Error(`Failed to load roots: ${response.statusText}`)
}
const data = await response.json()
return data.roots || []
}
/** /**
* Fetch merged resume positions from all roots. * Fetch merged resume positions from all roots.
*/ */
export async function fetchResumePositions(): Promise<Record<string, number>> { export async function fetchResumePositions(): Promise<Record<string, number>> {
try { try {
const roots = await fetchRoots() const response = await fetch("/api/meta/playback-state")
const merged: Record<string, number> = {} if (!response.ok) return {}
await Promise.all( const data = await response.json().catch(() => ({}))
roots.map(async (root) => { const positions = data?.data?.resume_positions
const response = await fetch(`/api/meta/${encodeURIComponent(root.root_id)}/playback-state`) if (!positions || typeof positions !== "object") {
if (!response.ok) return return {}
const data = await response.json().catch(() => ({})) }
const positions = data?.data?.resume_positions const normalized: Record<string, number> = {}
if (positions && typeof positions === "object") { for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
Object.assign(merged, positions) if (!value || typeof value !== "object") continue
} const pos = (value as { pos?: unknown }).pos
}), if (typeof pos === "number" && Number.isFinite(pos) && pos > 0) {
) normalized[slug] = pos
return merged }
}
return normalized
} catch { } catch {
return {} return {}
} }
@@ -164,8 +179,8 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
*/ */
export async function replaceRoots( export async function replaceRoots(
roots: Record<string, string>, roots: Record<string, string>,
): Promise<{ accepted: RootStatus[]; failed: unknown[] }> { ): Promise<{ accepted: RootEntry[]; failed: unknown[] }> {
const response = await fetch("/api/roots", { const response = await fetch("/api/config/roots", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots }), body: JSON.stringify({ roots }),
@@ -197,23 +212,50 @@ export async function playMedia(
filePath: string, filePath: string,
playerId?: string | null, playerId?: string | null,
playerCustomCmd?: string | null, playerCustomCmd?: string | null,
timing?: ActionTimingContext,
): Promise<void> { ): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath) const normalizedPath = normalizeMediaPath(filePath)
const body: Record<string, unknown> = { file_path: normalizedPath } const body: Record<string, unknown> = { file_path: normalizedPath }
if (playerId) body.player_id = playerId if (playerId) body.player_id = playerId
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("play")
try { try {
const fetchStart = nowMs()
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
const clientSentMs = Date.now()
const actionStartEpochMs = clientSentMs - actionToFetchMs
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, { const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
"X-MediaHive-Trace-Id": traceId,
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
const fetchMs = Math.max(0, nowMs() - fetchStart)
const totalMs = Math.max(0, nowMs() - actionStart)
const serverTiming = response.headers.get("server-timing")
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
logActionTiming(
"play",
responseTraceId,
response.status,
actionToFetchMs,
fetchMs,
totalMs,
serverTiming,
timing?.source,
)
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json()
throw new Error(error.detail || response.statusText) throw new Error(error.detail || response.statusText)
} }
} catch (e) { } catch (e) {
console.error("Play media error:", e) const totalMs = Math.max(0, nowMs() - actionStart)
console.error(`Play media error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
alert(`Failed to play media.\n\n${e}`) alert(`Failed to play media.\n\n${e}`)
} }
} }
@@ -221,20 +263,50 @@ export async function playMedia(
/** /**
* Open a folder in the system file manager * Open a folder in the system file manager
*/ */
export async function openFolder(rootId: string, folderPath: string): Promise<void> { export async function openFolder(
rootId: string,
folderPath: string,
timing?: ActionTimingContext,
): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath) const normalizedPath = normalizeMediaPath(folderPath)
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("open-folder")
try { try {
const fetchStart = nowMs()
const actionToFetchMs = Math.max(0, fetchStart - actionStart)
const clientSentMs = Date.now()
const actionStartEpochMs = clientSentMs - actionToFetchMs
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, { const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
"X-MediaHive-Trace-Id": traceId,
"X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3),
"X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3),
},
body: JSON.stringify({ folder_path: normalizedPath }), body: JSON.stringify({ folder_path: normalizedPath }),
}) })
const fetchMs = Math.max(0, nowMs() - fetchStart)
const totalMs = Math.max(0, nowMs() - actionStart)
const serverTiming = response.headers.get("server-timing")
const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId
logActionTiming(
"open-folder",
responseTraceId,
response.status,
actionToFetchMs,
fetchMs,
totalMs,
serverTiming,
timing?.source,
)
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json()
throw new Error(error.detail || response.statusText) throw new Error(error.detail || response.statusText)
} }
} catch (e) { } catch (e) {
console.error("Open folder error:", e) const totalMs = Math.max(0, nowMs() - actionStart)
console.error(`Open folder error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e)
alert(`Failed to open folder.\n\n${e}`) alert(`Failed to open folder.\n\n${e}`)
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+8 -19
View File
@@ -42,6 +42,10 @@
type="search" type="search"
class="search-input" class="search-input"
placeholder="Search..." placeholder="Search..."
:spellcheck="false"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
v-model="localSearch" v-model="localSearch"
v-bind="navAttrs(navRow, 2)" v-bind="navAttrs(navRow, 2)"
:data-nav-entry-col="localSearch ? 2 : undefined" :data-nav-entry-col="localSearch ? 2 : undefined"
@@ -105,7 +109,7 @@
<div class="settings-header-spacer"></div> <div class="settings-header-spacer"></div>
</div> </div>
<div class="settings-content"> <div class="settings-content scrollbar-hidden">
<section class="settings-section"> <section class="settings-section">
<h2 class="settings-section-title">Media Roots</h2> <h2 class="settings-section-title">Media Roots</h2>
<p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p> <p class="settings-section-desc">Folders scanned and indexed by MediaHive.</p>
@@ -302,7 +306,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue"
import { useRouter, useRoute } from "vue-router" import { useRouter, useRoute } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp" import logoUrl from "../assets/mediahive.webp"
import { fetchRoots, replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api" import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
import type { PlayerInfo } from "../api" import type { PlayerInfo } from "../api"
import HexKeyboard from "./HexKeyboard.vue" import HexKeyboard from "./HexKeyboard.vue"
import { import {
@@ -330,6 +334,7 @@ interface RootEntry {
const props = defineProps<{ const props = defineProps<{
currentView: "movies" | "series" | "search" currentView: "movies" | "series" | "search"
searchQuery: string searchQuery: string
roots: RootEntry[]
mpcBeConnected: boolean mpcBeConnected: boolean
navRow: number navRow: number
position: "top" | "after-hero" | "after-movie-header" | "after-series-hero" position: "top" | "after-hero" | "after-movie-header" | "after-series-hero"
@@ -353,7 +358,7 @@ window.addEventListener("pywebviewready", _onPywebviewReady, { once: true })
onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady)) onUnmounted(() => window.removeEventListener("pywebviewready", _onPywebviewReady))
const showSettings = computed(() => route.path === "/settings") const showSettings = computed(() => route.path === "/settings")
const roots = ref<RootEntry[]>([]) const roots = computed(() => props.roots)
function openSettings() { function openSettings() {
if (showSettings.value) return if (showSettings.value) return
@@ -404,25 +409,11 @@ async function refreshPlayers() {
} }
} }
async function refreshRoots() {
try {
const data = await fetchRoots()
roots.value = data.map((r) => ({
root_id: r.root_id,
path: r.path,
status: r.status,
}))
} catch (e) {
console.error("Failed to fetch roots:", e)
}
}
async function removeRoot(rootId: string) { async function removeRoot(rootId: string) {
const filtered = roots.value.filter((r) => r.root_id !== rootId) const filtered = roots.value.filter((r) => r.root_id !== rootId)
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path])) const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
try { try {
await replaceRoots(newRoots) await replaceRoots(newRoots)
await refreshRoots()
} catch (e) { } catch (e) {
console.error("Failed to remove root:", e) console.error("Failed to remove root:", e)
alert("Failed to remove root") alert("Failed to remove root")
@@ -437,7 +428,6 @@ async function addRoot() {
newRoots[suggestedId] = folder newRoots[suggestedId] = folder
try { try {
await replaceRoots(newRoots) await replaceRoots(newRoots)
await refreshRoots()
closeSettings() closeSettings()
} catch (e) { } catch (e) {
console.error("Failed to add root:", e) console.error("Failed to add root:", e)
@@ -447,7 +437,6 @@ async function addRoot() {
watch(showSettings, (visible) => { watch(showSettings, (visible) => {
if (visible) { if (visible) {
void refreshRoots()
void refreshPlayers() void refreshPlayers()
} }
}) })
+189 -78
View File
@@ -30,6 +30,7 @@
<div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div> <div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div>
<video <video
v-if="slot.sourcePaths.length > 0" v-if="slot.sourcePaths.length > 0"
:key="`${item.id}-${slot.index}-${slot.sourcePaths.join('|')}`"
:ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)" :ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)"
:class="{ 'is-ready': isVideoReady(slot.index) }" :class="{ 'is-ready': isVideoReady(slot.index) }"
:autoplay="safariAutoplay" :autoplay="safariAutoplay"
@@ -179,31 +180,29 @@
</div> </div>
</div> </div>
<section v-if="item.type === 'movies' && similarMovies.length > 0" class="similar-movies-section"> <section
<h2 class="similar-movies-title">Similar In Library</h2> v-if="item.type === 'movies' && collectionMovies.length > 1"
class="similar-movies-section"
>
<div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar"> <div class="similar-movies-grid" data-sync-scroll-row="true" data-sync-scroll-group="similar">
<button <a
v-for="(movie, similarIndex) in similarMovies" v-for="(movie, collectionIndex) in collectionMovies"
:key="movie.tmdbId" :key="movie.localId"
type="button" href="#"
class="similar-movie-card cast-card media-card" class="similar-movie-card media-card"
v-bind="navAttrs(similarNavRow, similarIndex)" :class="{ 'similar-movie-card--current': movie.isCurrent }"
@click="handleSelectMovie(movie.localId)" :aria-current="movie.isCurrent ? 'true' : undefined"
v-bind="navAttrs(collectionNavRow, collectionIndex)"
@click.prevent="handleSelectCollectionMovie(movie.localId, movie.isCurrent)"
> >
<img <img
v-if="movie.coverPath" v-if="movie.coverPath"
:src="getCoverUrl(movie.coverPath, movie.rootId)" :src="getCoverUrl(movie.coverPath, movie.rootId)"
:alt="movie.title || 'Movie'" :alt="movie.title || 'Movie'"
class="similar-movie-poster cast-photo" class="similar-movie-poster"
/> />
<div v-else class="similar-movie-poster cast-photo similar-movie-poster-fallback"></div> <div v-else class="similar-movie-poster similar-movie-poster-fallback"></div>
<div class="similar-movie-meta cast-copy"> </a>
<span class="similar-movie-name cast-name">{{ movie.title }}</span>
<span class="similar-movie-sub cast-character">
{{ movie.year || "Unknown Year" }}
</span>
</div>
</button>
</div> </div>
</section> </section>
</div> </div>
@@ -226,6 +225,7 @@
:play-label="getPlayLabel(versionActionMenu.filePath)" :play-label="getPlayLabel(versionActionMenu.filePath)"
@play="handlePlayVersion(versionActionMenu.filePath)" @play="handlePlayVersion(versionActionMenu.filePath)"
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)" @open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
@close="closeVersionActionMenu"
/> />
</Teleport> </Teleport>
</div> </div>
@@ -251,13 +251,14 @@ import {
navAttrs, navAttrs,
registerOutOfBoundsNavigationHandler, registerOutOfBoundsNavigationHandler,
FOCUSABLE_ATTR, FOCUSABLE_ATTR,
setModalOpen,
} from "../composables/useKeyboardNavigation" } from "../composables/useKeyboardNavigation"
const props = defineProps<{ const props = defineProps<{
item: MediaItem item: MediaItem
allMovies: MovieUi[] allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean hasResumePosition: (mediaId: string | null) => boolean
getRootName: (rootId: string | null | undefined) => string | null getRootName: (rootId: string | null | undefined) => string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -555,6 +556,11 @@ watch(
) )
videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing")) videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing"))
await nextTick() await nextTick()
for (let i = 0; i < videoRefs.value.length; i++) {
if (slots[i]?.sourcePaths.length > 0) {
videoRefs.value[i]?.load()
}
}
setTimeout(() => { setTimeout(() => {
startStaggeredPlayback() startStaggeredPlayback()
}, 100) }, 100)
@@ -650,73 +656,102 @@ const movieKeywords = computed(() => {
const viewportWidth = ref(typeof window !== "undefined" ? window.innerWidth : 1920) const viewportWidth = ref(typeof window !== "undefined" ? window.innerWidth : 1920)
const similarNavRow = computed(() => 3 + movieVersions.value.length) const collectionNavRow = computed(() => 3 + movieVersions.value.length)
const castNavRow = computed(() => { const castNavRow = computed(() => {
const hasDesktopSimilarShortcut = const hasDesktopSimilarShortcut =
viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && similarMovies.value.length > 0 viewportWidth.value > DESKTOP_NAV_SHORTCUT_MIN_WIDTH && collectionMovies.value.length > 1
// Desktop with similar row: keep visual cast placement but move it below similar in nav rows. // Desktop with similar row: keep visual cast placement but move it below similar in nav rows.
// Narrow layout (or no similar): preserve existing cast row directly after releases. // Narrow layout (or no similar): preserve existing cast row directly after releases.
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
}) })
const similarMovies = computed((): Array<{ const collectionMovies = computed((): Array<{
tmdbId: number
title: string title: string
localId: string localId: string
coverPath: string | null coverPath: string | null
rootId: string | null rootId: string | null
year: string | null year: string | null
hyphenLang: string | null
isCurrent: boolean
}> => { }> => {
if (props.item.type !== "movies") return [] if (props.item.type !== "movies") return []
const movie = props.item.data as Movie const movie = props.item.data as Movie
const similar = movie.info?.similar || [] const collectionName = movie.info?.collection?.trim()
if (similar.length === 0) return [] if (!collectionName) return []
const normalizedCollectionName = collectionName.toLowerCase()
const byTmdbId = new Map<number, MovieUi>()
for (const libraryMovie of props.allMovies || []) {
const tmdbId = libraryMovie.info?.tmdb_id
if (libraryMovie.id === props.item.id) continue
if (typeof tmdbId === "number" && !byTmdbId.has(tmdbId)) {
byTmdbId.set(tmdbId, libraryMovie)
}
}
const matches: Array<{ const matches: Array<{
tmdbId: number
title: string title: string
localId: string localId: string
coverPath: string | null coverPath: string | null
rootId: string | null rootId: string | null
year: string | null year: string | null
hyphenLang: string | null
isCurrent: boolean
}> = [] }> = []
const seenTmdbIds = new Set<number>() let hasCurrentInMatches = false
for (const similarEntry of similar) {
if (seenTmdbIds.has(similarEntry.id)) continue
seenTmdbIds.add(similarEntry.id)
const matched = byTmdbId.get(similarEntry.id) for (const libraryMovie of props.allMovies || []) {
if (!matched) continue const otherCollectionName = libraryMovie.info?.collection?.trim().toLowerCase()
if (otherCollectionName !== normalizedCollectionName) continue
const title = matched.title || matched.info?.title || similarEntry.title const title = libraryMovie.title || libraryMovie.info?.title
if (!title) continue if (!title) continue
const isCurrent = libraryMovie.id === props.item.id
if (isCurrent) hasCurrentInMatches = true
matches.push({ matches.push({
tmdbId: similarEntry.id,
title, title,
localId: matched.id, localId: libraryMovie.id,
coverPath: matched.cover_path || null, coverPath: libraryMovie.cover_path || null,
rootId: matched.root_id || null, rootId: libraryMovie.root_id || null,
year: matched.year ? String(matched.year) : matched.info?.release_date?.slice(0, 4) || null, year: libraryMovie.year
? String(libraryMovie.year)
: libraryMovie.info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang(libraryMovie.info?.original_language),
isCurrent,
}) })
} }
return matches.slice(0, 24) if (!hasCurrentInMatches) {
matches.push({
title: props.item.title || (props.item.data as Movie).info?.title || "Current movie",
localId: props.item.id,
coverPath: props.item.cover_path || null,
rootId: props.item.root_id || null,
year: props.item.year
? String(props.item.year)
: (props.item.data as Movie).info?.release_date?.slice(0, 4) || null,
hyphenLang: normalizeHyphenationLang((props.item.data as Movie).info?.original_language),
isCurrent: true,
})
}
return matches
.sort((a, b) => {
const yearA = parseInt(a.year || "", 10)
const yearB = parseInt(b.year || "", 10)
const hasYearA = Number.isFinite(yearA)
const hasYearB = Number.isFinite(yearB)
if (hasYearA && hasYearB && yearA !== yearB) return yearA - yearB
if (hasYearA !== hasYearB) return hasYearA ? -1 : 1
return a.title.localeCompare(b.title)
})
.slice(0, 24)
}) })
function normalizeHyphenationLang(language: string | null | undefined): string | null {
if (!language) return null
const normalized = language.trim()
if (!/^[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(normalized)) return null
return normalized.toLowerCase()
}
function formatKeywordLabel(keyword: string): string { function formatKeywordLabel(keyword: string): string {
// Keep multi-word keywords together while visually narrowing internal spacing. // Keep multi-word keywords together while visually narrowing internal spacing.
return keyword.trim().replace(/\s+/g, "\u202F") return keyword.trim().replace(/\s+/g, "\u202F")
@@ -788,14 +823,19 @@ const versionActionMenu = ref<{
}) })
function closeVersionActionMenu() { function closeVersionActionMenu() {
const wasVisible = versionActionMenu.value.visible
versionActionMenu.value.visible = false versionActionMenu.value.visible = false
versionActionMenu.value.filePath = null versionActionMenu.value.filePath = null
versionActionMenu.value.rootName = null versionActionMenu.value.rootName = null
versionActionMenu.value.rootId = null versionActionMenu.value.rootId = null
if (wasVisible) {
setModalOpen(false)
}
} }
function getPlayLabel(filePath: string | null): string { function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? "Continue" : "Play" if (!filePath || props.item.type !== "movies") return "Play"
return props.hasResumePosition(props.item.id) ? "Continue" : "Play"
} }
function handlePlayVersion(filePath: string | null) { function handlePlayVersion(filePath: string | null) {
@@ -808,6 +848,7 @@ function handlePlayVersion(filePath: string | null) {
function handleVersionContextMenu(event: MouseEvent, version: Torrent) { function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
setModalOpen(true)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id) const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
versionActionMenu.value = { versionActionMenu.value = {
visible: true, visible: true,
@@ -893,20 +934,61 @@ function handleSelectMovie(movieId: string) {
emit("selectMovie", movieId) emit("selectMovie", movieId)
} }
function handleSelectCollectionMovie(movieId: string, isCurrent: boolean) {
if (isCurrent) return
handleSelectMovie(movieId)
}
function handleResize() { function handleResize() {
viewportWidth.value = window.innerWidth viewportWidth.value = window.innerWidth
} }
function handleGamepadAction(event: Event) {
const actionEvent = event as CustomEvent<{ action?: string }>
if (actionEvent.detail?.action !== "menu") return
const active = document.activeElement as HTMLElement | null
if (!active || !active.hasAttribute("data-nav-release-item")) return
const row = parseInt(active.getAttribute("data-nav-row") || "-1", 10)
if (row < 0) return
const index = row - 2 // releases start at nav row 2
const version = movieVersions.value[index]
if (!version) return
actionEvent.preventDefault()
setModalOpen(true)
const rect = active.getBoundingClientRect()
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
versionActionMenu.value = {
visible: true,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
filePath: version.playable_file || null,
rootName: props.getRootName(rootId) || null,
rootId,
}
nextTick(() => {
const firstAction = document.querySelector(
".version-action-menu .version-action-item:not(:disabled)",
) as HTMLElement | null
firstAction?.focus()
})
}
onMounted(() => { onMounted(() => {
document.addEventListener("keydown", handleMovieMenuKeydown, true) document.addEventListener("keydown", handleMovieMenuKeydown, true)
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true }) window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true })
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", handleMovieMenuKeydown, true) document.removeEventListener("keydown", handleMovieMenuKeydown, true)
window.removeEventListener("resize", handleResize) window.removeEventListener("resize", handleResize)
window.removeEventListener("mousemove", handleHoverAudioMouseMove) window.removeEventListener("mousemove", handleHoverAudioMouseMove)
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
clearHoverAudioIdleTimer() clearHoverAudioIdleTimer()
disposeOutOfBoundsHandler?.() disposeOutOfBoundsHandler?.()
disposeOutOfBoundsHandler = null disposeOutOfBoundsHandler = null
@@ -933,6 +1015,9 @@ onUnmounted(() => {
.similar-movies-section { .similar-movies-section {
margin-top: 20px; margin-top: 20px;
position: relative;
left: calc(-50vw + 50%);
width: 100vw;
} }
.similar-movies-title { .similar-movies-title {
@@ -943,7 +1028,12 @@ onUnmounted(() => {
.similar-movies-grid { .similar-movies-grid {
--sync-row-tail: 0px; --sync-row-tail: 0px;
--sync-row-right-deadzone: 32px; --similar-safe-start: 32px;
--similar-safe-end: 32px;
--sync-row-left-deadzone: var(--similar-safe-start);
--sync-row-right-deadzone: var(--similar-safe-end);
margin: 0;
padding: 0 calc(var(--similar-safe-end) + var(--sync-row-tail)) 0 var(--similar-safe-start);
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 6px; gap: 6px;
@@ -963,33 +1053,52 @@ onUnmounted(() => {
color: inherit; color: inherit;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
position: relative;
border-radius: 0;
/* Keep poster clipping local to the poster element. */
overflow: visible;
}
.similar-movie-card--current {
cursor: default;
}
.similar-movie-card::after {
content: "";
position: absolute;
inset: 0;
border: 0 solid rgba(255, 255, 255, 0.95);
pointer-events: none;
transition: border-width 120ms ease;
}
.similar-movie-card:focus-visible,
html:not(.mouse-active) .similar-movie-card.nav-focused {
outline: none;
}
.similar-movie-card:focus-visible::after,
html:not(.mouse-active) .similar-movie-card.nav-focused::after {
border-width: 2px;
} }
.similar-movie-poster { .similar-movie-poster {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden;
border-radius: 0;
box-shadow: 0 0 0.4rem black;
transition: filter 140ms ease;
}
.similar-movie-card--current .similar-movie-poster {
filter: sepia(0.85);
} }
.similar-movie-poster-fallback { .similar-movie-poster-fallback {
background: linear-gradient(135deg, #282d3a, #171b24); background: linear-gradient(135deg, #282d3a, #171b24);
} }
.similar-movie-meta {
inset: auto 0 0 0;
}
.similar-movie-name {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.similar-movie-sub {
white-space: nowrap;
}
.movie-menu-backdrop { .movie-menu-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1110,6 +1219,7 @@ onUnmounted(() => {
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 0 0.4rem black;
} }
.synopsis-poster { .synopsis-poster {
@@ -1453,6 +1563,13 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
margin-left: 0; margin-left: 0;
margin-top: 0; margin-top: 0;
} }
.similar-movies-grid {
--similar-safe-start: 32px;
--similar-safe-end: 32px;
--sync-row-left-deadzone: 32px;
--sync-row-right-deadzone: 32px;
}
} }
/* Showreel gallery */ /* Showreel gallery */
@@ -1473,20 +1590,14 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
overflow-x: auto; overflow-x: auto;
padding-bottom: 8px; padding-bottom: 8px;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
} }
.showreel-images::-webkit-scrollbar { .showreel-images::-webkit-scrollbar {
height: 6px; width: 0;
} height: 0;
display: none;
.showreel-images::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.showreel-images::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
} }
.showreel-image { .showreel-image {
+47 -1
View File
@@ -1,5 +1,12 @@
<template> <template>
<div v-if="visible" ref="menuRef" class="version-action-menu" :style="menuStyle"> <div
v-if="visible"
ref="menuRef"
class="version-action-menu"
:style="menuStyle"
tabindex="-1"
@keydown="handleKeydown"
>
<div class="version-action-path" :title="resolvedPath"> <div class="version-action-path" :title="resolvedPath">
{{ resolvedPath }} {{ resolvedPath }}
</div> </div>
@@ -47,6 +54,7 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
play: [] play: []
openFolder: [] openFolder: []
close: []
}>() }>()
const menuRef = ref<HTMLElement | null>(null) const menuRef = ref<HTMLElement | null>(null)
@@ -73,6 +81,44 @@ const menuStyle = computed(() => ({
const disabled = computed(() => !props.filePath) const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)")
)
}
function focusNext(delta: number) {
const elements = getFocusableElements()
if (elements.length === 0) return
const currentIndex = elements.findIndex((el) => el === document.activeElement)
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
elements[nextIndex].focus()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Tab") {
event.preventDefault()
focusNext(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
focusNext(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
focusNext(-1)
return
}
if (event.key === "Escape") {
event.preventDefault()
emit("close")
}
}
function clampToViewport() { function clampToViewport() {
const menu = menuRef.value const menu = menuRef.value
if (!menu) return if (!menu) return
+59 -14
View File
@@ -3,12 +3,13 @@
class="version-row" class="version-row"
:class="{ :class="{
'version-best': best, 'version-best': best,
'version-selectable': isSelectable, 'version-selectable': isSelectable && !inertCard,
'version-disabled': isDisabled, 'version-disabled': isDisabled,
'version-menu': variant === 'menu', 'version-menu': variant === 'menu',
'version-with-actions': showActions, 'version-with-actions': showActions,
'version-inert': inertCard,
}" }"
tabindex="0" :tabindex="inertCard ? undefined : 0"
:title="resolvedTitle" :title="resolvedTitle"
v-bind="$attrs" v-bind="$attrs"
@click="handleActivate" @click="handleActivate"
@@ -57,6 +58,12 @@
:alt="streamingServiceLogo.alt" :alt="streamingServiceLogo.alt"
:title="streamingServiceLogo.alt" :title="streamingServiceLogo.alt"
/> />
<img
v-if="showHdr10PlusLogo"
class="version-hdr10plus-logo"
:src="hdr10plusLogoUrl"
alt="HDR10+"
/>
<DolbyBadges <DolbyBadges
class="version-dolby" class="version-dolby"
:has-dolby-vision="hasDolbyVision" :has-dolby-vision="hasDolbyVision"
@@ -70,14 +77,16 @@
tabindex="0" tabindex="0"
@click.stop="emit('play')" @click.stop="emit('play')"
:disabled="!torrent.playable_file" :disabled="!torrent.playable_file"
:title="playLabel"
> >
{{ playLabel }}
</button> </button>
<button <button
class="ctx-btn ctx-btn-folder" class="ctx-btn ctx-btn-folder"
tabindex="0" tabindex="0"
@click.stop="emit('openFolder')" @click.stop="emit('openFolder')"
:disabled="!torrent.playable_file" :disabled="!torrent.playable_file"
title="Open Folder"
> >
📁 📁
</button> </button>
@@ -100,6 +109,7 @@ import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
import huluLogoUrl from "../assets/service-hulu.webp" import huluLogoUrl from "../assets/service-hulu.webp"
import disneyLogoUrl from "../assets/service-disney.svg" import disneyLogoUrl from "../assets/service-disney.svg"
import itunesLogoUrl from "../assets/service-itunes.png" import itunesLogoUrl from "../assets/service-itunes.png"
import hdr10plusLogoUrl from "../assets/hdr10plus-logo.png"
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
@@ -116,6 +126,9 @@ const props = withDefaults(
playLabel?: string playLabel?: string
title?: string title?: string
variant?: "default" | "menu" variant?: "default" | "menu"
/** When true, the card itself is not interactive (no tabindex, no click/keyboard handlers).
* Use with showActions to make only the inline buttons interactive. */
inertCard?: boolean
}>(), }>(),
{ {
best: false, best: false,
@@ -126,6 +139,7 @@ const props = withDefaults(
playLabel: "Play", playLabel: "Play",
title: undefined, title: undefined,
variant: "default", variant: "default",
inertCard: false,
}, },
) )
@@ -317,6 +331,18 @@ const showHdrBadge = computed(() => {
) )
}) })
const hdr10PlusPattern = /hdr10\+|hdr10plus/i
const hasHdr10Plus = computed(() => {
if (props.torrent.hdr10plus) return true
const text = [props.torrent.title, props.torrent.quality, props.torrent.codec, props.torrent.audio]
.filter(Boolean)
.join(" ")
return hdr10PlusPattern.test(text)
})
const showHdr10PlusLogo = computed(() => hasHdr10Plus.value && !hasDolbyVision.value)
const isSelectable = computed(() => { const isSelectable = computed(() => {
if (props.selectable !== undefined) return props.selectable if (props.selectable !== undefined) return props.selectable
return Boolean(props.torrent.playable_file) return Boolean(props.torrent.playable_file)
@@ -335,7 +361,7 @@ const resolvedTitle = computed(() => {
}) })
function handleActivate(event: MouseEvent | KeyboardEvent) { function handleActivate(event: MouseEvent | KeyboardEvent) {
if (!isSelectable.value || isDisabled.value) return if (props.inertCard || !isSelectable.value || isDisabled.value) return
emit("activate", event) emit("activate", event)
} }
</script> </script>
@@ -390,6 +416,15 @@ html.mouse-active .version-row.version-best:hover {
outline-offset: 2px; outline-offset: 2px;
} }
.version-row.version-inert {
cursor: default;
}
.version-row.version-inert .version-main,
.version-row.version-inert .version-dolby-cell {
pointer-events: none;
}
.version-row.version-disabled { .version-row.version-disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.75; opacity: 0.75;
@@ -500,6 +535,15 @@ html.mouse-active .version-row.version-best:hover {
object-fit: contain; object-fit: contain;
} }
.version-hdr10plus-logo {
align-self: stretch;
display: block;
width: auto;
height: 100%;
max-height: 100%;
object-fit: contain;
}
.v-badge.res { .v-badge.res {
background: #111111; background: #111111;
color: #f8fafc; color: #f8fafc;
@@ -579,31 +623,32 @@ html.mouse-active .version-row.version-best:hover {
} }
.ctx-btn { .ctx-btn {
border: 1px solid rgba(255, 255, 255, 0.18); border: none;
background: rgba(255, 255, 255, 0.08); background: transparent;
color: #fff; color: rgba(255, 255, 255, 0.65);
border-radius: 6px; border-radius: 6px;
padding: 6px 10px; padding: 4px 8px;
font-size: 0.78rem; font-size: 2em;
line-height: 1;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease;
} }
html.mouse-active .ctx-btn:hover:not(:disabled), html.mouse-active .ctx-btn:hover:not(:disabled),
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled), html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
.ctx-btn:focus-visible:not(:disabled) { .ctx-btn:focus-visible:not(:disabled) {
background: rgba(255, 255, 255, 0.16); color: #fff;
border-color: rgba(255, 255, 255, 0.35);
outline: none; outline: none;
} }
.ctx-btn:disabled { .ctx-btn:disabled {
opacity: 0.5; opacity: 0.35;
cursor: not-allowed; cursor: not-allowed;
} }
.ctx-btn-folder { .ctx-btn-folder {
width: 34px; width: auto;
text-align: center; text-align: center;
padding: 6px 0; padding: 4px 8px;
} }
</style> </style>
+65 -249
View File
@@ -167,51 +167,24 @@
</div> </div>
</section> </section>
<!-- Context menu --> <!-- Episode release menu -->
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="contextMenu.visible" v-if="episodeReleaseMenu.visible"
class="context-menu-backdrop" class="episode-release-menu-backdrop"
@click="closeContextMenu" @click="closeEpisodeReleaseMenu"
@contextmenu.prevent="closeContextMenu" @contextmenu.prevent="closeEpisodeReleaseMenu"
></div> ></div>
<div <EpisodeReleaseMenu
v-if="contextMenu.visible && contextMenu.episode" :visible="episodeReleaseMenu.visible"
class="context-menu" :x="episodeReleaseMenu.x"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" :y="episodeReleaseMenu.y"
> :episode-name="episodeReleaseMenu.episode?.name || `Episode ${episodeReleaseMenu.episode?.episode_number}`"
<div class="context-menu-header"> :releases="episodeReleaseMenuReleases"
{{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }} :has-resume-position="props.hasResumePosition"
</div> @play="handlePlayVersion"
<div v-if="Object.values(contextMenu.episode.files || {}).length > 0"> @open-folder="handleOpenFolderFromMenu"
<ReleaseVersionCard @close="closeEpisodeReleaseMenu"
v-for="(torrent, index) in sortTorrentsByPreference(Object.values(contextMenu.episode.files || {}))"
:key="index"
class="context-menu-version"
:torrent="torrent"
variant="menu"
compact-flags
:title="
torrent.playable_file
? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.'
: 'No playable file'
"
@activate="handleVersionActivate(torrent, $event)"
@keydown="handleVersionShortcutKeydown($event, torrent)"
@contextmenu="handleVersionContextMenu($event, torrent)"
/>
</div>
<div v-else class="context-menu-empty">No versions available</div>
</div>
<ReleaseActionMenu
:visible="versionActionMenu.visible"
:x="versionActionMenu.x"
:y="versionActionMenu.y"
:file-path="versionActionMenu.filePath"
:root-name="versionActionMenu.rootName"
:play-label="getPlayLabel(versionActionMenu.filePath)"
@play="handlePlayVersion(versionActionMenu.filePath)"
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
/> />
</Teleport> </Teleport>
</div> </div>
@@ -219,11 +192,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue" import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
import type { Series, Season, Episode, Torrent, MovieUi } from "../types" import type { Series, Season, Episode, MovieUi } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api" import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import ReleaseVersionCard from "./ReleaseVersionCard.vue" import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings" import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{ const props = defineProps<{
@@ -486,8 +458,8 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Context menu state // Episode release menu state (single-layer menu for all releases)
const contextMenu = ref<{ const episodeReleaseMenu = ref<{
visible: boolean visible: boolean
x: number x: number
y: number y: number
@@ -499,24 +471,13 @@ const contextMenu = ref<{
episode: null, episode: null,
}) })
const versionActionMenu = ref<{
visible: boolean
x: number
y: number
filePath: string | null
rootName: string | null
rootId: string | null
}>({
visible: false,
x: 0,
y: 0,
filePath: null,
rootName: null,
rootId: null,
})
const releaseMenuOriginElement = ref<HTMLElement | null>(null) const releaseMenuOriginElement = ref<HTMLElement | null>(null)
const episodeReleaseMenuReleases = computed(() => {
if (!episodeReleaseMenu.value.episode) return []
return sortTorrentsByPreference(Object.values(episodeReleaseMenu.value.episode.files || {}))
})
function normalizeMatchText(value: string | null | undefined): string { function normalizeMatchText(value: string | null | undefined): string {
return (value || "") return (value || "")
.toLowerCase() .toLowerCase()
@@ -578,7 +539,7 @@ const matchingSeriesMovies = computed(() => {
}) })
}) })
// Show context menu on right-click // Show episode release menu on right-click (single-layer menu)
function handleContextMenu(event: MouseEvent, episode: Episode) { function handleContextMenu(event: MouseEvent, episode: Episode) {
event.preventDefault() event.preventDefault()
releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null
@@ -586,23 +547,16 @@ function handleContextMenu(event: MouseEvent, episode: Episode) {
} }
function openEpisodeReleaseMenu(episode: Episode, x: number, y: number) { function openEpisodeReleaseMenu(episode: Episode, x: number, y: number) {
closeVersionActionMenu() setModalOpen(true)
contextMenu.value = { episodeReleaseMenu.value = {
visible: true, visible: true,
x, x,
y, y,
episode, episode,
} }
// Add Escape key listener (capturing phase to intercept before other handlers) // Add Escape key listener as safety net (capture phase)
nextTick(() => { nextTick(() => {
document.addEventListener("keydown", handleContextMenuKeydown, true) document.addEventListener("keydown", handleEpisodeMenuEscape, true)
// Focus first selectable version card.
const firstCard = document.querySelector(
".context-menu .version-row.version-selectable",
) as HTMLElement
if (firstCard) {
firstCard.focus()
}
}) })
} }
@@ -616,102 +570,48 @@ function openEpisodeReleaseMenuFromElement(episode: Episode, element: HTMLElemen
openEpisodeReleaseMenu(episode, rect.left + rect.width / 2, rect.top + rect.height / 2) openEpisodeReleaseMenu(episode, rect.left + rect.width / 2, rect.top + rect.height / 2)
} }
// Handle Escape and arrow keys in context menu (capturing phase to intercept before global handler) // Capture-phase Escape handler as safety net for episode release menu
function handleContextMenuKeydown(event: KeyboardEvent) { function handleEpisodeMenuEscape(event: KeyboardEvent) {
if (!contextMenu.value.visible) return if (!episodeReleaseMenu.value.visible) return
const popupFocusable = getPopupFocusableElements()
if (event.key === "Tab") {
if (popupFocusable.length === 0) return
event.preventDefault()
event.stopPropagation()
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
const delta = event.shiftKey ? -1 : 1
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
popupFocusable[nextIndex].focus()
return
}
if (
event.key === "ArrowDown" ||
event.key === "ArrowRight" ||
event.key === "ArrowUp" ||
event.key === "ArrowLeft"
) {
if (popupFocusable.length === 0) return
event.preventDefault()
event.stopPropagation()
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
const delta = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
popupFocusable[nextIndex].focus()
return
}
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (versionActionMenu.value.visible) { closeEpisodeReleaseMenu()
closeVersionActionMenu()
return
}
closeContextMenu()
} }
} }
function getPopupFocusableElements(): HTMLElement[] { // Close episode release menu
const releaseItems = Array.from( function closeEpisodeReleaseMenu() {
document.querySelectorAll<HTMLElement>(".context-menu .version-row.version-selectable"), episodeReleaseMenu.value.visible = false
) episodeReleaseMenu.value.episode = null
const actionItems = versionActionMenu.value.visible document.removeEventListener("keydown", handleEpisodeMenuEscape, true)
? Array.from( setModalOpen(false)
document.querySelectorAll<HTMLElement>(
".version-action-menu .version-action-item:not(:disabled)",
),
)
: []
return [...releaseItems, ...actionItems]
}
// Close context menu
function closeContextMenu() {
closeVersionActionMenu()
contextMenu.value.visible = false
document.removeEventListener("keydown", handleContextMenuKeydown, true)
nextTick(() => { nextTick(() => {
releaseMenuOriginElement.value?.focus() releaseMenuOriginElement.value?.focus()
}) })
} }
function closeVersionActionMenu() {
versionActionMenu.value.visible = false
versionActionMenu.value.filePath = null
versionActionMenu.value.rootName = null
versionActionMenu.value.rootId = null
}
function handleGamepadAction(event: Event) { function handleGamepadAction(event: Event) {
const actionEvent = event as CustomEvent<{ action?: string }> const actionEvent = event as CustomEvent<{ action?: string }>
if (actionEvent.detail?.action !== "menu") return if (actionEvent.detail?.action !== "menu") return
const active = document.activeElement as HTMLElement | null const active = document.activeElement as HTMLElement | null
if (!active || !active.classList.contains("episode-tile")) return if (!active) return
const seasonIndex = parseInt(active.getAttribute("data-season-index") || "-1", 10) // Episode tile on series page
const episodeIndex = parseInt(active.getAttribute("data-episode-index") || "-1", 10) if (active.classList.contains("episode-tile")) {
if (seasonIndex < 0 || episodeIndex < 0) return const seasonIndex = parseInt(active.getAttribute("data-season-index") || "-1", 10)
const episodeIndex = parseInt(active.getAttribute("data-episode-index") || "-1", 10)
if (seasonIndex < 0 || episodeIndex < 0) return
const season = props.series.seasons?.[seasonIndex] const season = props.series.seasons?.[seasonIndex]
const episode = season?.episodes?.[episodeIndex] const episode = season?.episodes?.[episodeIndex]
if (!episode) return if (!episode) return
actionEvent.preventDefault() actionEvent.preventDefault()
openEpisodeReleaseMenuFromElement(episode, active) openEpisodeReleaseMenuFromElement(episode, active)
return
}
} }
// Play specific version // Play specific version
@@ -719,61 +619,18 @@ function handlePlayVersion(filePath: string | null) {
if (filePath) { if (filePath) {
emit("play", filePath) emit("play", filePath)
} }
closeVersionActionMenu() closeEpisodeReleaseMenu()
closeContextMenu()
} }
function getPlayLabel(filePath: string | null): string { // Open folder for a version from the episode release menu
return props.hasResumePosition(filePath) ? "Continue" : "Play" function handleOpenFolderFromMenu(filePath: string) {
} if (!filePath) return
const torrent = episodeReleaseMenu.value.episode?.files
// Open folder for a version ? Object.values(episodeReleaseMenu.value.episode.files).find((t) => t.playable_file === filePath)
function handleOpenFolder(folderPath: string, rootId?: string | null) { : undefined
if (!folderPath) return const rootId = torrent?.root_id ?? props.series.root_id ?? null
emit("openFolder", folderPath, rootId) emit("openFolder", filePath, rootId)
closeVersionActionMenu() closeEpisodeReleaseMenu()
closeContextMenu()
}
function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) {
if (!torrent.playable_file) return
const rootId = torrent.root_id ?? props.series.root_id ?? null
if (event.altKey) {
handleOpenFolder(torrent.playable_file, rootId)
return
}
handlePlayVersion(torrent.playable_file)
}
function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) {
if (!torrent.playable_file) return
const rootId = torrent.root_id ?? props.series.root_id ?? null
const key = event.key.toLowerCase()
if (key === "e" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
event.stopPropagation()
handleOpenFolder(torrent.playable_file, rootId)
}
}
function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
event.preventDefault()
event.stopPropagation()
const rootId = torrent.root_id ?? props.series.root_id ?? null
versionActionMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
filePath: torrent.playable_file || null,
rootName: props.getRootName(rootId) || null,
rootId,
}
nextTick(() => {
const firstAction = document.querySelector(
".version-action-menu .version-action-item:not(:disabled)",
) as HTMLElement | null
firstAction?.focus()
})
} }
// Video refs for hover effects // Video refs for hover effects
@@ -1712,51 +1569,10 @@ html.mouse-active .episode-tile:hover .tile-play {
} }
} }
/* Context menu styles */ /* Episode release menu backdrop */
.context-menu-backdrop { .episode-release-menu-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 999; z-index: 999;
} }
.context-menu {
position: fixed;
z-index: 1000;
background: rgba(20, 20, 30, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
min-width: 280px;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
overflow: visible;
padding: 8px;
}
.context-menu-header {
padding: 10px 12px;
font-weight: 600;
font-size: 0.9rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-menu-version {
margin-bottom: 6px;
}
.context-menu-version:last-child {
margin-bottom: 0;
}
.context-menu-empty {
padding: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
}
</style> </style>
@@ -28,6 +28,8 @@ const activeNavigationScope = ref<string | null>(null)
const desiredCol = ref<number | null>(null) const desiredCol = ref<number | null>(null)
// Track if global handlers are installed // Track if global handlers are installed
let handlersInstalled = false let handlersInstalled = false
// Track open modal count — when > 0, global keyboard navigation is suspended
let modalOpenCount = 0
// Data attribute names // Data attribute names
const FOCUSABLE_ATTR = "data-nav-focusable" const FOCUSABLE_ATTR = "data-nav-focusable"
@@ -54,6 +56,7 @@ let syncedRowsCurrentOffset = 0
let syncedRowsTargetOffset = 0 let syncedRowsTargetOffset = 0
let lastSyncedAnchorCol: number | null = null let lastSyncedAnchorCol: number | null = null
let lastSyncedRowsAnimationAt: number | null = null let lastSyncedRowsAnimationAt: number | null = null
let activeSyncedScrollGroup = DEFAULT_SYNC_SCROLL_GROUP
// Global metrics measured once from the first synced row. All calculations use // Global metrics measured once from the first synced row. All calculations use
// these same values for every row to avoid per-row DOM query inconsistencies. // these same values for every row to avoid per-row DOM query inconsistencies.
@@ -131,6 +134,13 @@ function getMetrics(group: string): ScrollMetrics | null {
// the first synced row is already scrolled to. This avoids animating from 0 // the first synced row is already scrolled to. This avoids animating from 0
// every time the view is entered. // every time the view is entered.
function initCurrentOffsetFromDOM(group: string) { function initCurrentOffsetFromDOM(group: string) {
if (activeSyncedScrollGroup !== group) {
stopSyncedRowAnimation()
activeSyncedScrollGroup = group
syncedRowsCurrentOffset = 0
syncedRowsTargetOffset = 0
}
if (syncedRowsCurrentOffset !== 0) return if (syncedRowsCurrentOffset !== 0) return
const rows = getSyncRowsByGroup(group) const rows = getSyncRowsByGroup(group)
for (const row of rows) { for (const row of rows) {
@@ -172,13 +182,19 @@ function clampRowScrollOffset(row: HTMLElement, offset: number): number {
return Math.min(Math.max(offset, 0), getRowMaxScroll(row)) return Math.min(Math.max(offset, 0), getRowMaxScroll(row))
} }
function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) { function applySyncedRowScroll(
offset: number,
rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup),
) {
for (const row of rows) { for (const row of rows) {
row.scrollLeft = clampRowScrollOffset(row, offset) row.scrollLeft = clampRowScrollOffset(row, offset)
} }
} }
function setAllRowTails(tailPx: number, rows: HTMLElement[] = getSyncedRows()) { function setAllRowTails(
tailPx: number,
rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup),
) {
const value = `${Math.max(0, tailPx)}px` const value = `${Math.max(0, tailPx)}px`
for (const row of rows) { for (const row of rows) {
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value) row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value)
@@ -224,7 +240,7 @@ function stopSyncedRowAnimation() {
} }
function animateSyncedRows(now: number) { function animateSyncedRows(now: number) {
const rows = getSyncedRows() const rows = getSyncRowsByGroup(activeSyncedScrollGroup)
if (rows.length === 0) { if (rows.length === 0) {
stopSyncedRowAnimation() stopSyncedRowAnimation()
return return
@@ -383,7 +399,7 @@ function handleSyncedRowResize() {
resetSyncedRows(true) resetSyncedRows(true)
return return
} }
updateSyncedRowTarget(lastSyncedAnchorCol) updateSyncedRowTarget(lastSyncedAnchorCol, activeRow || null)
} }
function ensureElementVisibleVertically(element: HTMLElement) { function ensureElementVisibleVertically(element: HTMLElement) {
@@ -746,6 +762,8 @@ function shouldAllowNavigationFromInput(target: HTMLElement, direction: string):
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (modalOpenCount > 0) return
const target = event.target as HTMLElement const target = event.target as HTMLElement
const direction = { const direction = {
@@ -795,6 +813,8 @@ function handleKeyDown(event: KeyboardEvent) {
} }
function handleEnterKey(event: KeyboardEvent) { function handleEnterKey(event: KeyboardEvent) {
if (modalOpenCount > 0) return
if (event.key !== "Enter") return if (event.key !== "Enter") return
if (event.defaultPrevented) return if (event.defaultPrevented) return
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
@@ -821,6 +841,14 @@ export function setActiveNavigationScope(scope: string | null) {
} }
} }
/**
* Suspend global keyboard navigation while a modal/popup is open.
* Call with `true` when opening, `false` when closing. Supports nesting.
*/
export function setModalOpen(open: boolean) {
modalOpenCount = Math.max(0, modalOpenCount + (open ? 1 : -1))
}
export function installKeyboardNavigation() { export function installKeyboardNavigation() {
if (handlersInstalled) return if (handlersInstalled) return
handlersInstalled = true handlersInstalled = true
+181 -197
View File
@@ -13,31 +13,24 @@ import type {
MediaIndex, MediaIndex,
TaskInfo, TaskInfo,
WsMessage, WsMessage,
WsRootStatus,
} from "../types" } from "../types"
interface RootState { interface RootState {
rootId: string
ws: WebSocket | null
movieMap: Map<string, MovieUi> movieMap: Map<string, MovieUi>
seriesMap: Map<string, SeriesUi> seriesMap: Map<string, SeriesUi>
peopleMap: Map<number, Person> peopleMap: Map<number, Person>
connected: boolean
initialized: boolean initialized: boolean
pendingMessages: WsMessage[] pendingMessages: WsMessage[]
reconnectTimer: ReturnType<typeof setTimeout> | null
} }
export interface RootStatusEntry extends WsRootStatus {}
const MERGED_KEY_DELIMITER = "::" const MERGED_KEY_DELIMITER = "::"
/** /**
* Composable that connects to per-root MediaHive WebSockets and keeps * Composable that connects to one all-roots MediaHive WebSocket and keeps
* a merged media index updated in real time. * a merged media index updated in real time.
*
* The server sends per-root:
* - "init" → full index (movies + series) on connect
* - "upsert" → single item inserted or updated
* - "remove" → single item removed
* - "task" → background task progress
*/ */
export function useMediaWebSocket() { export function useMediaWebSocket() {
type RootTaskInfo = TaskInfo & { root_id: string } type RootTaskInfo = TaskInfo & { root_id: string }
@@ -47,8 +40,11 @@ export function useMediaWebSocket() {
const error = shallowRef<string | null>(null) const error = shallowRef<string | null>(null)
const connected = shallowRef(false) const connected = shallowRef(false)
const tasks = shallowRef<Map<string, RootTaskInfo>>(new Map()) const tasks = shallowRef<Map<string, RootTaskInfo>>(new Map())
const roots = shallowRef<Map<string, RootStatusEntry>>(new Map())
const roots = shallowRef<Map<string, RootState>>(new Map()) const rootStates = shallowRef<Map<string, RootState>>(new Map())
const wsRef = shallowRef<WebSocket | null>(null)
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let disposed = false let disposed = false
// Single periodic sweep for completed tasks instead of one timeout per task // Single periodic sweep for completed tasks instead of one timeout per task
@@ -190,16 +186,6 @@ export function useMediaWebSocket() {
} }
} }
function normalizeSimilarMember(member: unknown): { id: number; title: string } {
if (!Array.isArray(member)) {
return { id: 0, title: "" }
}
return {
id: typeof member[0] === "number" ? member[0] : 0,
title: typeof member[1] === "string" ? member[1] : "",
}
}
function normalizePerson(member: unknown): Person | null { function normalizePerson(member: unknown): Person | null {
if (!Array.isArray(member)) return null if (!Array.isArray(member)) return null
const gender = normalizeCastGender(member[2]) const gender = normalizeCastGender(member[2])
@@ -223,7 +209,7 @@ export function useMediaWebSocket() {
} }
} }
function normalizeInfo<T extends { cast?: unknown; similar?: unknown }>( function normalizeInfo<T extends { cast?: unknown }>(
info: T | null, info: T | null,
people: Map<number, Person>, people: Map<number, Person>,
): T | null { ): T | null {
@@ -235,10 +221,6 @@ export function useMediaWebSocket() {
.filter((member) => member.name.length > 0) .filter((member) => member.name.length > 0)
next = { ...next, cast } as T next = { ...next, cast } as T
} }
if (Array.isArray((info as { similar?: unknown }).similar)) {
const similar = ((info as { similar?: unknown[] }).similar || []).map(normalizeSimilarMember)
next = { ...next, similar } as T
}
return next return next
} }
@@ -387,10 +369,41 @@ export function useMediaWebSocket() {
return merged return merged
} }
function ensureRootState(rootId: string): RootState {
const existing = rootStates.value.get(rootId)
if (existing) {
return existing
}
const created: RootState = {
movieMap: new Map(),
seriesMap: new Map(),
peopleMap: new Map(),
initialized: false,
pendingMessages: [],
}
rootStates.value.set(rootId, created)
return created
}
function pruneMissingRoots(nextRoots: Map<string, RootStatusEntry>) {
for (const rootId of rootStates.value.keys()) {
if (!nextRoots.has(rootId)) {
rootStates.value.delete(rootId)
}
}
for (const [taskKey, task] of tasks.value.entries()) {
if (!nextRoots.has(task.root_id)) {
tasks.value.delete(taskKey)
}
}
tasks.value = new Map(tasks.value)
}
function buildIndex(): MediaIndex { function buildIndex(): MediaIndex {
const movies: MovieUi[] = [] const movies: MovieUi[] = []
const series: SeriesUi[] = [] const series: SeriesUi[] = []
for (const state of roots.value.values()) { for (const state of rootStates.value.values()) {
movies.push(...state.movieMap.values()) movies.push(...state.movieMap.values())
series.push(...state.seriesMap.values()) series.push(...state.seriesMap.values())
} }
@@ -406,68 +419,84 @@ export function useMediaWebSocket() {
function updateMergedState() { function updateMergedState() {
mediaIndex.value = buildIndex() mediaIndex.value = buildIndex()
// Consider a root "connected" only after init is received.
let anyInitialized = false let anyInitialized = false
for (const state of roots.value.values()) { for (const state of rootStates.value.values()) {
if (state.connected && state.initialized) { if (state.initialized) {
anyInitialized = true anyInitialized = true
break break
} }
} }
if (anyInitialized) {
if (anyInitialized || roots.value.size === 0) {
loading.value = false loading.value = false
error.value = null error.value = null
} }
connected.value = anyInitialized
connected.value = wsRef.value?.readyState === WebSocket.OPEN
} }
function processJson(state: RootState, text: string) { function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
const msg = JSON.parse(text) as WsMessage const state = ensureRootState(rootId)
// Prevent out-of-order corruption: buffer delta messages until we receive state.peopleMap.clear()
// the initial full-state payload. for (const [id, person] of Object.entries(rootData.people || {})) {
if (msg.type !== "init" && !state.initialized) { const parsed = Number(id)
state.pendingMessages.push(msg) const normalized = normalizePerson(person)
return if (Number.isFinite(parsed)) {
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null })
}
} }
switch (msg.type) { state.movieMap.clear()
case "init": { state.seriesMap.clear()
state.peopleMap.clear() for (const [id, m] of Object.entries(rootData.movies || {})) {
for (const [id, person] of Object.entries(msg.data.people || {})) { state.movieMap.set(id, withMovieIdentity(id, m, rootId, state.peopleMap))
const parsed = Number(id) }
const normalized = normalizePerson(person) for (const [id, s] of Object.entries(rootData.series || {})) {
if (Number.isFinite(parsed)) { state.seriesMap.set(id, withSeriesIdentity(id, s, rootId, state.peopleMap))
state.peopleMap.set(parsed, normalized || { name: "", profile_path: null, gender: null }) }
}
}
state.movieMap.clear() state.initialized = true
state.seriesMap.clear()
for (const [id, m] of Object.entries(msg.data.movies || {})) {
state.movieMap.set(id, withMovieIdentity(id, m, state.rootId, state.peopleMap))
}
for (const [id, s] of Object.entries(msg.data.series || {})) {
state.seriesMap.set(id, withSeriesIdentity(id, s, state.rootId, state.peopleMap))
}
state.initialized = true
// Replay any deltas that arrived before init completed. if (state.pendingMessages.length > 0) {
if (state.pendingMessages.length > 0) { const queued = state.pendingMessages
const queued = state.pendingMessages state.pendingMessages = []
state.pendingMessages = [] for (const queuedMsg of queued) {
for (const queuedMsg of queued) { processMessage(queuedMsg)
processJson(state, JSON.stringify(queuedMsg))
}
}
updateMergedState()
console.log(
`[WS ${state.rootId}] init: ${state.movieMap.size} movies, ${state.seriesMap.size} series`,
)
break
} }
}
}
function processMessage(msg: WsMessage) {
switch (msg.type) {
case "roots": {
const next = new Map<string, RootStatusEntry>()
for (const root of msg.roots || []) {
next.set(root.root_id, { ...root })
ensureRootState(root.root_id)
}
roots.value = next
pruneMissingRoots(next)
updateMergedState()
return
}
case "init": {
for (const [rootId, rootData] of Object.entries(msg.roots || {})) {
applyRootInit(rootId, rootData)
}
updateMergedState()
return
}
case "upsert": { case "upsert": {
const state = ensureRootState(msg.root_id)
if (!state.initialized) {
state.pendingMessages.push(msg)
return
}
if (msg.people) { if (msg.people) {
for (const [id, person] of Object.entries(msg.people)) { for (const [id, person] of Object.entries(msg.people)) {
const parsed = Number(id) const parsed = Number(id)
@@ -481,160 +510,109 @@ export function useMediaWebSocket() {
if (msg.kind === "movie") { if (msg.kind === "movie") {
state.movieMap.set( state.movieMap.set(
msg.id, msg.id,
withMovieIdentity(msg.id, msg.item as Movie, state.rootId, state.peopleMap), withMovieIdentity(msg.id, msg.item as Movie, msg.root_id, state.peopleMap),
) )
} else { } else {
state.seriesMap.set( state.seriesMap.set(
msg.id, msg.id,
withSeriesIdentity(msg.id, msg.item as Series, state.rootId, state.peopleMap), withSeriesIdentity(msg.id, msg.item as Series, msg.root_id, state.peopleMap),
) )
} }
updateMergedState() updateMergedState()
break return
} }
case "remove": { case "remove": {
const state = ensureRootState(msg.root_id)
if (!state.initialized) {
state.pendingMessages.push(msg)
return
}
if (msg.kind === "movie") { if (msg.kind === "movie") {
state.movieMap.delete(msg.id) state.movieMap.delete(msg.id)
} else { } else {
state.seriesMap.delete(msg.id) state.seriesMap.delete(msg.id)
} }
updateMergedState() updateMergedState()
break return
} }
case "task": { case "task": {
const info = msg.data const info = msg.data
const taskKey = `${state.rootId}:${info.id}` const taskKey = `${msg.root_id}:${info.id}`
tasks.value.set(taskKey, { ...info, root_id: state.rootId }) tasks.value.set(taskKey, { ...info, root_id: msg.root_id })
tasks.value = new Map(tasks.value) tasks.value = new Map(tasks.value)
if (info.status === "completed" || info.status === "cancelled" || info.status === "error") { if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
completedTaskIds.add(taskKey) completedTaskIds.add(taskKey)
startTaskSweep() startTaskSweep()
} }
break
}
}
}
function handleMessage(state: RootState, event: MessageEvent) {
try {
let text: string
if (event.data instanceof Blob) {
event.data.text().then((t) => processJson(state, t))
return return
} else if (event.data instanceof ArrayBuffer) {
text = new TextDecoder().decode(event.data)
} else {
text = event.data as string
} }
processJson(state, text)
} catch (e) {
console.error(`[WS ${state.rootId}] Failed to handle message:`, e)
} }
} }
function connectRoot(rootId: string) { function handleRawMessage(event: MessageEvent) {
if (disposed) return const processText = (text: string) => {
const existing = roots.value.get(rootId) try {
if (existing?.ws) { processMessage(JSON.parse(text) as WsMessage)
// Already connecting or connected } catch (e) {
console.error("[WS] Failed to handle message:", e)
}
}
if (event.data instanceof Blob) {
void event.data.text().then(processText)
return return
} }
if (event.data instanceof ArrayBuffer) {
processText(new TextDecoder().decode(event.data))
return
}
processText(event.data as string)
}
function scheduleReconnect() {
if (disposed) return
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, 2000)
}
function connect() {
if (disposed) return
if (wsRef.value && wsRef.value.readyState <= WebSocket.OPEN) return
const proto = location.protocol === "https:" ? "wss:" : "ws:" const proto = location.protocol === "https:" ? "wss:" : "ws:"
const url = `${proto}//${location.host}/api/ws/${encodeURIComponent(rootId)}` const url = `${proto}//${location.host}/api/ws`
const state: RootState = { console.log(`[WS] Connecting to ${url}...`)
rootId, const ws = new WebSocket(url)
ws: null, wsRef.value = ws
movieMap: new Map(),
seriesMap: new Map(), ws.onopen = () => {
peopleMap: new Map(), connected.value = true
connected: false, error.value = null
initialized: false, console.log("[WS] Connected")
pendingMessages: [],
reconnectTimer: null,
} }
roots.value.set(rootId, state)
function doConnect() { ws.onmessage = (ev) => handleRawMessage(ev)
if (disposed) return
console.log(`[WS ${rootId}] Connecting to ${url}...`)
const ws = new WebSocket(url)
state.ws = ws
ws.onopen = () => { ws.onclose = (ev) => {
state.connected = true if (wsRef.value === ws) {
state.initialized = false wsRef.value = null
state.pendingMessages = []
updateMergedState()
console.log(`[WS ${rootId}] Connected`)
}
ws.onmessage = (ev) => handleMessage(state, ev)
ws.onclose = (ev) => {
state.connected = false
state.initialized = false
state.pendingMessages = []
state.ws = null
updateMergedState()
console.log(`[WS ${rootId}] Closed (code=${ev.code})`)
scheduleReconnect()
}
ws.onerror = (ev) => {
console.error(`[WS ${rootId}] Error:`, ev)
if (!mediaIndex.value) {
error.value = "WebSocket connection failed"
}
} }
connected.value = false
console.log(`[WS] Closed (code=${ev.code})`)
scheduleReconnect()
} }
function scheduleReconnect() { ws.onerror = (ev) => {
if (disposed) return console.error("[WS] Error:", ev)
if (state.reconnectTimer) clearTimeout(state.reconnectTimer) if (!mediaIndex.value) {
state.reconnectTimer = setTimeout(() => { error.value = "WebSocket connection failed"
console.log(`[WS ${rootId}] Reconnecting...`)
doConnect()
}, 2000)
}
doConnect()
}
function disconnectRoot(rootId: string) {
const state = roots.value.get(rootId)
if (!state) return
if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer)
state.reconnectTimer = null
}
if (state.ws) {
state.ws.onclose = null
state.ws.close()
state.ws = null
}
state.connected = false
roots.value.delete(rootId)
updateMergedState()
}
function setActiveRoots(rootIds: string[]) {
if (disposed) return
const desired = new Set(rootIds)
const current = new Set(roots.value.keys())
// Add new roots
for (const rid of desired) {
if (!current.has(rid)) {
connectRoot(rid)
}
}
// Remove old roots
for (const rid of current) {
if (!desired.has(rid)) {
disconnectRoot(rid)
} }
} }
} }
@@ -642,18 +620,24 @@ export function useMediaWebSocket() {
function disconnect() { function disconnect() {
disposed = true disposed = true
stopTaskSweep() stopTaskSweep()
for (const state of roots.value.values()) {
if (state.reconnectTimer) { if (reconnectTimer) {
clearTimeout(state.reconnectTimer) clearTimeout(reconnectTimer)
} reconnectTimer = null
if (state.ws) {
state.ws.onclose = null
state.ws.close()
}
} }
if (wsRef.value) {
wsRef.value.onclose = null
wsRef.value.close()
wsRef.value = null
}
connected.value = false
roots.value.clear() roots.value.clear()
rootStates.value.clear()
} }
connect()
onUnmounted(disconnect) onUnmounted(disconnect)
return { return {
@@ -662,7 +646,7 @@ export function useMediaWebSocket() {
error: readonly(error), error: readonly(error),
connected: readonly(connected), connected: readonly(connected),
tasks: readonly(tasks), tasks: readonly(tasks),
setActiveRoots, roots: readonly(roots),
disconnect, disconnect,
} }
} }
+1 -2
View File
@@ -621,7 +621,7 @@ async function performSearch(
movie.info?.keywords?.join(" "), movie.info?.keywords?.join(" "),
movie.info?.overview, movie.info?.overview,
movie.info?.tagline, movie.info?.tagline,
movie.info?.similar?.map((s) => s.title).join(" "), movie.info?.collection,
), ),
getMoviePathScore(movie, query), getMoviePathScore(movie, query),
) )
@@ -719,7 +719,6 @@ async function performSearch(
seriesItem.info?.keywords?.join(" "), seriesItem.info?.keywords?.join(" "),
seriesItem.info?.overview, seriesItem.info?.overview,
seriesItem.info?.tagline, seriesItem.info?.tagline,
seriesItem.info?.similar?.map((s) => s.title).join(" "),
seriesItem.info?.networks?.join(" "), seriesItem.info?.networks?.join(" "),
), ),
getSeriesPathScore(seriesItem, query), getSeriesPathScore(seriesItem, query),
+19
View File
@@ -61,6 +61,17 @@ html:not(.pointer-visible) * {
overflow: hidden; overflow: hidden;
} }
.scrollbar-hidden {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* Scrollbar styling */ /* Scrollbar styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -525,6 +536,14 @@ html.mouse-active .media-card:hover .media-card-info {
justify-content: center; justify-content: center;
padding: 40px 20px; padding: 40px 20px;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-overlay::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
} }
.modal-content { .modal-content {
+33 -12
View File
@@ -19,15 +19,11 @@ export interface Person {
gender?: CastGender | null gender?: CastGender | null
} }
export interface SimilarMedia {
id: number
title: string
}
export interface Info { export interface Info {
tmdb_id: number tmdb_id: number
title: string | null title: string | null
original_title: string | null original_title: string | null
original_language: string | null
alternative_titles: string[] | null alternative_titles: string[] | null
rating: number | null rating: number | null
vote_count: number | null vote_count: number | null
@@ -35,9 +31,9 @@ export interface Info {
genres: string[] | null genres: string[] | null
release_date: string | null release_date: string | null
runtime: number | null runtime: number | null
collection: string | null
status: string | null status: string | null
tagline: string | null tagline: string | null
similar: SimilarMedia[] | null
keywords: string[] | null keywords: string[] | null
cast: CastMember[] | null cast: CastMember[] | null
director: string | null director: string | null
@@ -193,17 +189,35 @@ export interface TaskInfo {
} }
// WebSocket message types (matching server msgspec tagged structs) // WebSocket message types (matching server msgspec tagged structs)
export interface WsRootStatus {
root_id: string
path: string
status: string
error: string | null
snapshot_loaded: boolean
movies: number
series: number
}
export interface WsRootInitData {
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, PersonWire>
}
export interface WsRootsMessage {
type: "roots"
roots: WsRootStatus[]
}
export interface WsInitMessage { export interface WsInitMessage {
type: "init" type: "init"
data: { roots: Record<string, WsRootInitData>
movies: Record<string, Movie>
series: Record<string, Series>
people?: Record<string, PersonWire>
}
} }
export interface WsUpsertMessage { export interface WsUpsertMessage {
type: "upsert" type: "upsert"
root_id: string
kind: "movie" | "series" kind: "movie" | "series"
id: string id: string
item: Movie | Series item: Movie | Series
@@ -212,13 +226,20 @@ export interface WsUpsertMessage {
export interface WsRemoveMessage { export interface WsRemoveMessage {
type: "remove" type: "remove"
root_id: string
kind: "movie" | "series" kind: "movie" | "series"
id: string id: string
} }
export interface WsTaskMessage { export interface WsTaskMessage {
type: "task" type: "task"
root_id: string
data: TaskInfo data: TaskInfo
} }
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage export type WsMessage =
| WsRootsMessage
| WsInitMessage
| WsUpsertMessage
| WsRemoveMessage
| WsTaskMessage
+253
View File
@@ -0,0 +1,253 @@
"""Custom access logging middleware for FastAPI/Uvicorn."""
import logging
import sys
import time
from ipaddress import IPv6Address
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("mediahive.access")
_RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (white)
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
def format_ipv6_network(ip: str) -> str:
"""Format IPv6 address to show only network part (first 64 bits).
Special addresses are returned as-is for clarity:
- ::1 (loopback)
- :: (unspecified)
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
- fe80:: (link-local, returned as-is since interface-specific)
"""
try:
# Strip brackets that some proxies add around IPv6
ip = ip.strip("[]")
# Strip zone ID (e.g., fe80::1%eth0)
if "%" in ip:
ip = ip.split("%")[0]
addr = IPv6Address(ip)
# Special cases - return as-is or with minimal processing
if addr.is_loopback: # ::1
return "::1"
if addr.is_unspecified: # ::
return "::"
if addr.ipv4_mapped: # ::ffff:x.x.x.x
return str(addr.ipv4_mapped)
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
return str(addr)
# Regular addresses: truncate to /64 network prefix
network_int = int(addr) >> 64
# Format as IPv6 with trailing ::
# Split into 4 groups of 16 bits
groups = []
for _ in range(4):
groups.insert(0, format(network_int & 0xFFFF, "x"))
network_int >>= 16
# Compress consecutive zero groups
result = ":".join(groups) + "::"
# Simplify leading zeros in groups and compress, then strip trailing ::
return str(IPv6Address(result + "0")).removesuffix("::")
except Exception:
return ip
def format_client_ip(ip: str) -> str:
"""Format client IP, compressing IPv6 to network part only."""
if not ip or ip == "-":
return "-"
# Strip brackets for detection (some proxies add them)
stripped = ip.strip("[]")
if ":" in stripped:
return format_ipv6_network(ip)
return ip
def status_color(status: int) -> str:
"""Return color code based on HTTP status."""
if status < 200:
return _STATUS_INFO
if status < 300:
return _STATUS_OK
if status < 400:
return _STATUS_REDIRECT
if status < 500:
return _STATUS_CLIENT_ERR
return _STATUS_SERVER_ERR
def method_color(method: str) -> str:
"""Return color code based on HTTP method."""
if method in ("GET", "HEAD", "OPTIONS"):
return _METHOD_READ
return _METHOD_WRITE
def format_access_log(
client: str,
status: int,
method: str,
host: str,
path: str,
duration_ms: float,
extra: str = "",
) -> str:
"""Format access log line with colors and aligned fields."""
# Format components with fixed widths for alignment
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
timing = f"{duration_ms:.0f}ms"
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
status_str = f"{status_color(status)}{status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}"
method_str = f"{method_color(method)}{method_padded}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
# Format: "IP STATUS METHOD host path [extra] TIMING"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return (
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
)
# WebSocket connection counter (mod 100)
_ws_counter = 0
def _next_ws_id() -> int:
"""Get next WebSocket connection ID (0-99)."""
global _ws_counter
ws_id = _ws_counter
_ws_counter = (_ws_counter + 1) % 100
return ws_id
def log_ws_open(ws) -> int:
"""Log WebSocket connection open. Returns connection ID for use in close."""
ws_id = _next_ws_id()
client = ws.client.host if ws.client else "-"
host = ws.headers.get("host", "-")
path = ws.url.path
origin = ws.headers.get("origin")
ip = format_client_ip(client).ljust(19)
# ID right-aligned like status codes (3 chars), emoji formatted like method
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
# Determine if origin should be shown (omit when same as host)
# Origin header includes scheme (e.g., "https://example.com"), compare host part
origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
return ws_id
# WebSocket close codes to human-readable status
WS_CLOSE_CODES = {
1000: "ok",
1001: "going away",
1002: "protocol error",
1003: "unsupported",
1005: "no status",
1006: "abnormal",
1007: "invalid data",
1008: "policy violation",
1009: "too large",
1010: "extension required",
1011: "server error",
1012: "restarting",
1013: "try again",
1014: "bad gateway",
1015: "tls error",
}
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
"""Log WebSocket connection close with duration and status."""
# ID right-aligned like status codes (3 chars), "closed" formatted like method
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
# Pad within the dim color to keep full width in color (8 display chars)
closed_str = f"{_TIMING}closed {_RESET}"
timing = f"{duration * 1000:.0f}ms"
# Convert close code to status text
if close_code is None:
code = "----"
status = "unknown"
else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
# Status code and text in normal color, not dim
status_str = f"{code} {status}"
timing_str = f"{_TIMING}{timing}{_RESET}"
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
class AccessLogMiddleware(BaseHTTPMiddleware):
"""Middleware that logs HTTP requests with custom format."""
async def dispatch(self, request: Request, call_next) -> Response:
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
client = request.client.host if request.client else "-"
host = request.headers.get("host", "-")
method = request.method
path = request.url.path
if request.url.query:
path = f"{path}?{request.url.query}"
status = response.status_code
extra = getattr(request.state, "log_extra", "")
line = format_access_log(
client, status, method, host, path, duration_ms, extra=extra
)
logger.info(line)
return response
def configure_access_logging():
"""Configure the access logger to output to stderr."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
# Suppress uvicorn access logs to avoid duplicate request lines.
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
# Suppress uvicorn websocket "connection open/closed" messages.
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
# Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
+2 -2
View File
@@ -33,8 +33,8 @@ Examples:
Exclude paths by creating .mediahive/scanignore (gitignore syntax). Exclude paths by creating .mediahive/scanignore (gitignore syntax).
The server exposes per-root endpoints: The server exposes a unified endpoint:
WS /api/ws/{root_id} Live index updates & task progress WS /api/ws Live index updates, task progress, and root status changes
""", """,
) )
parser.add_argument( parser.add_argument(
+31
View File
@@ -432,6 +432,8 @@ _dovi_profile_re = re.compile(
) )
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:") _audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
_subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:") _subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:")
# showinfo emits e.g. "side data - HDR Dynamic Metadata SMPTE2094-40 (HDR10+)"
_showinfo_hdr10plus_re = re.compile(r"SMPTE2094-40|HDR Dynamic Metadata", re.IGNORECASE)
def _lang_code(raw: str | None) -> str | None: def _lang_code(raw: str | None) -> str | None:
@@ -492,6 +494,35 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
or "dynamic hdr" in lower_text or "dynamic hdr" in lower_text
) )
# `ffmpeg -i` only reads container headers, not frame-level side data.
# Run a tiny showinfo decode (5 frames) to detect HDR10+ dynamic metadata
# (SMPTE ST 2094-40) when the header scan didn't already confirm it.
if info.hdr and not info.hdr10plus and not info.dovi:
showinfo_cmd = [
"ffmpeg",
"-hide_banner",
"-ss",
"0",
"-i",
video_path,
"-vf",
"showinfo",
"-frames:v",
"5",
"-an",
"-f",
"null",
"-",
]
showinfo_result = await _run_ffmpeg(
showinfo_cmd, timeout_seconds=15, allow_nonzero_exit=True
)
if showinfo_result is not None:
si_stdout, si_stderr = showinfo_result
si_text = (si_stderr + si_stdout).decode("utf-8", errors="replace")
if _showinfo_hdr10plus_re.search(si_text):
info.hdr10plus = True
dovi_match = _dovi_profile_re.search(text) dovi_match = _dovi_profile_re.search(text)
if dovi_match: if dovi_match:
info.dovi_profile = int(dovi_match.group(1)) info.dovi_profile = int(dovi_match.group(1))
+17 -16
View File
@@ -18,7 +18,6 @@ from mediahive.models.tmdb import (
Info, Info,
Person, Person,
SeasonInfo, SeasonInfo,
SimilarMedia,
) )
# TMDb API configuration # TMDb API configuration
@@ -148,19 +147,19 @@ async def tmdb_api_request(
async def fetch_movie_details(movie_id: int) -> dict | None: async def fetch_movie_details(movie_id: int) -> dict | None:
"""Fetch movie info including credits, similar, keywords, and alt titles.""" """Fetch movie info including credits, keywords, alt titles, and collection."""
# Use append_to_response to get multiple data in one request # Use append_to_response to get multiple data in one request
return await tmdb_api_request( return await tmdb_api_request(
f"/movie/{movie_id}", f"/movie/{movie_id}",
{"append_to_response": "credits,similar,keywords,alternative_titles"}, {"append_to_response": "credits,keywords,alternative_titles"},
) )
async def fetch_series_details(series_id: int) -> dict | None: async def fetch_series_details(series_id: int) -> dict | None:
"""Fetch detailed TV series info including credits, similar, and keywords.""" """Fetch detailed TV series info including credits and keywords."""
# Use append_to_response to get multiple data in one request # Use append_to_response to get multiple data in one request
return await tmdb_api_request( return await tmdb_api_request(
f"/tv/{series_id}", {"append_to_response": "credits,similar,keywords"} f"/tv/{series_id}", {"append_to_response": "credits,keywords"}
) )
@@ -385,7 +384,7 @@ async def fetch_movie_info(
result = data["results"][0] result = data["results"][0]
movie_id = result["id"] movie_id = result["id"]
# Fetch full details with credits, similar movies, and keywords # Fetch full details with credits, keywords, alt titles, and collection
details = await fetch_movie_details(movie_id) details = await fetch_movie_details(movie_id)
if not details: if not details:
# Fall back to basic info from search # Fall back to basic info from search
@@ -394,6 +393,7 @@ async def fetch_movie_info(
tmdb_id=movie_id, tmdb_id=movie_id,
title=result.get("title"), title=result.get("title"),
original_title=result.get("original_title"), original_title=result.get("original_title"),
original_language=result.get("original_language"),
rating=result.get("vote_average"), rating=result.get("vote_average"),
vote_count=result.get("vote_count"), vote_count=result.get("vote_count"),
overview=result.get("overview"), overview=result.get("overview"),
@@ -450,15 +450,19 @@ async def fetch_movie_info(
directors = [c["name"] for c in crew if c.get("job") == "Director"] directors = [c["name"] for c in crew if c.get("job") == "Director"]
director = directors[0] if directors else None director = directors[0] if directors else None
# Extract similar movies (limit to 10) collection_data = details.get("belongs_to_collection")
similar_data = details.get("similar", {}).get("results", [])[:10] collection = None
similar = [SimilarMedia(id=s["id"], title=s["title"]) for s in similar_data] if isinstance(collection_data, dict):
collection_name = collection_data.get("name")
if isinstance(collection_name, str):
collection = collection_name or None
return ( return (
Info( Info(
tmdb_id=movie_id, tmdb_id=movie_id,
title=details.get("title"), title=details.get("title"),
original_title=details.get("original_title"), original_title=details.get("original_title"),
original_language=details.get("original_language"),
alternative_titles=alternative_titles, alternative_titles=alternative_titles,
rating=details.get("vote_average"), rating=details.get("vote_average"),
vote_count=details.get("vote_count"), vote_count=details.get("vote_count"),
@@ -466,9 +470,9 @@ async def fetch_movie_info(
genres=genres or None, genres=genres or None,
release_date=details.get("release_date"), release_date=details.get("release_date"),
runtime=details.get("runtime"), runtime=details.get("runtime"),
collection=collection,
status=details.get("status"), status=details.get("status"),
tagline=details.get("tagline"), tagline=details.get("tagline"),
similar=similar or None,
keywords=keywords or None, keywords=keywords or None,
cast=cast or None, cast=cast or None,
director=director, director=director,
@@ -513,7 +517,7 @@ async def fetch_series_info(
result = data["results"][0] result = data["results"][0]
series_id = result["id"] series_id = result["id"]
# Fetch full details with credits, similar shows, and keywords # Fetch full details with credits and keywords
details = await fetch_series_details(series_id) details = await fetch_series_details(series_id)
if not details: if not details:
# Fall back to basic info from search # Fall back to basic info from search
@@ -522,6 +526,7 @@ async def fetch_series_info(
tmdb_id=series_id, tmdb_id=series_id,
title=result.get("name"), title=result.get("name"),
original_title=result.get("original_name"), original_title=result.get("original_name"),
original_language=result.get("original_language"),
rating=result.get("vote_average"), rating=result.get("vote_average"),
vote_count=result.get("vote_count"), vote_count=result.get("vote_count"),
overview=result.get("overview"), overview=result.get("overview"),
@@ -564,10 +569,6 @@ async def fetch_series_info(
# Extract networks # Extract networks
networks = [n["name"] for n in details.get("networks", [])] networks = [n["name"] for n in details.get("networks", [])]
# Extract similar series (limit to 10)
similar_data = details.get("similar", {}).get("results", [])[:10]
similar = [SimilarMedia(id=s["id"], title=s["name"]) for s in similar_data]
# Get first air date # Get first air date
first_air_date = details.get("first_air_date") first_air_date = details.get("first_air_date")
@@ -576,6 +577,7 @@ async def fetch_series_info(
tmdb_id=series_id, tmdb_id=series_id,
title=details.get("name"), title=details.get("name"),
original_title=details.get("original_name"), original_title=details.get("original_name"),
original_language=details.get("original_language"),
rating=details.get("vote_average"), rating=details.get("vote_average"),
vote_count=details.get("vote_count"), vote_count=details.get("vote_count"),
overview=details.get("overview"), overview=details.get("overview"),
@@ -583,7 +585,6 @@ async def fetch_series_info(
release_date=first_air_date, release_date=first_air_date,
status=details.get("status"), status=details.get("status"),
tagline=details.get("tagline"), tagline=details.get("tagline"),
similar=similar or None,
keywords=keywords or None, keywords=keywords or None,
cast=cast or None, cast=cast or None,
creators=creators or None, creators=creators or None,
+27 -11
View File
@@ -9,6 +9,7 @@ debounced background task.
import asyncio import asyncio
import contextlib import contextlib
import logging import logging
from collections.abc import Callable
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -23,10 +24,6 @@ from mediahive.models.data import (
TaskInfo, TaskInfo,
) )
from mediahive.models.events import Remove, Task, Upsert from mediahive.models.events import Remove, Task, Upsert
from mediahive.models.protocol import (
WsInit,
WsInitData,
)
from mediahive.models.tmdb import Person from mediahive.models.tmdb import Person
logger = logging.getLogger("mediahive.index_store") logger = logging.getLogger("mediahive.index_store")
@@ -64,6 +61,8 @@ class IndexStore:
# Connected WebSocket clients # Connected WebSocket clients
self._clients: set[WebSocket] = set() self._clients: set[WebSocket] = set()
# Passive listeners for broadcast events (used by server-level WS fan-in)
self._listeners: set[Callable[[object], None]] = set()
# Snapshot debounce state # Snapshot debounce state
self._snapshot_dirty = False self._snapshot_dirty = False
@@ -388,19 +387,30 @@ class IndexStore:
# WebSocket management # WebSocket management
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def add_listener(self, listener: Callable[[object], None]) -> None:
"""Register a listener called for each broadcast message."""
self._listeners.add(listener)
def remove_listener(self, listener: Callable[[object], None]) -> None:
"""Unregister a previously registered broadcast listener."""
self._listeners.discard(listener)
async def connect(self, ws: WebSocket) -> None: async def connect(self, ws: WebSocket) -> None:
"""Accept a WS client and send the full index as init.""" """Accept a WS client and send the full index as init."""
await ws.accept() await ws.accept()
self._clients.add(ws) self._clients.add(ws)
logger.info("WS client connected (%d total)", len(self._clients)) logger.info("WS client connected (%d total)", len(self._clients))
# Send full current state # Send full current state
msg = WsInit( msg = {
data=WsInitData( "type": "init",
movies=dict(self.movies), "roots": {
series=dict(self.series), "": {
people=dict(self.people), "movies": dict(self.movies),
) "series": dict(self.series),
) "people": dict(self.people),
}
},
}
await ws.send_bytes(msgspec.json.encode(msg)) await ws.send_bytes(msgspec.json.encode(msg))
def disconnect(self, ws: WebSocket) -> None: def disconnect(self, ws: WebSocket) -> None:
@@ -410,6 +420,12 @@ class IndexStore:
def _broadcast(self, msg: object) -> None: def _broadcast(self, msg: object) -> None:
"""Broadcast a message to all connected WS clients (non-blocking).""" """Broadcast a message to all connected WS clients (non-blocking)."""
for listener in tuple(self._listeners):
try:
listener(msg)
except Exception:
logger.exception("IndexStore listener failed")
data = msgspec.json.encode(msg) data = msgspec.json.encode(msg)
dead: list[WebSocket] = [] dead: list[WebSocket] = []
for ws in self._clients: for ws in self._clients:
+67 -14
View File
@@ -8,8 +8,8 @@ from __future__ import annotations
import msgspec import msgspec
from fastapi.responses import Response from fastapi.responses import Response
from .data import Movie, Series from .data import Movie, Series, TaskInfo
from .events import Remove, ScanEvent, Task, Upsert from .events import ScanEvent
from .tmdb import Person from .tmdb import Person
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -17,33 +17,78 @@ from .tmdb import Person
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class WsInitData(msgspec.Struct): class WsRootStatus(msgspec.Struct):
"""Payload of the init message.""" """Current status for one configured root."""
root_id: str
path: str
status: str
error: str | None = None
snapshot_loaded: bool = False
movies: int = 0
series: int = 0
class WsRootInitData(msgspec.Struct):
"""Initial full index payload for one root."""
movies: dict[str, Movie] movies: dict[str, Movie]
series: dict[str, Series] series: dict[str, Series]
people: dict[int, Person] people: dict[int, Person]
class WsInit(msgspec.Struct, tag="init"): class WsRoots(msgspec.Struct, tag="roots"):
"""Full index sent on WS connect.""" """Root list and status update."""
data: WsInitData roots: list[WsRootStatus]
class WsInit(msgspec.Struct, tag="init"):
"""Full index payload keyed by root_id."""
roots: dict[str, WsRootInitData]
class WsUpsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated for one root."""
root_id: str
kind: str # "movie" or "series"
id: str
item: Movie | Series
people: dict[int, Person] | None = None
class WsRemove(msgspec.Struct, tag="remove"):
"""Single item removed for one root."""
root_id: str
kind: str
id: str
class WsTask(msgspec.Struct, tag="task"):
"""Task progress update for one root."""
root_id: str
data: TaskInfo
# Union of all outbound WS messages (for documentation / future decoding) # Union of all outbound WS messages (for documentation / future decoding)
WsMessage = WsInit | Upsert | Remove | Task WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
# Re-export unified types for backward compatibility # Re-export unified types for backward compatibility
__all__ = [ __all__ = [
"Remove",
"ScanEvent", "ScanEvent",
"Task",
"Upsert",
"WsInit", "WsInit",
"WsInitData",
"WsMessage", "WsMessage",
"WsRemove",
"WsRootInitData",
"WsRootStatus",
"WsRoots",
"WsTask",
"WsUpsert",
] ]
@@ -67,11 +112,19 @@ class OpenFolderRequest(msgspec.Struct):
class RootsRequest(msgspec.Struct): class RootsRequest(msgspec.Struct):
"""PUT /api/roots body.""" """PUT /api/config/roots body."""
roots: dict[str, str] roots: dict[str, str]
class PlaybackStateUpdateRequest(msgspec.Struct):
"""POST /api/meta/playback-state body."""
root_id: str
file_path: str
pos: int | None = None
class RootEntryResponse(msgspec.Struct): class RootEntryResponse(msgspec.Struct):
"""Single root entry in responses.""" """Single root entry in responses."""
@@ -80,7 +133,7 @@ class RootEntryResponse(msgspec.Struct):
class RootStatusResponse(msgspec.Struct): class RootStatusResponse(msgspec.Struct):
"""Per-root status in GET /api/roots.""" """Legacy per-root status shape kept for non-WS callers."""
root_id: str root_id: str
path: str path: str
+2 -8
View File
@@ -27,13 +27,6 @@ class Person(msgspec.Struct, array_like=True):
gender: str | None = None gender: str | None = None
class SimilarMedia(msgspec.Struct, array_like=True):
"""Pointer to a similar movie/series on TMDb."""
id: int
title: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TMDb result types # TMDb result types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -72,6 +65,7 @@ class Info(msgspec.Struct):
tmdb_id: int tmdb_id: int
title: str | None = None title: str | None = None
original_title: str | None = None original_title: str | None = None
original_language: str | None = None
alternative_titles: list[str] | None = None alternative_titles: list[str] | None = None
rating: float | None = None rating: float | None = None
vote_count: int | None = None vote_count: int | None = None
@@ -79,9 +73,9 @@ class Info(msgspec.Struct):
genres: list[str] | None = None genres: list[str] | None = None
release_date: str | None = None release_date: str | None = None
runtime: int | None = None runtime: int | None = None
collection: str | None = None
status: str | None = None status: str | None = None
tagline: str | None = None tagline: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None keywords: list[str] | None = None
cast: list[CastCredit] | None = None cast: list[CastCredit] | None = None
director: str | None = None director: str | None = None
+1 -1
View File
@@ -280,7 +280,7 @@ class Supervisor:
base_name = _derive_root_name(configured_path) base_name = _derive_root_name(configured_path)
unique_name = base_name unique_name = base_name
suffix = 2 suffix = 2
existing_names = {e.name for e in candidates} existing_names = {e.root_id for e in candidates}
while unique_name in existing_names: while unique_name in existing_names:
unique_name = f"{base_name}{suffix}" unique_name = f"{base_name}{suffix}"
suffix += 1 suffix += 1
+600 -23
View File
@@ -16,9 +16,14 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import threading
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from collections import deque
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@@ -29,20 +34,37 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
from fastapi_vue import Frontend from fastapi_vue import Frontend
from mediahive.__main__ import DEVMODE from mediahive.__main__ import DEVMODE
from mediahive.access_logging import (
AccessLogMiddleware,
configure_access_logging,
log_ws_close,
log_ws_open,
)
from mediahive.config import load_config from mediahive.config import load_config
from mediahive.hivescan.images import close_image_client from mediahive.hivescan.images import close_image_client
from mediahive.hivescan.scanner import RootScanner from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client from mediahive.hivescan.tmdb_client import close_http_client
from mediahive.models.events import Remove, Task, Upsert
from mediahive.models.protocol import ( from mediahive.models.protocol import (
OpenFolderRequest, OpenFolderRequest,
PlaybackStateUpdateRequest,
PlayMediaRequest, PlayMediaRequest,
RootsRequest, RootsRequest,
WsInit,
WsRemove,
WsRootInitData,
WsRoots,
WsRootStatus,
WsTask,
WsUpsert,
) )
from mediahive.players import detect_players, launch_player from mediahive.players import detect_players, launch_player
from mediahive.root_registry import Supervisor from mediahive.root_registry import Supervisor
logger = logging.getLogger("mediahive.server") logger = logging.getLogger("mediahive.server")
configure_access_logging()
MPC_BE_DEFAULT_PORT = 13579 MPC_BE_DEFAULT_PORT = 13579
# Suppress console windows when spawning subprocesses on Windows # Suppress console windows when spawning subprocesses on Windows
@@ -66,6 +88,268 @@ if sys.platform == "win32":
from ctypes import wintypes from ctypes import wintypes
@dataclass
class _PlaybackEntry:
"""Single resume position entry with timestamp."""
pos: int
ts: datetime
def to_dict(self) -> dict:
return {
"pos": self.pos,
"ts": self.ts,
}
@staticmethod
def from_dict(data: dict) -> _PlaybackEntry | None:
if not isinstance(data, dict):
return None
pos = data.get("pos")
ts = data.get("ts")
if not isinstance(pos, int) or pos < 0:
return None
if isinstance(ts, str):
try:
ts = datetime.fromisoformat(ts)
except ValueError, TypeError:
return None
elif isinstance(ts, datetime):
pass
else:
return None
return _PlaybackEntry(pos=pos, ts=ts)
@dataclass
class _PlaybackRootSnapshot:
file_path: Path
signature: tuple[bool, int, int] | None = None
entries: dict[str, _PlaybackEntry] = field(default_factory=dict)
class PlaybackStateCache:
"""Background cache for merged playback-state across all active roots.
Stores resume positions by movie slug with timestamps. When merging
across roots, picks the most recent entry for each slug.
"""
def __init__(self, poll_interval: float = 60.0) -> None:
self._poll_interval = poll_interval
self._roots: dict[str, _PlaybackRootSnapshot] = {}
self._merged_entries: dict[str, _PlaybackEntry] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name="mediahive-playback-state-cache",
daemon=True,
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=max(2.0, self._poll_interval + 1.0))
self._thread = None
def get_merged_entries(self) -> dict[str, _PlaybackEntry]:
"""Return merged resume entries keyed by slug (most recent wins)."""
with self._lock:
return {
slug: _PlaybackEntry(e.pos, e.ts)
for slug, e in self._merged_entries.items()
}
def update_resume_position(
self,
root_id: str,
root_path: Path,
slug: str,
pos: int | None,
) -> None:
"""Read-modify-write one root file and refresh the in-memory cache immediately."""
file_path = root_path / ".mediahive" / "playback-state.json"
entries = self._read_resume_entries(file_path)
if pos is None:
entries.pop(slug, None)
else:
entries[slug] = _PlaybackEntry(pos=pos, ts=datetime.now())
self._write_resume_entries(file_path, entries)
snapshot = _PlaybackRootSnapshot(
file_path=file_path,
signature=self._signature(file_path),
entries=entries,
)
with self._lock:
self._roots[root_id] = snapshot
self._merged_entries = self._build_merged_entries(self._roots)
def _run(self) -> None:
while not self._stop.is_set():
try:
self._refresh_once()
except Exception:
logger.exception("Playback-state cache refresh failed")
if self._stop.wait(self._poll_interval):
break
def _refresh_once(self) -> None:
contexts = supervisor.all_contexts()
with self._lock:
previous = self._roots
next_roots: dict[str, _PlaybackRootSnapshot] = {}
merged: dict[str, _PlaybackEntry] = {}
for root_id, ctx in contexts.items():
file_path = ctx.root_path / ".mediahive" / "playback-state.json"
snapshot = previous.get(root_id)
if snapshot is None or snapshot.file_path != file_path:
snapshot = _PlaybackRootSnapshot(file_path=file_path)
signature = self._signature(file_path)
if signature != snapshot.signature:
snapshot.signature = signature
snapshot.entries = self._read_resume_entries(file_path)
next_roots[root_id] = snapshot
# Merge: for each slug, keep the entry with the most recent timestamp
for slug, entry in snapshot.entries.items():
existing = merged.get(slug)
if existing is None or entry.ts > existing.ts:
merged[slug] = entry
with self._lock:
self._roots = next_roots
self._merged_entries = merged
@staticmethod
def _signature(path: Path) -> tuple[bool, int, int]:
try:
stat = path.stat()
return (True, stat.st_mtime_ns, stat.st_size)
except OSError:
return (False, 0, 0)
@staticmethod
def _read_resume_entries(path: Path) -> dict[str, _PlaybackEntry]:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except OSError, json.JSONDecodeError:
return {}
if not isinstance(raw, dict):
return {}
positions = raw.get("resume_positions")
if not isinstance(positions, dict):
return {}
entries: dict[str, _PlaybackEntry] = {}
for slug, data in positions.items():
entry = _PlaybackEntry.from_dict(data)
if entry is not None:
entries[str(slug)] = entry
return entries
@staticmethod
def _write_resume_entries(path: Path, entries: dict[str, _PlaybackEntry]) -> None:
data = {
"resume_positions": {
slug: entry.to_dict() for slug, entry in sorted(entries.items())
}
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text(
json.dumps(data, indent=2, sort_keys=True, default=str),
encoding="utf-8",
)
tmp_path.replace(path)
except OSError:
logger.exception("Failed to write playback-state: %s", path)
@staticmethod
def _build_merged_entries(
roots: dict[str, _PlaybackRootSnapshot],
) -> dict[str, _PlaybackEntry]:
merged: dict[str, _PlaybackEntry] = {}
for snapshot in roots.values():
for slug, entry in snapshot.entries.items():
existing = merged.get(slug)
if existing is None or entry.ts > existing.ts:
merged[slug] = entry
return merged
class EventLoopLagMonitor:
"""Tracks event-loop scheduling lag over a sliding window."""
def __init__(
self, sample_interval: float = 0.05, window_seconds: float = 10.0
) -> None:
self._sample_interval = sample_interval
self._window_seconds = window_seconds
self._task: asyncio.Task | None = None
self._last_lag_ms = 0.0
self._samples: deque[tuple[float, float]] = deque()
def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._task = asyncio.create_task(self._run(), name="mediahive-event-loop-lag")
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
self._task = None
def snapshot(self) -> tuple[float, float]:
now = time.perf_counter()
self._prune(now)
max_window_ms = max((lag for _, lag in self._samples), default=0.0)
return self._last_lag_ms, max_window_ms
async def _run(self) -> None:
interval = self._sample_interval
next_tick = time.perf_counter() + interval
while True:
await asyncio.sleep(interval)
now = time.perf_counter()
lag_ms = max(0.0, (now - next_tick) * 1000.0)
self._last_lag_ms = lag_ms
self._samples.append((now, lag_ms))
self._prune(now)
next_tick = now + interval
def _prune(self, now: float) -> None:
cutoff = now - self._window_seconds
while self._samples and self._samples[0][0] < cutoff:
self._samples.popleft()
playback_state_cache = PlaybackStateCache()
event_loop_lag_monitor = EventLoopLagMonitor()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -78,6 +362,37 @@ def _get_context(root_id: str):
return ctx return ctx
def _normalize_media_path_value(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _expand_torrent_playable_path(file_key: str, playable_file: str | None) -> str:
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
if playable_file.startswith(f"{file_key}/"):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
def _resolve_movie_slug_for_file_path(ctx, file_path: str) -> str | None:
target = _normalize_media_path_value(file_path)
for movie_id, movie in ctx.store.movies.items():
for file_key, torrent in movie.files.items():
normalized_key = _normalize_media_path_value(file_key)
if normalized_key == target:
return movie_id
playable_path = _expand_torrent_playable_path(
file_key, torrent.playable_file
)
if _normalize_media_path_value(playable_path) == target:
return movie_id
return None
def _load_root_metadata(root_path: Path, meta_key: str): def _load_root_metadata(root_path: Path, meta_key: str):
"""Load allowed per-root metadata values from .mediahive.""" """Load allowed per-root metadata values from .mediahive."""
key = meta_key.strip().lower().strip("/") key = meta_key.strip().lower().strip("/")
@@ -105,6 +420,53 @@ def _load_root_metadata(root_path: Path, meta_key: str):
) )
def _all_root_statuses() -> list[WsRootStatus]:
"""Return root statuses in WebSocket wire format."""
return [
WsRootStatus(
root_id=s["root_id"],
path=s["path"],
status=s["status"],
error=s.get("error"),
snapshot_loaded=bool(s.get("snapshot_loaded")),
movies=int(s.get("movies", 0)),
series=int(s.get("series", 0)),
)
for s in supervisor.all_statuses()
]
def _full_ws_init(root_ids: set[str] | None = None) -> WsInit:
"""Build an init payload for all roots or only selected root_ids."""
roots: dict[str, WsRootInitData] = {}
for rid, ctx in supervisor.all_contexts().items():
if root_ids is not None and rid not in root_ids:
continue
roots[rid] = WsRootInitData(
movies=dict(ctx.store.movies),
series=dict(ctx.store.series),
people=dict(ctx.store.people),
)
return WsInit(roots=roots)
def _translate_store_event(root_id: str, event: object):
"""Convert per-root store events into unified websocket messages."""
if isinstance(event, Upsert):
return WsUpsert(
root_id=root_id,
kind=event.kind,
id=event.id,
item=event.item,
people=event.people,
)
if isinstance(event, Remove):
return WsRemove(root_id=root_id, kind=event.kind, id=event.id)
if isinstance(event, Task):
return WsTask(root_id=root_id, data=event.data)
return None
def _open_with_default_app(path: Path) -> None: def _open_with_default_app(path: Path) -> None:
if sys.platform == "win32": if sys.platform == "win32":
os.startfile(str(path)) os.startfile(str(path))
@@ -343,7 +705,7 @@ async def _activate_all_roots() -> None:
desired.update(cfg.roots) desired.update(cfg.roots)
if not desired: if not desired:
logger.info("No roots configured; waiting for PUT /api/roots") logger.info("No roots configured; waiting for PUT /api/config/roots")
return return
# Validate paths in a thread pool (macOS permission-dialog safe) # Validate paths in a thread pool (macOS permission-dialog safe)
@@ -373,6 +735,8 @@ async def _activate_all_roots() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
await frontend.load() await frontend.load()
playback_state_cache.start()
event_loop_lag_monitor.start()
# Defer root activation to a background task so the server starts # Defer root activation to a background task so the server starts
# immediately and macOS permission dialogs do not block startup. # immediately and macOS permission dialogs do not block startup.
@@ -384,6 +748,9 @@ async def lifespan(_app: FastAPI):
try: try:
yield yield
finally: finally:
playback_state_cache.stop()
await event_loop_lag_monitor.stop()
activation_task.cancel() activation_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await activation_task await activation_task
@@ -402,6 +769,9 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE) app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
# Custom access logging (uvicorn access logs are suppressed in access_logging)
app.add_middleware(AccessLogMiddleware)
# Allow CORS for development # Allow CORS for development
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -433,13 +803,7 @@ async def get_config():
# --- Root management --- # --- Root management ---
@app.get("/api/roots") @app.put("/api/config/roots")
async def get_roots():
"""List all active roots with their status."""
return {"roots": supervisor.all_statuses()}
@app.put("/api/roots")
async def put_roots(request: Request): async def put_roots(request: Request):
"""Atomically replace the full root set.""" """Atomically replace the full root set."""
body = msgspec.json.decode(await request.body(), type=RootsRequest) body = msgspec.json.decode(await request.body(), type=RootsRequest)
@@ -455,25 +819,123 @@ async def put_roots(request: Request):
} }
# --- Per-root WebSocket --- # --- Unified WebSocket ---
@app.websocket("/api/ws/{root_id}") @app.websocket("/api/ws")
async def ws_endpoint(ws: WebSocket, root_id: str) -> None: async def ws_endpoint(ws: WebSocket) -> None:
"""Live index updates and task progress for a single root.""" """Live updates stream for all roots and all connected clients."""
ctx = supervisor.get(root_id) listeners: dict[str, object] = {}
if ctx is None: attached_contexts = supervisor.all_contexts()
await ws.close(code=1008, reason="Unknown root") outbound: asyncio.Queue[bytes] = asyncio.Queue()
return
start = time.perf_counter()
ws_id = log_ws_open(ws)
close_code: int | None = None
prev_root_ids: set[str] = set()
prev_meta: dict[str, tuple[str, str, str | None, bool]] = {}
def _sync_listeners() -> tuple[
set[str], dict[str, tuple[str, str, str | None, bool]]
]:
nonlocal attached_contexts
current_contexts = supervisor.all_contexts()
current_ids = set(current_contexts.keys())
for rid in list(listeners.keys()):
if rid in current_ids:
continue
old_ctx = attached_contexts.get(rid)
listener = listeners.pop(rid)
if old_ctx is not None:
old_ctx.store.remove_listener(listener)
for rid, ctx in current_contexts.items():
if rid in listeners:
continue
def _listener(event: object, *, _rid=rid) -> None:
translated = _translate_store_event(_rid, event)
if translated is None:
return
outbound.put_nowait(msgspec.json.encode(translated))
listeners[rid] = _listener
ctx.store.add_listener(_listener)
attached_contexts = current_contexts
meta = {
s.root_id: (s.path, s.status, s.error, s.snapshot_loaded)
for s in _all_root_statuses()
}
return current_ids, meta
async def _send_outbound() -> None:
while True:
payload = await outbound.get()
await ws.send_bytes(payload)
async def _watch_roots() -> None:
nonlocal prev_root_ids, prev_meta
while True:
current_ids, current_meta = _sync_listeners()
root_set_changed = current_ids != prev_root_ids
meta_changed = current_meta != prev_meta
if root_set_changed or meta_changed:
outbound.put_nowait(
msgspec.json.encode(WsRoots(roots=_all_root_statuses()))
)
if root_set_changed:
outbound.put_nowait(msgspec.json.encode(_full_ws_init()))
else:
became_loaded = {
rid
for rid, meta in current_meta.items()
if rid in prev_meta and not prev_meta[rid][3] and meta[3]
}
if became_loaded:
outbound.put_nowait(
msgspec.json.encode(_full_ws_init(became_loaded))
)
prev_root_ids = current_ids
prev_meta = current_meta
await asyncio.sleep(1.0)
await ws.accept()
current_ids, current_meta = _sync_listeners()
prev_root_ids = current_ids
prev_meta = current_meta
await ws.send_bytes(msgspec.json.encode(WsRoots(roots=_all_root_statuses())))
await ws.send_bytes(msgspec.json.encode(_full_ws_init()))
sender_task = asyncio.create_task(_send_outbound())
watcher_task = asyncio.create_task(_watch_roots())
await ctx.store.connect(ws)
try: try:
while True: while True:
await ws.receive_text() await ws.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect as exc:
ctx.store.disconnect(ws) close_code = exc.code
except OSError, RuntimeError: except OSError, RuntimeError:
ctx.store.disconnect(ws) pass
finally:
sender_task.cancel()
watcher_task.cancel()
with suppress(asyncio.CancelledError):
await sender_task
with suppress(asyncio.CancelledError):
await watcher_task
for rid, listener in list(listeners.items()):
ctx = attached_contexts.get(rid)
if ctx is not None:
ctx.store.remove_listener(listener)
log_ws_close(ws_id, close_code, time.perf_counter() - start)
# --- Media actions --- # --- Media actions ---
@@ -487,45 +949,106 @@ async def list_players():
@app.post("/api/play/{root_id}") @app.post("/api/play/{root_id}")
async def play_media(root_id: str, request: Request): async def play_media(root_id: str, request: Request, response: Response):
"""Open a media file with the selected player.""" """Open a media file with the selected player."""
req_start = time.perf_counter()
trace_id = request.headers.get("x-mediahive-trace-id", "")
client_sent_ms_hdr = request.headers.get("x-mediahive-client-sent-ms")
client_to_server_ms: float | None = None
if client_sent_ms_hdr:
with suppress(ValueError):
client_to_server_ms = max(
0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)
)
ctx = _get_context(root_id) ctx = _get_context(root_id)
body_t0 = time.perf_counter()
req = msgspec.json.decode(await request.body(), type=PlayMediaRequest) req = msgspec.json.decode(await request.body(), type=PlayMediaRequest)
decode_ms = (time.perf_counter() - body_t0) * 1000.0
resolve_t0 = time.perf_counter()
file_path = _resolve_root_scoped_path(ctx.root_path, req.file_path) file_path = _resolve_root_scoped_path(ctx.root_path, req.file_path)
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
if not file_path.exists(): if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}") raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}")
# Resolve player path if a specific detected player was chosen # Resolve player path if a specific detected player was chosen
player_path: str | None = None player_path: str | None = None
detect_ms = 0.0
if req.player_id and req.player_id not in ("default", "custom"): if req.player_id and req.player_id not in ("default", "custom"):
detect_t0 = time.perf_counter()
for p in detect_players(): for p in detect_players():
if p.id == req.player_id: if p.id == req.player_id:
player_path = p.path player_path = p.path
break break
detect_ms = (time.perf_counter() - detect_t0) * 1000.0
if not player_path: if not player_path:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"Player not found: {req.player_id}" status_code=400, detail=f"Player not found: {req.player_id}"
) )
try: try:
launch_t0 = time.perf_counter()
launch_player( launch_player(
req.player_id or "default", req.player_id or "default",
file_path, file_path,
player_path=player_path, player_path=player_path,
custom_cmd=req.player_custom_cmd, custom_cmd=req.player_custom_cmd,
) )
launch_ms = (time.perf_counter() - launch_t0) * 1000.0
total_ms = (time.perf_counter() - req_start) * 1000.0
loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot()
if trace_id:
response.headers["X-MediaHive-Trace-Id"] = trace_id
response.headers["Server-Timing"] = (
f"app;dur={total_ms:.1f},"
f"decode;dur={decode_ms:.1f},"
f"resolve;dur={resolve_ms:.1f},"
f"detect;dur={detect_ms:.1f},"
f"launch;dur={launch_ms:.1f},"
f"looplag;dur={loop_lag_ms:.1f},"
f"looplagmax;dur={loop_lag_max_ms:.1f}"
)
request.state.log_extra = (
f"trace={trace_id or '-'} "
f"phase[decode={decode_ms:.1f}ms resolve={resolve_ms:.1f}ms "
f"detect={detect_ms:.1f}ms launch={launch_ms:.1f}ms] "
f"loopLag={loop_lag_ms:.1f}/{loop_lag_max_ms:.1f}ms"
)
if client_to_server_ms is not None:
request.state.log_extra = (
f"{request.state.log_extra} clientToServer={client_to_server_ms:.1f}ms"
)
return {"status": "ok"} return {"status": "ok"}
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e: except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
raise HTTPException(status_code=500, detail=f"Failed to play media: {e}") raise HTTPException(status_code=500, detail=f"Failed to play media: {e}")
@app.post("/api/open-folder/{root_id}") @app.post("/api/open-folder/{root_id}")
async def open_folder(root_id: str, request: Request): async def open_folder(root_id: str, request: Request, response: Response):
"""Open a folder in the system file explorer.""" """Open a folder in the system file explorer."""
req_start = time.perf_counter()
trace_id = request.headers.get("x-mediahive-trace-id", "")
client_sent_ms_hdr = request.headers.get("x-mediahive-client-sent-ms")
client_to_server_ms: float | None = None
if client_sent_ms_hdr:
with suppress(ValueError):
client_to_server_ms = max(
0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)
)
ctx = _get_context(root_id) ctx = _get_context(root_id)
body_t0 = time.perf_counter()
req = msgspec.json.decode(await request.body(), type=OpenFolderRequest) req = msgspec.json.decode(await request.body(), type=OpenFolderRequest)
decode_ms = (time.perf_counter() - body_t0) * 1000.0
resolve_t0 = time.perf_counter()
target_path = _resolve_root_scoped_path(ctx.root_path, req.folder_path) target_path = _resolve_root_scoped_path(ctx.root_path, req.folder_path)
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
if not target_path.exists(): if not target_path.exists():
raise HTTPException( raise HTTPException(
@@ -533,6 +1056,7 @@ async def open_folder(root_id: str, request: Request):
) )
try: try:
open_t0 = time.perf_counter()
if sys.platform == "win32": if sys.platform == "win32":
native_path = str(target_path).replace("/", "\\") native_path = str(target_path).replace("/", "\\")
if target_path.is_file(): if target_path.is_file():
@@ -550,6 +1074,30 @@ async def open_folder(root_id: str, request: Request):
folder = target_path.parent if target_path.is_file() else target_path folder = target_path.parent if target_path.is_file() else target_path
subprocess.Popen(["xdg-open", str(folder)]) subprocess.Popen(["xdg-open", str(folder)])
open_ms = (time.perf_counter() - open_t0) * 1000.0
total_ms = (time.perf_counter() - req_start) * 1000.0
loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot()
if trace_id:
response.headers["X-MediaHive-Trace-Id"] = trace_id
response.headers["Server-Timing"] = (
f"app;dur={total_ms:.1f},"
f"decode;dur={decode_ms:.1f},"
f"resolve;dur={resolve_ms:.1f},"
f"open;dur={open_ms:.1f},"
f"looplag;dur={loop_lag_ms:.1f},"
f"looplagmax;dur={loop_lag_max_ms:.1f}"
)
request.state.log_extra = (
f"trace={trace_id or '-'} "
f"phase[decode={decode_ms:.1f}ms resolve={resolve_ms:.1f}ms open={open_ms:.1f}ms] "
f"loopLag={loop_lag_ms:.1f}/{loop_lag_max_ms:.1f}ms"
)
if client_to_server_ms is not None:
request.state.log_extra = (
f"{request.state.log_extra} clientToServer={client_to_server_ms:.1f}ms"
)
return {"status": "ok"} return {"status": "ok"}
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e: except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}") raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}")
@@ -562,6 +1110,35 @@ async def root_metadata(root_id: str, meta_key: str):
return {"key": meta_key, "data": _load_root_metadata(ctx.root_path, meta_key)} return {"key": meta_key, "data": _load_root_metadata(ctx.root_path, meta_key)}
@app.get("/api/meta/playback-state")
async def merged_playback_state():
"""Return merged playback-state resume positions from in-memory cache.
Merges across all roots, preferring the most recent timestamp for each slug.
Format: {"key": "playback-state", "data": {"resume_positions": {slug: {pos, ts}}}}
"""
entries = playback_state_cache.get_merged_entries()
positions = {slug: entry.to_dict() for slug, entry in entries.items()}
return {"key": "playback-state", "data": {"resume_positions": positions}}
@app.post("/api/meta/playback-state")
async def write_playback_state(request: Request):
"""Update one playback-state entry via backend-managed read-modify-write."""
req = msgspec.json.decode(await request.body(), type=PlaybackStateUpdateRequest)
ctx = _get_context(req.root_id)
slug = _resolve_movie_slug_for_file_path(ctx, req.file_path)
if slug is None:
raise HTTPException(
status_code=404,
detail=f"Movie not found for file path: {req.file_path}",
)
pos = None if req.pos is None or req.pos <= 0 else int(req.pos)
playback_state_cache.update_resume_position(req.root_id, ctx.root_path, slug, pos)
return {"status": "ok", "slug": slug, "pos": pos}
# --- MPC-BE / Player status --- # --- MPC-BE / Player status ---
@@ -620,7 +1197,7 @@ def _serve_file_response(full_path: Path, file_path: str, request: Request):
file_stat = full_path.stat() file_stat = full_path.stat()
file_size = file_stat.st_size file_size = file_stat.st_size
etag = _build_file_etag(file_size, file_stat.st_mtime_ns) etag = _build_file_etag(file_size, file_stat.st_mtime_ns)
cache_control = "public, max-age=600" cache_control = "public, max-age=604800, immutable"
range_header = request.headers.get("range") range_header = request.headers.get("range")
if not range_header and _etag_matches_if_none_match( if not range_header and _etag_matches_if_none_match(
+140 -58
View File
@@ -120,59 +120,127 @@ _DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
def _default_playback_state() -> dict[str, object]: def _default_playback_state() -> dict[str, object]:
return { return {
"current": None, "current": None,
"resume_positions": {},
} }
def _load_playback_state(path: Path) -> dict[str, object]: def _normalize_media_path(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _expand_playable_file(file_key: str, playable_file: str | None) -> str:
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
if playable_file.startswith(f"{file_key}/"):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
def _fetch_resume_positions(backend_url: str) -> dict[str, int]:
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
method="GET",
)
try: try:
raw = json.loads(path.read_text(encoding="utf-8")) with urllib.request.urlopen(req, timeout=2) as resp:
raw = json.loads(resp.read().decode("utf-8"))
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
return {}
data = raw.get("data") if isinstance(raw, dict) else None
positions = data.get("resume_positions") if isinstance(data, dict) else None
if not isinstance(positions, dict):
return {}
cleaned: dict[str, int] = {}
for slug, value in positions.items():
if not isinstance(slug, str) or not isinstance(value, dict):
continue
pos = value.get("pos")
if isinstance(pos, int) and pos > 0:
cleaned[slug] = pos * 1000
return cleaned
def _post_resume_position(
backend_url: str,
root_id: str,
file_path: str,
pos: int | None,
) -> bool:
body = json.dumps({
"root_id": root_id,
"file_path": file_path,
"pos": pos,
}).encode("utf-8")
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=2):
return True
except OSError, TimeoutError, urllib.error.URLError:
logger.warning("Failed to post playback-state update for %s", file_path)
return False
def _load_movie_slug_map(index_path: Path) -> dict[str, str]:
try:
raw = json.loads(index_path.read_text(encoding="utf-8"))
except OSError, TypeError, json.JSONDecodeError: except OSError, TypeError, json.JSONDecodeError:
return _default_playback_state() return {}
if not isinstance(raw, dict): movies = raw.get("movies") if isinstance(raw, dict) else None
return _default_playback_state() if not isinstance(movies, dict):
return {}
current = raw.get("current") mapping: dict[str, str] = {}
resume_positions = raw.get("resume_positions") for movie_id, movie in movies.items():
normalized: dict[str, object] = { if not isinstance(movie_id, str) or not isinstance(movie, dict):
"current": current if isinstance(current, dict) else None, continue
"resume_positions": {}, files = movie.get("files")
} if not isinstance(files, dict):
continue
if isinstance(resume_positions, dict): for file_key, torrent in files.items():
cleaned_positions: dict[str, int] = {} if not isinstance(file_key, str):
for key, value in resume_positions.items(): continue
if isinstance(key, str) and isinstance(value, (int, float)): normalized_key = _normalize_media_path(file_key)
cleaned_positions[key] = max(0, int(value)) mapping[normalized_key] = movie_id
normalized["resume_positions"] = cleaned_positions playable_file = (
torrent.get("playable_file") if isinstance(torrent, dict) else None
return normalized )
expanded = _expand_playable_file(
file_key, playable_file if isinstance(playable_file, str) else None
def _save_playback_state(path: Path, state: dict[str, object]) -> None: )
path.parent.mkdir(parents=True, exist_ok=True) mapping[_normalize_media_path(expanded)] = movie_id
tmp_path = path.with_suffix(f"{path.suffix}.tmp") return mapping
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
tmp_path.replace(path)
def _media_key_for_filepath( def _media_key_for_filepath(
filepath: str, roots: list[Path] filepath: str, roots: dict[str, Path]
) -> tuple[str, Path] | None: ) -> tuple[str | None, str, str] | None:
"""Resolve a filepath to a (relative_key, matched_root) tuple.""" """Resolve a filepath to a (movie_slug, root_id, relative_key) tuple."""
for root in roots: for root_id, root in roots.items():
try: try:
relative = Path(filepath).resolve().relative_to(root.resolve()) relative = Path(filepath).resolve().relative_to(root.resolve())
return relative.as_posix(), root relative_key = relative.as_posix()
index_path = root / ".mediahive" / "index.json"
movie_slug = _load_movie_slug_map(index_path).get(
_normalize_media_path(relative_key)
)
return movie_slug, root_id, relative_key
except OSError, RuntimeError, ValueError: except OSError, RuntimeError, ValueError:
continue continue
return None return None
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool: def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
if position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
return True
if duration_ms <= 0: if duration_ms <= 0:
return False return False
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
@@ -248,7 +316,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
def _start_gamepad_remote( def _start_gamepad_remote(
stop_event: threading.Event, roots: list[Path] stop_event: threading.Event, roots: dict[str, Path], backend_url: str
) -> threading.Thread: ) -> threading.Thread:
"""Start background XInput polling and send mapped commands to MPC-BE.""" """Start background XInput polling and send mapped commands to MPC-BE."""
get_state = _load_xinput_get_state() get_state = _load_xinput_get_state()
@@ -278,18 +346,11 @@ def _start_gamepad_remote(
status_updated_at = 0.0 status_updated_at = 0.0
status_miss_count = 0 status_miss_count = 0
# Use the first root's playback state path as primary playback_state = _default_playback_state()
primary_root = roots[0] if roots else Path.cwd() resume_positions = _fetch_resume_positions(backend_url)
playback_state_path = primary_root / ".mediahive" / "playback-state.json"
playback_state = _load_playback_state(playback_state_path)
resume_positions = playback_state["resume_positions"]
if not isinstance(resume_positions, dict):
resume_positions = {}
playback_state["resume_positions"] = resume_positions
if playback_state.get("current") is not None:
playback_state["current"] = None
_save_playback_state(playback_state_path, playback_state)
tracked_media_key: str | None = None tracked_media_key: str | None = None
tracked_root_id: str | None = None
tracked_relative_path = ""
tracked_filepath = "" tracked_filepath = ""
resume_applied_for_key: str | None = None resume_applied_for_key: str | None = None
last_playback_state_flush_at = 0.0 last_playback_state_flush_at = 0.0
@@ -302,12 +363,11 @@ def _start_gamepad_remote(
request_pool.submit(_seek_mpcbe_to_position, position_ms) request_pool.submit(_seek_mpcbe_to_position, position_ms)
) )
def flush_playback_state() -> None:
_save_playback_state(playback_state_path, playback_state)
def clear_tracked_current(*, clear_resume_applied: bool) -> None: def clear_tracked_current(*, clear_resume_applied: bool) -> None:
nonlocal \ nonlocal \
tracked_media_key, \ tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \ tracked_filepath, \
last_playback_state_flush_at, \ last_playback_state_flush_at, \
resume_applied_for_key resume_applied_for_key
@@ -316,38 +376,56 @@ def _start_gamepad_remote(
resume_applied_for_key = None resume_applied_for_key = None
return return
tracked_media_key = None tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = "" tracked_filepath = ""
playback_state["current"] = None playback_state["current"] = None
last_playback_state_flush_at = 0.0 last_playback_state_flush_at = 0.0
if clear_resume_applied: if clear_resume_applied:
resume_applied_for_key = None resume_applied_for_key = None
flush_playback_state()
def finalize_tracked_current() -> None: def finalize_tracked_current() -> None:
nonlocal \ nonlocal \
tracked_media_key, \ tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \ tracked_filepath, \
resume_applied_for_key, \ resume_applied_for_key, \
last_playback_state_flush_at last_playback_state_flush_at
if tracked_media_key is None: if tracked_media_key is None:
if playback_state.get("current") is not None: if playback_state.get("current") is not None:
playback_state["current"] = None playback_state["current"] = None
flush_playback_state()
return return
position_ms = player_position_ms or 0 position_ms = player_position_ms or 0
duration_ms = player_duration_ms or 0 duration_ms = player_duration_ms or 0
if _should_clear_resume(position_ms, duration_ms): if _should_clear_resume(position_ms, duration_ms):
resume_positions.pop(tracked_media_key, None) resume_positions.pop(tracked_media_key, None)
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url, tracked_root_id, tracked_relative_path, None
)
elif position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
# Ignore brief starts; keep the previous saved resume position.
pass
else: else:
position_seconds = max(0, position_ms // 1000)
resume_positions[tracked_media_key] = position_ms resume_positions[tracked_media_key] = position_ms
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url,
tracked_root_id,
tracked_relative_path,
position_seconds,
)
tracked_media_key = None tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = "" tracked_filepath = ""
playback_state["current"] = None playback_state["current"] = None
resume_applied_for_key = None resume_applied_for_key = None
last_playback_state_flush_at = 0.0 last_playback_state_flush_at = 0.0
flush_playback_state()
def persist_tracked_current(now: float, *, force: bool = False) -> None: def persist_tracked_current(now: float, *, force: bool = False) -> None:
nonlocal last_playback_state_flush_at nonlocal last_playback_state_flush_at
@@ -367,7 +445,6 @@ def _start_gamepad_remote(
"updated_at": int(time.time()), "updated_at": int(time.time()),
} }
last_playback_state_flush_at = now last_playback_state_flush_at = now
flush_playback_state()
def maybe_apply_resume(now: float) -> None: def maybe_apply_resume(now: float) -> None:
nonlocal player_position_ms, resume_applied_for_key nonlocal player_position_ms, resume_applied_for_key
@@ -388,7 +465,6 @@ def _start_gamepad_remote(
if _should_clear_resume(saved_position, player_duration_ms): if _should_clear_resume(saved_position, player_duration_ms):
resume_positions.pop(tracked_media_key, None) resume_positions.pop(tracked_media_key, None)
resume_applied_for_key = tracked_media_key resume_applied_for_key = tracked_media_key
flush_playback_state()
return return
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS: if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
return return
@@ -409,6 +485,8 @@ def _start_gamepad_remote(
status_updated_at, \ status_updated_at, \
status_miss_count, \ status_miss_count, \
tracked_media_key, \ tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \ tracked_filepath, \
resume_applied_for_key resume_applied_for_key
if status_future is None or not status_future.done(): if status_future is None or not status_future.done():
@@ -437,6 +515,8 @@ def _start_gamepad_remote(
filepath, position_ms, duration_ms, state = status filepath, position_ms, duration_ms, state = status
resolved = _media_key_for_filepath(filepath, roots) if filepath else None resolved = _media_key_for_filepath(filepath, roots) if filepath else None
media_key = resolved[0] if resolved else None media_key = resolved[0] if resolved else None
root_id = resolved[1] if resolved else None
relative_path = resolved[2] if resolved else ""
if tracked_media_key is not None and media_key != tracked_media_key: if tracked_media_key is not None and media_key != tracked_media_key:
finalize_tracked_current() finalize_tracked_current()
@@ -445,6 +525,8 @@ def _start_gamepad_remote(
clear_tracked_current(clear_resume_applied=True) clear_tracked_current(clear_resume_applied=True)
elif tracked_media_key != media_key: elif tracked_media_key != media_key:
tracked_media_key = media_key tracked_media_key = media_key
tracked_root_id = root_id
tracked_relative_path = relative_path
tracked_filepath = filepath tracked_filepath = filepath
resume_applied_for_key = None resume_applied_for_key = None
@@ -872,7 +954,7 @@ def winmain() -> None:
def _activate_initial_roots() -> None: def _activate_initial_roots() -> None:
body = json.dumps({"roots": initial_roots}).encode("utf-8") body = json.dumps({"roots": initial_roots}).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
url=f"{backend_url}/api/roots", url=f"{backend_url}/api/config/roots",
data=body, data=body,
method="PUT", method="PUT",
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
@@ -900,7 +982,7 @@ def winmain() -> None:
poll_thread: threading.Thread | None = None poll_thread: threading.Thread | None = None
# Resolve all root paths for gamepad remote # Resolve all root paths for gamepad remote
gamepad_roots = [Path(p) for p in initial_roots.values()] gamepad_roots = {root_id: Path(p) for root_id, p in initial_roots.items()}
def on_shown() -> None: def on_shown() -> None:
api._window = window api._window = window
@@ -913,7 +995,7 @@ def winmain() -> None:
nonlocal poll_thread nonlocal poll_thread
if poll_thread is None and _supports_gamepad_remote(): if poll_thread is None and _supports_gamepad_remote():
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots) poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots, backend_url)
threading.Thread( threading.Thread(
target=_activate_initial_roots, target=_activate_initial_roots,
+3
View File
@@ -116,3 +116,6 @@ ignore = [
# Allow unused local variables in ctypes COM boilerplate # Allow unused local variables in ctypes COM boilerplate
"F841", "F841",
] ]
[tool.ruff.lint.per-file-ignores]
"mediahive/access_logging.py" = ["BLE001", "G004"]