diff --git a/docs/API.md b/docs/API.md index 1809f9e..41b83b8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,29 +1,31 @@ # API MediaHive exposes a small local API used by the desktop app and frontend. +All media paths are scoped to a **root**, identified by a stable `root_id`. ## Endpoints | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/api/health` | Lightweight health check. | -| `GET` | `/api/config` | Returns the currently selected media folder. | -| `POST` | `/api/change-folder` | Persists and switches the active media folder without restarting the app. | -| `GET` | `/api/index` | Returns the current in-memory media index. | -| `GET` | `/api/playback/resume-positions` | Returns saved resume positions by media path. | -| `GET` | `/api/status` | Returns scanner and library status information. | -| `POST` | `/api/scan` | Triggers a new scan if the scanner is active. | -| `POST` | `/api/play` | Opens a media file with the system player. | -| `POST` | `/api/open-folder` | Opens a folder in the system file explorer, or selects a file in its parent folder. | +| `GET` | `/api/config` | Returns the current root configuration. | +| `GET` | `/api/roots` | List all active roots with status. | +| `PUT` | `/api/roots` | Atomically replace the full root set. | +| `GET` | `/api/roots/{root_id}/index` | Returns the media index for one root. | +| `GET` | `/api/roots/{root_id}/status` | Returns scanner and library status for one root. | +| `POST` | `/api/roots/{root_id}/scan` | Triggers a new scan for one root. | +| `POST` | `/api/roots/{root_id}/play` | Opens a media file with the system player. | +| `POST` | `/api/roots/{root_id}/open-folder` | Opens a folder in the system file explorer. | +| `GET` | `/api/roots/{root_id}/playback/resume-positions` | Returns saved resume positions for one root. | | `GET` | `/api/player/status` | Returns whether remote player control is currently available. | | `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. | -| `GET` | `/api/media/{file_path:path}` | Serves files from the active media root. | -| `WS` | `/api/ws` | Streams live index updates and task progress events. | +| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. | +| `WS` | `/api/roots/{root_id}/ws` | Streams live index updates and task progress for one root. | ## Notes -- `POST /api/change-folder` validates the new folder, saves it to config, and switches the in-memory scanner asynchronously. -- `POST /api/play` and `POST /api/open-folder` expect JSON request bodies matching the frontend calls. -- `GET /api/media/{file_path:path}` is constrained to the current media root. +- `PUT /api/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set. +- `POST /api/roots/{root_id}/play` and `POST /api/roots/{root_id}/open-folder` 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/player/status` returns `{ "remote": true|false }`. - `GET /api/mpcbe/status` returns `false` on non-Windows platforms. diff --git a/docs/multi-index-plan.md b/docs/multi-index-plan.md new file mode 100644 index 0000000..a7cc325 --- /dev/null +++ b/docs/multi-index-plan.md @@ -0,0 +1,93 @@ +# 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**: first 12 hex chars of SHA-256 of the *normalized* absolute path. +- **Normalization**: resolve symlinks, lower-case Windows drive letter, strip trailing slashes, forward slashes only (`as_posix()`). +- **Name**: derived from path basename; collisions resolved with `2`, `3`, … suffix. + +### 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 `/.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. Compute `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 + +Every `Movie.id` and `Series.id` is namespaced with its `root_id`: +- Format: `{root_id}:{content_hash}` +- Old snapshots are auto-migrated on load: IDs lacking the prefix get it prepended. + +## API + +| Endpoint | Description | +|----------|-------------| +| `GET /api/roots` | List all roots (name, path, root_id, status) | +| `PUT /api/roots` | Atomically replace full root map `{name: path}` | +| `GET /api/roots/{root_id}/index` | Full index for one root | +| `GET /api/roots/{root_id}/status` | Per-root scanning/loading/error state | +| `POST /api/roots/{root_id}/scan` | Trigger scan for one root | +| `WS /api/roots/{root_id}/ws` | Per-root WebSocket (init/upsert/remove/task) | +| `GET /api/media/{root_id}/{path:path}` | Serve media file scoped to root | +| `POST /api/roots/{root_id}/play` | Play file within root | +| `POST /api/roots/{root_id}/open-folder` | Open folder within root | +| `GET /api/roots/{root_id}/playback/resume-positions` | Per-root resume positions | +| `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`. +- All media URLs are root-qualified (`/api/media/{root_id}/...`). diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 50e989b..d93c64d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -184,7 +184,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { useRouter, useRoute } from 'vue-router'; import type { Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode, TaskInfo } from './types'; -import { playMedia, openFolder, isMpcBeReachable, fetchResumePositions, normalizeMediaPath, getPlayerStatus } from './api'; +import { playMedia, openFolder, isMpcBeReachable, fetchResumePositions, normalizeMediaPath, getPlayerStatus, fetchRoots } from './api'; import { useKeyboardNavigation } from './composables/useKeyboardNavigation'; import { useMediaWebSocket } from './composables/useMediaWebSocket'; import Header from './components/Header.vue'; @@ -226,11 +226,53 @@ function normalizeHistoryPath(value: string): string { } // WebSocket-driven media index -const { mediaIndex, loading, error, connected: wsConnected, tasks } = useMediaWebSocket(); +const { mediaIndex, loading, error, connected: wsConnected, tasks, setActiveRoots } = useMediaWebSocket(); // Active tasks for the debug overlay const activeTasks = computed(() => Array.from(tasks.value.values())); +// Poll for active roots and connect WS to them +const rootStatuses = ref>(new Map()); + +async function refreshRoots() { + try { + const roots = await fetchRoots(); + const newMap = new Map(); + const activeIds: string[] = []; + for (const r of roots) { + newMap.set(r.root_id, { path: r.path, status: r.status }); + if (r.status === 'ready' || r.status === 'scanning' || r.status === 'loading') { + activeIds.push(r.root_id); + } + } + rootStatuses.value = newMap; + setActiveRoots(activeIds); + } catch (e) { + console.error('Failed to fetch roots:', e); + } +} + +let rootsPollTimer: number | null = null; +function startRootsPolling() { + if (rootsPollTimer !== null) return; + void refreshRoots(); + rootsPollTimer = window.setInterval(refreshRoots, 5000); +} +function stopRootsPolling() { + if (rootsPollTimer !== null) { + window.clearInterval(rootsPollTimer); + rootsPollTimer = null; + } +} + +onMounted(() => { + startRootsPolling(); +}); + +onUnmounted(() => { + stopRootsPolling(); +}); + const searchResults = ref([]); const isSearching = ref(false); const mpcBeConnected = ref(false); @@ -609,6 +651,7 @@ function movieToMediaItem(movie: Movie): MediaItem { type: 'movies', resolution: resolution, data: movie, + root_id: movie.root_id, }; } @@ -637,6 +680,7 @@ function seriesToMediaItem(series: Series): MediaItem { showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null, type: 'series', data: series, + root_id: series.root_id, }; } @@ -1430,10 +1474,34 @@ function reloadPage() { window.location.reload(); } +function findRootIdForPath(filePath: string): string | null { + if (!mediaIndex.value) return null; + for (const movie of mediaIndex.value.movies) { + for (const torrent of Object.values(movie.torrents || {})) { + if (torrent.playable_file === filePath) return movie.root_id; + } + } + for (const series of mediaIndex.value.series) { + for (const season of series.seasons || []) { + for (const episode of season.episodes || []) { + for (const torrent of Object.values(episode.torrents || {})) { + if (torrent.playable_file === filePath) return series.root_id; + } + } + } + } + return null; +} + async function handlePlay(filePath: string) { + const rootId = findRootIdForPath(filePath); + if (!rootId) { + console.error('Cannot play: unknown root for path', filePath); + return; + } mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS; try { - await playMedia(filePath); + await playMedia(rootId, filePath); const connected = await tryConnectMpcBe(); if (connected) { mpcBeConnected.value = true; @@ -1445,8 +1513,13 @@ async function handlePlay(filePath: string) { } async function handleOpenFolder(folderPath: string) { + const rootId = findRootIdForPath(folderPath); + if (!rootId) { + console.error('Cannot open folder: unknown root for path', folderPath); + return; + } try { - await openFolder(folderPath); + await openFolder(rootId, folderPath); } catch (e) { console.error('Failed to open folder:', e); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 848d1e6..4d17faf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,9 +1,22 @@ -import type { MediaIndex } from './types'; + export interface PlayerStatus { remote: boolean; } +export interface RootStatus { + root_id: string; + path: string; + status: string; + error: string | null; + movies: number; + series: number; +} + +export interface RootsResponse { + roots: RootStatus[]; +} + export function normalizeMediaPath(input: string): string { return input .replace(/\\/g, '/') @@ -71,12 +84,53 @@ export function getVideoSourceAttributes(path: string | null | undefined): Video } /** - * Load the media index from the server + * Fetch active roots and their statuses */ -export async function loadMediaIndex(): Promise { - const response = await fetch('/api/index'); +export async function fetchRoots(): Promise { + const response = await fetch('/api/roots'); if (!response.ok) { - throw new Error(`Failed to load media index: ${response.statusText}`); + throw new Error(`Failed to load roots: ${response.statusText}`); + } + const data = await response.json(); + return data.roots || []; +} + +/** + * Fetch merged resume positions from all roots. + */ +export async function fetchResumePositions(): Promise> { + try { + const roots = await fetchRoots(); + const merged: Record = {}; + await Promise.all( + roots.map(async (root) => { + const response = await fetch(`/api/roots/${encodeURIComponent(root.root_id)}/playback/resume-positions`); + if (!response.ok) return; + const data = await response.json().catch(() => ({})); + const positions = data?.resume_positions; + if (positions && typeof positions === 'object') { + Object.assign(merged, positions); + } + }) + ); + return merged; + } catch { + return {}; + } +} + +/** + * Replace the full root set atomically + */ +export async function replaceRoots(roots: Record): Promise<{ accepted: RootStatus[]; failed: unknown[] }> { + const response = await fetch('/api/roots', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ roots }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({ detail: response.statusText })); + throw new Error(err.detail || response.statusText); } return response.json(); } @@ -84,10 +138,10 @@ export async function loadMediaIndex(): Promise { /** * Play a media file with the system's default player */ -export async function playMedia(filePath: string): Promise { +export async function playMedia(rootId: string, filePath: string): Promise { const normalizedPath = normalizeMediaPath(filePath); try { - const response = await fetch('/api/play', { + const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/play`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: normalizedPath }), @@ -105,10 +159,10 @@ export async function playMedia(filePath: string): Promise { /** * Open a folder in the system file manager */ -export async function openFolder(folderPath: string): Promise { +export async function openFolder(rootId: string, folderPath: string): Promise { const normalizedPath = normalizeMediaPath(folderPath); try { - const response = await fetch('/api/open-folder', { + const response = await fetch(`/api/roots/${encodeURIComponent(rootId)}/open-folder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_path: normalizedPath }), @@ -148,18 +202,6 @@ export async function isMpcBeReachable(): Promise { } } -export async function fetchResumePositions(): Promise> { - try { - const response = await fetch('/api/playback/resume-positions'); - if (!response.ok) return {}; - const data = await response.json().catch(() => ({})); - const resumePositions = data?.resume_positions; - return resumePositions && typeof resumePositions === 'object' ? resumePositions : {}; - } catch { - return {}; - } -} - /** * Convert a cover path to a displayable URL. * Uses FastAPI server for async file serving. @@ -167,7 +209,7 @@ export async function fetchResumePositions(): Promise> { * The path comes from the server already converted to Windows format (Z:\...) * Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them */ -export function getCoverUrl(coverPath: string | null): string { +export function getCoverUrl(coverPath: string | null, rootId?: string | null): string { if (!coverPath) { return ''; } @@ -177,7 +219,7 @@ export function getCoverUrl(coverPath: string | null): string { } // Convert relative path to URL path for FastAPI server - // .mediahive/covers/Movies/... -> /media/.mediahive/covers/Movies/... + // .mediahive/covers/Movies/... -> /api/media/{root_id}/.mediahive/covers/Movies/... let urlPath = coverPath; // Remove drive letter (Z:) and convert backslashes to forward slashes @@ -194,30 +236,18 @@ export function getCoverUrl(coverPath: string | null): string { // Encode URI components but preserve slashes const encodedPath = urlPath.split('/').map(segment => encodeURIComponent(segment)).join('/'); - return `/api/media${encodedPath}`; + const rid = rootId || 'unknown'; + return `/api/media/${encodeURIComponent(rid)}${encodedPath}`; } /** - * Invoke the native OS folder picker via pywebview, then switch the server's - * media folder in-place and reload the page. Only works inside the packaged - * desktop app. + * Invoke the native OS folder picker via pywebview, then add the selected + * folder to the server's root list. Only works inside the packaged desktop app. */ -export async function pickFolderAndRestart(): Promise { +export async function pickFolderAndAddRoot(): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const api = (window as any).pywebview?.api; - if (!api) return; + if (!api) return null; const folder: string | null = await api.pick_folder(); - if (!folder) return; - const res = await fetch('/api/change-folder', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ folder }), - }); - if (res.ok) { - // Give the server a moment to complete the background folder switch before reloading - setTimeout(() => window.location.reload(), 500); - } else { - const err = await res.json().catch(() => ({ detail: res.statusText })); - alert(`Failed to change folder: ${err.detail || res.statusText}`); - } + return folder; } diff --git a/frontend/src/components/CollageHero.vue b/frontend/src/components/CollageHero.vue index 9234340..40d7f59 100644 --- a/frontend/src/components/CollageHero.vue +++ b/frontend/src/components/CollageHero.vue @@ -472,26 +472,26 @@ function getImageUrl(item: MediaItem): string | undefined { return undefined; } - return getCoverUrl(imagePath); + return getCoverUrl(imagePath, item.root_id); } function getVideoSources(item: MediaItem): Array<{ src: string; type: string; codecs: string }> { if (isVideoPath(item.cover_path)) { - return [{ src: getVideoPreviewUrl(getCoverUrl(item.cover_path)), ...getVideoSourceAttributes(item.cover_path) }]; + return [{ src: getVideoPreviewUrl(getCoverUrl(item.cover_path, item.root_id)), ...getVideoSourceAttributes(item.cover_path) }]; } const showreelSourceSets = item.showreel_source_sets; if (showreelSourceSets && showreelSourceSets.length > 0) { return showreelSourceSets[0] .filter(path => isVideoPath(path)) - .map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path)), ...getVideoSourceAttributes(path) })); + .map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)), ...getVideoSourceAttributes(path) })); } const showreel = item.showreel_images; if (showreel && showreel.length > 0) { return showreel .filter(path => isVideoPath(path)) - .map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path)), ...getVideoSourceAttributes(path) })); + .map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)), ...getVideoSourceAttributes(path) })); } return []; diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 5b91c5a..562e10a 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -62,16 +62,57 @@ Player Open -
+
+ + +
+
+ Media Roots + +
+
+
+
+ {{ root.name }} + {{ root.path }} +
+
+ {{ root.status }} + +
+
+
+
+ +
+
@@ -81,7 +122,14 @@ import { ref, watch, computed, onMounted, onUnmounted } from 'vue'; import { useRouter } from 'vue-router'; import { navAttrs } from '../composables/useKeyboardNavigation'; import logoUrl from '../assets/mediahive.webp'; -import { pickFolderAndRestart } from '../api'; +import { fetchRoots, replaceRoots, pickFolderAndAddRoot } from '../api'; + +interface RootEntry { + root_id: string; + name: string; + path: string; + status: string; +} const props = defineProps<{ currentView: 'movies' | 'series'; @@ -100,18 +148,68 @@ const router = useRouter(); const searchInputRef = ref(null); const localSearch = ref(props.searchQuery); -// True only when running inside the packaged pywebview desktop app. -// pywebview injects window.pywebview asynchronously, so we listen for the -// 'pywebviewready' event rather than checking at component creation time. const isDesktopApp = ref(typeof (window as any).pywebview !== 'undefined'); function _onPywebviewReady() { isDesktopApp.value = true; } window.addEventListener('pywebviewready', _onPywebviewReady, { once: true }); onUnmounted(() => window.removeEventListener('pywebviewready', _onPywebviewReady)); -async function changeFolder() { - await pickFolderAndRestart(); +const showRootsPanel = ref(false); +const roots = ref([]); + +async function refreshRoots() { + try { + const data = await fetchRoots(); + roots.value = data.map(r => ({ + root_id: r.root_id, + name: r.path.split('/').pop() || r.path.split('\\').pop() || r.root_id, + path: r.path, + status: r.status, + })); + } catch (e) { + console.error('Failed to fetch roots:', e); + } } +async function removeRoot(rootId: string) { + const filtered = roots.value.filter(r => r.root_id !== rootId); + const newRoots = Object.fromEntries(filtered.map(r => [r.name, r.path])); + try { + await replaceRoots(newRoots); + await refreshRoots(); + } catch (e) { + console.error('Failed to remove root:', e); + alert('Failed to remove root'); + } +} + +async function addRoot() { + const folder = await pickFolderAndAddRoot(); + if (!folder) return; + const name = folder.split('/').pop() || folder.split('\\').pop() || 'media'; + // Resolve name collisions + let uniqueName = name; + let suffix = 2; + const currentNames = new Set(roots.value.map(r => r.name)); + while (currentNames.has(uniqueName)) { + uniqueName = `${name}${suffix}`; + suffix++; + } + const newRoots = Object.fromEntries(roots.value.map(r => [r.name, r.path])); + newRoots[uniqueName] = folder; + try { + await replaceRoots(newRoots); + await refreshRoots(); + showRootsPanel.value = false; + } catch (e) { + console.error('Failed to add root:', e); + alert('Failed to add root'); + } +} + +watch(showRootsPanel, (visible) => { + if (visible) void refreshRoots(); +}); + // Check if we're on a detail page const isDetailPage = computed(() => { return props.position === 'after-movie-header' || props.position === 'after-series-hero'; @@ -213,4 +311,149 @@ onUnmounted(() => { background: #22c55e; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18); } + +.header-settings { + position: relative; +} + +.roots-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 320px; + background: rgba(20, 20, 20, 0.95); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 16px; + z-index: 1000; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.roots-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.roots-panel-title { + font-weight: 600; + font-size: 0.95rem; +} + +.roots-panel-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.2rem; + cursor: pointer; + padding: 0 4px; +} + +.roots-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 240px; + overflow-y: auto; +} + +.roots-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + gap: 8px; +} + +.roots-item--ready { + border-left: 3px solid #22c55e; +} + +.roots-item--scanning { + border-left: 3px solid #f59e0b; +} + +.roots-item--loading { + border-left: 3px solid #3b82f6; +} + +.roots-item--error { + border-left: 3px solid #ef4444; +} + +.roots-item-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.roots-item-name { + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.roots-item-path { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.roots-item-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.roots-item-status { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.roots-item-remove { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1rem; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} + +.roots-item-remove:hover { + color: #ef4444; +} + +.roots-actions { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.roots-add-btn { + width: 100%; + padding: 8px; + background: rgba(255, 255, 255, 0.08); + border: 1px dashed rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.2s; +} + +.roots-add-btn:hover { + background: rgba(255, 255, 255, 0.15); +} diff --git a/frontend/src/components/HeroSection.vue b/frontend/src/components/HeroSection.vue index 28d9e32..e0ca857 100644 --- a/frontend/src/components/HeroSection.vue +++ b/frontend/src/components/HeroSection.vue @@ -47,7 +47,7 @@ const emit = defineEmits<{ }>(); const coverUrl = computed(() => { - return getCoverUrl(props.item.cover_path); + return getCoverUrl(props.item.cover_path, props.item.root_id); }); const resolution = computed(() => { diff --git a/frontend/src/components/MediaCard.vue b/frontend/src/components/MediaCard.vue index d630904..8fc5678 100644 --- a/frontend/src/components/MediaCard.vue +++ b/frontend/src/components/MediaCard.vue @@ -92,16 +92,16 @@ const posterImageUrl = computed(() => { if (!props.item.cover_path || isVideoPath(props.item.cover_path)) { return null; } - return getCoverUrl(props.item.cover_path); + return getCoverUrl(props.item.cover_path, props.item.root_id); }); const posterVideoUrl = computed(() => { if (props.item.cover_path && isVideoPath(props.item.cover_path)) { - return getCoverUrl(props.item.cover_path); + return getCoverUrl(props.item.cover_path, props.item.root_id); } const fallbackVideo = props.item.showreel_images?.find(path => isVideoPath(path)); - return fallbackVideo ? getCoverUrl(fallbackVideo) : null; + return fallbackVideo ? getCoverUrl(fallbackVideo, props.item.root_id) : null; }); const rating = computed(() => { diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index 31994de..0035d56 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -113,7 +113,7 @@ > @@ -341,7 +341,7 @@ watch(collageSlots, async (slots) => { }, { immediate: true }); function getShowreelUrl(path: string): string { - return getVideoPreviewUrl(getCoverUrl(path)); + return getVideoPreviewUrl(getCoverUrl(path, props.item.root_id)); } function getShowreelSourceAttributes(path: string): VideoSourceAttributes { @@ -359,7 +359,7 @@ const backdropStyle = computed(() => { if (props.item.type !== 'movies') return {}; const movie = props.item.data as Movie; const imagePath = movie.backdrop_path; - const imageUrl = getCoverUrl(imagePath); + const imageUrl = getCoverUrl(imagePath, props.item.root_id); if (imageUrl) { return { backgroundImage: `url("${imageUrl}")` }; } @@ -368,7 +368,7 @@ const backdropStyle = computed(() => { const synopsisPosterUrl = computed(() => { if (props.item.type !== 'movies') return null; - return getCoverUrl(props.item.cover_path); + return getCoverUrl(props.item.cover_path, props.item.root_id); }); const movieGenres = computed(() => { diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 6388891..40666e4 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -502,7 +502,7 @@ function handleEpisodeHover(key: string, isEntering: boolean) { // Backdrop URL - only use backdrop_path, fall back to collage (handled in template) const backdropUrl = computed(() => { if (props.series.info?.backdrop_path) { - return getCoverUrl(props.series.info.backdrop_path); + return getCoverUrl(props.series.info.backdrop_path, props.series.root_id); } return null; }); @@ -523,7 +523,7 @@ const ratingClass = computed(() => { // Get season poster function getSeasonPoster(season: Season): string | undefined { if (season.poster_path) { - return getCoverUrl(season.poster_path); + return getCoverUrl(season.poster_path, props.series.root_id); } return undefined; } @@ -536,14 +536,14 @@ function getEpisodeVideoSources(episode: Episode): Array<{ src: string; type: st : []; return sources.map((path) => ({ - src: getVideoPreviewUrl(getCoverUrl(path)), + src: getVideoPreviewUrl(getCoverUrl(path, props.series.root_id)), ...getVideoSourceAttributes(path), })); } // Collage slice style for season posters function getCollageSliceStyle(season: Season, index: number) { - const posterUrl = season.poster_path ? getCoverUrl(season.poster_path) : null; + const posterUrl = season.poster_path ? getCoverUrl(season.poster_path, props.series.root_id) : null; const totalSlices = Math.min(seasonsWithPosters.value.length, 5); const sliceWidth = 100 / totalSlices; diff --git a/frontend/src/composables/useMediaWebSocket.ts b/frontend/src/composables/useMediaWebSocket.ts index 09e2ab0..07a7544 100644 --- a/frontend/src/composables/useMediaWebSocket.ts +++ b/frontend/src/composables/useMediaWebSocket.ts @@ -1,17 +1,24 @@ import { ref, readonly, onUnmounted } from 'vue'; import type { Movie, Series, MediaIndex, TaskInfo, WsMessage } from '../types'; +interface RootState { + rootId: string; + ws: WebSocket | null; + movieMap: Map; + seriesMap: Map; + connected: boolean; + reconnectTimer: ReturnType | null; +} + /** - * Composable that connects to the MediaHive WebSocket and keeps - * the media index updated in real time. + * Composable that connects to per-root MediaHive WebSockets and keeps + * a merged media index updated in real time. * - * The server sends: + * 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 - * - * Messages are msgspec-encoded binary JSON with a "type" tag field. */ export function useMediaWebSocket() { const mediaIndex = ref(null); @@ -20,17 +27,16 @@ export function useMediaWebSocket() { const connected = ref(false); const tasks = ref>(new Map()); - let ws: WebSocket | null = null; - let reconnectTimer: ReturnType | null = null; + const roots = ref>(new Map()); let disposed = false; - // Lookup maps for fast upsert / remove - const movieMap = new Map(); - const seriesMap = new Map(); - function buildIndex(): MediaIndex { - const movies = Array.from(movieMap.values()); - const series = Array.from(seriesMap.values()); + const movies: Movie[] = []; + const series: Series[] = []; + for (const state of roots.value.values()) { + movies.push(...state.movieMap.values()); + series.push(...state.seriesMap.values()); + } return { version: 0, generated_at: new Date().toISOString(), @@ -43,63 +49,57 @@ export function useMediaWebSocket() { }; } - function handleMessage(event: MessageEvent) { - try { - // Server sends binary frames (msgspec json bytes) - let text: string; - if (event.data instanceof Blob) { - // Will be handled by the blob reader below - event.data.text().then((t) => processJson(t)); - return; - } else if (event.data instanceof ArrayBuffer) { - text = new TextDecoder().decode(event.data); - } else { - text = event.data as string; + function updateMergedState() { + mediaIndex.value = buildIndex(); + // Loading is done when at least one root has connected and sent init + let anyConnected = false; + for (const state of roots.value.values()) { + if (state.connected) { + anyConnected = true; + break; } - processJson(text); - } catch (e) { - console.error('[WS] Failed to handle message:', e); } + if (anyConnected) { + loading.value = false; + error.value = null; + } + connected.value = anyConnected; } - function processJson(text: string) { + function processJson(state: RootState, text: string) { const msg = JSON.parse(text) as WsMessage; switch (msg.type) { case 'init': { - movieMap.clear(); - seriesMap.clear(); - for (const m of msg.data.movies) movieMap.set(m.id, m); - for (const s of msg.data.series) seriesMap.set(s.id, s); - mediaIndex.value = buildIndex(); - loading.value = false; - error.value = null; - console.log(`[WS] init: ${movieMap.size} movies, ${seriesMap.size} series`); + state.movieMap.clear(); + state.seriesMap.clear(); + for (const m of msg.data.movies) state.movieMap.set(m.id, m); + for (const s of msg.data.series) state.seriesMap.set(s.id, s); + updateMergedState(); + console.log(`[WS ${state.rootId}] init: ${state.movieMap.size} movies, ${state.seriesMap.size} series`); break; } case 'upsert': { if (msg.kind === 'movie') { - movieMap.set(msg.item.id, msg.item as Movie); + state.movieMap.set(msg.item.id, msg.item as Movie); } else { - seriesMap.set(msg.item.id, msg.item as Series); + state.seriesMap.set(msg.item.id, msg.item as Series); } - // Rebuild the index ref so Vue detects the change - mediaIndex.value = buildIndex(); + updateMergedState(); break; } case 'remove': { if (msg.kind === 'movie') { - movieMap.delete(msg.id); + state.movieMap.delete(msg.id); } else { - seriesMap.delete(msg.id); + state.seriesMap.delete(msg.id); } - mediaIndex.value = buildIndex(); + updateMergedState(); break; } case 'task': { const info = msg.data; if (info.status === 'completed' || info.status === 'cancelled' || info.status === 'error') { - // Keep finished tasks briefly so the UI can show completion tasks.value.set(info.id, info); setTimeout(() => { tasks.value.delete(info.id); @@ -108,71 +108,143 @@ export function useMediaWebSocket() { } else { tasks.value.set(info.id, info); } - // Trigger reactivity tasks.value = new Map(tasks.value); break; } } } - function connect() { - if (disposed) return; - - // Build WS URL relative to current page - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${proto}//${location.host}/api/ws`; - - console.log(`[WS] Connecting to ${url}...`); - ws = new WebSocket(url); - - ws.onopen = () => { - connected.value = true; - error.value = null; - console.log('[WS] Connected'); - }; - - ws.onmessage = handleMessage; - - ws.onclose = (ev) => { - connected.value = false; - console.log(`[WS] Closed (code=${ev.code})`); - scheduleReconnect(); - }; - - ws.onerror = (ev) => { - console.error('[WS] Error:', ev); - if (!mediaIndex.value) { - error.value = 'WebSocket connection failed'; + function handleMessage(state: RootState, event: MessageEvent) { + try { + let text: string; + if (event.data instanceof Blob) { + event.data.text().then((t) => processJson(state, t)); + return; + } else if (event.data instanceof ArrayBuffer) { + text = new TextDecoder().decode(event.data); + } else { + text = event.data as string; } - }; + processJson(state, text); + } catch (e) { + console.error(`[WS ${state.rootId}] Failed to handle message:`, e); + } } - function scheduleReconnect() { + function connectRoot(rootId: string) { if (disposed) return; - if (reconnectTimer) clearTimeout(reconnectTimer); - reconnectTimer = setTimeout(() => { - console.log('[WS] Reconnecting...'); - connect(); - }, 2000); + const existing = roots.value.get(rootId); + if (existing?.ws) { + // Already connecting or connected + return; + } + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${proto}//${location.host}/api/roots/${encodeURIComponent(rootId)}/ws`; + + const state: RootState = { + rootId, + ws: null, + movieMap: new Map(), + seriesMap: new Map(), + connected: false, + reconnectTimer: null, + }; + roots.value.set(rootId, state); + + function doConnect() { + if (disposed) return; + console.log(`[WS ${rootId}] Connecting to ${url}...`); + const ws = new WebSocket(url); + state.ws = ws; + + ws.onopen = () => { + state.connected = true; + updateMergedState(); + console.log(`[WS ${rootId}] Connected`); + }; + + ws.onmessage = (ev) => handleMessage(state, ev); + + ws.onclose = (ev) => { + state.connected = false; + 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'; + } + }; + } + + function scheduleReconnect() { + if (disposed) return; + if (state.reconnectTimer) clearTimeout(state.reconnectTimer); + state.reconnectTimer = setTimeout(() => { + 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); + } + } } function disconnect() { disposed = true; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - if (ws) { - ws.onclose = null; // prevent reconnect - ws.close(); - ws = null; + for (const state of roots.value.values()) { + if (state.reconnectTimer) { + clearTimeout(state.reconnectTimer); + } + if (state.ws) { + state.ws.onclose = null; + state.ws.close(); + } } + roots.value.clear(); } - // Start the connection - connect(); - - // Clean up on component unmount onUnmounted(disconnect); return { @@ -181,6 +253,7 @@ export function useMediaWebSocket() { error: readonly(error), connected: readonly(connected), tasks: readonly(tasks), + setActiveRoots, disconnect, }; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 70e3750..ed41a29 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -69,6 +69,7 @@ export interface Movie { showreel_images: string[] | null; showreel_source_sets: string[][] | null; torrents: { [key: string]: Torrent }; + root_id: string | null; } export interface Episode { @@ -104,6 +105,7 @@ export interface Series { cover_path: string | null; backdrop_path: string | null; seasons: Season[]; + root_id: string | null; } export interface MediaStats { @@ -156,6 +158,7 @@ export interface MediaItem { type: MediaType; resolution?: string | null; data: Movie | Series | EpisodeWithSeries; + root_id: string | null; // Optional search match info - only present in search results searchMatchInfo?: SearchMatchInfo; } diff --git a/mediahive/__main__.py b/mediahive/__main__.py index 40e5fda..7cf32a9 100644 --- a/mediahive/__main__.py +++ b/mediahive/__main__.py @@ -1,4 +1,5 @@ import argparse +import json import os import sys from pathlib import Path @@ -9,20 +10,10 @@ DEFAULT_PORT = 8420 DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1" -def resolve_media_root(path: str | None = None) -> Path: - """Resolve the media root folder from a path, MEDIAHIVE_PATH env, or cwd.""" - match Path(path or os.environ.get("MEDIAHIVE_PATH") or Path.cwd()).parts: - case (*rest, ".mediahive", "index.json"): - ... - case (*rest, ".mediahive"): - ... - case rest: - ... - mediaroot = Path(*rest).resolve() - if not mediaroot.exists() or not mediaroot.is_dir(): - sys.stderr.write(f"Error: Folder does not exist: {mediaroot}\n") - sys.exit(1) - return mediaroot +def _derive_name(path: str) -> str: + """Derive a root name from a path.""" + p = Path(path) + return p.name or p.anchor.strip("/\\").lower() or "media" def main(): @@ -30,9 +21,10 @@ def main(): description="MediaHive - Media scanning, indexing, and streaming" ) parser.add_argument( - "media_folder", - nargs="?", - help="Path to the media folder (default: MEDIAHIVE_PATH or current directory)", + "media_folders", + nargs="*", + metavar="MEDIA_FOLDER", + help="One or more media folders to index (default: none — configure via UI or API)", ) parser.add_argument( "-l", @@ -43,8 +35,21 @@ def main(): args = parser.parse_args() - mediaroot = resolve_media_root(args.media_folder) - os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix() + if args.media_folders: + roots: dict[str, str] = {} + for path in args.media_folders: + # Defer filesystem validation to the server so startup is never + # blocked by macOS permission dialogs or missing paths. + p = Path(path).expanduser() + name = _derive_name(p.as_posix()) + # Resolve collisions + base_name = name + suffix = 2 + while name in roots: + name = f"{base_name}{suffix}" + suffix += 1 + roots[name] = p.as_posix() + os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots) dev = {"reload": True, "reload_dirs": ["mediahive"]} server.run( diff --git a/mediahive/config.py b/mediahive/config.py index 20ff159..4c59f91 100644 --- a/mediahive/config.py +++ b/mediahive/config.py @@ -16,6 +16,7 @@ import msgspec.toml class Config(msgspec.Struct): media_folder: str | None = None + roots: dict[str, str] | None = None def config_dir() -> Path: @@ -32,11 +33,25 @@ def config_path() -> Path: return config_dir() / "config.toml" +def _migrate_legacy_media_folder(cfg: Config) -> Config: + """If roots is empty but media_folder exists, seed roots with it.""" + if cfg.roots: + return cfg + if not cfg.media_folder: + return cfg + path = Path(cfg.media_folder) + name = path.name or path.anchor.strip("/\\").lower() or "media" + # Resolve collisions simply by using the basename; if user had weird layout + # they can rename via the UI later. + return msgspec.structs.replace(cfg, roots={name: cfg.media_folder}) + + def load_config() -> Config: path = config_path() if path.exists(): try: - return msgspec.toml.decode(path.read_bytes(), type=Config) + cfg = msgspec.toml.decode(path.read_bytes(), type=Config) + return _migrate_legacy_media_folder(cfg) except Exception: return Config() return Config() diff --git a/mediahive/hivescan/__main__.py b/mediahive/hivescan/__main__.py index 131b15a..b6ab6f4 100644 --- a/mediahive/hivescan/__main__.py +++ b/mediahive/hivescan/__main__.py @@ -1,4 +1,5 @@ import argparse +import json import logging import os from pathlib import Path @@ -16,11 +17,11 @@ Examples: Exclude paths by creating .mediahive/scanignore (gitignore syntax). -The server exposes: - WS /ws Live index updates & task progress - POST /api/scan Trigger a new scan - GET /api/status Current server status - GET /api/index Full index as JSON (HTTP fallback) +The server exposes per-root endpoints: + WS /api/roots/{root_id}/ws Live index updates & task progress + POST /api/roots/{root_id}/scan Trigger a new scan + GET /api/roots/{root_id}/status Current root status + GET /api/roots/{root_id}/index Full index as JSON (HTTP fallback) """, ) parser.add_argument( @@ -41,12 +42,11 @@ The server exposes: args = parser.parse_args() - media_root = Path(args.media_folder).resolve() - if not media_root.exists() or not media_root.is_dir(): - print(f"Error: Folder does not exist: {media_root}") - exit(1) - - os.environ["MEDIAHIVE_PATH"] = media_root.as_posix() + # Defer filesystem validation to the server; pass raw path via env. + media_root = Path(args.media_folder).expanduser() + os.environ["MEDIAHIVE_ROOTS"] = json.dumps({ + media_root.name or "media": media_root.as_posix() + }) logging.basicConfig( level=logging.INFO, diff --git a/mediahive/hivescan/indexer.py b/mediahive/hivescan/indexer.py index d41b138..03effe6 100644 --- a/mediahive/hivescan/indexer.py +++ b/mediahive/hivescan/indexer.py @@ -380,6 +380,7 @@ async def _process_movies( fetch_covers: bool, generate_showreels: bool, media_root: Optional[str] = None, + root_id: Optional[str] = None, ) -> AsyncIterator[Tuple[Movie, Optional[Tuple[str, Path, str]]]]: """ Async generator that processes all movies. @@ -473,7 +474,8 @@ async def _process_movies( year = group_data["year"] display_title = tmdb_info.title - item_id = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12] + content_hash = hashlib.md5(f"movie:{tmdb_id}".encode()).hexdigest()[:12] + item_id = f"{root_id}:{content_hash}" if root_id else content_hash media_folder = get_media_folder_path(display_title, year, "movie", cover_dir) # Find/download cover @@ -551,6 +553,7 @@ async def _process_movies( showreel_images=showreel_paths if showreel_paths else None, showreel_source_sets=showreel_source_sets if showreel_source_sets else None, torrents=torrents, + root_id=root_id, ) yield movie, showreel_task @@ -559,7 +562,8 @@ async def _process_movies( items = group_data["items"] title = group_data["title"] year = group_data["year"] - item_id = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12] + content_hash = hashlib.md5(f"movie:{title}:{year}".encode()).hexdigest()[:12] + item_id = f"{root_id}:{content_hash}" if root_id else content_hash cover_path = ( await find_cover_image(title, year, "movie", cover_dir) @@ -620,6 +624,7 @@ async def _process_movies( showreel_images=showreel_paths if showreel_paths else None, showreel_source_sets=showreel_source_sets if showreel_source_sets else None, torrents=torrents, + root_id=root_id, ) yield movie, showreel_task @@ -630,6 +635,7 @@ async def _process_series( fetch_covers: bool, generate_showreels: bool, media_root: Optional[str] = None, + root_id: Optional[str] = None, ) -> AsyncIterator[Tuple[Series, List[Tuple[str, Path, int, int, str]]]]: """ Async generator that processes all series. @@ -714,7 +720,8 @@ async def _process_series( torrent_titles = group_data["torrent_titles"] display_title = tmdb_info.title - series_id = hashlib.md5(f"series:{tmdb_id}".encode()).hexdigest()[:12] + content_hash = hashlib.md5(f"series:{tmdb_id}".encode()).hexdigest()[:12] + series_id = f"{root_id}:{content_hash}" if root_id else content_hash logger.debug(" [%d/%d] %s", series_idx, len(tmdb_groups), display_title) @@ -779,6 +786,7 @@ async def _process_series( cover_path=make_relative_path(cover_path, media_root), backdrop_path=make_relative_path(backdrop_path, media_root), seasons=seasons_data, + root_id=root_id, ) yield series, ep_reel_tasks @@ -786,7 +794,8 @@ async def _process_series( for key, group_data in no_tmdb_groups.items(): items = group_data["items"] title = group_data["title"] - series_id = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12] + content_hash = hashlib.md5(f"series:{title}".encode()).hexdigest()[:12] + series_id = f"{root_id}:{content_hash}" if root_id else content_hash cover_path = ( await find_cover_image(title, None, "series", cover_dir) @@ -823,5 +832,6 @@ async def _process_series( newest=newest, cover_path=make_relative_path(cover_path, media_root), seasons=seasons_data, + root_id=root_id, ) yield series, ep_reel_tasks diff --git a/mediahive/hivescan/scanner.py b/mediahive/hivescan/scanner.py index 816cd74..bd087dc 100644 --- a/mediahive/hivescan/scanner.py +++ b/mediahive/hivescan/scanner.py @@ -10,6 +10,8 @@ The scanner recursively walks the media root, respecting ignore patterns defined in ``.mediahive/scanignore`` (gitignore-style syntax). """ +from __future__ import annotations + import asyncio import logging import os @@ -47,647 +49,623 @@ Send = Callable[[ScanEvent], Awaitable[None]] # --------------------------------------------------------------------------- -# Configuration (populated by ``start``) -# --------------------------------------------------------------------------- - -_output_dir: Optional[Path] = None -_media_root: Optional[Path] = None -_scanignore: Optional[ScanIgnore] = None - -# Runtime state -_send: Optional[Send] = None -_scan_task: Optional[asyncio.Task] = None -_showreel_queue: asyncio.Queue = asyncio.Queue() -_showreel_worker_task: Optional[asyncio.Task] = None -_rescan_worker_task: Optional[asyncio.Task] = None -_seen_mtimes: dict[str, int] = {} - - -# --------------------------------------------------------------------------- -# Public lifecycle +# RootScanner — per-root scanning runtime # --------------------------------------------------------------------------- -async def start(send: Send) -> None: - """ - Initialise and start the scanner. +class RootScanner: + """Scanner instance for a single media root.""" - Reads ``MEDIAHIVE_PATH`` from the environment, loads the scanignore - rules from ``.mediahive/scanignore``, and starts background workers - that recursively walk the media root. - """ - global _send, _output_dir, _media_root, _scanignore - global _showreel_worker_task, _rescan_worker_task + def __init__( + self, + root_id: str, + media_root: Path, + send: Send, + ): + self.root_id = root_id + self.media_root = media_root + self._send = send + self._output_dir = media_root / DEFAULT_OUTPUT_FOLDER + self._scanignore = ScanIgnore(media_root) - _send = send + # Runtime state + self._scan_task: Optional[asyncio.Task] = None + self._showreel_queue: asyncio.Queue = asyncio.Queue() + self._showreel_worker_task: Optional[asyncio.Task] = None + self._rescan_worker_task: Optional[asyncio.Task] = None + self._seen_mtimes: dict[str, int] = {} - media_path = os.environ.get("MEDIAHIVE_PATH", "") - if not media_path: - logger.error("MEDIAHIVE_PATH environment variable must be set") - return + # ------------------------------------------------------------------ + # Public lifecycle + # ------------------------------------------------------------------ - _media_root = Path(media_path).resolve() - _output_dir = _media_root / DEFAULT_OUTPUT_FOLDER - _scanignore = ScanIgnore(_media_root) + async def start(self) -> None: + """Initialise and start background workers.""" + await AsyncPath(self._output_dir).mkdir(parents=True, exist_ok=True) + set_cache_dir(self._output_dir / ".tmdb-cache") - await AsyncPath(_output_dir).mkdir(parents=True, exist_ok=True) - set_cache_dir(_output_dir / ".tmdb-cache") - - logger.info( - "Scanner started — root=%s, scanignore=%s", - _media_root, - "loaded" if _scanignore.file_path.exists() else "defaults only", - ) - - _showreel_worker_task = asyncio.create_task(_showreel_worker()) - _rescan_worker_task = asyncio.create_task(_rescan_loop()) - - -async def stop() -> None: - """Cancel all background tasks.""" - for task in (_scan_task, _showreel_worker_task, _rescan_worker_task): - if task and not task.done(): - task.cancel() - - -def is_scanning() -> bool: - return _scan_task is not None and not _scan_task.done() - - -def showreel_queue_size() -> int: - return _showreel_queue.qsize() - - -def trigger_scan() -> bool: - """Start a scan. Returns False if one is already running.""" - if is_scanning(): - return False - _start_scan() - return True - - -# --------------------------------------------------------------------------- -# Internal scan orchestration -# --------------------------------------------------------------------------- - - -def _start_scan(): - global _scan_task - _scan_task = asyncio.create_task(_run_scan()) - - -async def _rescan_loop(): - try: - while True: - _start_scan() - if _scan_task: - await _scan_task - await asyncio.sleep(30) - except asyncio.CancelledError: - return - except Exception: - logger.exception("Rescan loop error") - - -async def _discover_downloads(task_id: str) -> List[ParsedContent]: - """Recursively walk the media root, respecting scanignore rules. - - Each non-ignored **leaf directory** (a directory whose children are only - files, i.e. a single download/torrent folder) and each non-ignored - top-level file is treated as a download to parse. - - Sends live task progress so the user can see which directories are being - explored and how many items have been found so far. - """ - downloads: List[ParsedContent] = [] - media_root_str = _media_root.as_posix() if _media_root else None - dirs_visited = 0 - - async def _report(detail: str) -> None: - await _send( - Task( - data=TaskInfo( - id=task_id, - status="running", - progress=0, - detail=detail, - ) - ) + logger.info( + "Scanner started for root %s — path=%s, scanignore=%s", + self.root_id, + self.media_root, + "loaded" if self._scanignore.file_path.exists() else "defaults only", ) - # Media container folder names (indicates the parent is a single media item) - MEDIA_CONTAINER_DIRS = {"BDMV", "VIDEO_TS", "HVDVD_TS"} - VIDEO_EXTENSIONS = { - ".mkv", - ".mp4", - ".avi", - ".m4v", - ".mov", - ".wmv", - ".flv", - ".webm", - ".ts", - ".m2ts", - } + self._showreel_worker_task = asyncio.create_task(self._showreel_worker()) + self._rescan_worker_task = asyncio.create_task(self._rescan_loop()) - async def _walk(directory: Path) -> None: - nonlocal dirs_visited - ap = AsyncPath(directory) - if not await ap.is_dir(): - return + async def stop(self) -> None: + """Cancel all background tasks.""" + for task in (self._scan_task, self._showreel_worker_task, self._rescan_worker_task): + if task and not task.done(): + task.cancel() + # Wait briefly for graceful shutdown + for task in (self._scan_task, self._showreel_worker_task, self._rescan_worker_task): + if task and not task.done(): + try: + await asyncio.wait_for(task, timeout=2.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass - child_dirs: list[Path] = [] - child_files: list[Path] = [] - is_media_container = False + def is_scanning(self) -> bool: + return self._scan_task is not None and not self._scan_task.done() + def showreel_queue_size(self) -> int: + return self._showreel_queue.qsize() + + def trigger_scan(self) -> bool: + """Start a scan. Returns False if one is already running.""" + if self.is_scanning(): + return False + self._start_scan() + return True + + # ------------------------------------------------------------------ + # Internal scan orchestration + # ------------------------------------------------------------------ + + def _start_scan(self) -> None: + self._scan_task = asyncio.create_task(self._run_scan()) + + async def _rescan_loop(self) -> None: try: - for item_async in ap.iterdir(): - item = Path(item_async) - if item.name.startswith("."): - continue - if _scanignore and _scanignore.is_excluded(item): - continue - - if await AsyncPath(item).is_dir(): - # Check if this is a BluRay/DVD structure - if item.name.upper() in MEDIA_CONTAINER_DIRS: - is_media_container = True - child_dirs.append(item) - else: - child_files.append(item) - except OSError, PermissionError: - logger.debug("Cannot list directory: %s", directory) + while True: + self._start_scan() + if self._scan_task: + await self._scan_task + await asyncio.sleep(30) + except asyncio.CancelledError: return + except Exception: + logger.exception("Rescan loop error") - # If directory contains BDMV/VIDEO_TS, treat entire directory as a single download - if is_media_container: - relpath = make_relative_path(str(directory), media_root_str) - try: - stat_info = await ap.stat() - mtime = int(stat_info.st_mtime) - except OSError: - return - if relpath not in _seen_mtimes or _seen_mtimes[relpath] != mtime: - _seen_mtimes[relpath] = mtime - downloads.append(await parse_download(directory)) - return + async def _discover_downloads(self, task_id: str) -> List[ParsedContent]: + """Recursively walk the media root, respecting scanignore rules.""" + downloads: List[ParsedContent] = [] + media_root_str = self.media_root.as_posix() + dirs_visited = 0 - if child_dirs: - # Branch directory — log it and recurse into subdirectories - dirs_visited += 1 - rel = make_relative_path(str(directory), media_root_str) or str(directory) - if dirs_visited % 5 == 1: # throttle progress updates - await _report(f"Scanning: {rel} ({len(downloads)} found)") - logger.info("Scanning: %s (%d found so far)", rel, len(downloads)) - for child in child_dirs: - await _walk(child) - # Yield control periodically so WS messages flush - await asyncio.sleep(0) - # Also process any video files directly in this directory - for child_file in child_files: - if child_file.suffix.lower() in VIDEO_EXTENSIONS: - relpath = make_relative_path(str(child_file), media_root_str) - try: - stat_info = await AsyncPath(child_file).stat() - mtime = int(stat_info.st_mtime) - except OSError: - continue - if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime: - continue - _seen_mtimes[relpath] = mtime - downloads.append(await parse_download(child_file)) - else: - # Leaf directory — treat the directory itself as a download - relpath = make_relative_path(str(directory), media_root_str) - try: - stat_info = await ap.stat() - mtime = int(stat_info.st_mtime) - except OSError: - return - if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime: - return - _seen_mtimes[relpath] = mtime - downloads.append(await parse_download(directory)) - - # Walk immediate children of the media root (skip root itself) - logger.info("Starting filesystem discovery at %s", _media_root) - await _report(f"Scanning: {_media_root}") - - root_ap = AsyncPath(_media_root) - try: - root_children = list(root_ap.iterdir()) - except OSError, PermissionError: - logger.error("Cannot list media root: %s", _media_root) - return downloads - - for item_async in root_children: - item = Path(item_async) - if item.name.startswith("."): - continue - if _scanignore and _scanignore.is_excluded(item): - logger.debug("Excluded: %s", item.name) - continue - if await AsyncPath(item).is_dir(): - await _walk(item) - else: - # Top-level file — parse directly - relpath = make_relative_path(str(item), media_root_str) - try: - stat_info = await AsyncPath(item).stat() - mtime = int(stat_info.st_mtime) - except OSError: - continue - if relpath in _seen_mtimes and _seen_mtimes[relpath] == mtime: - continue - _seen_mtimes[relpath] = mtime - downloads.append(await parse_download(item)) - - logger.info( - "Discovery complete: %d downloads found, %d directories visited", - len(downloads), - dirs_visited, - ) - return downloads - - -async def _run_scan(): - """ - Full scan pipeline: - 1. Recursively walk media root (respecting scanignore) — with live progress - 2. Categorise → movies / series - 3. Iterate async generators, send each item as Upsert - 4. Queue showreel tasks - """ - task_id = f"scan-{uuid.uuid4().hex[:8]}" - media_root_str = _media_root.as_posix() if _media_root else None - - try: - logger.info("Scan started (%s)", task_id) - await _send( - Task( - data=TaskInfo( - id=task_id, - status="running", - progress=0, - detail="Starting scan...", - ) - ) - ) - - downloads = await _discover_downloads(task_id) - - if not downloads: - logger.info("No new downloads found (%s)", task_id) - await _send( - Task( - data=TaskInfo( - id=task_id, - status="completed", - progress=1, - detail="No new items", - ) - ) - ) - return - - logger.info("Found %d items to process", len(downloads)) - await _send( - Task( - data=TaskInfo( - id=task_id, - status="running", - progress=0, - detail=f"Processing {len(downloads)} items...", - ) - ) - ) - - categories = categorize_downloads(downloads) - n_movies = len(categories[ContentType.MOVIE]) - n_series = len(categories[ContentType.SERIES]) - total = n_movies + n_series - processed = 0 - - logger.info("Categorised: %d movies, %d series", n_movies, n_series) - - # Process movies - if n_movies: - await _send( + async def _report(detail: str) -> None: + await self._send( Task( data=TaskInfo( id=task_id, status="running", progress=0, - detail=f"Processing {n_movies} movies...", + detail=detail, ) ) ) - async for movie, showreel_task in _process_movies( - categories, - _output_dir, - fetch_covers=True, - generate_showreels=True, - media_root=media_root_str, - ): - await _send(Upsert(kind="movie", item=movie)) - if showreel_task: - await _showreel_queue.put(("movie", showreel_task, movie)) - logger.info( - "[%d/%d] Movie: %s (showreel queued, queue=%d)", - processed + 1, - total, - movie.title, - _showreel_queue.qsize(), - ) + + MEDIA_CONTAINER_DIRS = {"BDMV", "VIDEO_TS", "HVDVD_TS"} + VIDEO_EXTENSIONS = { + ".mkv", + ".mp4", + ".avi", + ".m4v", + ".mov", + ".wmv", + ".flv", + ".webm", + ".ts", + ".m2ts", + } + + async def _walk(directory: Path) -> None: + nonlocal dirs_visited + ap = AsyncPath(directory) + if not await ap.is_dir(): + return + + child_dirs: list[Path] = [] + child_files: list[Path] = [] + is_media_container = False + + try: + for item_async in ap.iterdir(): + item = Path(item_async) + if item.name.startswith("."): + continue + if self._scanignore and self._scanignore.is_excluded(item): + continue + + if await AsyncPath(item).is_dir(): + if item.name.upper() in MEDIA_CONTAINER_DIRS: + is_media_container = True + child_dirs.append(item) + else: + child_files.append(item) + except (OSError, PermissionError): + logger.debug("Cannot list directory: %s", directory) + return + + if is_media_container: + relpath = make_relative_path(str(directory), media_root_str) + try: + stat_info = await ap.stat() + mtime = int(stat_info.st_mtime) + except OSError: + return + if relpath not in self._seen_mtimes or self._seen_mtimes[relpath] != mtime: + self._seen_mtimes[relpath] = mtime + downloads.append(await parse_download(directory)) + return + + if child_dirs: + dirs_visited += 1 + rel = make_relative_path(str(directory), media_root_str) or str(directory) + if dirs_visited % 5 == 1: + await _report(f"Scanning: {rel} ({len(downloads)} found)") + logger.info("Scanning: %s (%d found so far)", rel, len(downloads)) + for child in child_dirs: + await _walk(child) + await asyncio.sleep(0) + for child_file in child_files: + if child_file.suffix.lower() in VIDEO_EXTENSIONS: + relpath = make_relative_path(str(child_file), media_root_str) + try: + stat_info = await AsyncPath(child_file).stat() + mtime = int(stat_info.st_mtime) + except OSError: + continue + if relpath in self._seen_mtimes and self._seen_mtimes[relpath] == mtime: + continue + self._seen_mtimes[relpath] = mtime + downloads.append(await parse_download(child_file)) else: - logger.info( - "[%d/%d] Movie: %s (no showreel task)", - processed + 1, - total, - movie.title, - ) - processed += 1 - progress = round(processed / total, 3) if total else 1 - await _send( - Task( - data=TaskInfo( - id=task_id, - status="running", - progress=progress, - detail=movie.title, - ) - ) - ) + relpath = make_relative_path(str(directory), media_root_str) + try: + stat_info = await ap.stat() + mtime = int(stat_info.st_mtime) + except OSError: + return + if relpath in self._seen_mtimes and self._seen_mtimes[relpath] == mtime: + return + self._seen_mtimes[relpath] = mtime + downloads.append(await parse_download(directory)) - # Process series - if n_series: - await _send( - Task( - data=TaskInfo( - id=task_id, - status="running", - progress=processed / total if total else 0.5, - detail=f"Processing {n_series} series...", - ) - ) - ) - async for series, ep_reel_tasks in _process_series( - categories, - _output_dir, - fetch_covers=True, - generate_showreels=True, - media_root=media_root_str, - ): - await _send(Upsert(kind="series", item=series)) - for task in ep_reel_tasks: - await _showreel_queue.put(("episode", task, series)) - if ep_reel_tasks: - logger.info( - "[%d/%d] Series: %s (%d episode reels queued, queue=%d)", - processed + 1, - total, - series.title, - len(ep_reel_tasks), - _showreel_queue.qsize(), - ) - else: - logger.info( - "[%d/%d] Series: %s (no reel tasks)", - processed + 1, - total, - series.title, - ) - processed += 1 - progress = round(processed / total, 3) if total else 1 - await _send( - Task( - data=TaskInfo( - id=task_id, - status="running", - progress=progress, - detail=series.title, - ) - ) - ) + logger.info("Starting filesystem discovery at %s", self.media_root) + await _report(f"Scanning: {self.media_root}") - await _send( - Task( - data=TaskInfo( - id=task_id, - status="completed", - progress=1, - detail=f"Done — {n_movies} movies, {n_series} series", - ) - ) - ) - logger.info( - "Scan complete (%s): %d movies, %d series, showreel queue=%d", - task_id, - n_movies, - n_series, - _showreel_queue.qsize(), - ) - - except asyncio.CancelledError: - await _send( - Task( - data=TaskInfo( - id=task_id, - status="cancelled", - progress=0, - detail="Scan cancelled", - ) - ) - ) - logger.info("Scan cancelled (%s)", task_id) - except Exception: - logger.exception("Scan failed (%s)", task_id) - await _send( - Task( - data=TaskInfo( - id=task_id, - status="error", - progress=0, - detail="Scan error", - ) - ) - ) - - -# --------------------------------------------------------------------------- -# Showreel worker -# --------------------------------------------------------------------------- - - -async def _showreel_worker(): - """Background worker that generates showreels one at a time.""" - logger.info("Showreel worker started") - media_root_path = Path(_media_root) if _media_root else None - media_root_str = _media_root.as_posix() if _media_root else None - - while True: + root_ap = AsyncPath(self.media_root) try: - kind, task_data, item = await _showreel_queue.get() - task_id = f"showreel-{uuid.uuid4().hex[:8]}" - remaining = _showreel_queue.qsize() + root_children = list(root_ap.iterdir()) + except (OSError, PermissionError): + logger.error("Cannot list media root: %s", self.media_root) + return downloads - if kind == "movie": - movie: Movie = item - video_path, media_folder, title = task_data - logger.info( - "Showreel dequeued: %s (video=%s, folder=%s, %d remaining)", - title, - video_path, - media_folder, - remaining, - ) - if await movie_showreels_exist(media_folder): - logger.info("Showreel skipped (already exists): %s", title) - _showreel_queue.task_done() + for item_async in root_children: + item = Path(item_async) + if item.name.startswith("."): + continue + if self._scanignore and self._scanignore.is_excluded(item): + logger.debug("Excluded: %s", item.name) + continue + if await AsyncPath(item).is_dir(): + await _walk(item) + else: + relpath = make_relative_path(str(item), media_root_str) + try: + stat_info = await AsyncPath(item).stat() + mtime = int(stat_info.st_mtime) + except OSError: continue - await _send( + if relpath in self._seen_mtimes and self._seen_mtimes[relpath] == mtime: + continue + self._seen_mtimes[relpath] = mtime + downloads.append(await parse_download(item)) + + logger.info( + "Discovery complete: %d downloads found, %d directories visited", + len(downloads), + dirs_visited, + ) + return downloads + + async def _run_scan(self) -> None: + """ + Full scan pipeline: + 1. Discover downloads + 2. Categorise → movies / series + 3. Iterate async generators, send each item as Upsert + 4. Queue showreel tasks + """ + task_id = f"scan-{uuid.uuid4().hex[:8]}" + media_root_str = self.media_root.as_posix() + + try: + logger.info("Scan started (%s) for root %s", task_id, self.root_id) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="running", + progress=0, + detail="Starting scan...", + ) + ) + ) + + downloads = await self._discover_downloads(task_id) + + if not downloads: + logger.info("No new downloads found (%s)", task_id) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="completed", + progress=1, + detail="No new items", + ) + ) + ) + return + + logger.info("Found %d items to process", len(downloads)) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="running", + progress=0, + detail=f"Processing {len(downloads)} items...", + ) + ) + ) + + categories = categorize_downloads(downloads) + n_movies = len(categories[ContentType.MOVIE]) + n_series = len(categories[ContentType.SERIES]) + total = n_movies + n_series + processed = 0 + + logger.info("Categorised: %d movies, %d series", n_movies, n_series) + + # Process movies + if n_movies: + await self._send( Task( data=TaskInfo( id=task_id, status="running", progress=0, - detail=f"Showreel: {title}", + detail=f"Processing {n_movies} movies...", ) ) ) - generated = await generate_showreel_images( - video_path, - media_folder, - title=title, - ) - if generated: - source_sets = get_existing_showreel_source_sets( - media_folder, media_root=media_root_path - ) - paths = [sources[0] for sources in source_sets if sources] - movie.showreel_images = paths if paths else None - movie.showreel_source_sets = source_sets if source_sets else None - await _send(Upsert(kind="movie", item=movie)) - await _send( - Task( - data=TaskInfo( - id=task_id, - status="completed", - progress=1, - detail=f"Showreel: {title} ({len(generated)} reels)", - ) - ) + async for movie, showreel_task in _process_movies( + categories, + self._output_dir, + fetch_covers=True, + generate_showreels=True, + media_root=media_root_str, + root_id=self.root_id, + ): + await self._send(Upsert(kind="movie", item=movie)) + if showreel_task: + await self._showreel_queue.put(("movie", showreel_task, movie)) + logger.info( + "[%d/%d] Movie: %s (showreel queued, queue=%d)", + processed + 1, + total, + movie.title, + self._showreel_queue.qsize(), ) else: - logger.warning("Showreel generation returned nothing: %s", title) - await _send( - Task( - data=TaskInfo( - id=task_id, - status="error", - progress=0, - detail=f"Showreel failed: {title}", - ) - ) - ) - - elif kind == "episode": - series: Series = item - video_path, media_folder, season_num, episode_num, series_title = ( - task_data - ) - ep_code = f"S{season_num:02d}E{episode_num:02d}" - logger.info( - "Episode reel dequeued: %s %s (video=%s, %d remaining)", - series_title, - ep_code, - video_path, - remaining, - ) - if await episode_reel_exists(media_folder, season_num, episode_num): logger.info( - "Episode reel skipped (already exists): %s %s", - series_title, - ep_code, + "[%d/%d] Movie: %s (no showreel task)", + processed + 1, + total, + movie.title, ) - _showreel_queue.task_done() - continue - await _send( + processed += 1 + progress = round(processed / total, 3) if total else 1 + await self._send( Task( data=TaskInfo( id=task_id, status="running", - progress=0, - detail=f"Reel: {series_title} {ep_code}", + progress=progress, + detail=movie.title, ) ) ) - reel_path = await generate_episode_reel( - video_path, - media_folder, - season_num, - episode_num, - ) - if reel_path: - reel_sources = get_existing_episode_reel_sources( - media_folder, - season_num, - episode_num, - media_root=media_root_path, + + # Process series + if n_series: + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="running", + progress=processed / total if total else 0.5, + detail=f"Processing {n_series} series...", + ) ) - for season in series.seasons: - if season.season_number == season_num: - for episode in season.episodes: - if episode.episode_number == episode_num: - episode.reel_image = ( - reel_sources[0] - if reel_sources - else make_relative_path( - reel_path, - media_root_str, - ) - ) - episode.reel_sources = ( - reel_sources if reel_sources else None - ) - await _send(Upsert(kind="series", item=series)) - await _send( + ) + async for series, ep_reel_tasks in _process_series( + categories, + self._output_dir, + fetch_covers=True, + generate_showreels=True, + media_root=media_root_str, + root_id=self.root_id, + ): + await self._send(Upsert(kind="series", item=series)) + for task in ep_reel_tasks: + await self._showreel_queue.put(("episode", task, series)) + if ep_reel_tasks: + logger.info( + "[%d/%d] Series: %s (%d episode reels queued, queue=%d)", + processed + 1, + total, + series.title, + len(ep_reel_tasks), + self._showreel_queue.qsize(), + ) + else: + logger.info( + "[%d/%d] Series: %s (no reel tasks)", + processed + 1, + total, + series.title, + ) + processed += 1 + progress = round(processed / total, 3) if total else 1 + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="running", + progress=progress, + detail=series.title, + ) + ) + ) + + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="completed", + progress=1, + detail=f"Done — {n_movies} movies, {n_series} series", + ) + ) + ) + logger.info( + "Scan complete (%s): %d movies, %d series, showreel queue=%d", + task_id, + n_movies, + n_series, + self._showreel_queue.qsize(), + ) + + except asyncio.CancelledError: + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="cancelled", + progress=0, + detail="Scan cancelled", + ) + ) + ) + logger.info("Scan cancelled (%s)", task_id) + except Exception: + logger.exception("Scan failed (%s)", task_id) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="error", + progress=0, + detail="Scan error", + ) + ) + ) + + # ------------------------------------------------------------------ + # Showreel worker + # ------------------------------------------------------------------ + + async def _showreel_worker(self) -> None: + """Background worker that generates showreels one at a time.""" + logger.info("Showreel worker started for root %s", self.root_id) + media_root_path = self.media_root + media_root_str = self.media_root.as_posix() + + while True: + try: + kind, task_data, item = await self._showreel_queue.get() + task_id = f"showreel-{uuid.uuid4().hex[:8]}" + remaining = self._showreel_queue.qsize() + + if kind == "movie": + movie: Movie = item + video_path, media_folder, title = task_data + logger.info( + "Showreel dequeued: %s (video=%s, folder=%s, %d remaining)", + title, + video_path, + media_folder, + remaining, + ) + if await movie_showreels_exist(media_folder): + logger.info("Showreel skipped (already exists): %s", title) + self._showreel_queue.task_done() + continue + await self._send( Task( data=TaskInfo( id=task_id, - status="completed", - progress=1, + status="running", + progress=0, + detail=f"Showreel: {title}", + ) + ) + ) + generated = await generate_showreel_images( + video_path, + media_folder, + title=title, + ) + if generated: + source_sets = get_existing_showreel_source_sets( + media_folder, media_root=media_root_path + ) + paths = [sources[0] for sources in source_sets if sources] + movie.showreel_images = paths if paths else None + movie.showreel_source_sets = source_sets if source_sets else None + await self._send(Upsert(kind="movie", item=movie)) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="completed", + progress=1, + detail=f"Showreel: {title} ({len(generated)} reels)", + ) + ) + ) + else: + logger.warning("Showreel generation returned nothing: %s", title) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="error", + progress=0, + detail=f"Showreel failed: {title}", + ) + ) + ) + + elif kind == "episode": + series: Series = item + video_path, media_folder, season_num, episode_num, series_title = ( + task_data + ) + ep_code = f"S{season_num:02d}E{episode_num:02d}" + logger.info( + "Episode reel dequeued: %s %s (video=%s, %d remaining)", + series_title, + ep_code, + video_path, + remaining, + ) + if await episode_reel_exists(media_folder, season_num, episode_num): + logger.info( + "Episode reel skipped (already exists): %s %s", + series_title, + ep_code, + ) + self._showreel_queue.task_done() + continue + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="running", + progress=0, detail=f"Reel: {series_title} {ep_code}", ) ) ) - else: - logger.warning( - "Episode reel generation failed: %s %s", - series_title, - ep_code, + reel_path = await generate_episode_reel( + video_path, + media_folder, + season_num, + episode_num, ) - await _send( - Task( - data=TaskInfo( - id=task_id, - status="error", - progress=0, - detail=f"Reel failed: {series_title} {ep_code}", + if reel_path: + reel_sources = get_existing_episode_reel_sources( + media_folder, + season_num, + episode_num, + media_root=media_root_path, + ) + for season in series.seasons: + if season.season_number == season_num: + for episode in season.episodes: + if episode.episode_number == episode_num: + episode.reel_image = ( + reel_sources[0] + if reel_sources + else make_relative_path( + reel_path, + media_root_str, + ) + ) + episode.reel_sources = ( + reel_sources if reel_sources else None + ) + await self._send(Upsert(kind="series", item=series)) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="completed", + progress=1, + detail=f"Reel: {series_title} {ep_code}", + ) + ) + ) + else: + logger.warning( + "Episode reel generation failed: %s %s", + series_title, + ep_code, + ) + await self._send( + Task( + data=TaskInfo( + id=task_id, + status="error", + progress=0, + detail=f"Reel failed: {series_title} {ep_code}", + ) ) ) - ) - _showreel_queue.task_done() + self._showreel_queue.task_done() - except asyncio.CancelledError: - logger.info("Showreel worker shutting down") - return - except Exception: - logger.exception( - "Showreel worker error (queue size=%d)", _showreel_queue.qsize() - ) - try: - _showreel_queue.task_done() - except ValueError: - pass + except asyncio.CancelledError: + logger.info("Showreel worker shutting down for root %s", self.root_id) + return + except Exception: + logger.exception( + "Showreel worker error (queue size=%d)", self._showreel_queue.qsize() + ) + try: + self._showreel_queue.task_done() + except ValueError: + pass + + + +# --------------------------------------------------------------------------- +# Legacy module-level API removed — use RootScanner per root instead. +# --------------------------------------------------------------------------- diff --git a/mediahive/hivescan/scanning.py b/mediahive/hivescan/scanning.py index 927c368..54762c1 100644 --- a/mediahive/hivescan/scanning.py +++ b/mediahive/hivescan/scanning.py @@ -120,7 +120,7 @@ async def find_episode_files( episodes[ep_info].append( (Path(f).as_posix(), (await af.stat()).st_size) ) - except OSError, PermissionError: + except (OSError, PermissionError): pass _episode_files_cache[cache_key] = episodes @@ -197,7 +197,7 @@ async def find_playable_file(path: Path) -> Optional[str]: result = nested_video_ts_ifo.as_posix() _playable_file_cache[cache_key] = result return result - except OSError, PermissionError: + except (OSError, PermissionError): pass # Find largest video file @@ -209,7 +209,7 @@ async def find_playable_file(path: Path) -> Optional[str]: if "sample" in Path(f).name.lower(): continue video_files.append((Path(f).as_posix(), (await af.stat()).st_size)) - except OSError, PermissionError: + except (OSError, PermissionError): pass if not video_files: @@ -262,7 +262,7 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str ts_num = name[4:6] size = (await af.stat()).st_size title_sets[ts_num].append((Path(f).as_posix(), size)) - except OSError, PermissionError: + except (OSError, PermissionError): _bluray_probe_file_cache[cache_key] = None return None @@ -304,7 +304,7 @@ async def find_metadata_probe_file(playable_path: Optional[str]) -> Optional[str if not await af.is_file(): continue candidates.append((Path(f).as_posix(), (await af.stat()).st_size)) - except OSError, PermissionError: + except (OSError, PermissionError): _bluray_probe_file_cache[cache_key] = None return None diff --git a/mediahive/hivescan/utils.py b/mediahive/hivescan/utils.py index 397a4ce..90453a0 100644 --- a/mediahive/hivescan/utils.py +++ b/mediahive/hivescan/utils.py @@ -99,7 +99,7 @@ async def get_added_timestamp(path: Path) -> Optional[int]: ap = AsyncPath(path) try: stat_info = await ap.stat() - except OSError, PermissionError: + except (OSError, PermissionError): return None if await ap.is_dir(): @@ -124,7 +124,7 @@ async def get_directory_size(path: Path) -> int: for item in ap.rglob("*"): if await AsyncPath(item).is_file(): total += (await AsyncPath(item).stat()).st_size - except OSError, PermissionError: + except (OSError, PermissionError): pass return total diff --git a/mediahive/index_store.py b/mediahive/index_store.py index d3c4fb0..feaa360 100644 --- a/mediahive/index_store.py +++ b/mediahive/index_store.py @@ -48,9 +48,10 @@ class IndexStore: # This would make IndexStore testable without FastAPI's WebSocket. """ - def __init__(self, snapshot_path: Path, media_root: Optional[str] = None): + def __init__(self, snapshot_path: Path, media_root: Optional[str] = None, root_id: Optional[str] = None): self.snapshot_path = snapshot_path self.media_root = media_root + self.root_id = root_id # The index: keyed by item id self.movies: dict[str, Movie] = {} @@ -67,6 +68,14 @@ class IndexStore: # Persistence # ------------------------------------------------------------------ + def _maybe_migrate_id(self, item_id: str) -> str: + """Prepend root_id to legacy item IDs that lack it.""" + if not self.root_id: + return item_id + if ":" in item_id: + return item_id + return f"{self.root_id}:{item_id}" + async def load_snapshot(self) -> None: """Load index from disk snapshot (recovery on startup).""" ap = AsyncPath(self.snapshot_path) @@ -110,6 +119,8 @@ class IndexStore: m.showreel_source_sets = ( [[p] for p in filtered_images] if filtered_images else None ) + m.id = self._maybe_migrate_id(m.id) + m.root_id = self.root_id self.movies[m.id] = m for s in data.series: for season in s.seasons: @@ -139,6 +150,8 @@ class IndexStore: else: ep.reel_image = None ep.reel_sources = None + s.id = self._maybe_migrate_id(s.id) + s.root_id = self.root_id self.series[s.id] = s logger.info( "Loaded snapshot: %d movies, %d series", @@ -193,6 +206,8 @@ class IndexStore: def upsert_movie(self, item: Movie) -> bool: """Insert or update a movie. Returns True if it was a real change.""" + if not item.root_id and self.root_id: + item.root_id = self.root_id existing = self.movies.get(item.id) if existing is not None: if msgspec.json.encode(existing) == msgspec.json.encode(item): @@ -204,6 +219,8 @@ class IndexStore: def upsert_series(self, item: Series) -> bool: """Insert or update a series. Returns True if it was a real change.""" + if not item.root_id and self.root_id: + item.root_id = self.root_id existing = self.series.get(item.id) if existing is not None: if msgspec.json.encode(existing) == msgspec.json.encode(item): diff --git a/mediahive/models/data.py b/mediahive/models/data.py index c9c8b40..a46b7f1 100644 --- a/mediahive/models/data.py +++ b/mediahive/models/data.py @@ -76,6 +76,7 @@ class Movie(msgspec.Struct): showreel_images: list[str] | None = None showreel_source_sets: list[list[str]] | None = None torrents: dict[str, Torrent] = {} + root_id: str | None = None class Series(msgspec.Struct): @@ -89,6 +90,7 @@ class Series(msgspec.Struct): cover_path: str | None = None backdrop_path: str | None = None seasons: list[Season] = [] + root_id: str | None = None # --------------------------------------------------------------------------- diff --git a/mediahive/models/protocol.py b/mediahive/models/protocol.py index a407c72..2781a00 100644 --- a/mediahive/models/protocol.py +++ b/mediahive/models/protocol.py @@ -1,5 +1,4 @@ -""" -Protocol structures for API and WebSocket communication. +"""Protocol structures for API and WebSocket communication. All types are msgspec.Structs for fast serialization. """ @@ -52,37 +51,41 @@ __all__ = [ # --------------------------------------------------------------------------- -class ScanRequest(msgspec.Struct): - """POST /api/scan body.""" - - paths: list[str] | None = None - - -class StatusResponse(msgspec.Struct): - """GET /api/status response.""" - - scanning: bool = False - movies: int = 0 - series: int = 0 - showreel_queue: int = 0 - - class PlayMediaRequest(msgspec.Struct): - """POST /api/play body (mediahive server).""" + """POST /api/roots/{root_id}/play body.""" file_path: str = "" class OpenFolderRequest(msgspec.Struct): - """POST /api/open-folder body (mediahive server).""" + """POST /api/roots/{root_id}/open-folder body.""" folder_path: str = "" -class ChangeFolderRequest(msgspec.Struct): - """POST /api/change-folder body.""" +class RootsRequest(msgspec.Struct): + """PUT /api/roots body.""" - folder: str + roots: dict[str, str] + + +class RootEntryResponse(msgspec.Struct): + """Single root entry in responses.""" + + name: str + path: str + root_id: str + + +class RootStatusResponse(msgspec.Struct): + """Per-root status in GET /api/roots.""" + + root_id: str + path: str + status: str + error: str | None = None + movies: int = 0 + series: int = 0 # --------------------------------------------------------------------------- diff --git a/mediahive/root_registry.py b/mediahive/root_registry.py new file mode 100644 index 0000000..7cc1d62 --- /dev/null +++ b/mediahive/root_registry.py @@ -0,0 +1,295 @@ +"""Root registry, per-root context, and supervisor for multi-root media support.""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import os +from pathlib import Path +from typing import Optional + +import msgspec + +from mediahive.config import load_config, save_config +from mediahive.index_store import IndexStore +from mediahive.models.events import ScanEvent, Task, Upsert +from mediahive.models.data import TaskInfo + +logger = logging.getLogger("mediahive.root_registry") + +# --------------------------------------------------------------------------- +# Root ID +# --------------------------------------------------------------------------- + + +def _normalize_path(path: str) -> str: + """Canonicalize a path for stable ID generation. + + - resolve() to follow symlinks and normalize .. + - lower-case drive letter on Windows + - strip trailing separators + - use forward slashes + """ + p = Path(path).expanduser().resolve() + posix = p.as_posix() + # Windows drive letter normalization + if len(posix) >= 2 and posix[1] == ":": + posix = posix[0].lower() + posix[1:] + # Strip trailing slash (except root "/") + while len(posix) > 1 and posix.endswith("/"): + posix = posix[:-1] + return posix + + +def compute_root_id(path: str) -> str: + """Return a stable 12-char hex root ID from a normalized path.""" + normalized = _normalize_path(path) + h = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return h[:12] + + +# --------------------------------------------------------------------------- +# Root entry +# --------------------------------------------------------------------------- + + +class RootEntry(msgspec.Struct): + name: str + path: str + root_id: str + + +# --------------------------------------------------------------------------- +# Root context +# --------------------------------------------------------------------------- + + +class RootContext: + """Runtime container for a single media root.""" + + def __init__(self, root_id: str, root_path: Path): + self.root_id = root_id + self.root_path = root_path + self.status = "loading" + self.error: Optional[str] = None + + snapshot_path = root_path / ".mediahive" / "index.json" + self.store = IndexStore(snapshot_path, media_root=root_path.as_posix(), root_id=root_id) + + # Scanner is injected later by the supervisor + self.scanner: Optional[object] = None + + # Event queue and consumer + self._events: asyncio.Queue[ScanEvent] = asyncio.Queue() + self._consumer_task: Optional[asyncio.Task] = None + + async def start(self) -> None: + """Load snapshot and start event consumer.""" + try: + await self.store.load_snapshot() + self.status = "ready" + logger.info( + "Root %s ready: %d movies, %d series", + self.root_id, + len(self.store.movies), + len(self.store.series), + ) + except Exception as exc: + self.status = "error" + self.error = str(exc) + logger.exception("Root %s failed to load snapshot", self.root_id) + + self._consumer_task = asyncio.create_task(self._consume_events()) + + async def stop(self) -> None: + """Stop consumer, flush snapshot, stop scanner.""" + if self.scanner is not None: + try: + await self.scanner.stop() + except Exception: + logger.exception("Error stopping scanner for root %s", self.root_id) + self.scanner = None + + if self._consumer_task and not self._consumer_task.done(): + self._consumer_task.cancel() + try: + await self._consumer_task + except asyncio.CancelledError: + pass + + try: + await self.store.flush_snapshot() + except Exception: + logger.exception("Error flushing snapshot for root %s", self.root_id) + + async def send_event(self, event: ScanEvent) -> None: + """Called by the scanner to push an event into this root's queue.""" + await self._events.put(event) + + async def _consume_events(self) -> None: + while True: + try: + event = await self._events.get() + if isinstance(event, Upsert): + if event.kind == "movie": + self.store.upsert_movie(event.item) + else: + self.store.upsert_series(event.item) + elif isinstance(event, Task): + self.store.broadcast_task(event.data) + except asyncio.CancelledError: + return + except Exception: + logger.exception("Error processing scan event for root %s", self.root_id) + + +# --------------------------------------------------------------------------- +# Supervisor +# --------------------------------------------------------------------------- + + +class Supervisor: + """Manages the active set of RootContexts and handles atomic replacement.""" + + def __init__(self): + # Active contexts keyed by root_id + self._contexts: dict[str, RootContext] = {} + self._lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # Read helpers + # ------------------------------------------------------------------ + + def get(self, root_id: str) -> Optional[RootContext]: + return self._contexts.get(root_id) + + def all_contexts(self) -> dict[str, RootContext]: + return self._contexts.copy() + + def all_statuses(self) -> list[dict]: + return [ + { + "root_id": ctx.root_id, + "path": ctx.root_path.as_posix(), + "status": ctx.status, + "error": ctx.error, + "movies": len(ctx.store.movies), + "series": len(ctx.store.series), + } + for ctx in self._contexts.values() + ] + + def merged_index(self) -> dict: + """Return a merged index snapshot from all ready roots.""" + movies = [] + series = [] + total_movie_versions = 0 + total_series_episodes = 0 + for ctx in self._contexts.values(): + if ctx.status != "ready" and ctx.status != "scanning": + continue + movies.extend(ctx.store.movies.values()) + series.extend(ctx.store.series.values()) + total_movie_versions += sum(len(m.torrents) for m in ctx.store.movies.values()) + total_series_episodes += sum( + sum(len(season.episodes) for season in s.seasons) + for s in ctx.store.series.values() + ) + + from datetime import datetime + from mediahive.models.data import IndexSnapshot, MediaStats + + return { + "version": 7, + "generated_at": datetime.now().isoformat(), + "stats": { + "total_movies": len(movies), + "total_movie_versions": total_movie_versions, + "total_series": len(series), + "total_series_episodes": total_series_episodes, + }, + "movies": movies, + "series": series, + } + + # ------------------------------------------------------------------ + # Atomic replacement + # ------------------------------------------------------------------ + + async def replace_roots(self, roots: dict[str, str]) -> tuple[list[RootEntry], list[dict]]: + """Atomically replace the active root set. + + Returns (accepted_entries, failed_entries_with_reason). + """ + async with self._lock: + # Validate and canonicalize + candidates: list[RootEntry] = [] + seen_paths: set[str] = set() + seen_ids: set[str] = set() + failed: list[dict] = [] + + for name, path_str in roots.items(): + name = name.strip() + if not name: + failed.append({"name": name, "path": path_str, "reason": "empty name"}) + continue + p = Path(path_str).expanduser().resolve() + if not p.exists() or not p.is_dir(): + failed.append({"name": name, "path": path_str, "reason": "not a directory"}) + continue + norm = _normalize_path(p.as_posix()) + if norm in seen_paths: + failed.append({"name": name, "path": path_str, "reason": "duplicate path"}) + continue + seen_paths.add(norm) + rid = compute_root_id(str(p)) + if rid in seen_ids: + # Extremely unlikely hash collision — fall back to full hash + rid = hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16] + seen_ids.add(rid) + candidates.append(RootEntry(name=name, path=norm, root_id=rid)) + + # Build desired root_id set + desired_ids = {e.root_id for e in candidates} + + # Stop scanners for roots that are being removed or changed + old_contexts = list(self._contexts.values()) + for ctx in old_contexts: + if ctx.root_id not in desired_ids: + asyncio.create_task(ctx.stop()) + + # Prepare new contexts + new_contexts: dict[str, RootContext] = {} + for entry in candidates: + existing = self._contexts.get(entry.root_id) + if existing and existing.root_path.as_posix() == entry.path: + # Reuse existing context + new_contexts[entry.root_id] = existing + else: + # If existing path changed, stop old one + if existing: + asyncio.create_task(existing.stop()) + ctx = RootContext(entry.root_id, Path(entry.path)) + await ctx.start() + new_contexts[entry.root_id] = ctx + + # Atomic swap + self._contexts = new_contexts + + # Persist to config + cfg = load_config() + save_config( + msgspec.structs.replace( + cfg, + roots={e.name: e.path for e in candidates}, + ) + ) + + return candidates, failed + + async def shutdown(self) -> None: + async with self._lock: + for ctx in list(self._contexts.values()): + await ctx.stop() + self._contexts.clear() diff --git a/mediahive/server.py b/mediahive/server.py index 5010984..0d9a7e4 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -6,6 +6,8 @@ pipeline with live WebSocket updates. Excluded paths are controlled by ``.mediahive/scanignore`` (gitignore-style syntax). """ +from __future__ import annotations + import asyncio import json import logging @@ -22,25 +24,21 @@ from pathlib import Path import aiofiles import msgspec -import msgspec.structs from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi_vue import Frontend from mediahive.__main__ import DEVMODE -from mediahive.config import load_config, save_config -from mediahive.hivescan.scanner import start as start_scanner -from mediahive.hivescan.scanner import stop as stop_scanner -from mediahive.index_store import IndexStore -from mediahive.models.events import ScanEvent, Task, Upsert +from mediahive.config import load_config +from mediahive.hivescan.scanner import RootScanner from mediahive.models.protocol import ( - ChangeFolderRequest, MsgspecResponse, OpenFolderRequest, PlayMediaRequest, - StatusResponse, + RootsRequest, ) +from mediahive.root_registry import Supervisor, compute_root_id logger = logging.getLogger("mediahive.server") @@ -54,35 +52,49 @@ _POPEN_KWARGS: dict = ( # Vue Frontend static files frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"]) -# Media root path (initialized in lifespan) -MEDIAROOT = None +# Supervisor manages all root contexts +supervisor = Supervisor() -# In-memory index store (available immediately, switched to real root later) -_BOOTSTRAP_SNAPSHOT = Path(tempfile.gettempdir()) / "mediahive" / "index.json" -store: IndexStore = IndexStore(_BOOTSTRAP_SNAPSHOT, media_root=None) - -# Whether the scanner subsystem is active -_scanner_active = False - -# Background folder switch task and lock so startup/change-folder cannot race -_folder_switch_task: asyncio.Task | None = None -_folder_switch_lock = asyncio.Lock() - -# Queue for scanner → server events -_scan_events: asyncio.Queue[ScanEvent] = asyncio.Queue() -_consumer_task: asyncio.Task | None = None _RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$") -def _require_media_root() -> Path: - if MEDIAROOT is None: - raise HTTPException(status_code=503, detail="Media root not initialized yet") - return MEDIAROOT +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- -async def _send_event(event: ScanEvent) -> None: - """Push a scan event onto the queue (passed to hivescan as *send*).""" - await _scan_events.put(event) +def _get_context(root_id: str): + ctx = supervisor.get(root_id) + if ctx is None: + raise HTTPException(status_code=404, detail=f"Root not found: {root_id}") + return ctx + + +def _load_resume_positions(root_path: Path) -> dict[str, int]: + playback_state_path = root_path / ".mediahive" / "playback-state.json" + try: + raw = json.loads(playback_state_path.read_text(encoding="utf-8")) + except Exception: + return {} + + resume_positions = raw.get("resume_positions") if isinstance(raw, dict) else None + if not isinstance(resume_positions, dict): + return {} + + cleaned: dict[str, int] = {} + for key, value in resume_positions.items(): + if isinstance(key, str) and isinstance(value, (int, float)): + cleaned[key] = max(0, int(value)) + return cleaned + + +def _open_with_default_app(path: Path) -> None: + if sys.platform == "win32": + os.startfile(str(path)) + return + + opener = "open" if sys.platform == "darwin" else "xdg-open" + subprocess.Popen([opener, str(path)], **_POPEN_KWARGS) def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: @@ -127,70 +139,103 @@ def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: return start, min(end, file_size - 1) -async def _consume_scan_events() -> None: - """Background task: apply incoming scan events to the IndexStore.""" - while True: +def _validate_root_paths(roots: dict[str, str]) -> dict[str, str]: + """Validate root paths on the filesystem. + + Runs in a thread pool so macOS permission dialogs (and other blocking + filesystem checks) do not halt the asyncio event loop. + """ + validated: dict[str, str] = {} + for name, path_str in roots.items(): + p = Path(path_str).expanduser().resolve() + if not p.exists() or not p.is_dir(): + logger.warning("Root path invalid, skipping: %s", path_str) + continue + validated[name] = p.as_posix() + return validated + + +async def _attach_scanners() -> None: + """Ensure every active root context has a running scanner.""" + for ctx in supervisor.all_contexts().values(): + if ctx.scanner is None and ctx.status in ("ready", "loading"): + try: + scanner = RootScanner(ctx.root_id, ctx.root_path, ctx.send_event) + await scanner.start() + ctx.scanner = scanner + except Exception: + logger.exception("Failed to attach scanner for root %s", ctx.root_id) + + +async def _activate_all_roots() -> None: + """Background task: validate and activate all configured roots. + + This is deferred from lifespan startup so the server can begin accepting + requests immediately. Filesystem validation runs in a thread pool to avoid + blocking the event loop (and to let macOS permission dialogs appear without + stalling the server). + """ + desired: dict[str, str] = {} + + # 1. Persisted config roots + cfg = load_config() + if cfg.roots: + desired.update(cfg.roots) + + # 2. CLI roots via MEDIAHIVE_ROOTS (JSON dict) + env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS") + if env_roots_raw: try: - event = await _scan_events.get() - if isinstance(event, Upsert): - if event.kind == "movie": - store.upsert_movie(event.item) - else: - store.upsert_series(event.item) - elif isinstance(event, Task): - store.broadcast_task(event.data) - except asyncio.CancelledError: - return + env_roots = json.loads(env_roots_raw) + if isinstance(env_roots, dict): + desired.update(env_roots) except Exception: - logger.exception("Error processing scan event") + logger.exception("Failed to parse MEDIAHIVE_ROOTS") + + if not desired: + logger.info("No roots configured; waiting for PUT /api/roots") + return + + # Validate paths in a thread pool (macOS permission-dialog safe) + validated = await asyncio.to_thread(_validate_root_paths, desired) + if not validated: + logger.warning("No valid roots found after validation") + return + + try: + await supervisor.replace_roots(validated) + except Exception: + logger.exception("Failed to replace roots during background activation") + return + + await _attach_scanners() + logger.info("Background root activation complete; %d root(s) active", len(supervisor.all_contexts())) + + +# --------------------------------------------------------------------------- +# Lifespan +# --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI): - global MEDIAROOT, store, _scanner_active, _consumer_task, _folder_switch_task - await frontend.load() - # Bring up the API immediately with an empty in-memory store. - # Media-root initialization/scanner startup are deferred to a background task - # so macOS permission prompts cannot block server readiness. - store = IndexStore(_BOOTSTRAP_SNAPSHOT, media_root=None) - await store.load_snapshot() - _scanner_active = False + # Defer root activation to a background task so the server starts + # immediately and macOS permission dialogs do not block startup. + activation_task = asyncio.create_task(_activate_all_roots()) - initial_root_raw = os.environ.get("MEDIAHIVE_PATH") - defer_initial_root = os.environ.get("MEDIAHIVE_DEFER_INITIAL_ROOT") == "1" - - if initial_root_raw and not defer_initial_root: - initial_root = Path(initial_root_raw).expanduser() - if not initial_root.is_absolute(): - initial_root = Path.cwd() / initial_root - _folder_switch_task = asyncio.create_task(_switch_folder(initial_root)) - logger.info("Server started; scheduled initial media root activation") - else: - logger.info( - "Server started without active media root; waiting for folder activation" - ) + logger.info("Server ready; waiting for root activation") yield - # Shutdown - if _folder_switch_task and not _folder_switch_task.done(): - _folder_switch_task.cancel() - try: - await _folder_switch_task - except asyncio.CancelledError: - pass + activation_task.cancel() + try: + await activation_task + except asyncio.CancelledError: + pass - await stop_scanner() - _scanner_active = False - if _consumer_task: - _consumer_task.cancel() - try: - await _consumer_task - except asyncio.CancelledError: - pass - await store.flush_snapshot() + await supervisor.shutdown() app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE) @@ -205,44 +250,9 @@ app.add_middleware( ) -def normalize_path(url_path: str) -> Path: - """ - Convert URL path to filesystem path. - URL: /media/.mediahive/Movies/... - Returns: MEDIAROOT/.mediahive/Movies/... - """ - clean_path = url_path.lstrip("/") - return _require_media_root() / clean_path - - -def _load_resume_positions() -> dict[str, int]: - playback_state_path = _require_media_root() / ".mediahive" / "playback-state.json" - try: - raw = json.loads(playback_state_path.read_text(encoding="utf-8")) - except Exception: - return {} - - resume_positions = raw.get("resume_positions") if isinstance(raw, dict) else None - if not isinstance(resume_positions, dict): - return {} - - cleaned: dict[str, int] = {} - for key, value in resume_positions.items(): - if isinstance(key, str) and isinstance(value, (int, float)): - cleaned[key] = max(0, int(value)) - return cleaned - - -def _open_with_default_app(path: Path) -> None: - if sys.platform == "win32": - os.startfile(str(path)) - return - - opener = "open" if sys.platform == "darwin" else "xdg-open" - subprocess.Popen([opener, str(path)], **_POPEN_KWARGS) - - -# === API Endpoints === +# --------------------------------------------------------------------------- +# API Endpoints +# --------------------------------------------------------------------------- @app.get("/api/health") @@ -254,179 +264,120 @@ async def health_check(): @app.get("/api/config") async def get_config(): """Return current server configuration.""" - return {"media_folder": MEDIAROOT.as_posix() if MEDIAROOT else None} - - -@app.post("/api/change-folder") -async def change_folder_endpoint(request: Request): - """Switch the media root folder without restarting the server. - - Validates and persists the new folder, then returns immediately. - The actual in-memory switch runs as a background task so the HTTP - response is not held up by the (potentially slow) scanner teardown. - The client should poll /api/config or reload after a short delay. - """ - body = msgspec.json.decode(await request.body(), type=ChangeFolderRequest) - new_root = Path(body.folder).resolve() - if not new_root.exists() or not new_root.is_dir(): - raise HTTPException( - status_code=400, detail=f"Folder does not exist: {new_root}" - ) - - # Persist first — if the background switch crashes, the next launch still uses the new path cfg = load_config() - save_config(msgspec.structs.replace(cfg, media_folder=new_root.as_posix())) - logger.info("Config saved: media_folder=%s", new_root) - - # Schedule the in-memory switch without blocking this response - asyncio.create_task(_switch_folder(new_root)) - return {"status": "ok"} + return {"roots": cfg.roots} -async def _switch_folder(new_root: Path) -> None: - global MEDIAROOT, store, _consumer_task, _scan_events, _scanner_active - - async with _folder_switch_lock: - try: - # Cancel scanner tasks immediately — no need to wait 30 s - await stop_scanner() - _scanner_active = False - - # Tear down the old event consumer - if _consumer_task and not _consumer_task.done(): - _consumer_task.cancel() - try: - await _consumer_task - except asyncio.CancelledError: - pass - - # Flush the old index snapshot - await store.flush_snapshot() - - # Update env and module globals - os.environ["MEDIAHIVE_PATH"] = new_root.as_posix() - MEDIAROOT = new_root - - # Fresh event queue — discard any stale events from the old folder - _scan_events = asyncio.Queue() - - # Re-initialise the index store - snapshot_path = MEDIAROOT / ".mediahive" / "index.json" - store = IndexStore(snapshot_path, media_root=MEDIAROOT.as_posix()) - await store.load_snapshot() - logger.info( - "Index store ready: %d movies, %d series", - len(store.movies), - len(store.series), - ) - - # Restart consumer and scanner - _consumer_task = asyncio.create_task(_consume_scan_events()) - await start_scanner(_send_event) - _scanner_active = True - - logger.info("Switched media folder to %s", MEDIAROOT) - except Exception: - logger.exception("Error switching media folder to %s", new_root) +# --- Root management --- -@app.get("/api/index") -async def get_index(): - """Return the full media index from the in-memory store.""" - return MsgspecResponse(store.get_full_index()) +@app.get("/api/roots") +async def get_roots(): + """List all active roots with their status.""" + return {"roots": supervisor.all_statuses()} -@app.get("/api/playback/resume-positions") -async def playback_resume_positions(): - """Return saved per-file resume positions under the current media root.""" - return {"resume_positions": _load_resume_positions()} +@app.put("/api/roots") +async def put_roots(request: Request): + """Atomically replace the full root set.""" + body = msgspec.json.decode(await request.body(), type=RootsRequest) + accepted, failed = await supervisor.replace_roots(body.roots) + + # Start scanners for newly accepted roots + await _attach_scanners() + + return { + "status": "ok", + "accepted": [ + {"name": e.name, "path": e.path, "root_id": e.root_id} for e in accepted + ], + "failed": failed, + } -# --------------------------------------------------------------------------- -# Scanning API (active when HIVESCAN_PATHS is configured) -# --------------------------------------------------------------------------- +@app.get("/api/roots/{root_id}/index") +async def get_root_index(root_id: str): + """Return the full index for a single root.""" + ctx = _get_context(root_id) + return MsgspecResponse(ctx.store.get_full_index()) -@app.websocket("/api/ws") -async def ws_endpoint(ws: WebSocket): - """Live index updates and task progress.""" - await store.connect(ws) +@app.get("/api/roots/{root_id}/status") +async def get_root_status(root_id: str): + """Return status for a single root.""" + ctx = _get_context(root_id) + scanning = ctx.scanner is not None and ctx.scanner.is_scanning() + return { + "root_id": ctx.root_id, + "path": ctx.root_path.as_posix(), + "status": ctx.status, + "error": ctx.error, + "scanning": scanning, + "movies": len(ctx.store.movies), + "series": len(ctx.store.series), + "showreel_queue": ctx.scanner.showreel_queue_size() if ctx.scanner else 0, + } + + +@app.post("/api/roots/{root_id}/scan") +async def trigger_root_scan(root_id: str): + """Trigger a scan for a single root.""" + ctx = _get_context(root_id) + if ctx.scanner is None: + raise HTTPException(status_code=503, detail="Scanner not active") + started = ctx.scanner.trigger_scan() + return {"status": "started" if started else "already_running"} + + +# --- Per-root WebSocket --- + + +@app.websocket("/api/roots/{root_id}/ws") +async def ws_endpoint(ws: WebSocket, root_id: str): + """Live index updates and task progress for a single root.""" + ctx = supervisor.get(root_id) + if ctx is None: + await ws.close(code=1008, reason="Unknown root") + return + + await ctx.store.connect(ws) try: while True: await ws.receive_text() except WebSocketDisconnect: - store.disconnect(ws) + ctx.store.disconnect(ws) except Exception: - store.disconnect(ws) + ctx.store.disconnect(ws) -@app.post("/api/scan") -async def trigger_scan(): - """Trigger a new scan. Returns 409 if a scan is already running.""" - if not _scanner_active: - raise HTTPException(status_code=503, detail="Scanner not active yet") - from mediahive.hivescan.scanner import trigger_scan as _trigger - - started = _trigger() - return {"status": "started" if started else "already_running"} +# --- Media actions --- -@app.get("/api/status") -async def server_status(): - """Return current server status.""" - if _scanner_active: - from mediahive.hivescan.scanner import is_scanning, showreel_queue_size - - return MsgspecResponse( - StatusResponse( - scanning=is_scanning(), - movies=len(store.movies), - series=len(store.series), - showreel_queue=showreel_queue_size(), - ) - ) - return MsgspecResponse( - StatusResponse( - movies=len(store.movies), - series=len(store.series), - ) - ) - - -@app.post("/api/play") -async def play_media(request: Request): - """ - Open a media file with the system's default player. - """ +@app.post("/api/roots/{root_id}/play") +async def play_media(root_id: str, request: Request): + """Open a media file with the system's default player.""" + ctx = _get_context(root_id) req = msgspec.json.decode(await request.body(), type=PlayMediaRequest) - print(f"[play] Received path: {req.file_path}") - file_path = _require_media_root() / req.file_path + file_path = ctx.root_path / req.file_path if not file_path.exists(): - print(f"[play] File not found: {file_path}") raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}") try: _open_with_default_app(file_path) - return {"status": "ok"} - except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to play media: {e}") -@app.post("/api/open-folder") -async def open_folder(request: Request): - """ - Open a folder in the system file explorer. - If the path is a file, opens the parent folder and selects the file. - """ +@app.post("/api/roots/{root_id}/open-folder") +async def open_folder(root_id: str, request: Request): + """Open a folder in the system file explorer.""" + ctx = _get_context(root_id) req = msgspec.json.decode(await request.body(), type=OpenFolderRequest) - print(f"[open-folder] Received path: {req.folder_path}") - target_path = _require_media_root() / req.folder_path + target_path = ctx.root_path / req.folder_path if not target_path.exists(): - print(f"[open-folder] Path not found: {target_path}") raise HTTPException( status_code=404, detail=f"Path not found: {req.folder_path}" ) @@ -434,12 +385,10 @@ async def open_folder(request: Request): try: if sys.platform == "win32": if target_path.is_file(): - # Open parent folder and select the file subprocess.Popen( ["explorer", "/select,", str(target_path)], **_POPEN_KWARGS ) else: - # Open the folder directly subprocess.Popen(["explorer", str(target_path)], **_POPEN_KWARGS) elif sys.platform == "darwin": if target_path.is_file(): @@ -447,28 +396,22 @@ async def open_folder(request: Request): else: subprocess.Popen(["open", str(target_path)]) else: - # Linux - just open the folder (no standard way to select) folder = target_path.parent if target_path.is_file() else target_path subprocess.Popen(["xdg-open", str(folder)]) return {"status": "ok"} - except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}") -def _mpcbe_request(path: str, timeout: float = 0.75) -> bool: - """Call MPC-BE's local web interface and return True on HTTP success.""" - if sys.platform != "win32": - return False +@app.get("/api/roots/{root_id}/playback/resume-positions") +async def root_playback_resume_positions(root_id: str): + """Return saved per-file resume positions under a specific root.""" + ctx = _get_context(root_id) + return {"resume_positions": _load_resume_positions(ctx.root_path)} - url = f"{MPC_BE_BASE_URL}{path}" - req = urllib.request.Request(url=url, method="GET") - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - return 200 <= resp.status < 300 - except urllib.error.URLError, TimeoutError, OSError: - return False + +# --- MPC-BE / Player status --- @app.get("/api/mpcbe/status") @@ -483,17 +426,32 @@ async def player_status(): return {"remote": _mpcbe_request("/")} -@app.get("/api/media/{file_path:path}") -async def serve_media_file(file_path: str, request: Request): - """ - Serve a media file asynchronously. - """ - full_path = normalize_path(file_path) - media_root = _require_media_root() +def _mpcbe_request(path: str, timeout: float = 0.75) -> bool: + """Call MPC-BE's local web interface and return True on HTTP success.""" + if sys.platform != "win32": + return False + + url = f"{MPC_BE_BASE_URL}{path}" + req = urllib.request.Request(url=url, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return 200 <= resp.status < 300 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +# --- Media file serving --- + + +@app.get("/api/media/{root_id}/{file_path:path}") +async def serve_media_file(root_id: str, file_path: str, request: Request): + """Serve a media file asynchronously, scoped to a root.""" + ctx = _get_context(root_id) + full_path = ctx.root_path / file_path.lstrip("/") # Security: ensure path doesn't escape base try: - full_path.resolve().relative_to(media_root.resolve()) + full_path.resolve().relative_to(ctx.root_path.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") @@ -505,19 +463,15 @@ async def serve_media_file(file_path: str, request: Request): file_size = full_path.stat().st_size - # Guess content type content_type, _ = mimetypes.guess_type(str(full_path)) if content_type is None: content_type = "application/octet-stream" - # For images, use FileResponse which handles caching headers if content_type.startswith("image/"): return FileResponse( full_path, media_type=content_type, - headers={ - "Cache-Control": "public, max-age=86400", - }, + headers={"Cache-Control": "public, max-age=86400"}, ) async def stream_file(start: int, end: int): diff --git a/mediahive/winmain.py b/mediahive/winmain.py index 1a79e8e..ad7fedf 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -154,12 +154,15 @@ def _save_playback_state(path: Path, state: dict[str, object]) -> None: tmp_path.replace(path) -def _media_key_for_filepath(filepath: str, media_root: Path) -> str | None: - try: - relative = Path(filepath).resolve().relative_to(media_root.resolve()) - except Exception: - return None - return relative.as_posix() +def _media_key_for_filepath(filepath: str, roots: list[Path]) -> tuple[str, Path] | None: + """Resolve a filepath to a (relative_key, matched_root) tuple.""" + for root in roots: + try: + relative = Path(filepath).resolve().relative_to(root.resolve()) + return relative.as_posix(), root + except Exception: + continue + return None def _should_clear_resume(position_ms: int, duration_ms: int) -> bool: @@ -191,7 +194,7 @@ def _mpcbe_request(path: str, timeout: float = MPC_BE_REQUEST_TIMEOUT) -> bool: try: with urllib.request.urlopen(req, timeout=timeout) as resp: return 200 <= resp.status < 300 - except urllib.error.URLError, TimeoutError, OSError: + except (urllib.error.URLError, TimeoutError, OSError): return False @@ -222,7 +225,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None: try: with urllib.request.urlopen(req, timeout=MPC_BE_REQUEST_TIMEOUT) as resp: response_html = resp.read().decode("utf-8", errors="replace") - except urllib.error.URLError, TimeoutError, OSError: + except (urllib.error.URLError, TimeoutError, OSError): return None state_match = _STATE_RE.search(response_html) @@ -242,7 +245,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None: def _start_gamepad_remote( - stop_event: threading.Event, media_root: Path + stop_event: threading.Event, roots: list[Path] ) -> threading.Thread: """Start background XInput polling and send mapped commands to MPC-BE.""" get_state = _load_xinput_get_state() @@ -273,7 +276,10 @@ def _start_gamepad_remote( player_state: int | None = None status_updated_at = 0.0 status_miss_count = 0 - playback_state_path = media_root / ".mediahive" / "playback-state.json" + + # Use the first root's playback state path as primary + primary_root = roots[0] if roots else Path.cwd() + playback_state_path = primary_root / ".mediahive" / "playback-state.json" playback_state = _load_playback_state(playback_state_path) resume_positions = playback_state["resume_positions"] if not isinstance(resume_positions, dict): @@ -429,7 +435,8 @@ def _start_gamepad_remote( status_miss_count = 0 filepath, position_ms, duration_ms, state = status - media_key = _media_key_for_filepath(filepath, media_root) if filepath else None + resolved = _media_key_for_filepath(filepath, roots) if filepath else None + media_key = resolved[0] if resolved else None if tracked_media_key is not None and media_key != tracked_media_key: finalize_tracked_current() @@ -676,7 +683,6 @@ def _icon_path() -> str | None: def _webview_start_kwargs() -> dict[str, str]: """Return platform-specific pywebview startup kwargs.""" - # On macOS, force Qt backend so pywebview uses Chromium/WebEngine instead of WKWebView. if sys.platform == "darwin": return {"gui": "qt"} return {} @@ -751,7 +757,7 @@ def winmain() -> None: parser.add_argument( "media_folder", nargs="?", - help="Path to the media folder (default: saved config, MEDIAHIVE_PATH, or cwd)", + help="Path to the media folder (default: saved config or initial setup dialog)", ) args = parser.parse_args() @@ -761,33 +767,42 @@ def winmain() -> None: if getattr(sys, "frozen", False): _setup_logging() - # Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user - folder = ( - args.media_folder - or os.environ.get("MEDIAHIVE_PATH") - or load_config().media_folder - ) + cfg = load_config() - if not folder: + # Build initial roots dict (filesystem is NOT touched here — validation is + # deferred to the server's background activation task). + initial_roots: dict[str, str] = {} + if args.media_folder: + p = _normalize_media_root_input(args.media_folder) + name = p.name or "media" + initial_roots[name] = p.as_posix() + elif cfg.roots: + initial_roots = cfg.roots + elif cfg.media_folder: + p = _normalize_media_root_input(cfg.media_folder) + name = p.name or "media" + initial_roots[name] = p.as_posix() + + if not initial_roots: folder = _run_initial_setup() if not folder: - return # user cancelled the folder picker + return # user cancelled + p = _normalize_media_root_input(folder) + name = p.name or "media" + initial_roots[name] = p.as_posix() - mediaroot = _normalize_media_root_input(folder) - os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix() - os.environ["MEDIAHIVE_DEFER_INITIAL_ROOT"] = "1" + # Persist resolved roots + if cfg.roots != initial_roots: + save_config(msgspec.structs.replace(cfg, roots=initial_roots)) + + # Pass roots to the server via env (validation deferred to server startup) + os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots) backend_port = _reserve_backend_port() backend_url = f"http://{BACKEND_HOST}:{backend_port}" os.environ["MEDIAHIVE_BACKEND_URL"] = backend_url - # Persist the resolved path so subsequent launches remember it. - cfg = load_config() - if cfg.media_folder != mediaroot.as_posix(): - save_config(msgspec.structs.replace(cfg, media_folder=mediaroot.as_posix())) - - # Run the FastAPI backend on a background thread so the main thread is - # free for pywebview (Edge WebView2 requires the GUI on the main thread). + # Run the FastAPI backend on a background thread config = uvicorn.Config( "mediahive.server:app", host=BACKEND_HOST, @@ -801,19 +816,19 @@ def winmain() -> None: ) backend_thread.start() - def _activate_initial_folder() -> None: - body = json.dumps({"folder": mediaroot.as_posix()}).encode("utf-8") + def _activate_initial_roots() -> None: + body = json.dumps({"roots": initial_roots}).encode("utf-8") req = urllib.request.Request( - url=f"{backend_url}/api/change-folder", + url=f"{backend_url}/api/roots", data=body, - method="POST", + method="PUT", headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=10): - logger.info("Requested initial media folder activation") + logger.info("Requested initial roots activation") except Exception as exc: - logger.warning("Initial media folder activation request failed: %s", exc) + logger.warning("Initial roots activation request failed: %s", exc) if not _wait_for_backend(timeout=HEALTH_TIMEOUT): server.should_exit = True @@ -831,6 +846,9 @@ def winmain() -> None: poll_stop = threading.Event() poll_thread: threading.Thread | None = None + # Resolve all root paths for gamepad remote + gamepad_roots = [Path(p) for p in initial_roots.values()] + def on_shown() -> None: api._window = window try: @@ -842,13 +860,12 @@ def winmain() -> None: nonlocal poll_thread if poll_thread is None and _supports_gamepad_remote(): - poll_thread = _start_gamepad_remote(poll_stop, mediaroot) + poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots) - # Trigger initial folder activation after main UI is shown. threading.Thread( - target=_activate_initial_folder, + target=_activate_initial_roots, daemon=True, - name="mediahive-initial-folder-activation", + name="mediahive-initial-roots-activation", ).start() webview.start(func=on_shown, icon=_icon_path(), **_webview_start_kwargs())