Rework settings dialog: floating window, integrated activity log and scan progress, auto-update controls

This commit is contained in:
2026-09-24 04:27:37 +00:00
parent 7e2fcc05f6
commit 9b12e53039
11 changed files with 956 additions and 423 deletions
+3
View File
@@ -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 }`. |
+16 -145
View File
@@ -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<RootTaskInfo[]>(() => 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<ProgressRootState[]>(() => {
const byRoot = new Map<string, RootTaskInfo[]>()
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 {
+32 -3
View File
@@ -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<string | null> {
export async function pickFolder(): Promise<string | null> {
// 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<UpdateStatus> {
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<void> {
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<void> {
const response = await fetch("/api/update/restart", { method: "POST" })
if (!response.ok) throw new Error(`Failed to restart for update: ${response.status}`)
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
import type { TaskInfo } from "../types"
export type RootTaskInfo = TaskInfo & { root_id: string }
export interface ProgressRootState {
rootId: string
rootLabel: string
scanTarget: string | null
phaseLabel: string
phaseDetail: string | null
progressPercent: number
progressLabel: string | null
isDeterminate: boolean
toneClass: string
}
function normalizePosixPath(value: string): string {
return value.replace(/\\/g, "/")
}
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(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
if (posixRoot) {
const lowRaw = posixRaw.toLowerCase()
const lowRoot = posixRoot.toLowerCase()
if (lowRaw === lowRoot) return posixRoot
if (lowRaw.startsWith(`${lowRoot}/`)) {
return posixRaw.slice(posixRoot.length).replace(/^\/+/, "")
}
}
return posixRaw
}
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(rootPath, detail)
if (detail.startsWith("Scanning:")) {
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
} else if (/^Processing\s+\d+\s+(items|movies|series)/i.test(detail)) {
phaseLabel = "Preparing titles"
} else if (/^(Starting scan|No new items|Done|Scan cancelled)/i.test(detail)) {
phaseLabel = isInitialScanMode ? "Scanning folders" : "Checking for updates"
} else {
phaseLabel = "Fetching metadata"
phaseDetail = detail || null
}
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"
} else if (otherRunningCount > 0) {
phaseLabel = "Finalizing updates"
} else if (latestError) {
phaseLabel = "Needs attention"
phaseDetail = latestError.detail || "A background task failed"
toneClass = "activity-root-error"
}
return {
rootId,
rootLabel: rootId,
scanTarget,
phaseLabel,
phaseDetail,
progressPercent,
progressLabel,
isDeterminate,
toneClass,
}
}
export function computeProgressRoots(
tasks: Iterable<RootTaskInfo>,
getRootPath: (rootId: string) => string | null,
isInitialScanMode: boolean,
): ProgressRootState[] {
const byRoot = new Map<string, RootTaskInfo[]>()
for (const task of tasks) {
const list = byRoot.get(task.root_id) || []
list.push(task)
byRoot.set(task.root_id, list)
}
const rows: ProgressRootState[] = []
for (const [rootId, rootTasks] of byRoot) {
const row = describeRootProgress(rootId, getRootPath(rootId), rootTasks, isInitialScanMode)
if (row) rows.push(row)
}
return rows.sort((a, b) => a.rootLabel.localeCompare(b.rootLabel))
}
@@ -0,0 +1,9 @@
import { ref } from "vue"
// Settings is an overlay, not a route: opening it must not change the URL or
// the view behind it, so the open state is plain shared local state.
const settingsOpen = ref(false)
export function useSettingsOpen() {
return settingsOpen
}
+2 -2
View File
@@ -37,9 +37,9 @@ const router = createRouter({
component: EmptyRouteComponent,
},
{
// Settings is now an overlay with no URL of its own; keep old links working.
path: "/settings",
name: "settings",
component: EmptyRouteComponent,
redirect: "/movies",
},
{
path: "/series",
+1
View File
@@ -19,6 +19,7 @@ from platformdirs import user_config_path, user_log_path
class Config(msgspec.Struct, omit_defaults=True):
roots: dict[str, str] | None = None
auto_update: bool = True
# Runtime config shared between the CLI entrypoint and the server process via
+43 -1
View File
@@ -29,6 +29,7 @@ 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 (
@@ -39,7 +40,8 @@ from fastapi.responses import (
)
from fastapi_vue import Frontend, env
from mediahive.config import config, load_config, log_dir
from mediahive import updater
from mediahive.config import config, load_config, log_dir, save_config
from mediahive.hivescan.images import close_image_client
from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client
@@ -1079,6 +1081,46 @@ async def get_version():
return {"version": version}
# --- Auto-update (Velopack, GUI builds only) ---
@app.get("/api/update")
async def get_update_status():
"""Version, auto-update preference, and any staged (downloaded) update."""
version = (await get_version())["version"]
cfg = load_config()
pending = await asyncio.to_thread(updater.pending_update)
return {
"version": version,
"auto_update": cfg.auto_update,
"pending_version": pending,
}
@app.put("/api/config/auto-update")
async def put_auto_update(request: Request):
"""Enable/disable automatic update downloads (persisted in config)."""
body = msgspec.json.decode(await request.body())
enabled = bool(body.get("enabled", True))
cfg = load_config()
save_config(msgspec.structs.replace(cfg, auto_update=enabled))
with suppress(AttributeError, TypeError):
config.auto_update = enabled
if enabled:
# Catch up on anything missed while updates were disabled.
asyncio.create_task(asyncio.to_thread(updater.check_and_download))
return {"auto_update": enabled}
@app.post("/api/update/restart")
async def restart_for_update():
"""Apply the staged update and restart into it (never returns on success)."""
applied = await asyncio.to_thread(updater.apply_pending_and_restart)
if not applied:
raise HTTPException(status_code=404, detail="No downloaded update to apply")
return {"status": "restarting"}
def _read_log() -> str:
"""Return the full application log file."""
path = log_dir() / "mediahive.log"
+82
View File
@@ -0,0 +1,82 @@
"""Optional Velopack auto-update integration (GUI builds only).
In development and portable-ZIP runs Velopack is either not installed or the
app is not a Velopack installation; every helper degrades to a no-op then, so
callers never need to special-case those environments.
"""
import logging
from mediahive.config import load_config
logger = logging.getLogger(__name__)
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
def _manager():
"""Return a Velopack UpdateManager, or None when updates are unavailable."""
try:
import velopack
except ImportError:
return None
try:
return velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
except RuntimeError, OSError:
# Not a Velopack installation (dev / portable run).
return None
def pending_update() -> str | None:
"""Version of a downloaded update staged for the next launch, if any."""
mgr = _manager()
if mgr is None:
return None
try:
asset = mgr.get_update_pending_restart()
except RuntimeError, OSError:
return None
return str(asset.Version) if asset is not None else None
def apply_pending_and_restart() -> bool:
"""Apply the staged update and restart into it. False when nothing pending."""
mgr = _manager()
if mgr is None:
return False
try:
asset = mgr.get_update_pending_restart()
if asset is None:
return False
logger.info("Velopack: applying staged update %s and restarting", asset.Version)
mgr.apply_updates_and_restart(asset)
except (RuntimeError, OSError) as exc:
logger.warning("Velopack: failed to apply staged update: %s", exc)
return False
return True
def check_and_download() -> None:
"""Download available updates in the background, unless disabled in config.
Downloaded updates are applied automatically by Velopack on the next app
start, so the running session is never interrupted. Network failures and
non-Velopack runs are expected and skipped quietly.
"""
if not load_config().auto_update:
logger.info("Velopack: automatic updates disabled, skipping check")
return
mgr = _manager()
if mgr is None:
return
try:
info = mgr.check_for_updates()
if info is None:
logger.info("Velopack: no update available")
return
version = info.TargetFullRelease.Version
logger.info("Velopack: downloading update %s", version)
mgr.download_updates(info)
logger.info("Velopack: update %s staged, applies on next launch", version)
except (RuntimeError, OSError) as exc:
logger.info("Velopack update check skipped: %s", exc)
+3 -14
View File
@@ -37,6 +37,7 @@ from fastapi_vue.logging import patch_log_config
from fastapi_vue.startupbox import print_box
from tracerite.html import html_traceback
from mediahive import updater
from mediahive.config import config, load_config, log_dir, save_config
from mediahive.volume_control import get_volume, set_volume, volume_max
@@ -48,7 +49,6 @@ HEALTH_TIMEOUT = 2 # seconds
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579"
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
@@ -989,25 +989,14 @@ def _velopack_startup() -> None:
def _check_for_updates() -> None:
"""Download available updates in the background.
"""Download available updates in the background (unless disabled in config).
Downloaded updates are applied automatically by Velopack on the next app
start (via _velopack_startup), so the running session is never
interrupted. Not a Velopack install (dev/portable) and network failures
are expected and skipped quietly.
"""
try:
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
info = mgr.check_for_updates()
if info is None:
logger.info("Velopack: no update available")
return
version = info.TargetFullRelease.Version
logger.info("Velopack: downloading update %s", version)
mgr.download_updates(info)
logger.info("Velopack: update %s staged, applies on next launch", version)
except (RuntimeError, OSError) as exc:
logger.info("Velopack update check skipped: %s", exc)
updater.check_and_download()
def gui_main() -> None: