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
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) {
+60 -2
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import ctypes
import importlib.metadata
import json
import logging
import mimetypes
@@ -30,11 +31,16 @@ import aiofiles
import msgspec
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
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 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.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client
@@ -1073,6 +1079,58 @@ async def health_check():
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")
async def get_config():
"""Return current server configuration."""