Compare commits
11
Commits
039824f236
...
603884c5a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603884c5a2 | ||
|
|
2c4baf693c | ||
|
|
0a0c812efd | ||
|
|
508dae02b3 | ||
|
|
f27bb4e7f3 | ||
|
|
5b40e306d1 | ||
|
|
aea73ccd36 | ||
|
|
9e0ccb13bd | ||
|
|
aeb6958aab | ||
|
|
3711d3dd3c | ||
|
|
c891bc84a8 |
+1
-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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user