diff --git a/docs/API.md b/docs/API.md index 9f4b1d7..0517640 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,6 +11,9 @@ All media paths are scoped to a **root**, identified by a friendly `root_id` | `GET` | `/api/health` | Lightweight health check. | | `GET` | `/api/config` | Returns the current root configuration. | | `PUT` | `/api/config/roots` | Atomically replace the full root set. Returns `{ "status": "ok", "accepted": [{path, root_id}], "failed": [...] }`. | +| `GET` | `/api/update` | Returns `{ "version", "auto_update", "pending_version" }` — installed version, auto-update preference, and any downloaded update staged for the next launch (Velopack GUI builds only; `null` elsewhere). | +| `PUT` | `/api/config/auto-update` | Enable/disable automatic update downloads. Body `{ "enabled": bool }`, persisted in config. | +| `POST` | `/api/update/restart` | Applies the staged update and restarts into it. `404` when no update is pending. | | `POST` | `/api/play/{root_id}` | Opens a media file with a media player. Also starts an assumed-playback session (see notes). | | `GET` | `/api/players` | Lists detected media players. Returns `{ "players": [{id, name, family, path}] }`, including synthetic `default` and `custom` entries. | | `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. | diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ceacc0f..244108e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -45,6 +45,9 @@ :current-view="headerCurrentView" :search-query="searchQuery" :roots="headerRoots" + :scan-tasks="tasks" + :scan-connected="wsConnected" + :initial-scan-mode="isInitialScanMode" :mpc-be-connected="mpcBeConnected" :nav-row="1" :position="headerPosition" @@ -209,7 +212,6 @@ import type { SeriesUi, MediaItem, EpisodeWithSeries, - TaskInfo, SeriesResumePoint, } from "./types" import { @@ -228,6 +230,8 @@ import { } from "./composables/useKeyboardNavigation" import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation" import { useMediaWebSocket } from "./composables/useMediaWebSocket" +import { computeProgressRoots, type RootTaskInfo } from "./composables/useScanProgress" +import { useSettingsOpen } from "./composables/useSettingsOpen" import Header from "./components/Header.vue" import CollageHero from "./components/CollageHero.vue" import MediaRow from "./components/MediaRow.vue" @@ -292,20 +296,6 @@ const { roots: rootStatuses, } = useMediaWebSocket() -type RootTaskInfo = TaskInfo & { root_id: string } - -interface ProgressRootState { - rootId: string - rootLabel: string - scanTarget: string | null - phaseLabel: string - phaseDetail: string | null - progressPercent: number - progressLabel: string | null - isDeterminate: boolean - toneClass: string -} - const activeTasks = computed(() => Array.from(tasks.value.values())) function getRootName(rootId: string | null | undefined): string | null { @@ -313,132 +303,15 @@ function getRootName(rootId: string | null | undefined): string | null { return rootStatuses.value.get(rootId)?.root_id || null } -function normalizePosixPath(value: string): string { - return value.replace(/\\/g, "/") -} +const progressRoots = computed(() => + computeProgressRoots( + activeTasks.value, + (rootId) => rootStatuses.value.get(rootId)?.path || null, + isInitialScanMode.value, + ), +) -function extractScanPath(detail: string): string | null { - if (!detail.startsWith("Scanning:")) return null - let value = detail.replace(/^Scanning:\s*/i, "").trim() - value = value.replace(/\s*\(\d+\s+found\)\s*$/i, "").trim() - return value || null -} - -function buildScanTarget(rootId: string, rootPath: string | null, detail: string): string | null { - const rawPath = extractScanPath(detail) - if (!rawPath) return null - - const posixRaw = normalizePosixPath(rawPath) - const posixRoot = rootPath ? normalizePosixPath(rootPath) : null - - let relative = posixRaw - if (posixRoot) { - const lowRaw = posixRaw.toLowerCase() - const lowRoot = posixRoot.toLowerCase() - if (lowRaw === lowRoot) { - relative = "" - } else if (lowRaw.startsWith(`${lowRoot}/`)) { - relative = posixRaw.slice(posixRoot.length).replace(/^\/+/, "") - } - } - - if (!relative) return rootId - if (relative.toLowerCase().startsWith(`${rootId.toLowerCase()}/`)) return relative - return `${rootId}/${relative}` -} - -function describeRootProgress( - rootId: string, - rootPath: string | null, - tasksForRoot: RootTaskInfo[], - isInitialScanMode: boolean, -): ProgressRootState | null { - const running = tasksForRoot.filter((task) => task.status === "running") - const latestError = [...tasksForRoot].reverse().find((task) => task.status === "error") || null - - if (running.length === 0 && !latestError) return null - - const scanTask = running.find((task) => task.id.startsWith("scan-")) || null - const showreelCount = running.filter((task) => task.id.startsWith("showreel-")).length - const otherRunningCount = running.length - (scanTask ? 1 : 0) - showreelCount - - let phaseLabel = "Processing media" - let phaseDetail: string | null = null - let scanTarget: string | null = null - let isDeterminate = false - let progressPercent = 0 - let progressLabel: string | null = null - let toneClass = "" - - if (scanTask) { - const detail = (scanTask.detail || "").trim() - scanTarget = buildScanTarget(rootId, rootPath, detail) - if (detail.startsWith("Scanning:")) { - phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates" - phaseDetail = isInitialScanMode ? "Looking for new files" : "Running background scan" - } else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) { - phaseLabel = "Preparing titles" - phaseDetail = "Matching files and grouping releases" - } else { - phaseLabel = "Fetching metadata" - phaseDetail = detail ? `Current title: ${detail}` : "Updating titles and artwork" - } - if (scanTask.progress > 0 && scanTask.progress <= 1) { - isDeterminate = true - progressPercent = Math.max(1, Math.round(scanTask.progress * 100)) - progressLabel = `${progressPercent}%` - } - } else if (showreelCount > 0) { - phaseLabel = "Generating previews" - phaseDetail = - showreelCount === 1 ? "Building 1 preview reel" : `Building ${showreelCount} preview reels` - } else if (otherRunningCount > 0) { - phaseLabel = "Finalizing updates" - phaseDetail = "Applying library changes" - } else if (latestError) { - phaseLabel = "Needs attention" - phaseDetail = latestError.detail || "A background task failed" - toneClass = "activity-root-error" - } - - if (showreelCount > 0 && scanTask) { - phaseDetail = phaseDetail - ? `${phaseDetail}. Preview generation is running in parallel.` - : "Preview generation is running in parallel" - } - - return { - rootId, - rootLabel: rootId, - scanTarget, - phaseLabel, - phaseDetail, - progressPercent, - progressLabel, - isDeterminate, - toneClass, - } -} - -const progressRoots = computed(() => { - const byRoot = new Map() - for (const task of activeTasks.value) { - const list = byRoot.get(task.root_id) || [] - list.push(task) - byRoot.set(task.root_id, list) - } - - const rows: ProgressRootState[] = [] - const initial = isInitialScanMode.value - for (const [rootId, rootTasks] of byRoot) { - const rootPath = rootStatuses.value.get(rootId)?.path || null - const row = describeRootProgress(rootId, rootPath, rootTasks, initial) - if (row) rows.push(row) - } - return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel)) -}) - -const isSettingsView = computed(() => route.path === "/settings") +const isSettingsView = useSettingsOpen() const hasLibraryItems = computed(() => { if (!mediaIndex.value) return false return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0 @@ -453,10 +326,8 @@ const headerRoots = computed(() => ) const showProgressPanel = computed(() => { - if (isInitialScanMode.value) { - return !wsConnected.value || progressRoots.value.length > 0 - } - if (!isSettingsView.value) return false + if (isSettingsView.value) return false + if (!isInitialScanMode.value) return false return !wsConnected.value || progressRoots.value.length > 0 }) @@ -1075,7 +946,7 @@ const activePanelScrollTop = computed(() => { }) const headerStyle = computed(() => { - if (route.path === "/settings") { + if (isSettingsView.value) { return {} } return { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a07f23e..ffaa4a4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -426,13 +426,42 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s } /** - * 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. + * Invoke the native OS folder picker via pywebview. + * Only works inside the packaged desktop app; returns null elsewhere. */ -export async function pickFolderAndAddRoot(): Promise { +export async function pickFolder(): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const api = (window as any).pywebview?.api if (!api) return null const folder: string | null = await api.pick_folder() return folder } + +export interface UpdateStatus { + version: string + auto_update: boolean + pending_version: string | null +} + +/** Fetch version, auto-update preference, and any downloaded pending update. */ +export async function fetchUpdateStatus(): Promise { + const response = await fetch("/api/update") + if (!response.ok) throw new Error(`Failed to fetch update status: ${response.status}`) + return response.json() +} + +/** Enable or disable automatic update downloads (persisted server-side). */ +export async function setAutoUpdate(enabled: boolean): Promise { + const response = await fetch("/api/config/auto-update", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }) + if (!response.ok) throw new Error(`Failed to save auto-update setting: ${response.status}`) +} + +/** Apply a downloaded update and restart the app into it. */ +export async function restartForUpdate(): Promise { + const response = await fetch("/api/update/restart", { method: "POST" }) + if (!response.ok) throw new Error(`Failed to restart for update: ${response.status}`) +} diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 90eebff..ccd80d9 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -88,227 +88,295 @@ - -
-
- -

Settings

-
-
- -
-
-

Media Roots

-

Folders scanned and indexed by MediaHive.

- -
-
+
+
+
+ +

Settings

+
+
+ +
+
+
+
+ MediaHive {{ updateStatus?.version ?? "…" }} + +
-
- {{ root.status }} - +
+
+
+ +
+
+
+ +
+

Application log

+
{{ appLog }}
+
+
+
+ +
+
Reconnecting...
+ +
@@ -317,11 +385,15 @@