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