11 Commits
Author SHA1 Message Date
LeoVasanko 603884c5a2 Live log view over WebSocket, full log, scroll pinned to bottom 2026-09-23 17:31:13 +00:00
LeoVasanko 2c4baf693c Diagnostics panel: log view only, full width
Drop the version/browser rows; the log pre now fills the layout width
instead of a fixed 80ch that overflowed once the scrollbar appeared.
2026-09-23 17:24:02 +00:00
LeoVasanko 0a0c812efd 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).
2026-09-23 17:19:56 +00:00
LeoVasanko 508dae02b3 Rely on pywebview[qt] for Qt6 on all Qt platforms
Its qt extra already pins QtPy + PyQt6 + PyQt6-WebEngine; the explicit
Darwin lines were redundant. Qt5 would only come from the unused qt5
extra.
2026-09-23 16:57:48 +00:00
LeoVasanko f27bb4e7f3 Revert "macOS: WKWebView system backend (variant B)"
This reverts commit 5b40e306d1.
2026-09-23 16:56:50 +00:00
LeoVasanko 5b40e306d1 macOS: WKWebView system backend (variant B)
release / gui-build (linux, bash) (push) Successful in 45s
release / gui-build (windows, cmd) (push) Successful in 1m14s
release / gui-build (macos, bash) (push) Successful in 55s
Default pywebview backend on macOS, no bundled Qt/Chromium — much
smaller installer and updates.
2026-09-23 03:52:40 +00:00
LeoVasanko aea73ccd36 macOS: PyQt6-WebEngine backend (modern Chromium ~122, variant A)
release / gui-build (linux, bash) (push) Successful in 57s
release / gui-build (windows, cmd) (push) Successful in 1m20s
release / gui-build (macos, bash) (push) Successful in 1m57s
PyQt5's QtWebEngine is Chromium 87, too old for aspect-ratio and other
modern CSS. Qt6 WebEngine tracks current Chromium.
2026-09-23 03:51:32 +00:00
LeoVasanko 9e0ccb13bd Tolerate expanded-component pkg layout on newer macOS
release / gui-build (linux, bash) (push) Successful in 44s
release / gui-build (windows, cmd) (push) Successful in 1m16s
release / gui-build (macos, bash) (push) Successful in 1m22s
pkgutil --expand may yield the component as an already-expanded
directory rather than an archived file; handle both. List the expanded
contents in the error message if the layout is unexpected.
2026-09-23 03:33:06 +00:00
LeoVasanko aeb6958aab Add Diagnostics section to settings: version, browser engine, log view
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Successful in 1m22s
release / gui-build (macos, bash) (push) Failing after 1m29s
For triaging webview rendering issues without devtools: settings panel
now shows app version, the webview user agent, and the application log
(pre-wrap, 80ch, scrollable, refreshable). New endpoints /api/version,
/api/log; client-side JS errors are captured globally and posted to
/api/client-log (client-errors.log in the log dir).
2026-09-23 03:29:12 +00:00
LeoVasanko 3711d3dd3c Fix macOS pkg post-processing breaking the payload
pkgutil --expand-full flattens the component payload to loose files that
--flatten cannot repack, producing a pkg Installer accepts but installs
nothing from (also breaking the postinstall auto-open). Use nested
regular --expand: product for the Distribution domains edit, component
for the postinstall sudo-prefix removal.
2026-09-23 03:03:53 +00:00
LeoVasanko c891bc84a8 Detect sidecar subtitle languages and show their flags in the UI 2026-09-23 01:45:04 +00:00
14 changed files with 498 additions and 32 deletions
+1 -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
+83 -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,16 @@
</div> </div>
</div> </div>
</section> </section>
<section class="settings-section">
<h2 class="settings-section-title">Diagnostics</h2>
<p class="settings-section-desc">Application log for troubleshooting.</p>
<div class="diag-log-header">
<span class="diag-label">Application log</span>
</div>
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -302,7 +316,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from "vue" import { ref, watch, computed, onMounted, onUnmounted, nextTick } 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"
@@ -409,6 +423,41 @@ async function refreshPlayers() {
} }
} }
const appLog = ref("")
const logEl = ref<HTMLElement | null>(null)
let logSocket: WebSocket | null = null
let pinnedToBottom = true
function onLogScroll() {
const el = logEl.value
if (!el) return
pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
function connectLogSocket() {
if (logSocket) return
const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
logSocket = ws
pinnedToBottom = true
ws.onmessage = async (ev) => {
appLog.value = String(ev.data)
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
}
ws.onclose = () => {
if (logSocket === ws) logSocket = null
if (showSettings.value) setTimeout(connectLogSocket, 3000)
}
}
function disconnectLogSocket() {
const ws = logSocket
logSocket = null
ws?.close()
}
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 +487,9 @@ async function addRoot() {
watch(showSettings, (visible) => { watch(showSettings, (visible) => {
if (visible) { if (visible) {
void refreshPlayers() void refreshPlayers()
connectLogSocket()
} else {
disconnectLogSocket()
} }
}) })
@@ -551,6 +603,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown) window.removeEventListener("keydown", handleKeydown)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction) window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
disconnectLogSocket()
}) })
</script> </script>
@@ -868,4 +921,31 @@ onUnmounted(() => {
font-size: 0.8rem; font-size: 0.8rem;
color: #22c55e; color: #22c55e;
} }
.diag-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.diag-log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
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>
+3 -2
View File
@@ -6,7 +6,7 @@
v-for="entry in flagEntries" v-for="entry in flagEntries"
:key="entry.countryCode" :key="entry.countryCode"
class="language-flag" class="language-flag"
:title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`" :title="formatLanguageFlagTitle(entry, externalCodes)"
v-html="entry.svg" v-html="entry.svg"
></span> ></span>
<span <span
@@ -22,11 +22,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed } from "vue"
import { buildLanguageFlags } from "../utils/languageFlags" import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags"
const props = defineProps<{ const props = defineProps<{
label?: string label?: string
codes: string[] | null | undefined codes: string[] | null | undefined
externalCodes?: string[] | null
compact?: boolean compact?: boolean
}>() }>()
@@ -44,6 +44,7 @@
<LanguageFlags <LanguageFlags
class="language-flags-subs" class="language-flags-subs"
:codes="torrent.subtitle_languages" :codes="torrent.subtitle_languages"
:external-codes="torrent.external_subtitle_languages"
:compact="compactFlags" :compact="compactFlags"
/> />
</div> </div>
+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) {
+1
View File
@@ -53,6 +53,7 @@ export interface Torrent {
audio: string | null audio: string | null
audio_languages: string[] | null audio_languages: string[] | null
subtitle_languages: string[] | null subtitle_languages: string[] | null
external_subtitle_languages?: string[] | null
hdr?: boolean hdr?: boolean
dovi?: boolean dovi?: boolean
atmos?: boolean atmos?: boolean
+168 -4
View File
@@ -16,17 +16,18 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
// Spanish (including LATAM variants collapsed to Spain flag) // Spanish (including LATAM variants collapsed to Spain flag)
es: "ES", es: "ES",
spa: "ES", spa: "ES",
esp: "ES",
esl: "ES", esl: "ES",
spl: "ES", spl: "ES",
"es-es": "ES", "es-es": "ES",
"es-419": "ES", "es-419": "ES",
"spa-la": "ES", "spa-la": "ES",
// Portuguese // Portuguese (Brazilian variant collapses to Portugal flag)
pt: "PT", pt: "PT",
por: "PT", por: "PT",
"pt-pt": "PT", "pt-pt": "PT",
"pt-br": "BR", "pt-br": "PT",
// Major European languages // Major European languages
fr: "FR", fr: "FR",
@@ -49,7 +50,7 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
fi: "FI", fi: "FI",
fin: "FI", fin: "FI",
pl: "PL", pl: "PL",
: "PL", pol: "PL",
cs: "CZ", cs: "CZ",
ces: "CZ", ces: "CZ",
cze: "CZ", cze: "CZ",
@@ -121,6 +122,113 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
eu: "ES", eu: "ES",
baq: "ES", baq: "ES",
eus: "ES", eus: "ES",
gl: "ES",
glg: "ES",
// Additional ISO 639-2 codes (bibliographic + terminology)
mk: "MK",
mkd: "MK",
mac: "MK",
et: "EE",
est: "EE",
lv: "LV",
lav: "LV",
lt: "LT",
lit: "LT",
is: "IS",
isl: "IS",
ice: "IS",
ga: "IE",
gle: "IE",
cy: "GB",
cym: "GB",
wel: "GB",
gd: "GB",
gla: "GB",
mt: "MT",
mlt: "MT",
sq: "AL",
sqi: "AL",
alb: "AL",
be: "BY",
bel: "BY",
bs: "BA",
bos: "BA",
scc: "RS",
scr: "HR",
nb: "NO",
nob: "NO",
nn: "NO",
nno: "NO",
kk: "KZ",
kaz: "KZ",
az: "AZ",
aze: "AZ",
hy: "AM",
hye: "AM",
arm: "AM",
ka: "GE",
kat: "GE",
geo: "GE",
uz: "UZ",
uzb: "UZ",
tk: "TM",
tuk: "TM",
tg: "TJ",
tgk: "TJ",
ky: "KG",
kir: "KG",
mn: "MN",
mon: "MN",
bo: "CN",
bod: "CN",
tib: "CN",
my: "MM",
mya: "MM",
bur: "MM",
km: "KH",
khm: "KH",
lo: "LA",
lao: "LA",
si: "LK",
sin: "LK",
ne: "NP",
nep: "NP",
bn: "BD",
ben: "BD",
ta: "IN",
tam: "IN",
te: "IN",
tel: "IN",
kn: "IN",
kan: "IN",
ml: "IN",
mal: "IN",
mr: "IN",
mar: "IN",
gu: "IN",
guj: "IN",
pa: "IN",
pan: "IN",
tl: "PH",
tgl: "PH",
fil: "PH",
af: "ZA",
afr: "ZA",
am: "ET",
amh: "ET",
so: "SO",
som: "SO",
ha: "NG",
hau: "NG",
yo: "NG",
yor: "NG",
ig: "NG",
ibo: "NG",
ku: "TR",
kur: "TR",
ps: "AF",
pus: "AF",
} }
function normalizeLanguageCode(code: string): string { function normalizeLanguageCode(code: string): string {
@@ -265,9 +373,13 @@ export function mapLanguageToCountry(code: string): string | null {
const direct = LANGUAGE_TO_COUNTRY[normalized] const direct = LANGUAGE_TO_COUNTRY[normalized]
if (direct) return direct if (direct) return direct
// region-tag style code like en-us / pt-br / es-mx // region-tag style code like en-us / pt-br / es-mx: variants collapse to
// the base language's host-country flag; only fall back to the region
// itself when the base language is unmapped.
const hyphenParts = normalized.split("-") const hyphenParts = normalized.split("-")
if (hyphenParts.length >= 2) { if (hyphenParts.length >= 2) {
const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]]
if (base) return base
const region = hyphenParts[hyphenParts.length - 1] const region = hyphenParts[hyphenParts.length - 1]
if (/^[a-z]{2}$/i.test(region)) { if (/^[a-z]{2}$/i.test(region)) {
return region.toUpperCase() return region.toUpperCase()
@@ -341,10 +453,15 @@ export function buildLanguageFlags(codes: string[] | null | undefined): {
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = { const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
eng: "English", eng: "English",
spa: "Spanish", spa: "Spanish",
esp: "Spanish",
"spa-la": "Spanish", "spa-la": "Spanish",
"es-419": "Spanish",
esl: "Spanish", esl: "Spanish",
spl: "Spanish", spl: "Spanish",
por: "Portuguese", por: "Portuguese",
"pt-br": "Portuguese",
nob: "Norwegian",
nno: "Norwegian",
fre: "French", fre: "French",
fra: "French", fra: "French",
ger: "German", ger: "German",
@@ -431,6 +548,53 @@ function summarizeLanguageCodes(codes: string[] | null | undefined): string {
return names.join(", ") return names.join(", ")
} }
const REGION_NAME_OVERRIDES: Record<string, string> = {
GB: "UK",
US: "US",
}
function toRegionName(countryCode: string): string {
const override = REGION_NAME_OVERRIDES[countryCode]
if (override) return override
const display = new Intl.DisplayNames(["en"], { type: "region" })
return display.of(countryCode) ?? countryCode
}
export function formatLanguageFlagTitle(
entry: LanguageFlagEntry,
externalCodes?: string[] | null,
): string {
const names: string[] = []
const variants: string[] = []
const external = new Set(
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
)
let hasExternal = false
for (const code of entry.sourceCodes) {
const normalized = resolveLanguageIdentifier(code)
const base = normalized.split("-", 1)[0]
const name = toLanguageName(base)
if (!names.includes(name)) names.push(name)
// Explicit region tags (en-us, es-419) become parenthesized variants;
// plain codes contribute their host country.
const suffix = normalized.split("-").pop() ?? ""
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase()
: mapLanguageToCountry(code)
const regionName = region ? toRegionName(region) : null
const variant = external.has(normalized)
? regionName
? `${regionName} srt`
: "srt"
: regionName
if (variant && !variants.includes(variant)) variants.push(variant)
if (external.has(normalized)) hasExternal = true
}
const title = names.join(" / ")
if (variants.length > 1 || hasExternal) return `${title} (${variants.join(", ")})`
return title
}
export function formatAudioSubtitleSummary( export function formatAudioSubtitleSummary(
audioCodes: string[] | null | undefined, audioCodes: string[] | null | undefined,
subtitleCodes: string[] | null | undefined, subtitleCodes: string[] | null | undefined,
+32 -3
View File
@@ -16,6 +16,7 @@ from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.scanning import ( from mediahive.hivescan.scanning import (
find_cover_image, find_cover_image,
find_episode_files, find_episode_files,
find_external_subtitle_languages,
find_metadata_probe_file, find_metadata_probe_file,
find_playable_file, find_playable_file,
) )
@@ -61,6 +62,18 @@ def _infer_hdr10plus(*values: str | None) -> bool:
return bool(_HDR10PLUS_RE.search(text)) return bool(_HDR10PLUS_RE.search(text))
def _merge_subtitle_languages(
probed: list[str] | None,
external: list[str],
) -> list[str] | None:
"""Union embedded subtitle languages with sidecar-subtitle languages."""
merged = list(probed or [])
for lang in external:
if lang not in merged:
merged.append(lang)
return merged or None
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None: def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
"""Store playable paths compactly relative to the file key when possible.""" """Store playable paths compactly relative to the file key when possible."""
if not playable_file: if not playable_file:
@@ -99,6 +112,7 @@ async def _build_torrent_info(
probe_target = await find_metadata_probe_file(playable_file) probe_target = await find_metadata_probe_file(playable_file)
if probe_target: if probe_target:
probe_info = await probe_media_info(str(probe_target)) probe_info = await probe_media_info(str(probe_target))
external_subs = await find_external_subtitle_languages(playable_file)
if item.content_hash and item.content_hash.size == 0: if item.content_hash and item.content_hash.size == 0:
item.content_hash.size = await asyncio.to_thread( item.content_hash.size = await asyncio.to_thread(
@@ -125,7 +139,11 @@ async def _build_torrent_info(
codec=item.codec, codec=item.codec,
audio=item.audio, audio=item.audio,
audio_languages=probe_info.audio_languages if probe_info else None, audio_languages=probe_info.audio_languages if probe_info else None,
subtitle_languages=probe_info.subtitle_languages if probe_info else None, subtitle_languages=_merge_subtitle_languages(
probe_info.subtitle_languages if probe_info else None,
external_subs,
),
external_subtitle_languages=external_subs or None,
hdr=probe_info.hdr if probe_info else False, hdr=probe_info.hdr if probe_info else False,
dovi=probe_info.dovi if probe_info else False, dovi=probe_info.dovi if probe_info else False,
atmos=probe_info.atmos if probe_info else False, atmos=probe_info.atmos if probe_info else False,
@@ -207,12 +225,16 @@ async def _collect_episode_files(
all_episode_files[key] = [] all_episode_files[key] = []
for file_path, file_size in files: for file_path, file_size in files:
probe = await get_probe(file_path) probe = await get_probe(file_path)
external_subs = await find_external_subtitle_languages(file_path)
all_episode_files[key].append({ all_episode_files[key].append({
"path": file_path, "path": file_path,
"size": file_size, "size": file_size,
"probed_resolution": probe.resolution, "probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages, "audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages, "subtitle_languages": _merge_subtitle_languages(
probe.subtitle_languages, external_subs
),
"external_subtitle_languages": external_subs or None,
"hdr": probe.hdr, "hdr": probe.hdr,
"dovi": probe.dovi, "dovi": probe.dovi,
"atmos": probe.atmos, "atmos": probe.atmos,
@@ -255,12 +277,18 @@ async def _collect_episode_files(
item.content_hash.path, item.content_hash.path,
) )
size = item.content_hash.size if item.content_hash else 0 size = item.content_hash.size if item.content_hash else 0
external_subs = await find_external_subtitle_languages(
playable
)
all_episode_files[key].append({ all_episode_files[key].append({
"path": playable, "path": playable,
"size": size, "size": size,
"probed_resolution": probe.resolution, "probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages, "audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages, "subtitle_languages": _merge_subtitle_languages(
probe.subtitle_languages, external_subs
),
"external_subtitle_languages": external_subs or None,
"hdr": probe.hdr, "hdr": probe.hdr,
"dovi": probe.dovi, "dovi": probe.dovi,
"atmos": probe.atmos, "atmos": probe.atmos,
@@ -343,6 +371,7 @@ def _build_episodes_data(
audio=f.get("audio"), audio=f.get("audio"),
audio_languages=f.get("audio_languages"), audio_languages=f.get("audio_languages"),
subtitle_languages=f.get("subtitle_languages"), subtitle_languages=f.get("subtitle_languages"),
external_subtitle_languages=f.get("external_subtitle_languages"),
hdr=bool(f.get("hdr")), hdr=bool(f.get("hdr")),
dovi=bool(f.get("dovi")), dovi=bool(f.get("dovi")),
atmos=bool(f.get("atmos")), atmos=bool(f.get("atmos")),
+67
View File
@@ -28,6 +28,23 @@ VIDEO_EXTENSIONS = {
".m2ts", ".m2ts",
} }
# External subtitle file extensions
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub"}
# Non-language tokens that may follow the language in a sidecar filename
_SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "signs"}
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
# with the codes ffmpeg reports for embedded tracks.
_ISO_639_1_TO_639_2 = {
"ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell",
"en": "eng", "es": "esp", "fi": "fin", "fr": "fra", "he": "heb",
"hi": "hin", "hu": "hun", "id": "ind", "it": "ita", "ja": "jpn",
"ko": "kor", "nl": "nld", "no": "nor", "pl": "pol", "pt": "por",
"ru": "rus", "sv": "swe", "th": "tha", "tr": "tur", "uk": "ukr",
"vi": "vie", "zh": "zho",
}
# Caches for expensive operations. These are per-scan only: the scanner # Caches for expensive operations. These are per-scan only: the scanner
# clears them at the start of every scan. Caching across scans is wrong — # clears them at the start of every scan. Caching across scans is wrong —
# an empty result recorded before a download finished (or during a transient # an empty result recorded before a download finished (or during a transient
@@ -322,6 +339,56 @@ async def find_playable_file(path: Path) -> str | None:
return result return result
def _sidecar_subtitle_language(video_stem: str, filename: str) -> str | None:
"""Language tag from a sidecar subtitle name like `<stem>.esp.srt`, if any."""
if not filename.startswith(video_stem + "."):
return None
suffix = Path(filename).suffix.lower()
if suffix not in SUBTITLE_EXTENSIONS:
return None
middle = filename[len(video_stem) + 1 : -len(suffix)]
tokens = [t for t in middle.split(".") if t]
while tokens and tokens[-1].lower() in _SUBTITLE_FLAG_TOKENS:
tokens.pop()
if not tokens:
return None
code = tokens[-1].lower()
if not code.isalpha() or not 2 <= len(code) <= 3:
return None
code = _ISO_639_1_TO_639_2.get(code, code)
return None if code == "und" else code
def _scan_external_subtitle_languages(video_path: Path) -> list[str]:
languages: list[str] = []
with os.scandir(video_path.parent) as entries:
for entry in entries:
if not entry.is_file(follow_symlinks=False):
continue
lang = _sidecar_subtitle_language(video_path.stem, entry.name)
if lang and lang not in languages:
languages.append(lang)
return languages
async def find_external_subtitle_languages(video_path: str | None) -> list[str]:
"""Languages of external subtitle files sitting next to a video file.
Matches sidecars named `<stem>.<lang>.<ext>` (e.g. `Movie.esp.srt` ->
``esp``), optionally with flags like ``forced``/``sdh`` after the language.
Bare `<stem>.<ext>` files carry no language tag and are ignored.
"""
if not video_path or "://" in video_path or video_path.startswith("concat:"):
return []
path = Path(video_path)
if path.suffix.lower() not in VIDEO_EXTENSIONS:
return []
try:
return await asyncio.to_thread(_scan_external_subtitle_languages, path)
except OSError, PermissionError:
return []
async def find_metadata_probe_file(playable_path: str | None) -> str | None: async def find_metadata_probe_file(playable_path: str | None) -> str | None:
"""Resolve a path suitable for ffmpeg stream metadata probing. """Resolve a path suitable for ffmpeg stream metadata probing.
+1
View File
@@ -26,6 +26,7 @@ class Torrent(msgspec.Struct, omit_defaults=True):
audio: str | None = None audio: str | None = None
audio_languages: list[str] | None = None audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None subtitle_languages: list[str] | None = None
external_subtitle_languages: list[str] | None = None
hdr: bool = False hdr: bool = False
dovi: bool = False dovi: bool = False
atmos: bool = False atmos: bool = False
+76 -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,74 @@ 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}
def _read_log() -> str:
"""Return the full application log file."""
path = log_dir() / "mediahive.log"
if not path.exists():
return ""
try:
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
@app.get("/api/log")
async def get_log():
"""Return the full application log file."""
return PlainTextResponse(_read_log())
@app.websocket("/api/log/ws")
async def ws_log(ws: WebSocket) -> None:
"""Stream the application log: full log on connect and on every change."""
await ws.accept()
last_sent: str | None = None
try:
while True:
current = _read_log()
if current != last_sent:
last_sent = current
await ws.send_text(current)
await asyncio.sleep(1.0)
except WebSocketDisconnect, OSError, RuntimeError:
pass
@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."""
+2 -4
View File
@@ -51,13 +51,11 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
[project.optional-dependencies] [project.optional-dependencies]
gui = [ gui = [
# pywebview's qt extra is Qt6-only (QtPy + PyQt6 + PyQt6-WebEngine);
# Qt5 would come from its separate qt5 extra, which we do not use.
"pywebview[qt]>=6.2.1; platform_system != 'Windows'", "pywebview[qt]>=6.2.1; platform_system != 'Windows'",
"pywebview>=6.2.1; platform_system == 'Windows'", "pywebview>=6.2.1; platform_system == 'Windows'",
"velopack>=1.2", "velopack>=1.2",
"qtpy>=2.4.1; platform_system == 'Darwin'",
"PyQt5>=5.15.11; platform_system == 'Darwin'",
# pywebview[qt] no longer pulls this in; the macOS Qt backend needs it
"PyQtWebEngine>=5.15.7; platform_system == 'Darwin'",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0", "pyinstaller>=6.0",
] ]
+6 -5
View File
@@ -88,11 +88,12 @@ if sys.platform == "darwin":
# pywebview Qt backend selected dynamically via webview.start(gui="qt") # pywebview Qt backend selected dynamically via webview.start(gui="qt")
"webview.platforms.qt", "webview.platforms.qt",
"qtpy", "qtpy",
"PyQt5", "PyQt6",
"PyQt5.QtCore", "PyQt6.QtCore",
"PyQt5.QtGui", "PyQt6.QtGui",
"PyQt5.QtWidgets", "PyQt6.QtWidgets",
"PyQt5.QtWebEngineWidgets", "PyQt6.QtWebEngineCore",
"PyQt6.QtWebEngineWidgets",
] ]
) )
+24 -6
View File
@@ -426,11 +426,14 @@ def force_macos_user_install(pkg: Path) -> None:
Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall
script: under a per-user install the script already runs as the script: under a per-user install the script already runs as the
installing user, and sudo would fail for lack of a tty. installing user, and sudo would fail for lack of a tty.
NB: only ever use `pkgutil --expand` (which keeps component Payloads
archived) — `--expand-full` flattens payloads to loose files that
`--flatten` cannot repack, producing a pkg that "installs" nothing.
""" """
expanded = pkg.with_name(pkg.stem + "-expanded") expanded = pkg.with_name(pkg.stem + "-expanded")
shutil.rmtree(expanded, ignore_errors=True) shutil.rmtree(expanded, ignore_errors=True)
# --expand-full also expands the component pkg, exposing its Scripts dir subprocess.run(["pkgutil", "--expand", str(pkg), str(expanded)], check=True)
subprocess.run(["pkgutil", "--expand-full", str(pkg), str(expanded)], check=True)
dist_xml = expanded / "Distribution" dist_xml = expanded / "Distribution"
xml = dist_xml.read_text() xml = dist_xml.read_text()
@@ -443,12 +446,27 @@ def force_macos_user_install(pkg: Path) -> None:
raise RuntimeError("Unexpected distribution.xml: <domains> not found") raise RuntimeError("Unexpected distribution.xml: <domains> not found")
dist_xml.write_text(new_xml) dist_xml.write_text(new_xml)
postinstalls = list(expanded.glob("*.pkg/Scripts/postinstall")) # Edit postinstall inside the component pkg. Depending on the macOS
if len(postinstalls) != 1: # version, --expand leaves the component as an archived file (needs a
raise RuntimeError(f"Unexpected pkg layout: postinstalls={postinstalls}") # nested expand/flatten round) or as an already-expanded directory.
postinstall = postinstalls[0] components = list(expanded.glob("*.pkg"))
if len(components) != 1:
contents = sorted(p.name for p in expanded.iterdir())
raise RuntimeError(f"Unexpected pkg layout: components={components} in {contents}")
component = components[0]
if component.is_dir():
comp_dir = component
else:
comp_dir = expanded / (component.stem + "-component")
subprocess.run(["pkgutil", "--expand", str(component), str(comp_dir)], check=True)
postinstall = comp_dir / "Scripts" / "postinstall"
script = postinstall.read_text() script = postinstall.read_text()
if 'sudo -u "$USER" ' not in script:
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
postinstall.write_text(script.replace('sudo -u "$USER" ', "")) postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
if comp_dir is not component:
subprocess.run(["pkgutil", "--flatten", str(comp_dir), str(component)], check=True)
shutil.rmtree(comp_dir)
subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True) subprocess.run(["pkgutil", "--flatten", str(expanded), str(pkg)], check=True)
shutil.rmtree(expanded) shutil.rmtree(expanded)