Installers for all platforms and related fixes #1

Merged
LeoVasanko merged 38 commits from installer into main 2026-09-23 17:41:21 +00:00
4 changed files with 241 additions and 8 deletions
Showing only changes of commit aeb6958aab - Show all commits
+24 -3
View File
@@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise<Record<string, ResumePosit
const rawEpisodes = (entry as { episodes?: unknown }).episodes const rawEpisodes = (entry as { episodes?: unknown }).episodes
if (rawEpisodes && typeof rawEpisodes === "object") { if (rawEpisodes && typeof rawEpisodes === "object") {
const watches: Record<string, EpisodeWatchEntry> = {} const watches: Record<string, EpisodeWatchEntry> = {}
for (const [key, watch] of Object.entries( for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
rawEpisodes as Record<string, unknown>,
)) {
if (!watch || typeof watch !== "object") continue if (!watch || typeof watch !== "object") continue
const w = watch as { pos?: unknown; done?: unknown } const w = watch as { pos?: unknown; done?: unknown }
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue 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() const folder: string | null = await api.pick_folder()
return 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 --> <!-- Detail mode: show current category + Details -->
<template v-else> <template v-else>
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory"> <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>
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button> <button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
</template> </template>
@@ -208,7 +210,9 @@
<section class="settings-section"> <section class="settings-section">
<h2 class="settings-section-title">Preferred Format</h2> <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-grid">
<div class="format-row format-row-stack"> <div class="format-row format-row-stack">
@@ -295,6 +299,30 @@
</div> </div>
</div> </div>
</section> </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> </div>
</div> </div>
@@ -306,7 +334,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue"
import { useRouter, useRoute } from "vue-router" import { useRouter, useRoute } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp" 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 type { PlayerInfo } from "../api"
import HexKeyboard from "./HexKeyboard.vue" import HexKeyboard from "./HexKeyboard.vue"
import { 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) { async function removeRoot(rootId: string) {
const filtered = roots.value.filter((r) => r.root_id !== rootId) const filtered = roots.value.filter((r) => r.root_id !== rootId)
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path])) const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
@@ -438,6 +490,10 @@ async function addRoot() {
watch(showSettings, (visible) => { watch(showSettings, (visible) => {
if (visible) { if (visible) {
void refreshPlayers() void refreshPlayers()
if (!diagnosticsFetched) {
diagnosticsFetched = true
void refreshDiagnostics()
}
} }
}) })
@@ -868,4 +924,69 @@ onUnmounted(() => {
font-size: 0.8rem; font-size: 0.8rem;
color: #22c55e; 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> </style>
+33
View File
@@ -6,6 +6,38 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
import { installGamepadNavigation } from "./composables/useGamepadNavigation" import { installGamepadNavigation } from "./composables/useGamepadNavigation"
import { installInputModalityTracking } from "./composables/useInputModality" 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() { function installReloadShortcut() {
document.addEventListener( document.addEventListener(
"keydown", "keydown",
@@ -27,6 +59,7 @@ installInputModalityTracking()
installKeyboardNavigation() installKeyboardNavigation()
installGamepadNavigation() installGamepadNavigation()
installReloadShortcut() installReloadShortcut()
installErrorCapture()
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW. // Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
+60 -2
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio import asyncio
import ctypes import ctypes
import importlib.metadata
import json import json
import logging import logging
import mimetypes import mimetypes
@@ -30,11 +31,16 @@ import aiofiles
import msgspec import msgspec
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 FileResponse, Response, StreamingResponse from fastapi.responses import (
FileResponse,
PlainTextResponse,
Response,
StreamingResponse,
)
from fastapi_vue import Frontend from fastapi_vue import Frontend
from mediahive.__main__ import DEVMODE from mediahive.__main__ import DEVMODE
from mediahive.config import load_config from mediahive.config import load_config, log_dir
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
@@ -1073,6 +1079,58 @@ async def health_check():
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/version")
async def get_version():
"""Return the installed MediaHive package version."""
try:
version = importlib.metadata.version("mediahive")
except importlib.metadata.PackageNotFoundError:
version = "dev"
return {"version": version}
@app.get("/api/log")
async def get_log():
"""Return the tail of the application log file (last ~64 KB)."""
path = log_dir() / "mediahive.log"
if not path.exists():
return PlainTextResponse("")
try:
with path.open("rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 64 * 1024))
data = f.read()
return PlainTextResponse(data.decode("utf-8", errors="replace"))
except OSError:
return PlainTextResponse("")
@app.post("/api/client-log", status_code=204)
async def post_client_log(request: Request):
"""Append a client-side (webview) error report to client-errors.log."""
try:
payload = msgspec.json.decode(await request.body())
except msgspec.DecodeError:
payload = {}
message = str(payload.get("message") or "")
stack = payload.get("stack")
source = payload.get("source")
try:
dirpath = log_dir()
dirpath.mkdir(parents=True, exist_ok=True)
with (dirpath / "client-errors.log").open("a", encoding="utf-8") as f:
timestamp = datetime.now().isoformat(timespec="seconds")
f.write(f"[{timestamp}] {message}\n")
if source:
f.write(f" source: {source}\n")
if stack:
f.write(f" stack: {stack}\n")
except OSError:
pass
return Response(status_code=204)
@app.get("/api/config") @app.get("/api/config")
async def get_config(): async def get_config():
"""Return current server configuration.""" """Return current server configuration."""