Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a10725e0 | ||
|
|
8b0c7a7af1 | ||
|
|
d01bf1d88f | ||
|
|
9b12e53039 | ||
|
|
7e2fcc05f6 | ||
|
|
ecd5d3bc9e | ||
|
|
82d5eb28fb |
@@ -29,9 +29,11 @@ jobs:
|
||||
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
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
|
||||
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
|
||||
# reuses an existing release and skips already-uploaded assets.
|
||||
@@ -43,18 +45,33 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
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
|
||||
if: matrix.os != 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
- `A` toggles play and pause.
|
||||
- `B` closes the player.
|
||||
- `Y` toggles mute.
|
||||
- D-pad up and down change volume.
|
||||
- D-pad left and right seek during playback, or step frames while paused.
|
||||

|
||||
**Gamepad controls are currently available only on MPC-BE, with its WebUI enabled.**
|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
@@ -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 }`. |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
+16
-145
@@ -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
@@ -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}`)
|
||||
}
|
||||
|
||||
+680
-259
File diff suppressed because it is too large
Load Diff
@@ -126,9 +126,7 @@
|
||||
@contextmenu="handleContextMenu($event, episode)"
|
||||
>
|
||||
<!-- SVG focus outline -->
|
||||
<svg class="tile-focus-outline" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="100" height="100" />
|
||||
</svg>
|
||||
<div class="tile-focus-outline"></div>
|
||||
|
||||
<!-- Episode preview 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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: inherit;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
opacity: 0;
|
||||
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 */
|
||||
html.mouse-active .episode-tile:hover .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 */
|
||||
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
||||
stroke: #ffffff;
|
||||
stroke-width: 5;
|
||||
html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline {
|
||||
border-color: #ffffff;
|
||||
border-width: 2.5px;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -106,6 +106,11 @@ html.mouse-active ::-webkit-scrollbar-thumb:hover {
|
||||
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 {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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:
|
||||
@@ -1033,6 +1022,16 @@ class JsApi:
|
||||
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
|
||||
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:
|
||||
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
|
||||
# Clamp to the platform's maximum so the slider never exceeds what
|
||||
|
||||
@@ -58,6 +58,8 @@ gui = [
|
||||
"velopack>=1.2",
|
||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||
"pyinstaller>=6.0",
|
||||
# scripts/guibuild.py reads the version with it (same logic as hatch-vcs).
|
||||
"setuptools_scm>=8",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
+11
-2
@@ -1,11 +1,20 @@
|
||||
#!/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.
|
||||
|
||||
Usage:
|
||||
uv run scripts/guibuild.py
|
||||
|
||||
This runs in the project environment where dependencies
|
||||
are available via pyproject.toml.
|
||||
Self-contained: inline script dependencies above make uv resolve the
|
||||
package (with the gui extra) plus this script's own direct imports.
|
||||
|
||||
This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
|
||||
Reference in New Issue
Block a user