Installers for all platforms and related fixes #1
+24
-3
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user