Merge installers: Velopack packaging on all platforms

Per-user installers with in-app auto-updates (Velopack Setup.exe / macOS
pkg / Linux AppImage) built by the Gitea release workflow. macOS GUI
switched to Qt6 WebEngine (modern Chromium). Diagnostics section in
settings (version, browser engine, log view, client error capture).
This commit is contained in:
2026-09-23 17:19:56 +00:00
16 changed files with 842 additions and 117 deletions
+24 -3
View File
@@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise<Record<string, ResumePosit
const rawEpisodes = (entry as { episodes?: unknown }).episodes
if (rawEpisodes && typeof rawEpisodes === "object") {
const watches: Record<string, EpisodeWatchEntry> = {}
for (const [key, watch] of Object.entries(
rawEpisodes as Record<string, unknown>,
)) {
for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
if (!watch || typeof watch !== "object") continue
const w = watch as { pos?: unknown; done?: unknown }
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
@@ -438,3 +436,26 @@ export async function pickFolderAndAddRoot(): Promise<string | null> {
const folder: string | null = await api.pick_folder()
return folder
}
/**
* Fetch the installed MediaHive version.
*/
export async function getVersion(): Promise<string> {
const response = await fetch("/api/version")
if (!response.ok) {
throw new Error(`Failed to load version: ${response.statusText}`)
}
const data = await response.json()
return data.version || "dev"
}
/**
* Fetch the tail of the application log.
*/
export async function getLog(): Promise<string> {
const response = await fetch("/api/log")
if (!response.ok) {
throw new Error(`Failed to load log: ${response.statusText}`)
}
return response.text()
}
+124 -3
View File
@@ -29,7 +29,9 @@
<!-- Detail mode: show current category + Details -->
<template v-else>
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
{{ currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series" }}
{{
currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series"
}}
</button>
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
</template>
@@ -208,7 +210,9 @@
<section class="settings-section">
<h2 class="settings-section-title">Preferred Format</h2>
<p class="settings-section-desc">Preferred format when multiple versions are available.</p>
<p class="settings-section-desc">
Preferred format when multiple versions are available.
</p>
<div class="format-grid">
<div class="format-row format-row-stack">
@@ -295,6 +299,30 @@
</div>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section-title">Diagnostics</h2>
<p class="settings-section-desc">
Version info and application log for troubleshooting.
</p>
<div class="diag-rows">
<div class="diag-row">
<span class="diag-label">App version</span>
<span class="diag-value">{{ appVersion || "…" }}</span>
</div>
<div class="diag-row">
<span class="diag-label">Browser engine</span>
<span class="diag-value">{{ browserEngine }}</span>
</div>
</div>
<div class="diag-log-header">
<span class="diag-label">Application log</span>
<button class="diag-refresh-btn" @click="refreshLog">Refresh</button>
</div>
<pre class="diag-log">{{ appLog }}</pre>
</section>
</div>
</div>
</div>
@@ -306,7 +334,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp"
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers, getVersion, getLog } from "../api"
import type { PlayerInfo } from "../api"
import HexKeyboard from "./HexKeyboard.vue"
import {
@@ -409,6 +437,30 @@ async function refreshPlayers() {
}
}
const appVersion = ref("")
const appLog = ref("")
const browserEngine = navigator.userAgent
let diagnosticsFetched = false
async function refreshLog() {
try {
appLog.value = await getLog()
} catch (e) {
console.error("Failed to fetch log:", e)
appLog.value = "Failed to load log."
}
}
async function refreshDiagnostics() {
try {
appVersion.value = await getVersion()
} catch (e) {
console.error("Failed to fetch version:", e)
appVersion.value = "unknown"
}
await refreshLog()
}
async function removeRoot(rootId: string) {
const filtered = roots.value.filter((r) => r.root_id !== rootId)
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
@@ -438,6 +490,10 @@ async function addRoot() {
watch(showSettings, (visible) => {
if (visible) {
void refreshPlayers()
if (!diagnosticsFetched) {
diagnosticsFetched = true
void refreshDiagnostics()
}
}
})
@@ -868,4 +924,69 @@ onUnmounted(() => {
font-size: 0.8rem;
color: #22c55e;
}
.diag-rows {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.diag-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.diag-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.diag-value {
font-size: 0.85rem;
color: var(--text-primary);
word-break: break-all;
}
.diag-log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.diag-refresh-btn {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.2s;
}
.diag-refresh-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
width: 80ch;
max-width: 100%;
max-height: 320px;
overflow-y: auto;
margin: 0;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
color: var(--text-secondary);
}
</style>
+33
View File
@@ -6,6 +6,38 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
import { installInputModalityTracking } from "./composables/useInputModality"
function postClientError(payload: {
message: string
stack: string | null
source: string | null
}) {
fetch("/api/client-log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => {})
}
function installErrorCapture() {
window.addEventListener("error", (event) => {
const source =
event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null
postClientError({
message: event.message || String(event.error ?? "Unknown error"),
stack: event.error?.stack ?? null,
source,
})
})
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason
postClientError({
message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`,
stack: reason instanceof Error ? (reason.stack ?? null) : null,
source: "unhandledrejection",
})
})
}
function installReloadShortcut() {
document.addEventListener(
"keydown",
@@ -27,6 +59,7 @@ installInputModalityTracking()
installKeyboardNavigation()
installGamepadNavigation()
installReloadShortcut()
installErrorCapture()
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
if ("serviceWorker" in navigator) {