7 Commits
Author SHA1 Message Date
LeoVasanko 38a10725e0 Fix a minor glitch with series episode highlight border.
release / gui-build (windows, cmd) (push) Successful in 1m19s
release / gui-build (linux, bash) (push) Successful in 1m44s
release / gui-build (macos, bash) (push) Successful in 1m54s
release / publish-pypi (push) Successful in 17s
2026-09-24 05:21:01 +00:00
LeoVasanko 8b0c7a7af1 Self-contained guibuild script deps; gate PyPI publish on all GUI builds 2026-09-24 05:07:34 +00:00
LeoVasanko d01bf1d88f Add GUI exit button and F11/Alt+Enter fullscreen toggle 2026-09-24 04:52:33 +00:00
LeoVasanko 9b12e53039 Rework settings dialog: floating window, integrated activity log and scan progress, auto-update controls 2026-09-24 04:27:37 +00:00
LeoVasanko 7e2fcc05f6 Use image for controller help. 2026-09-24 03:31:49 +00:00
LeoVasanko ecd5d3bc9e Update README 2026-09-24 00:59:16 +00:00
LeoVasanko 82d5eb28fb Add screenshot of the new series view. 2026-09-24 00:52:04 +00:00
19 changed files with 1071 additions and 458 deletions
+27 -10
View File
@@ -29,9 +29,11 @@ jobs:
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" . git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
git checkout -f "${{ gitea.sha }}" git checkout -f "${{ gitea.sha }}"
# guibuild.py carries its own inline (PEP 723) deps — mediahive[gui]
# from the local checkout — so no project sync or --extra is needed.
- name: Build GUI app and dist packages - name: Build GUI app and dist packages
shell: ${{ matrix.shell }} shell: ${{ matrix.shell }}
run: uv run --extra gui scripts/guibuild.py run: uv run scripts/guibuild.py
# Every platform converges on the one release for the tag; release.py # Every platform converges on the one release for the tag; release.py
# reuses an existing release and skips already-uploaded assets. # reuses an existing release and skips already-uploaded assets.
@@ -43,18 +45,33 @@ jobs:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run scripts/release.py run: uv run scripts/release.py
# Wheel/sdist are platform-independent; the linux job also pushes them
# to PyPI. Token is the PYPI_TOKEN repository secret.
- name: Publish to PyPI
if: matrix.os == 'linux'
shell: ${{ matrix.shell }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
- name: Attach platform artifact to the Gitea release - name: Attach platform artifact to the Gitea release
if: matrix.os != 'linux' if: matrix.os != 'linux'
shell: ${{ matrix.shell }} shell: ${{ matrix.shell }}
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: uv run scripts/release.py --no-dist run: uv run scripts/release.py --no-dist
# Publish the Python package to PyPI only once every platform's GUI build
# has succeeded. Jobs don't share a workspace, so the platform-independent
# wheel/sdist are rebuilt here (same tag version) instead of being passed
# around as artifacts.
publish-pypi:
needs: gui-build
runs-on: linux
steps:
- name: Checkout
shell: bash
run: |
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
git checkout -f "${{ gitea.sha }}"
- name: Build wheel and sdist
shell: bash
run: uv build
- name: Publish to PyPI
shell: bash
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
+7 -6
View File
@@ -46,12 +46,13 @@ MediaHive is designed to work with a mouse, keyboard, or gamepad.
MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead. MediaHive opens files with the OS default player, but one specific player may be configured via settings. You are of course free to use any player instead.
- `A` toggles play and pause. ![Controller bindings](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/controls.avif)
- `B` closes the player. **Gamepad controls are currently available only on MPC-BE, with its WebUI enabled.**
- `Y` toggles mute.
- D-pad up and down change volume.
- D-pad left and right seek during playback, or step frames while paused.
## Background ## Background
This project started as a personal project that I have used for browsing my warez for some time now. It is still in early development, but I have just now made it public for a wider audience. This project started as a personal project that I have used for browsing my warez for some time now. After it grew in number of users, I've put serious development effort into it to provide a truly polished view, while responding to user needs.
Little details include flags for audio and subtitle languages (also srt) and a series view with per episode video previews while avoiding spoilers of the episodes you haven't gotten to yet:
![Series view](https://git.zi.fi/LeoVasanko/mediahive/media/branch/main/docs/seriesview.avif)
+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/health` | Lightweight health check. |
| `GET` | `/api/config` | Returns the current root configuration. | | `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": [...] }`. | | `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). | | `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. | | `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 }`. | | `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+16 -145
View File
@@ -45,6 +45,9 @@
:current-view="headerCurrentView" :current-view="headerCurrentView"
:search-query="searchQuery" :search-query="searchQuery"
:roots="headerRoots" :roots="headerRoots"
:scan-tasks="tasks"
:scan-connected="wsConnected"
:initial-scan-mode="isInitialScanMode"
:mpc-be-connected="mpcBeConnected" :mpc-be-connected="mpcBeConnected"
:nav-row="1" :nav-row="1"
:position="headerPosition" :position="headerPosition"
@@ -209,7 +212,6 @@ import type {
SeriesUi, SeriesUi,
MediaItem, MediaItem,
EpisodeWithSeries, EpisodeWithSeries,
TaskInfo,
SeriesResumePoint, SeriesResumePoint,
} from "./types" } from "./types"
import { import {
@@ -228,6 +230,8 @@ import {
} from "./composables/useKeyboardNavigation" } from "./composables/useKeyboardNavigation"
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation" import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
import { useMediaWebSocket } from "./composables/useMediaWebSocket" import { useMediaWebSocket } from "./composables/useMediaWebSocket"
import { computeProgressRoots, type RootTaskInfo } from "./composables/useScanProgress"
import { useSettingsOpen } from "./composables/useSettingsOpen"
import Header from "./components/Header.vue" import Header from "./components/Header.vue"
import CollageHero from "./components/CollageHero.vue" import CollageHero from "./components/CollageHero.vue"
import MediaRow from "./components/MediaRow.vue" import MediaRow from "./components/MediaRow.vue"
@@ -292,20 +296,6 @@ const {
roots: rootStatuses, roots: rootStatuses,
} = useMediaWebSocket() } = 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())) const activeTasks = computed<RootTaskInfo[]>(() => Array.from(tasks.value.values()))
function getRootName(rootId: string | null | undefined): string | null { 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 return rootStatuses.value.get(rootId)?.root_id || null
} }
function normalizePosixPath(value: string): string { const progressRoots = computed(() =>
return value.replace(/\\/g, "/") computeProgressRoots(
} activeTasks.value,
(rootId) => rootStatuses.value.get(rootId)?.path || null,
isInitialScanMode.value,
),
)
function extractScanPath(detail: string): string | null { const isSettingsView = useSettingsOpen()
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 hasLibraryItems = computed(() => { const hasLibraryItems = computed(() => {
if (!mediaIndex.value) return false if (!mediaIndex.value) return false
return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0 return mediaIndex.value.movies.length > 0 || mediaIndex.value.series.length > 0
@@ -453,10 +326,8 @@ const headerRoots = computed(() =>
) )
const showProgressPanel = computed(() => { const showProgressPanel = computed(() => {
if (isInitialScanMode.value) { if (isSettingsView.value) return false
return !wsConnected.value || progressRoots.value.length > 0 if (!isInitialScanMode.value) return false
}
if (!isSettingsView.value) return false
return !wsConnected.value || progressRoots.value.length > 0 return !wsConnected.value || progressRoots.value.length > 0
}) })
@@ -1075,7 +946,7 @@ const activePanelScrollTop = computed(() => {
}) })
const headerStyle = computed(() => { const headerStyle = computed(() => {
if (route.path === "/settings") { if (isSettingsView.value) {
return {} return {}
} }
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 * Invoke the native OS folder picker via pywebview.
* folder to the server's root list. Only works inside the packaged desktop app. * 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api const api = (window as any).pywebview?.api
if (!api) return null if (!api) return null
const folder: string | null = await api.pick_folder() const folder: string | null = await api.pick_folder()
return 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
+10 -16
View File
@@ -126,9 +126,7 @@
@contextmenu="handleContextMenu($event, episode)" @contextmenu="handleContextMenu($event, episode)"
> >
<!-- SVG focus outline --> <!-- SVG focus outline -->
<svg class="tile-focus-outline" viewBox="0 0 100 100" preserveAspectRatio="none"> <div class="tile-focus-outline"></div>
<rect x="0" y="0" width="100" height="100" />
</svg>
<!-- Episode preview media --> <!-- Episode preview media -->
<div class="tile-media"> <div class="tile-media">
@@ -1845,25 +1843,21 @@ html:not(.mouse-active) .season-poster-card.nav-focused,
} }
} }
/* SVG focus outline styles for tiles */ /* Focus outline for tiles; border width is half the SVG stroke width used on
poster cards, since a CSS border paints fully inside while an SVG stroke
is centered on the path (half of it clipped away). */
.tile-focus-outline { .tile-focus-outline {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; border: 2px solid rgba(255, 255, 255, 0.9);
height: 100%; border-radius: inherit;
box-sizing: border-box;
pointer-events: none; pointer-events: none;
z-index: 5; z-index: 5;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.tile-focus-outline rect {
fill: none;
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 4;
vector-effect: non-scaling-stroke;
}
/* Show outline on hover and focus */ /* Show outline on hover and focus */
html.mouse-active .episode-tile:hover .tile-focus-outline, html.mouse-active .episode-tile:hover .tile-focus-outline,
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline { html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
@@ -1872,9 +1866,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
} }
/* Brighter outline for keyboard focus */ /* Brighter outline for keyboard focus */
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect { html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
stroke: #ffffff; border-color: #ffffff;
stroke-width: 5; border-width: 2.5px;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8)); filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
} }
+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, component: EmptyRouteComponent,
}, },
{ {
// Settings is now an overlay with no URL of its own; keep old links working.
path: "/settings", path: "/settings",
name: "settings", redirect: "/movies",
component: EmptyRouteComponent,
}, },
{ {
path: "/series", path: "/series",
+5
View File
@@ -106,6 +106,11 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1); transition: top 0.6s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Leave room for the fixed app-exit button in desktop (pywebview) mode. */
.header--gui {
padding-right: 3.5rem;
}
.header::before { .header::before {
content: ""; content: "";
position: absolute; position: absolute;
+1
View File
@@ -19,6 +19,7 @@ from platformdirs import user_config_path, user_log_path
class Config(msgspec.Struct, omit_defaults=True): class Config(msgspec.Struct, omit_defaults=True):
roots: dict[str, str] | None = None roots: dict[str, str] | None = None
auto_update: bool = True
# Runtime config shared between the CLI entrypoint and the server process via # 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 aiofiles
import msgspec import msgspec
import msgspec.structs
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import ( from fastapi.responses import (
@@ -39,7 +40,8 @@ from fastapi.responses import (
) )
from fastapi_vue import Frontend, env 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.images import close_image_client
from mediahive.hivescan.scanner import RootScanner from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client from mediahive.hivescan.tmdb_client import close_http_client
@@ -1079,6 +1081,46 @@ async def get_version():
return {"version": 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: def _read_log() -> str:
"""Return the full application log file.""" """Return the full application log file."""
path = log_dir() / "mediahive.log" 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)
+13 -14
View File
@@ -37,6 +37,7 @@ from fastapi_vue.logging import patch_log_config
from fastapi_vue.startupbox import print_box from fastapi_vue.startupbox import print_box
from tracerite.html import html_traceback from tracerite.html import html_traceback
from mediahive import updater
from mediahive.config import config, load_config, log_dir, save_config from mediahive.config import config, load_config, log_dir, save_config
from mediahive.volume_control import get_volume, set_volume, volume_max 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_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25 BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579" MPC_BE_URL = "http://127.0.0.1:13579"
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
GAMEPAD_REPEAT_SECONDS = 0.008 GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008 GAMEPAD_POLL_SECONDS = 0.008
MPC_BE_FRAME_REPEAT_SECONDS = 0.016 MPC_BE_FRAME_REPEAT_SECONDS = 0.016
@@ -989,25 +989,14 @@ def _velopack_startup() -> None:
def _check_for_updates() -> 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 Downloaded updates are applied automatically by Velopack on the next app
start (via _velopack_startup), so the running session is never start (via _velopack_startup), so the running session is never
interrupted. Not a Velopack install (dev/portable) and network failures interrupted. Not a Velopack install (dev/portable) and network failures
are expected and skipped quietly. are expected and skipped quietly.
""" """
try: updater.check_and_download()
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)
def gui_main() -> None: def gui_main() -> None:
@@ -1033,6 +1022,16 @@ class JsApi:
result = self._window.create_file_dialog(webview.FOLDER_DIALOG) result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
return result[0] if result else None return result[0] if result else None
def exit_app(self) -> None:
"""Close the window, shutting the app down (like the OS close button)."""
if self._window:
self._window.destroy()
def toggle_fullscreen(self) -> None:
"""Switch between fullscreen and windowed mode in place."""
if self._window:
self._window.toggle_fullscreen()
def set_volume(self, x: float) -> None: def set_volume(self, x: float) -> None:
"""Set system master volume from slider position ``x`` (0.0 .. 1.5).""" """Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
# Clamp to the platform's maximum so the slider never exceeds what # Clamp to the platform's maximum so the slider never exceeds what
+2
View File
@@ -58,6 +58,8 @@ gui = [
"velopack>=1.2", "velopack>=1.2",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0", "pyinstaller>=6.0",
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
"setuptools_scm>=8",
] ]
[dependency-groups] [dependency-groups]
+11 -2
View File
@@ -1,11 +1,20 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "mediahive[gui]",
# ]
#
# [tool.uv.sources]
# mediahive = { path = "../" }
# ///
"""Build the desktop GUI application and package it with Velopack. """Build the desktop GUI application and package it with Velopack.
Usage: Usage:
uv run scripts/guibuild.py uv run scripts/guibuild.py
This runs in the project environment where dependencies Self-contained: inline script dependencies above make uv resolve the
are available via pyproject.toml. package (with the gui extra) plus this script's own direct imports.
This script: This script:
1. Reads the version from pyproject.toml 1. Reads the version from pyproject.toml