Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
568605b09a | ||
|
|
b09118c8cb | ||
|
|
f79c044250 | ||
|
|
a4b0cd916a | ||
|
|
e1a961d21f | ||
|
|
1a6a3f0cf2 | ||
|
|
626fb9a0ae | ||
|
|
b6742f0f27 | ||
|
|
6d0e6d41a7 | ||
|
|
932c5af1ff | ||
|
|
eb49eb713e | ||
|
|
d7eea51334 | ||
|
|
01f424dbfb | ||
|
|
df9025760d | ||
|
|
09e51acd14 |
+4
-4
@@ -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/config` | Returns the current root configuration. |
|
||||
| `GET` | `/api/roots` | List all active roots with status. |
|
||||
| `PUT` | `/api/roots` | Atomically replace the full root set. |
|
||||
| `PUT` | `/api/config/roots` | Atomically replace the full root set. |
|
||||
| `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. |
|
||||
| `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/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/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
|
||||
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
@@ -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
@@ -37,6 +37,7 @@
|
||||
<Header
|
||||
:current-view="headerCurrentView"
|
||||
:search-query="searchQuery"
|
||||
:roots="headerRoots"
|
||||
:mpc-be-connected="mpcBeConnected"
|
||||
:nav-row="1"
|
||||
:position="headerPosition"
|
||||
@@ -70,7 +71,7 @@
|
||||
<!-- Browse/Search page (left panel) -->
|
||||
<main
|
||||
ref="browsePanelRef"
|
||||
class="main-content page-slider-panel"
|
||||
class="main-content page-slider-panel scrollbar-hidden"
|
||||
data-nav-scope="browse"
|
||||
@scroll.passive="handlePanelScroll('browse')"
|
||||
>
|
||||
@@ -165,7 +166,7 @@
|
||||
<!-- Detail page (right panel) -->
|
||||
<main
|
||||
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"
|
||||
@scroll.passive="handlePanelScroll('detail')"
|
||||
>
|
||||
@@ -206,9 +207,7 @@ import {
|
||||
openFolder,
|
||||
isMpcBeReachable,
|
||||
fetchResumePositions,
|
||||
normalizeMediaPath,
|
||||
getPlayerStatus,
|
||||
fetchRoots,
|
||||
} from "./api"
|
||||
import { useSettings } from "./composables/useSettings"
|
||||
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
|
||||
@@ -268,7 +267,7 @@ const {
|
||||
error,
|
||||
connected: wsConnected,
|
||||
tasks,
|
||||
setActiveRoots,
|
||||
roots: rootStatuses,
|
||||
} = useMediaWebSocket()
|
||||
|
||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
||||
@@ -287,14 +286,9 @@ interface ProgressRootState {
|
||||
|
||||
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 {
|
||||
if (!rootId) return null
|
||||
return rootStatuses.value.get(rootId)?.name || null
|
||||
return rootStatuses.value.get(rootId)?.root_id || null
|
||||
}
|
||||
|
||||
function normalizePosixPath(value: string): string {
|
||||
@@ -427,10 +421,14 @@ const hasLibraryItems = computed(() => {
|
||||
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
|
||||
})
|
||||
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 headerRoots = computed(() =>
|
||||
Array.from(rootStatuses.value.values()).sort((a, b) => a.root_id.localeCompare(b.root_id)),
|
||||
)
|
||||
|
||||
const showProgressPanel = computed(() => {
|
||||
if (isInitialScanMode.value) {
|
||||
return !wsConnected.value || progressRoots.value.length > 0
|
||||
@@ -480,51 +478,7 @@ watch(
|
||||
{ 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(() => {
|
||||
stopRootsPolling()
|
||||
if (libraryUpdateToastTimer !== null) {
|
||||
window.clearTimeout(libraryUpdateToastTimer)
|
||||
libraryUpdateToastTimer = null
|
||||
@@ -592,10 +546,9 @@ async function refreshPlayerStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
function hasResumePosition(filePath: string | null) {
|
||||
if (!filePath) return false
|
||||
const normalizedPath = normalizeMediaPath(filePath)
|
||||
return Number(resumePositions.value[normalizedPath] || 0) > 0
|
||||
function hasResumePosition(mediaId: string | null) {
|
||||
if (!mediaId) return false
|
||||
return Number(resumePositions.value[mediaId] || 0) > 0
|
||||
}
|
||||
|
||||
function startMpcBePolling() {
|
||||
@@ -1641,6 +1594,7 @@ function findRootIdForPath(filePath: string): string | null {
|
||||
}
|
||||
|
||||
async function handlePlay(filePath: string) {
|
||||
const actionStart = performance.now()
|
||||
const rootId = findRootIdForPath(filePath)
|
||||
if (!rootId) {
|
||||
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
|
||||
}
|
||||
try {
|
||||
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd)
|
||||
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd, {
|
||||
actionStartedAt: actionStart,
|
||||
source: "App.handlePlay",
|
||||
})
|
||||
if (isMpcFamilySelected()) {
|
||||
const connected = await tryConnectMpcBe()
|
||||
if (connected) {
|
||||
@@ -1664,13 +1621,17 @@ async function handlePlay(filePath: string) {
|
||||
}
|
||||
|
||||
async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
|
||||
const actionStart = performance.now()
|
||||
const rootId = explicitRootId || findRootIdForPath(folderPath)
|
||||
if (!rootId) {
|
||||
console.error("Cannot open folder: unknown root for path", folderPath)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await openFolder(rootId, folderPath)
|
||||
await openFolder(rootId, folderPath, {
|
||||
actionStartedAt: actionStart,
|
||||
source: "App.handleOpenFolder",
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("Failed to open folder:", e)
|
||||
}
|
||||
|
||||
+110
-38
@@ -9,18 +9,43 @@ export interface PlayerInfo {
|
||||
path: string | null
|
||||
}
|
||||
|
||||
export interface RootStatus {
|
||||
export interface RootEntry {
|
||||
root_id: string
|
||||
path: string
|
||||
status: string
|
||||
error: string | null
|
||||
snapshot_loaded: boolean
|
||||
movies: number
|
||||
series: number
|
||||
}
|
||||
|
||||
export interface RootsResponse {
|
||||
roots: RootStatus[]
|
||||
interface ActionTimingContext {
|
||||
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 {
|
||||
@@ -123,37 +148,27 @@ function splitAssetTypePath(assetPath: string): { assetType: string; relativePat
|
||||
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.
|
||||
*/
|
||||
export async function fetchResumePositions(): Promise<Record<string, number>> {
|
||||
try {
|
||||
const roots = await fetchRoots()
|
||||
const merged: Record<string, number> = {}
|
||||
await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
const response = await fetch(`/api/meta/${encodeURIComponent(root.root_id)}/playback-state`)
|
||||
if (!response.ok) return
|
||||
const response = await fetch("/api/meta/playback-state")
|
||||
if (!response.ok) return {}
|
||||
const data = await response.json().catch(() => ({}))
|
||||
const positions = data?.data?.resume_positions
|
||||
if (positions && typeof positions === "object") {
|
||||
Object.assign(merged, positions)
|
||||
if (!positions || typeof positions !== "object") {
|
||||
return {}
|
||||
}
|
||||
}),
|
||||
)
|
||||
return merged
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
|
||||
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 normalized
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
@@ -164,8 +179,8 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
|
||||
*/
|
||||
export async function replaceRoots(
|
||||
roots: Record<string, string>,
|
||||
): Promise<{ accepted: RootStatus[]; failed: unknown[] }> {
|
||||
const response = await fetch("/api/roots", {
|
||||
): Promise<{ accepted: RootEntry[]; failed: unknown[] }> {
|
||||
const response = await fetch("/api/config/roots", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots }),
|
||||
@@ -197,23 +212,50 @@ export async function playMedia(
|
||||
filePath: string,
|
||||
playerId?: string | null,
|
||||
playerCustomCmd?: string | null,
|
||||
timing?: ActionTimingContext,
|
||||
): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(filePath)
|
||||
const body: Record<string, unknown> = { file_path: normalizedPath }
|
||||
if (playerId) body.player_id = playerId
|
||||
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
|
||||
const actionStart = timing?.actionStartedAt ?? nowMs()
|
||||
const traceId = makeTraceId("play")
|
||||
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)}`, {
|
||||
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),
|
||||
})
|
||||
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) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || response.statusText)
|
||||
}
|
||||
} 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}`)
|
||||
}
|
||||
}
|
||||
@@ -221,20 +263,50 @@ export async function playMedia(
|
||||
/**
|
||||
* 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 actionStart = timing?.actionStartedAt ?? nowMs()
|
||||
const traceId = makeTraceId("open-folder")
|
||||
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)}`, {
|
||||
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 }),
|
||||
})
|
||||
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) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.detail || response.statusText)
|
||||
}
|
||||
} 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="episode-release-menu"
|
||||
:style="menuStyle"
|
||||
tabindex="-1"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="episode-release-header">{{ episodeName }}</div>
|
||||
<div v-if="releases.length > 0" class="episode-release-list">
|
||||
<ReleaseVersionCard
|
||||
v-for="(release, index) in releases"
|
||||
:key="index"
|
||||
:torrent="release"
|
||||
:best="index === 0"
|
||||
:selectable="!!release.playable_file"
|
||||
:disabled="!release.playable_file"
|
||||
compact-flags
|
||||
variant="menu"
|
||||
inert-card
|
||||
show-actions
|
||||
:play-label="getPlayLabel(release.playable_file)"
|
||||
@play="emit('play', release.playable_file || '')"
|
||||
@open-folder="emit('openFolder', release.playable_file || '')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="episode-release-empty">No versions available</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
|
||||
import type { Torrent } from "../types"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
episodeName: string
|
||||
releases: Torrent[]
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
play: [string]
|
||||
openFolder: [string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const menuLeft = ref(0)
|
||||
const menuTop = ref(0)
|
||||
const VIEWPORT_MARGIN = 12
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${menuLeft.value}px`,
|
||||
top: `${menuTop.value}px`,
|
||||
}))
|
||||
|
||||
function getPlayLabel(filePath: string | null | undefined): string {
|
||||
return props.hasResumePosition(filePath || null) ? "Continue" : "Play"
|
||||
}
|
||||
|
||||
function getFocusableElements(): HTMLElement[] {
|
||||
if (!menuRef.value) return []
|
||||
return Array.from(
|
||||
menuRef.value.querySelectorAll<HTMLElement>(
|
||||
'.ctx-btn: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")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function clampToViewport() {
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
|
||||
const width = menu.offsetWidth
|
||||
const height = menu.offsetHeight
|
||||
|
||||
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN)
|
||||
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)
|
||||
|
||||
menuLeft.value = Math.min(Math.max(props.x, VIEWPORT_MARGIN), maxLeft)
|
||||
menuTop.value = Math.min(Math.max(props.y, VIEWPORT_MARGIN), maxTop)
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (!props.visible) return
|
||||
clampToViewport()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.x, props.y, props.episodeName, props.releases.length],
|
||||
async ([visible]) => {
|
||||
if (!visible) return
|
||||
await nextTick()
|
||||
clampToViewport()
|
||||
// Focus first action button for keyboard navigation
|
||||
const firstBtn = menuRef.value?.querySelector(
|
||||
".ctx-btn:not(:disabled)",
|
||||
) as HTMLElement | null
|
||||
firstBtn?.focus()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener("resize", handleViewportChange)
|
||||
return
|
||||
}
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleViewportChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.episode-release-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: 420px;
|
||||
max-width: min(820px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.episode-release-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;
|
||||
}
|
||||
|
||||
.episode-release-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.episode-release-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -42,6 +42,10 @@
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
:spellcheck="false"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
v-model="localSearch"
|
||||
v-bind="navAttrs(navRow, 2)"
|
||||
:data-nav-entry-col="localSearch ? 2 : undefined"
|
||||
@@ -105,7 +109,7 @@
|
||||
<div class="settings-header-spacer"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="settings-content scrollbar-hidden">
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section-title">Media Roots</h2>
|
||||
<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 { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
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 HexKeyboard from "./HexKeyboard.vue"
|
||||
import {
|
||||
@@ -330,6 +334,7 @@ interface RootEntry {
|
||||
const props = defineProps<{
|
||||
currentView: "movies" | "series" | "search"
|
||||
searchQuery: string
|
||||
roots: RootEntry[]
|
||||
mpcBeConnected: boolean
|
||||
navRow: number
|
||||
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))
|
||||
|
||||
const showSettings = computed(() => route.path === "/settings")
|
||||
const roots = ref<RootEntry[]>([])
|
||||
const roots = computed(() => props.roots)
|
||||
|
||||
function openSettings() {
|
||||
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) {
|
||||
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
||||
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
||||
try {
|
||||
await replaceRoots(newRoots)
|
||||
await refreshRoots()
|
||||
} catch (e) {
|
||||
console.error("Failed to remove root:", e)
|
||||
alert("Failed to remove root")
|
||||
@@ -437,7 +428,6 @@ async function addRoot() {
|
||||
newRoots[suggestedId] = folder
|
||||
try {
|
||||
await replaceRoots(newRoots)
|
||||
await refreshRoots()
|
||||
closeSettings()
|
||||
} catch (e) {
|
||||
console.error("Failed to add root:", e)
|
||||
@@ -447,7 +437,6 @@ async function addRoot() {
|
||||
|
||||
watch(showSettings, (visible) => {
|
||||
if (visible) {
|
||||
void refreshRoots()
|
||||
void refreshPlayers()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<div class="collage-fallback-tile" :class="`collage-fallback-${slot.index + 1}`"></div>
|
||||
<video
|
||||
v-if="slot.sourcePaths.length > 0"
|
||||
:key="`${item.id}-${slot.index}-${slot.sourcePaths.join('|')}`"
|
||||
:ref="(el) => setVideoRef(el as HTMLVideoElement, slot.index)"
|
||||
:class="{ 'is-ready': isVideoReady(slot.index) }"
|
||||
:autoplay="safariAutoplay"
|
||||
@@ -179,31 +180,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="item.type === 'movies' && similarMovies.length > 0" class="similar-movies-section">
|
||||
<h2 class="similar-movies-title">Similar In Library</h2>
|
||||
<section
|
||||
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">
|
||||
<button
|
||||
v-for="(movie, similarIndex) in similarMovies"
|
||||
:key="movie.tmdbId"
|
||||
type="button"
|
||||
class="similar-movie-card cast-card media-card"
|
||||
v-bind="navAttrs(similarNavRow, similarIndex)"
|
||||
@click="handleSelectMovie(movie.localId)"
|
||||
<a
|
||||
v-for="(movie, collectionIndex) in collectionMovies"
|
||||
:key="movie.localId"
|
||||
href="#"
|
||||
class="similar-movie-card media-card"
|
||||
:class="{ 'similar-movie-card--current': movie.isCurrent }"
|
||||
:aria-current="movie.isCurrent ? 'true' : undefined"
|
||||
v-bind="navAttrs(collectionNavRow, collectionIndex)"
|
||||
@click.prevent="handleSelectCollectionMovie(movie.localId, movie.isCurrent)"
|
||||
>
|
||||
<img
|
||||
v-if="movie.coverPath"
|
||||
:src="getCoverUrl(movie.coverPath, movie.rootId)"
|
||||
: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 class="similar-movie-meta cast-copy">
|
||||
<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 v-else class="similar-movie-poster similar-movie-poster-fallback"></div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -226,6 +225,7 @@
|
||||
:play-label="getPlayLabel(versionActionMenu.filePath)"
|
||||
@play="handlePlayVersion(versionActionMenu.filePath)"
|
||||
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
|
||||
@close="closeVersionActionMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
@@ -251,13 +251,14 @@ import {
|
||||
navAttrs,
|
||||
registerOutOfBoundsNavigationHandler,
|
||||
FOCUSABLE_ATTR,
|
||||
setModalOpen,
|
||||
} from "../composables/useKeyboardNavigation"
|
||||
|
||||
const props = defineProps<{
|
||||
item: MediaItem
|
||||
allMovies: MovieUi[]
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
|
||||
hasResumePosition: (filePath: string | null) => boolean
|
||||
hasResumePosition: (mediaId: string | null) => boolean
|
||||
getRootName: (rootId: string | null | undefined) => string | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
@@ -555,6 +556,11 @@ watch(
|
||||
)
|
||||
videoStates.value = slots.map((slot) => (slot.sourcePaths.length > 0 ? "loading" : "missing"))
|
||||
await nextTick()
|
||||
for (let i = 0; i < videoRefs.value.length; i++) {
|
||||
if (slots[i]?.sourcePaths.length > 0) {
|
||||
videoRefs.value[i]?.load()
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
startStaggeredPlayback()
|
||||
}, 100)
|
||||
@@ -650,73 +656,102 @@ const movieKeywords = computed(() => {
|
||||
|
||||
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 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.
|
||||
// Narrow layout (or no similar): preserve existing cast row directly after releases.
|
||||
return hasDesktopSimilarShortcut ? 4 + movieVersions.value.length : 2 + movieVersions.value.length
|
||||
})
|
||||
|
||||
const similarMovies = computed((): Array<{
|
||||
tmdbId: number
|
||||
const collectionMovies = computed((): Array<{
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
rootId: string | null
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> => {
|
||||
if (props.item.type !== "movies") return []
|
||||
|
||||
const movie = props.item.data as Movie
|
||||
const similar = movie.info?.similar || []
|
||||
if (similar.length === 0) return []
|
||||
|
||||
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 collectionName = movie.info?.collection?.trim()
|
||||
if (!collectionName) return []
|
||||
const normalizedCollectionName = collectionName.toLowerCase()
|
||||
|
||||
const matches: Array<{
|
||||
tmdbId: number
|
||||
title: string
|
||||
localId: string
|
||||
coverPath: string | null
|
||||
rootId: string | null
|
||||
year: string | null
|
||||
hyphenLang: string | null
|
||||
isCurrent: boolean
|
||||
}> = []
|
||||
|
||||
const seenTmdbIds = new Set<number>()
|
||||
for (const similarEntry of similar) {
|
||||
if (seenTmdbIds.has(similarEntry.id)) continue
|
||||
seenTmdbIds.add(similarEntry.id)
|
||||
let hasCurrentInMatches = false
|
||||
|
||||
const matched = byTmdbId.get(similarEntry.id)
|
||||
if (!matched) continue
|
||||
for (const libraryMovie of props.allMovies || []) {
|
||||
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
|
||||
|
||||
const isCurrent = libraryMovie.id === props.item.id
|
||||
if (isCurrent) hasCurrentInMatches = true
|
||||
|
||||
matches.push({
|
||||
tmdbId: similarEntry.id,
|
||||
title,
|
||||
localId: matched.id,
|
||||
coverPath: matched.cover_path || null,
|
||||
rootId: matched.root_id || null,
|
||||
year: matched.year ? String(matched.year) : matched.info?.release_date?.slice(0, 4) || null,
|
||||
localId: libraryMovie.id,
|
||||
coverPath: libraryMovie.cover_path || null,
|
||||
rootId: libraryMovie.root_id || 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 {
|
||||
// Keep multi-word keywords together while visually narrowing internal spacing.
|
||||
return keyword.trim().replace(/\s+/g, "\u202F")
|
||||
@@ -788,14 +823,19 @@ const versionActionMenu = ref<{
|
||||
})
|
||||
|
||||
function closeVersionActionMenu() {
|
||||
const wasVisible = versionActionMenu.value.visible
|
||||
versionActionMenu.value.visible = false
|
||||
versionActionMenu.value.filePath = null
|
||||
versionActionMenu.value.rootName = null
|
||||
versionActionMenu.value.rootId = null
|
||||
if (wasVisible) {
|
||||
setModalOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -808,6 +848,7 @@ function handlePlayVersion(filePath: string | null) {
|
||||
function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModalOpen(true)
|
||||
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
|
||||
versionActionMenu.value = {
|
||||
visible: true,
|
||||
@@ -893,20 +934,61 @@ function handleSelectMovie(movieId: string) {
|
||||
emit("selectMovie", movieId)
|
||||
}
|
||||
|
||||
function handleSelectCollectionMovie(movieId: string, isCurrent: boolean) {
|
||||
if (isCurrent) return
|
||||
handleSelectMovie(movieId)
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
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(() => {
|
||||
document.addEventListener("keydown", handleMovieMenuKeydown, true)
|
||||
window.addEventListener("resize", handleResize)
|
||||
window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true })
|
||||
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleMovieMenuKeydown, true)
|
||||
window.removeEventListener("resize", handleResize)
|
||||
window.removeEventListener("mousemove", handleHoverAudioMouseMove)
|
||||
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
|
||||
clearHoverAudioIdleTimer()
|
||||
disposeOutOfBoundsHandler?.()
|
||||
disposeOutOfBoundsHandler = null
|
||||
@@ -933,6 +1015,9 @@ onUnmounted(() => {
|
||||
|
||||
.similar-movies-section {
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
left: calc(-50vw + 50%);
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.similar-movies-title {
|
||||
@@ -943,7 +1028,12 @@ onUnmounted(() => {
|
||||
|
||||
.similar-movies-grid {
|
||||
--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;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
@@ -963,33 +1053,52 @@ onUnmounted(() => {
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
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 {
|
||||
width: 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 {
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1110,6 +1219,7 @@ onUnmounted(() => {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0.4rem black;
|
||||
}
|
||||
|
||||
.synopsis-poster {
|
||||
@@ -1453,6 +1563,13 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
||||
margin-left: 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 */
|
||||
@@ -1473,20 +1590,14 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.showreel-images::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.showreel-image {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<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">
|
||||
{{ resolvedPath }}
|
||||
</div>
|
||||
@@ -47,6 +54,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
play: []
|
||||
openFolder: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
@@ -73,6 +81,44 @@ const menuStyle = computed(() => ({
|
||||
|
||||
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() {
|
||||
const menu = menuRef.value
|
||||
if (!menu) return
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
class="version-row"
|
||||
:class="{
|
||||
'version-best': best,
|
||||
'version-selectable': isSelectable,
|
||||
'version-selectable': isSelectable && !inertCard,
|
||||
'version-disabled': isDisabled,
|
||||
'version-menu': variant === 'menu',
|
||||
'version-with-actions': showActions,
|
||||
'version-inert': inertCard,
|
||||
}"
|
||||
tabindex="0"
|
||||
:tabindex="inertCard ? undefined : 0"
|
||||
:title="resolvedTitle"
|
||||
v-bind="$attrs"
|
||||
@click="handleActivate"
|
||||
@@ -57,6 +58,12 @@
|
||||
:alt="streamingServiceLogo.alt"
|
||||
:title="streamingServiceLogo.alt"
|
||||
/>
|
||||
<img
|
||||
v-if="showHdr10PlusLogo"
|
||||
class="version-hdr10plus-logo"
|
||||
:src="hdr10plusLogoUrl"
|
||||
alt="HDR10+"
|
||||
/>
|
||||
<DolbyBadges
|
||||
class="version-dolby"
|
||||
:has-dolby-vision="hasDolbyVision"
|
||||
@@ -70,14 +77,16 @@
|
||||
tabindex="0"
|
||||
@click.stop="emit('play')"
|
||||
:disabled="!torrent.playable_file"
|
||||
:title="playLabel"
|
||||
>
|
||||
▶ {{ playLabel }}
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
class="ctx-btn ctx-btn-folder"
|
||||
tabindex="0"
|
||||
@click.stop="emit('openFolder')"
|
||||
:disabled="!torrent.playable_file"
|
||||
title="Open Folder"
|
||||
>
|
||||
📁
|
||||
</button>
|
||||
@@ -100,6 +109,7 @@ import hboMaxLogoUrl from "../assets/service-hbo-max.webp"
|
||||
import huluLogoUrl from "../assets/service-hulu.webp"
|
||||
import disneyLogoUrl from "../assets/service-disney.svg"
|
||||
import itunesLogoUrl from "../assets/service-itunes.png"
|
||||
import hdr10plusLogoUrl from "../assets/hdr10plus-logo.png"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
@@ -116,6 +126,9 @@ const props = withDefaults(
|
||||
playLabel?: string
|
||||
title?: string
|
||||
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,
|
||||
@@ -126,6 +139,7 @@ const props = withDefaults(
|
||||
playLabel: "Play",
|
||||
title: undefined,
|
||||
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(() => {
|
||||
if (props.selectable !== undefined) return props.selectable
|
||||
return Boolean(props.torrent.playable_file)
|
||||
@@ -335,7 +361,7 @@ const resolvedTitle = computed(() => {
|
||||
})
|
||||
|
||||
function handleActivate(event: MouseEvent | KeyboardEvent) {
|
||||
if (!isSelectable.value || isDisabled.value) return
|
||||
if (props.inertCard || !isSelectable.value || isDisabled.value) return
|
||||
emit("activate", event)
|
||||
}
|
||||
</script>
|
||||
@@ -390,6 +416,15 @@ html.mouse-active .version-row.version-best:hover {
|
||||
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 {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.75;
|
||||
@@ -500,6 +535,15 @@ html.mouse-active .version-row.version-best:hover {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.version-hdr10plus-logo {
|
||||
align-self: stretch;
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.v-badge.res {
|
||||
background: #111111;
|
||||
color: #f8fafc;
|
||||
@@ -579,31 +623,32 @@ html.mouse-active .version-row.version-best:hover {
|
||||
}
|
||||
|
||||
.ctx-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 8px;
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
html.mouse-active .ctx-btn:hover:not(:disabled),
|
||||
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
|
||||
.ctx-btn:focus-visible:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ctx-btn:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ctx-btn-folder {
|
||||
width: 34px;
|
||||
width: auto;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -167,51 +167,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Context menu -->
|
||||
<!-- Episode release menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="context-menu-backdrop"
|
||||
@click="closeContextMenu"
|
||||
@contextmenu.prevent="closeContextMenu"
|
||||
v-if="episodeReleaseMenu.visible"
|
||||
class="episode-release-menu-backdrop"
|
||||
@click="closeEpisodeReleaseMenu"
|
||||
@contextmenu.prevent="closeEpisodeReleaseMenu"
|
||||
></div>
|
||||
<div
|
||||
v-if="contextMenu.visible && contextMenu.episode"
|
||||
class="context-menu"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
>
|
||||
<div class="context-menu-header">
|
||||
{{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }}
|
||||
</div>
|
||||
<div v-if="Object.values(contextMenu.episode.files || {}).length > 0">
|
||||
<ReleaseVersionCard
|
||||
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)"
|
||||
<EpisodeReleaseMenu
|
||||
:visible="episodeReleaseMenu.visible"
|
||||
:x="episodeReleaseMenu.x"
|
||||
:y="episodeReleaseMenu.y"
|
||||
:episode-name="episodeReleaseMenu.episode?.name || `Episode ${episodeReleaseMenu.episode?.episode_number}`"
|
||||
:releases="episodeReleaseMenuReleases"
|
||||
:has-resume-position="props.hasResumePosition"
|
||||
@play="handlePlayVersion"
|
||||
@open-folder="handleOpenFolderFromMenu"
|
||||
@close="closeEpisodeReleaseMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
@@ -219,11 +192,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
|
||||
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
|
||||
import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
|
||||
import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
|
||||
import { sortTorrentsByPreference } from "../composables/useSettings"
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -486,8 +458,8 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Context menu state
|
||||
const contextMenu = ref<{
|
||||
// Episode release menu state (single-layer menu for all releases)
|
||||
const episodeReleaseMenu = ref<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
@@ -499,24 +471,13 @@ const contextMenu = ref<{
|
||||
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 episodeReleaseMenuReleases = computed(() => {
|
||||
if (!episodeReleaseMenu.value.episode) return []
|
||||
return sortTorrentsByPreference(Object.values(episodeReleaseMenu.value.episode.files || {}))
|
||||
})
|
||||
|
||||
function normalizeMatchText(value: string | null | undefined): string {
|
||||
return (value || "")
|
||||
.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) {
|
||||
event.preventDefault()
|
||||
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) {
|
||||
closeVersionActionMenu()
|
||||
contextMenu.value = {
|
||||
setModalOpen(true)
|
||||
episodeReleaseMenu.value = {
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
episode,
|
||||
}
|
||||
// Add Escape key listener (capturing phase to intercept before other handlers)
|
||||
// Add Escape key listener as safety net (capture phase)
|
||||
nextTick(() => {
|
||||
document.addEventListener("keydown", handleContextMenuKeydown, true)
|
||||
// Focus first selectable version card.
|
||||
const firstCard = document.querySelector(
|
||||
".context-menu .version-row.version-selectable",
|
||||
) as HTMLElement
|
||||
if (firstCard) {
|
||||
firstCard.focus()
|
||||
}
|
||||
document.addEventListener("keydown", handleEpisodeMenuEscape, true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -616,92 +570,36 @@ function openEpisodeReleaseMenuFromElement(episode: Episode, element: HTMLElemen
|
||||
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)
|
||||
function handleContextMenuKeydown(event: KeyboardEvent) {
|
||||
if (!contextMenu.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
|
||||
}
|
||||
|
||||
// Capture-phase Escape handler as safety net for episode release menu
|
||||
function handleEpisodeMenuEscape(event: KeyboardEvent) {
|
||||
if (!episodeReleaseMenu.value.visible) return
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (versionActionMenu.value.visible) {
|
||||
closeVersionActionMenu()
|
||||
return
|
||||
}
|
||||
closeContextMenu()
|
||||
closeEpisodeReleaseMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function getPopupFocusableElements(): HTMLElement[] {
|
||||
const releaseItems = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(".context-menu .version-row.version-selectable"),
|
||||
)
|
||||
const actionItems = versionActionMenu.value.visible
|
||||
? Array.from(
|
||||
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)
|
||||
// Close episode release menu
|
||||
function closeEpisodeReleaseMenu() {
|
||||
episodeReleaseMenu.value.visible = false
|
||||
episodeReleaseMenu.value.episode = null
|
||||
document.removeEventListener("keydown", handleEpisodeMenuEscape, true)
|
||||
setModalOpen(false)
|
||||
nextTick(() => {
|
||||
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) {
|
||||
const actionEvent = event as CustomEvent<{ action?: string }>
|
||||
if (actionEvent.detail?.action !== "menu") return
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active || !active.classList.contains("episode-tile")) return
|
||||
if (!active) return
|
||||
|
||||
// Episode tile on series page
|
||||
if (active.classList.contains("episode-tile")) {
|
||||
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
|
||||
@@ -712,6 +610,8 @@ function handleGamepadAction(event: Event) {
|
||||
|
||||
actionEvent.preventDefault()
|
||||
openEpisodeReleaseMenuFromElement(episode, active)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Play specific version
|
||||
@@ -719,61 +619,18 @@ function handlePlayVersion(filePath: string | null) {
|
||||
if (filePath) {
|
||||
emit("play", filePath)
|
||||
}
|
||||
closeVersionActionMenu()
|
||||
closeContextMenu()
|
||||
closeEpisodeReleaseMenu()
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? "Continue" : "Play"
|
||||
}
|
||||
|
||||
// Open folder for a version
|
||||
function handleOpenFolder(folderPath: string, rootId?: string | null) {
|
||||
if (!folderPath) return
|
||||
emit("openFolder", folderPath, rootId)
|
||||
closeVersionActionMenu()
|
||||
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()
|
||||
})
|
||||
// Open folder for a version from the episode release menu
|
||||
function handleOpenFolderFromMenu(filePath: string) {
|
||||
if (!filePath) return
|
||||
const torrent = episodeReleaseMenu.value.episode?.files
|
||||
? Object.values(episodeReleaseMenu.value.episode.files).find((t) => t.playable_file === filePath)
|
||||
: undefined
|
||||
const rootId = torrent?.root_id ?? props.series.root_id ?? null
|
||||
emit("openFolder", filePath, rootId)
|
||||
closeEpisodeReleaseMenu()
|
||||
}
|
||||
|
||||
// Video refs for hover effects
|
||||
@@ -1712,51 +1569,10 @@ html.mouse-active .episode-tile:hover .tile-play {
|
||||
}
|
||||
}
|
||||
|
||||
/* Context menu styles */
|
||||
.context-menu-backdrop {
|
||||
/* Episode release menu backdrop */
|
||||
.episode-release-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
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>
|
||||
|
||||
@@ -28,6 +28,8 @@ const activeNavigationScope = ref<string | null>(null)
|
||||
const desiredCol = ref<number | null>(null)
|
||||
// Track if global handlers are installed
|
||||
let handlersInstalled = false
|
||||
// Track open modal count — when > 0, global keyboard navigation is suspended
|
||||
let modalOpenCount = 0
|
||||
|
||||
// Data attribute names
|
||||
const FOCUSABLE_ATTR = "data-nav-focusable"
|
||||
@@ -54,6 +56,7 @@ let syncedRowsCurrentOffset = 0
|
||||
let syncedRowsTargetOffset = 0
|
||||
let lastSyncedAnchorCol: 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
|
||||
// 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
|
||||
// every time the view is entered.
|
||||
function initCurrentOffsetFromDOM(group: string) {
|
||||
if (activeSyncedScrollGroup !== group) {
|
||||
stopSyncedRowAnimation()
|
||||
activeSyncedScrollGroup = group
|
||||
syncedRowsCurrentOffset = 0
|
||||
syncedRowsTargetOffset = 0
|
||||
}
|
||||
|
||||
if (syncedRowsCurrentOffset !== 0) return
|
||||
const rows = getSyncRowsByGroup(group)
|
||||
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))
|
||||
}
|
||||
|
||||
function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) {
|
||||
function applySyncedRowScroll(
|
||||
offset: number,
|
||||
rows: HTMLElement[] = getSyncRowsByGroup(activeSyncedScrollGroup),
|
||||
) {
|
||||
for (const row of rows) {
|
||||
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`
|
||||
for (const row of rows) {
|
||||
row.style.setProperty(SYNC_SCROLL_TAIL_VAR, value)
|
||||
@@ -224,7 +240,7 @@ function stopSyncedRowAnimation() {
|
||||
}
|
||||
|
||||
function animateSyncedRows(now: number) {
|
||||
const rows = getSyncedRows()
|
||||
const rows = getSyncRowsByGroup(activeSyncedScrollGroup)
|
||||
if (rows.length === 0) {
|
||||
stopSyncedRowAnimation()
|
||||
return
|
||||
@@ -383,7 +399,7 @@ function handleSyncedRowResize() {
|
||||
resetSyncedRows(true)
|
||||
return
|
||||
}
|
||||
updateSyncedRowTarget(lastSyncedAnchorCol)
|
||||
updateSyncedRowTarget(lastSyncedAnchorCol, activeRow || null)
|
||||
}
|
||||
|
||||
function ensureElementVisibleVertically(element: HTMLElement) {
|
||||
@@ -746,6 +762,8 @@ function shouldAllowNavigationFromInput(target: HTMLElement, direction: string):
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (modalOpenCount > 0) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
const direction = {
|
||||
@@ -795,6 +813,8 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function handleEnterKey(event: KeyboardEvent) {
|
||||
if (modalOpenCount > 0) return
|
||||
|
||||
if (event.key !== "Enter") return
|
||||
if (event.defaultPrevented) 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() {
|
||||
if (handlersInstalled) return
|
||||
handlersInstalled = true
|
||||
|
||||
@@ -13,31 +13,24 @@ import type {
|
||||
MediaIndex,
|
||||
TaskInfo,
|
||||
WsMessage,
|
||||
WsRootStatus,
|
||||
} from "../types"
|
||||
|
||||
interface RootState {
|
||||
rootId: string
|
||||
ws: WebSocket | null
|
||||
movieMap: Map<string, MovieUi>
|
||||
seriesMap: Map<string, SeriesUi>
|
||||
peopleMap: Map<number, Person>
|
||||
connected: boolean
|
||||
initialized: boolean
|
||||
pendingMessages: WsMessage[]
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
export interface RootStatusEntry extends WsRootStatus {}
|
||||
|
||||
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.
|
||||
*
|
||||
* 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() {
|
||||
type RootTaskInfo = TaskInfo & { root_id: string }
|
||||
@@ -47,8 +40,11 @@ export function useMediaWebSocket() {
|
||||
const error = shallowRef<string | null>(null)
|
||||
const connected = shallowRef(false)
|
||||
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
|
||||
|
||||
// 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 {
|
||||
if (!Array.isArray(member)) return null
|
||||
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,
|
||||
people: Map<number, Person>,
|
||||
): T | null {
|
||||
@@ -235,10 +221,6 @@ export function useMediaWebSocket() {
|
||||
.filter((member) => member.name.length > 0)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -387,10 +369,41 @@ export function useMediaWebSocket() {
|
||||
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 {
|
||||
const movies: MovieUi[] = []
|
||||
const series: SeriesUi[] = []
|
||||
for (const state of roots.value.values()) {
|
||||
for (const state of rootStates.value.values()) {
|
||||
movies.push(...state.movieMap.values())
|
||||
series.push(...state.seriesMap.values())
|
||||
}
|
||||
@@ -406,35 +419,28 @@ export function useMediaWebSocket() {
|
||||
|
||||
function updateMergedState() {
|
||||
mediaIndex.value = buildIndex()
|
||||
// Consider a root "connected" only after init is received.
|
||||
|
||||
let anyInitialized = false
|
||||
for (const state of roots.value.values()) {
|
||||
if (state.connected && state.initialized) {
|
||||
for (const state of rootStates.value.values()) {
|
||||
if (state.initialized) {
|
||||
anyInitialized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (anyInitialized) {
|
||||
|
||||
if (anyInitialized || roots.value.size === 0) {
|
||||
loading.value = false
|
||||
error.value = null
|
||||
}
|
||||
connected.value = anyInitialized
|
||||
|
||||
connected.value = wsRef.value?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
function processJson(state: RootState, text: string) {
|
||||
const msg = JSON.parse(text) as WsMessage
|
||||
function applyRootInit(rootId: string, rootData: { movies: Record<string, Movie>; series: Record<string, Series>; people?: Record<string, unknown> }) {
|
||||
const state = ensureRootState(rootId)
|
||||
|
||||
// Prevent out-of-order corruption: buffer delta messages until we receive
|
||||
// the initial full-state payload.
|
||||
if (msg.type !== "init" && !state.initialized) {
|
||||
state.pendingMessages.push(msg)
|
||||
return
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "init": {
|
||||
state.peopleMap.clear()
|
||||
for (const [id, person] of Object.entries(msg.data.people || {})) {
|
||||
for (const [id, person] of Object.entries(rootData.people || {})) {
|
||||
const parsed = Number(id)
|
||||
const normalized = normalizePerson(person)
|
||||
if (Number.isFinite(parsed)) {
|
||||
@@ -444,30 +450,53 @@ export function useMediaWebSocket() {
|
||||
|
||||
state.movieMap.clear()
|
||||
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, m] of Object.entries(rootData.movies || {})) {
|
||||
state.movieMap.set(id, withMovieIdentity(id, m, rootId, state.peopleMap))
|
||||
}
|
||||
for (const [id, s] of Object.entries(msg.data.series || {})) {
|
||||
state.seriesMap.set(id, withSeriesIdentity(id, s, state.rootId, state.peopleMap))
|
||||
for (const [id, s] of Object.entries(rootData.series || {})) {
|
||||
state.seriesMap.set(id, withSeriesIdentity(id, s, rootId, state.peopleMap))
|
||||
}
|
||||
|
||||
state.initialized = true
|
||||
|
||||
// Replay any deltas that arrived before init completed.
|
||||
if (state.pendingMessages.length > 0) {
|
||||
const queued = state.pendingMessages
|
||||
state.pendingMessages = []
|
||||
for (const queuedMsg of queued) {
|
||||
processJson(state, JSON.stringify(queuedMsg))
|
||||
processMessage(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": {
|
||||
const state = ensureRootState(msg.root_id)
|
||||
if (!state.initialized) {
|
||||
state.pendingMessages.push(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.people) {
|
||||
for (const [id, person] of Object.entries(msg.people)) {
|
||||
const parsed = Number(id)
|
||||
@@ -481,160 +510,109 @@ export function useMediaWebSocket() {
|
||||
if (msg.kind === "movie") {
|
||||
state.movieMap.set(
|
||||
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 {
|
||||
state.seriesMap.set(
|
||||
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()
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
const state = ensureRootState(msg.root_id)
|
||||
if (!state.initialized) {
|
||||
state.pendingMessages.push(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.kind === "movie") {
|
||||
state.movieMap.delete(msg.id)
|
||||
} else {
|
||||
state.seriesMap.delete(msg.id)
|
||||
}
|
||||
updateMergedState()
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
case "task": {
|
||||
const info = msg.data
|
||||
const taskKey = `${state.rootId}:${info.id}`
|
||||
tasks.value.set(taskKey, { ...info, root_id: state.rootId })
|
||||
const taskKey = `${msg.root_id}:${info.id}`
|
||||
tasks.value.set(taskKey, { ...info, root_id: msg.root_id })
|
||||
tasks.value = new Map(tasks.value)
|
||||
if (info.status === "completed" || info.status === "cancelled" || info.status === "error") {
|
||||
completedTaskIds.add(taskKey)
|
||||
startTaskSweep()
|
||||
}
|
||||
break
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(state: RootState, event: MessageEvent) {
|
||||
function handleRawMessage(event: MessageEvent) {
|
||||
const processText = (text: string) => {
|
||||
try {
|
||||
let text: string
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.text().then((t) => processJson(state, t))
|
||||
return
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
text = new TextDecoder().decode(event.data)
|
||||
} else {
|
||||
text = event.data as string
|
||||
}
|
||||
processJson(state, text)
|
||||
processMessage(JSON.parse(text) as WsMessage)
|
||||
} catch (e) {
|
||||
console.error(`[WS ${state.rootId}] Failed to handle message:`, e)
|
||||
console.error("[WS] Failed to handle message:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function connectRoot(rootId: string) {
|
||||
if (disposed) return
|
||||
const existing = roots.value.get(rootId)
|
||||
if (existing?.ws) {
|
||||
// Already connecting or connected
|
||||
if (event.data instanceof Blob) {
|
||||
void event.data.text().then(processText)
|
||||
return
|
||||
}
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const url = `${proto}//${location.host}/api/ws/${encodeURIComponent(rootId)}`
|
||||
|
||||
const state: RootState = {
|
||||
rootId,
|
||||
ws: null,
|
||||
movieMap: new Map(),
|
||||
seriesMap: new Map(),
|
||||
peopleMap: new Map(),
|
||||
connected: false,
|
||||
initialized: false,
|
||||
pendingMessages: [],
|
||||
reconnectTimer: null,
|
||||
}
|
||||
roots.value.set(rootId, state)
|
||||
|
||||
function doConnect() {
|
||||
if (disposed) return
|
||||
console.log(`[WS ${rootId}] Connecting to ${url}...`)
|
||||
const ws = new WebSocket(url)
|
||||
state.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
state.connected = true
|
||||
state.initialized = false
|
||||
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"
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
processText(new TextDecoder().decode(event.data))
|
||||
return
|
||||
}
|
||||
processText(event.data as string)
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (disposed) return
|
||||
if (state.reconnectTimer) clearTimeout(state.reconnectTimer)
|
||||
state.reconnectTimer = setTimeout(() => {
|
||||
console.log(`[WS ${rootId}] Reconnecting...`)
|
||||
doConnect()
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, 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[]) {
|
||||
function connect() {
|
||||
if (disposed) return
|
||||
const desired = new Set(rootIds)
|
||||
const current = new Set(roots.value.keys())
|
||||
if (wsRef.value && wsRef.value.readyState <= WebSocket.OPEN) return
|
||||
|
||||
// Add new roots
|
||||
for (const rid of desired) {
|
||||
if (!current.has(rid)) {
|
||||
connectRoot(rid)
|
||||
}
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const url = `${proto}//${location.host}/api/ws`
|
||||
|
||||
console.log(`[WS] Connecting to ${url}...`)
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.value = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
error.value = null
|
||||
console.log("[WS] Connected")
|
||||
}
|
||||
|
||||
// Remove old roots
|
||||
for (const rid of current) {
|
||||
if (!desired.has(rid)) {
|
||||
disconnectRoot(rid)
|
||||
ws.onmessage = (ev) => handleRawMessage(ev)
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
if (wsRef.value === ws) {
|
||||
wsRef.value = null
|
||||
}
|
||||
connected.value = false
|
||||
console.log(`[WS] Closed (code=${ev.code})`)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = (ev) => {
|
||||
console.error("[WS] Error:", ev)
|
||||
if (!mediaIndex.value) {
|
||||
error.value = "WebSocket connection failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,18 +620,24 @@ export function useMediaWebSocket() {
|
||||
function disconnect() {
|
||||
disposed = true
|
||||
stopTaskSweep()
|
||||
for (const state of roots.value.values()) {
|
||||
if (state.reconnectTimer) {
|
||||
clearTimeout(state.reconnectTimer)
|
||||
}
|
||||
if (state.ws) {
|
||||
state.ws.onclose = null
|
||||
state.ws.close()
|
||||
}
|
||||
}
|
||||
roots.value.clear()
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
if (wsRef.value) {
|
||||
wsRef.value.onclose = null
|
||||
wsRef.value.close()
|
||||
wsRef.value = null
|
||||
}
|
||||
|
||||
connected.value = false
|
||||
roots.value.clear()
|
||||
rootStates.value.clear()
|
||||
}
|
||||
|
||||
connect()
|
||||
onUnmounted(disconnect)
|
||||
|
||||
return {
|
||||
@@ -662,7 +646,7 @@ export function useMediaWebSocket() {
|
||||
error: readonly(error),
|
||||
connected: readonly(connected),
|
||||
tasks: readonly(tasks),
|
||||
setActiveRoots,
|
||||
roots: readonly(roots),
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ async function performSearch(
|
||||
movie.info?.keywords?.join(" "),
|
||||
movie.info?.overview,
|
||||
movie.info?.tagline,
|
||||
movie.info?.similar?.map((s) => s.title).join(" "),
|
||||
movie.info?.collection,
|
||||
),
|
||||
getMoviePathScore(movie, query),
|
||||
)
|
||||
@@ -719,7 +719,6 @@ async function performSearch(
|
||||
seriesItem.info?.keywords?.join(" "),
|
||||
seriesItem.info?.overview,
|
||||
seriesItem.info?.tagline,
|
||||
seriesItem.info?.similar?.map((s) => s.title).join(" "),
|
||||
seriesItem.info?.networks?.join(" "),
|
||||
),
|
||||
getSeriesPathScore(seriesItem, query),
|
||||
|
||||
@@ -61,6 +61,17 @@ html:not(.pointer-visible) * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hidden {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scrollbar-hidden::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -525,6 +536,14 @@ html.mouse-active .media-card:hover .media-card-info {
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-overlay::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
|
||||
+32
-11
@@ -19,15 +19,11 @@ export interface Person {
|
||||
gender?: CastGender | null
|
||||
}
|
||||
|
||||
export interface SimilarMedia {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
tmdb_id: number
|
||||
title: string | null
|
||||
original_title: string | null
|
||||
original_language: string | null
|
||||
alternative_titles: string[] | null
|
||||
rating: number | null
|
||||
vote_count: number | null
|
||||
@@ -35,9 +31,9 @@ export interface Info {
|
||||
genres: string[] | null
|
||||
release_date: string | null
|
||||
runtime: number | null
|
||||
collection: string | null
|
||||
status: string | null
|
||||
tagline: string | null
|
||||
similar: SimilarMedia[] | null
|
||||
keywords: string[] | null
|
||||
cast: CastMember[] | null
|
||||
director: string | null
|
||||
@@ -193,17 +189,35 @@ export interface TaskInfo {
|
||||
}
|
||||
|
||||
// WebSocket message types (matching server msgspec tagged structs)
|
||||
export interface WsInitMessage {
|
||||
type: "init"
|
||||
data: {
|
||||
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 {
|
||||
type: "init"
|
||||
roots: Record<string, WsRootInitData>
|
||||
}
|
||||
|
||||
export interface WsUpsertMessage {
|
||||
type: "upsert"
|
||||
root_id: string
|
||||
kind: "movie" | "series"
|
||||
id: string
|
||||
item: Movie | Series
|
||||
@@ -212,13 +226,20 @@ export interface WsUpsertMessage {
|
||||
|
||||
export interface WsRemoveMessage {
|
||||
type: "remove"
|
||||
root_id: string
|
||||
kind: "movie" | "series"
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WsTaskMessage {
|
||||
type: "task"
|
||||
root_id: string
|
||||
data: TaskInfo
|
||||
}
|
||||
|
||||
export type WsMessage = WsInitMessage | WsUpsertMessage | WsRemoveMessage | WsTaskMessage
|
||||
export type WsMessage =
|
||||
| WsRootsMessage
|
||||
| WsInitMessage
|
||||
| WsUpsertMessage
|
||||
| WsRemoveMessage
|
||||
| WsTaskMessage
|
||||
|
||||
@@ -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)
|
||||
@@ -33,8 +33,8 @@ Examples:
|
||||
|
||||
Exclude paths by creating .mediahive/scanignore (gitignore syntax).
|
||||
|
||||
The server exposes per-root endpoints:
|
||||
WS /api/ws/{root_id} Live index updates & task progress
|
||||
The server exposes a unified endpoint:
|
||||
WS /api/ws Live index updates, task progress, and root status changes
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
|
||||
@@ -432,6 +432,8 @@ _dovi_profile_re = re.compile(
|
||||
)
|
||||
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
|
||||
_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:
|
||||
@@ -492,6 +494,35 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
||||
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)
|
||||
if dovi_match:
|
||||
info.dovi_profile = int(dovi_match.group(1))
|
||||
|
||||
@@ -18,7 +18,6 @@ from mediahive.models.tmdb import (
|
||||
Info,
|
||||
Person,
|
||||
SeasonInfo,
|
||||
SimilarMedia,
|
||||
)
|
||||
|
||||
# TMDb API configuration
|
||||
@@ -148,19 +147,19 @@ async def tmdb_api_request(
|
||||
|
||||
|
||||
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
|
||||
return await tmdb_api_request(
|
||||
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:
|
||||
"""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
|
||||
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]
|
||||
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)
|
||||
if not details:
|
||||
# Fall back to basic info from search
|
||||
@@ -394,6 +393,7 @@ async def fetch_movie_info(
|
||||
tmdb_id=movie_id,
|
||||
title=result.get("title"),
|
||||
original_title=result.get("original_title"),
|
||||
original_language=result.get("original_language"),
|
||||
rating=result.get("vote_average"),
|
||||
vote_count=result.get("vote_count"),
|
||||
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"]
|
||||
director = directors[0] if directors else None
|
||||
|
||||
# Extract similar movies (limit to 10)
|
||||
similar_data = details.get("similar", {}).get("results", [])[:10]
|
||||
similar = [SimilarMedia(id=s["id"], title=s["title"]) for s in similar_data]
|
||||
collection_data = details.get("belongs_to_collection")
|
||||
collection = None
|
||||
if isinstance(collection_data, dict):
|
||||
collection_name = collection_data.get("name")
|
||||
if isinstance(collection_name, str):
|
||||
collection = collection_name or None
|
||||
|
||||
return (
|
||||
Info(
|
||||
tmdb_id=movie_id,
|
||||
title=details.get("title"),
|
||||
original_title=details.get("original_title"),
|
||||
original_language=details.get("original_language"),
|
||||
alternative_titles=alternative_titles,
|
||||
rating=details.get("vote_average"),
|
||||
vote_count=details.get("vote_count"),
|
||||
@@ -466,9 +470,9 @@ async def fetch_movie_info(
|
||||
genres=genres or None,
|
||||
release_date=details.get("release_date"),
|
||||
runtime=details.get("runtime"),
|
||||
collection=collection,
|
||||
status=details.get("status"),
|
||||
tagline=details.get("tagline"),
|
||||
similar=similar or None,
|
||||
keywords=keywords or None,
|
||||
cast=cast or None,
|
||||
director=director,
|
||||
@@ -513,7 +517,7 @@ async def fetch_series_info(
|
||||
result = data["results"][0]
|
||||
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)
|
||||
if not details:
|
||||
# Fall back to basic info from search
|
||||
@@ -522,6 +526,7 @@ async def fetch_series_info(
|
||||
tmdb_id=series_id,
|
||||
title=result.get("name"),
|
||||
original_title=result.get("original_name"),
|
||||
original_language=result.get("original_language"),
|
||||
rating=result.get("vote_average"),
|
||||
vote_count=result.get("vote_count"),
|
||||
overview=result.get("overview"),
|
||||
@@ -564,10 +569,6 @@ async def fetch_series_info(
|
||||
# Extract 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
|
||||
first_air_date = details.get("first_air_date")
|
||||
|
||||
@@ -576,6 +577,7 @@ async def fetch_series_info(
|
||||
tmdb_id=series_id,
|
||||
title=details.get("name"),
|
||||
original_title=details.get("original_name"),
|
||||
original_language=details.get("original_language"),
|
||||
rating=details.get("vote_average"),
|
||||
vote_count=details.get("vote_count"),
|
||||
overview=details.get("overview"),
|
||||
@@ -583,7 +585,6 @@ async def fetch_series_info(
|
||||
release_date=first_air_date,
|
||||
status=details.get("status"),
|
||||
tagline=details.get("tagline"),
|
||||
similar=similar or None,
|
||||
keywords=keywords or None,
|
||||
cast=cast or None,
|
||||
creators=creators or None,
|
||||
|
||||
+27
-11
@@ -9,6 +9,7 @@ debounced background task.
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -23,10 +24,6 @@ from mediahive.models.data import (
|
||||
TaskInfo,
|
||||
)
|
||||
from mediahive.models.events import Remove, Task, Upsert
|
||||
from mediahive.models.protocol import (
|
||||
WsInit,
|
||||
WsInitData,
|
||||
)
|
||||
from mediahive.models.tmdb import Person
|
||||
|
||||
logger = logging.getLogger("mediahive.index_store")
|
||||
@@ -64,6 +61,8 @@ class IndexStore:
|
||||
|
||||
# Connected WebSocket clients
|
||||
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
|
||||
self._snapshot_dirty = False
|
||||
@@ -388,19 +387,30 @@ class IndexStore:
|
||||
# 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:
|
||||
"""Accept a WS client and send the full index as init."""
|
||||
await ws.accept()
|
||||
self._clients.add(ws)
|
||||
logger.info("WS client connected (%d total)", len(self._clients))
|
||||
# Send full current state
|
||||
msg = WsInit(
|
||||
data=WsInitData(
|
||||
movies=dict(self.movies),
|
||||
series=dict(self.series),
|
||||
people=dict(self.people),
|
||||
)
|
||||
)
|
||||
msg = {
|
||||
"type": "init",
|
||||
"roots": {
|
||||
"": {
|
||||
"movies": dict(self.movies),
|
||||
"series": dict(self.series),
|
||||
"people": dict(self.people),
|
||||
}
|
||||
},
|
||||
}
|
||||
await ws.send_bytes(msgspec.json.encode(msg))
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
@@ -410,6 +420,12 @@ class IndexStore:
|
||||
|
||||
def _broadcast(self, msg: object) -> None:
|
||||
"""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)
|
||||
dead: list[WebSocket] = []
|
||||
for ws in self._clients:
|
||||
|
||||
@@ -8,8 +8,8 @@ from __future__ import annotations
|
||||
import msgspec
|
||||
from fastapi.responses import Response
|
||||
|
||||
from .data import Movie, Series
|
||||
from .events import Remove, ScanEvent, Task, Upsert
|
||||
from .data import Movie, Series, TaskInfo
|
||||
from .events import ScanEvent
|
||||
from .tmdb import Person
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -17,33 +17,78 @@ from .tmdb import Person
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WsInitData(msgspec.Struct):
|
||||
"""Payload of the init message."""
|
||||
class WsRootStatus(msgspec.Struct):
|
||||
"""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]
|
||||
series: dict[str, Series]
|
||||
people: dict[int, Person]
|
||||
|
||||
|
||||
class WsInit(msgspec.Struct, tag="init"):
|
||||
"""Full index sent on WS connect."""
|
||||
class WsRoots(msgspec.Struct, tag="roots"):
|
||||
"""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)
|
||||
WsMessage = WsInit | Upsert | Remove | Task
|
||||
WsMessage = WsRoots | WsInit | WsUpsert | WsRemove | WsTask
|
||||
|
||||
|
||||
# Re-export unified types for backward compatibility
|
||||
__all__ = [
|
||||
"Remove",
|
||||
"ScanEvent",
|
||||
"Task",
|
||||
"Upsert",
|
||||
"WsInit",
|
||||
"WsInitData",
|
||||
"WsMessage",
|
||||
"WsRemove",
|
||||
"WsRootInitData",
|
||||
"WsRootStatus",
|
||||
"WsRoots",
|
||||
"WsTask",
|
||||
"WsUpsert",
|
||||
]
|
||||
|
||||
|
||||
@@ -67,11 +112,19 @@ class OpenFolderRequest(msgspec.Struct):
|
||||
|
||||
|
||||
class RootsRequest(msgspec.Struct):
|
||||
"""PUT /api/roots body."""
|
||||
"""PUT /api/config/roots body."""
|
||||
|
||||
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):
|
||||
"""Single root entry in responses."""
|
||||
|
||||
@@ -80,7 +133,7 @@ class RootEntryResponse(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
|
||||
path: str
|
||||
|
||||
@@ -27,13 +27,6 @@ class Person(msgspec.Struct, array_like=True):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -72,6 +65,7 @@ class Info(msgspec.Struct):
|
||||
tmdb_id: int
|
||||
title: str | None = None
|
||||
original_title: str | None = None
|
||||
original_language: str | None = None
|
||||
alternative_titles: list[str] | None = None
|
||||
rating: float | None = None
|
||||
vote_count: int | None = None
|
||||
@@ -79,9 +73,9 @@ class Info(msgspec.Struct):
|
||||
genres: list[str] | None = None
|
||||
release_date: str | None = None
|
||||
runtime: int | None = None
|
||||
collection: str | None = None
|
||||
status: str | None = None
|
||||
tagline: str | None = None
|
||||
similar: list[SimilarMedia] | None = None
|
||||
keywords: list[str] | None = None
|
||||
cast: list[CastCredit] | None = None
|
||||
director: str | None = None
|
||||
|
||||
@@ -280,7 +280,7 @@ class Supervisor:
|
||||
base_name = _derive_root_name(configured_path)
|
||||
unique_name = base_name
|
||||
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:
|
||||
unique_name = f"{base_name}{suffix}"
|
||||
suffix += 1
|
||||
|
||||
+599
-22
@@ -16,9 +16,14 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
@@ -29,20 +34,37 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
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.hivescan.images import close_image_client
|
||||
from mediahive.hivescan.scanner import RootScanner
|
||||
from mediahive.hivescan.tmdb_client import close_http_client
|
||||
from mediahive.models.events import Remove, Task, Upsert
|
||||
from mediahive.models.protocol import (
|
||||
OpenFolderRequest,
|
||||
PlaybackStateUpdateRequest,
|
||||
PlayMediaRequest,
|
||||
RootsRequest,
|
||||
WsInit,
|
||||
WsRemove,
|
||||
WsRootInitData,
|
||||
WsRoots,
|
||||
WsRootStatus,
|
||||
WsTask,
|
||||
WsUpsert,
|
||||
)
|
||||
from mediahive.players import detect_players, launch_player
|
||||
from mediahive.root_registry import Supervisor
|
||||
|
||||
logger = logging.getLogger("mediahive.server")
|
||||
|
||||
configure_access_logging()
|
||||
|
||||
MPC_BE_DEFAULT_PORT = 13579
|
||||
|
||||
# Suppress console windows when spawning subprocesses on Windows
|
||||
@@ -66,6 +88,268 @@ if sys.platform == "win32":
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -78,6 +362,37 @@ def _get_context(root_id: str):
|
||||
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):
|
||||
"""Load allowed per-root metadata values from .mediahive."""
|
||||
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:
|
||||
if sys.platform == "win32":
|
||||
os.startfile(str(path))
|
||||
@@ -343,7 +705,7 @@ async def _activate_all_roots() -> None:
|
||||
desired.update(cfg.roots)
|
||||
|
||||
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
|
||||
|
||||
# Validate paths in a thread pool (macOS permission-dialog safe)
|
||||
@@ -373,6 +735,8 @@ async def _activate_all_roots() -> None:
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await frontend.load()
|
||||
playback_state_cache.start()
|
||||
event_loop_lag_monitor.start()
|
||||
|
||||
# Defer root activation to a background task so the server starts
|
||||
# immediately and macOS permission dialogs do not block startup.
|
||||
@@ -384,6 +748,9 @@ async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
playback_state_cache.stop()
|
||||
await event_loop_lag_monitor.stop()
|
||||
|
||||
activation_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await activation_task
|
||||
@@ -402,6 +769,9 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
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
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -433,13 +803,7 @@ async def get_config():
|
||||
# --- Root management ---
|
||||
|
||||
|
||||
@app.get("/api/roots")
|
||||
async def get_roots():
|
||||
"""List all active roots with their status."""
|
||||
return {"roots": supervisor.all_statuses()}
|
||||
|
||||
|
||||
@app.put("/api/roots")
|
||||
@app.put("/api/config/roots")
|
||||
async def put_roots(request: Request):
|
||||
"""Atomically replace the full root set."""
|
||||
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}")
|
||||
async def ws_endpoint(ws: WebSocket, root_id: str) -> None:
|
||||
"""Live index updates and task progress for a single root."""
|
||||
ctx = supervisor.get(root_id)
|
||||
if ctx is None:
|
||||
await ws.close(code=1008, reason="Unknown root")
|
||||
@app.websocket("/api/ws")
|
||||
async def ws_endpoint(ws: WebSocket) -> None:
|
||||
"""Live updates stream for all roots and all connected clients."""
|
||||
listeners: dict[str, object] = {}
|
||||
attached_contexts = supervisor.all_contexts()
|
||||
outbound: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
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:
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
ctx.store.disconnect(ws)
|
||||
except WebSocketDisconnect as exc:
|
||||
close_code = exc.code
|
||||
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 ---
|
||||
@@ -487,45 +949,106 @@ async def list_players():
|
||||
|
||||
|
||||
@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."""
|
||||
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)
|
||||
body_t0 = time.perf_counter()
|
||||
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)
|
||||
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}")
|
||||
|
||||
# Resolve player path if a specific detected player was chosen
|
||||
player_path: str | None = None
|
||||
detect_ms = 0.0
|
||||
if req.player_id and req.player_id not in ("default", "custom"):
|
||||
detect_t0 = time.perf_counter()
|
||||
for p in detect_players():
|
||||
if p.id == req.player_id:
|
||||
player_path = p.path
|
||||
break
|
||||
detect_ms = (time.perf_counter() - detect_t0) * 1000.0
|
||||
if not player_path:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Player not found: {req.player_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
launch_t0 = time.perf_counter()
|
||||
launch_player(
|
||||
req.player_id or "default",
|
||||
file_path,
|
||||
player_path=player_path,
|
||||
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"}
|
||||
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to play media: {e}")
|
||||
|
||||
|
||||
@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."""
|
||||
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)
|
||||
body_t0 = time.perf_counter()
|
||||
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)
|
||||
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
|
||||
|
||||
if not target_path.exists():
|
||||
raise HTTPException(
|
||||
@@ -533,6 +1056,7 @@ async def open_folder(root_id: str, request: Request):
|
||||
)
|
||||
|
||||
try:
|
||||
open_t0 = time.perf_counter()
|
||||
if sys.platform == "win32":
|
||||
native_path = str(target_path).replace("/", "\\")
|
||||
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
|
||||
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"}
|
||||
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as 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)}
|
||||
|
||||
|
||||
@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 ---
|
||||
|
||||
|
||||
@@ -620,7 +1197,7 @@ def _serve_file_response(full_path: Path, file_path: str, request: Request):
|
||||
file_stat = full_path.stat()
|
||||
file_size = file_stat.st_size
|
||||
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")
|
||||
if not range_header and _etag_matches_if_none_match(
|
||||
|
||||
+140
-58
@@ -120,59 +120,127 @@ _DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
|
||||
def _default_playback_state() -> dict[str, object]:
|
||||
return {
|
||||
"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:
|
||||
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:
|
||||
return _default_playback_state()
|
||||
return {}
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return _default_playback_state()
|
||||
movies = raw.get("movies") if isinstance(raw, dict) else None
|
||||
if not isinstance(movies, dict):
|
||||
return {}
|
||||
|
||||
current = raw.get("current")
|
||||
resume_positions = raw.get("resume_positions")
|
||||
normalized: dict[str, object] = {
|
||||
"current": current if isinstance(current, dict) else None,
|
||||
"resume_positions": {},
|
||||
}
|
||||
|
||||
if isinstance(resume_positions, dict):
|
||||
cleaned_positions: dict[str, int] = {}
|
||||
for key, value in resume_positions.items():
|
||||
if isinstance(key, str) and isinstance(value, (int, float)):
|
||||
cleaned_positions[key] = max(0, int(value))
|
||||
normalized["resume_positions"] = cleaned_positions
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _save_playback_state(path: Path, state: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
mapping: dict[str, str] = {}
|
||||
for movie_id, movie in movies.items():
|
||||
if not isinstance(movie_id, str) or not isinstance(movie, dict):
|
||||
continue
|
||||
files = movie.get("files")
|
||||
if not isinstance(files, dict):
|
||||
continue
|
||||
for file_key, torrent in files.items():
|
||||
if not isinstance(file_key, str):
|
||||
continue
|
||||
normalized_key = _normalize_media_path(file_key)
|
||||
mapping[normalized_key] = movie_id
|
||||
playable_file = (
|
||||
torrent.get("playable_file") if isinstance(torrent, dict) else None
|
||||
)
|
||||
expanded = _expand_playable_file(
|
||||
file_key, playable_file if isinstance(playable_file, str) else None
|
||||
)
|
||||
mapping[_normalize_media_path(expanded)] = movie_id
|
||||
return mapping
|
||||
|
||||
|
||||
def _media_key_for_filepath(
|
||||
filepath: str, roots: list[Path]
|
||||
) -> tuple[str, Path] | None:
|
||||
"""Resolve a filepath to a (relative_key, matched_root) tuple."""
|
||||
for root in roots:
|
||||
filepath: str, roots: dict[str, Path]
|
||||
) -> tuple[str | None, str, str] | None:
|
||||
"""Resolve a filepath to a (movie_slug, root_id, relative_key) tuple."""
|
||||
for root_id, root in roots.items():
|
||||
try:
|
||||
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:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
return False
|
||||
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(
|
||||
stop_event: threading.Event, roots: list[Path]
|
||||
stop_event: threading.Event, roots: dict[str, Path], backend_url: str
|
||||
) -> threading.Thread:
|
||||
"""Start background XInput polling and send mapped commands to MPC-BE."""
|
||||
get_state = _load_xinput_get_state()
|
||||
@@ -278,18 +346,11 @@ def _start_gamepad_remote(
|
||||
status_updated_at = 0.0
|
||||
status_miss_count = 0
|
||||
|
||||
# Use the first root's playback state path as primary
|
||||
primary_root = roots[0] if roots else Path.cwd()
|
||||
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)
|
||||
playback_state = _default_playback_state()
|
||||
resume_positions = _fetch_resume_positions(backend_url)
|
||||
tracked_media_key: str | None = None
|
||||
tracked_root_id: str | None = None
|
||||
tracked_relative_path = ""
|
||||
tracked_filepath = ""
|
||||
resume_applied_for_key: str | None = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
@@ -302,12 +363,11 @@ def _start_gamepad_remote(
|
||||
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:
|
||||
nonlocal \
|
||||
tracked_media_key, \
|
||||
tracked_root_id, \
|
||||
tracked_relative_path, \
|
||||
tracked_filepath, \
|
||||
last_playback_state_flush_at, \
|
||||
resume_applied_for_key
|
||||
@@ -316,38 +376,56 @@ def _start_gamepad_remote(
|
||||
resume_applied_for_key = None
|
||||
return
|
||||
tracked_media_key = None
|
||||
tracked_root_id = None
|
||||
tracked_relative_path = ""
|
||||
tracked_filepath = ""
|
||||
playback_state["current"] = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
if clear_resume_applied:
|
||||
resume_applied_for_key = None
|
||||
flush_playback_state()
|
||||
|
||||
def finalize_tracked_current() -> None:
|
||||
nonlocal \
|
||||
tracked_media_key, \
|
||||
tracked_root_id, \
|
||||
tracked_relative_path, \
|
||||
tracked_filepath, \
|
||||
resume_applied_for_key, \
|
||||
last_playback_state_flush_at
|
||||
if tracked_media_key is None:
|
||||
if playback_state.get("current") is not None:
|
||||
playback_state["current"] = None
|
||||
flush_playback_state()
|
||||
return
|
||||
|
||||
position_ms = player_position_ms or 0
|
||||
duration_ms = player_duration_ms or 0
|
||||
if _should_clear_resume(position_ms, duration_ms):
|
||||
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:
|
||||
position_seconds = max(0, position_ms // 1000)
|
||||
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_root_id = None
|
||||
tracked_relative_path = ""
|
||||
tracked_filepath = ""
|
||||
playback_state["current"] = None
|
||||
resume_applied_for_key = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
flush_playback_state()
|
||||
|
||||
def persist_tracked_current(now: float, *, force: bool = False) -> None:
|
||||
nonlocal last_playback_state_flush_at
|
||||
@@ -367,7 +445,6 @@ def _start_gamepad_remote(
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
last_playback_state_flush_at = now
|
||||
flush_playback_state()
|
||||
|
||||
def maybe_apply_resume(now: float) -> None:
|
||||
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):
|
||||
resume_positions.pop(tracked_media_key, None)
|
||||
resume_applied_for_key = tracked_media_key
|
||||
flush_playback_state()
|
||||
return
|
||||
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
||||
return
|
||||
@@ -409,6 +485,8 @@ def _start_gamepad_remote(
|
||||
status_updated_at, \
|
||||
status_miss_count, \
|
||||
tracked_media_key, \
|
||||
tracked_root_id, \
|
||||
tracked_relative_path, \
|
||||
tracked_filepath, \
|
||||
resume_applied_for_key
|
||||
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
|
||||
resolved = _media_key_for_filepath(filepath, roots) if filepath 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:
|
||||
finalize_tracked_current()
|
||||
@@ -445,6 +525,8 @@ def _start_gamepad_remote(
|
||||
clear_tracked_current(clear_resume_applied=True)
|
||||
elif tracked_media_key != media_key:
|
||||
tracked_media_key = media_key
|
||||
tracked_root_id = root_id
|
||||
tracked_relative_path = relative_path
|
||||
tracked_filepath = filepath
|
||||
resume_applied_for_key = None
|
||||
|
||||
@@ -872,7 +954,7 @@ def winmain() -> None:
|
||||
def _activate_initial_roots() -> None:
|
||||
body = json.dumps({"roots": initial_roots}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url=f"{backend_url}/api/roots",
|
||||
url=f"{backend_url}/api/config/roots",
|
||||
data=body,
|
||||
method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
@@ -900,7 +982,7 @@ def winmain() -> None:
|
||||
poll_thread: threading.Thread | None = None
|
||||
|
||||
# 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:
|
||||
api._window = window
|
||||
@@ -913,7 +995,7 @@ def winmain() -> None:
|
||||
|
||||
nonlocal poll_thread
|
||||
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(
|
||||
target=_activate_initial_roots,
|
||||
|
||||
@@ -116,3 +116,6 @@ ignore = [
|
||||
# Allow unused local variables in ctypes COM boilerplate
|
||||
"F841",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"mediahive/access_logging.py" = ["BLE001", "G004"]
|
||||
|
||||
Reference in New Issue
Block a user