Compare commits
5
Commits
039824f236
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc20c5578 | ||
|
|
550131d43b | ||
|
|
f2fc6f657f | ||
|
|
1477c240a1 | ||
|
|
c891bc84a8 |
@@ -0,0 +1,60 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
# Runner host prerequisites: git, uv, node/npm, .NET SDK.
|
||||
jobs:
|
||||
gui-build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos
|
||||
shell: bash
|
||||
- os: windows
|
||||
shell: cmd
|
||||
- os: linux
|
||||
shell: bash
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
# Plain git clone: full history so setuptools_scm sees tags, and both
|
||||
# shells work. Windows uses cmd: bash resolves to WSL (refuses SYSTEM
|
||||
# accounts) and powershell hits the script execution policy under SYSTEM.
|
||||
- name: Checkout
|
||||
shell: ${{ matrix.shell }}
|
||||
run: |
|
||||
git clone "${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
git checkout -f "${{ gitea.sha }}"
|
||||
|
||||
- name: Build GUI app and dist packages
|
||||
shell: ${{ matrix.shell }}
|
||||
run: uv run --extra gui scripts/guibuild.py
|
||||
|
||||
# Every platform converges on the one release for the tag; release.py
|
||||
# reuses an existing release and skips already-uploaded assets.
|
||||
# Only the linux job publishes the wheel/sdist (identical across platforms).
|
||||
- name: Create Gitea release and upload assets
|
||||
if: matrix.os == 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: uv run scripts/release.py
|
||||
|
||||
# Wheel/sdist are platform-independent; the linux job also pushes them
|
||||
# to PyPI. Token is the PYPI_TOKEN repository secret.
|
||||
- name: Publish to PyPI
|
||||
if: matrix.os == 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
run: uv publish
|
||||
|
||||
- name: Attach platform artifact to the Gitea release
|
||||
if: matrix.os != 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: uv run scripts/release.py --no-dist
|
||||
@@ -19,3 +19,4 @@ package-lock.json
|
||||
# Dotfiles
|
||||
.*
|
||||
!.gitignore
|
||||
!.gitea/
|
||||
|
||||
@@ -4,12 +4,24 @@
|
||||
|
||||
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
||||
|
||||
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
||||
## Downloads
|
||||
|
||||
## Getting Started
|
||||
- **Windows**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-setup.exe) · [Portable ZIP](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-portable.zip)
|
||||
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
|
||||
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
|
||||
|
||||
- Windows and macOS: Download the portable ZIP from the releases page, extract it anywhere, and run `MediaHive`.
|
||||
- Linux and other platforms: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
|
||||
### Linux
|
||||
|
||||
```
|
||||
wget https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage
|
||||
chmod +x MediaHive-linux.AppImage && ./MediaHive-linux.AppImage
|
||||
```
|
||||
|
||||
You may also run without installing via
|
||||
|
||||
```
|
||||
uvx --from mediahive[gui] mediahive
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -19,7 +31,7 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
|
||||
- Remembers per-episode playback positions and offers series continue points
|
||||
- Hand off playback to your preferred system player
|
||||
|
||||
Windows and macOS builds are currently portable-only (no installer). On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||
|
||||
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
|
||||
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
|
||||
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
|
||||
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
||||
- Roots may also be provided at startup via the `MEDIAHIVE_ROOTS` environment variable (JSON dict of name → path), which overrides the persisted configuration.
|
||||
- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration.
|
||||
- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes.
|
||||
|
||||
## WebSocket
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Development
|
||||
|
||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (portable ZIPs on Windows/macOS, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (installer/AppImage downloads, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -44,8 +44,8 @@ This launches the same pywebview-based desktop flow used by the Windows build.
|
||||
|
||||
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
|
||||
|
||||
- `./scripts/guibuild.py` builds the PyInstaller desktop app and a versioned portable ZIP under `build/`.
|
||||
- `./scripts/release.py` publishes a release to the Gitea releases page.
|
||||
- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). Requires node/npm and the .NET SDK (>= 10 runtime) installed on the build host; `vpk` and ffmpeg are downloaded once into a persistent user cache (`~/.cache/mediahive-build`, `%LOCALAPPDATA%\mediahive-build` on Windows).
|
||||
- `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release.
|
||||
|
||||
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
|
||||
|
||||
|
||||
+1
-3
@@ -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
|
||||
|
||||
@@ -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,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -302,7 +316,7 @@
|
||||
</template>
|
||||
|
||||
<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 { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
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) {
|
||||
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
||||
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
||||
@@ -438,6 +487,9 @@ async function addRoot() {
|
||||
watch(showSettings, (visible) => {
|
||||
if (visible) {
|
||||
void refreshPlayers()
|
||||
connectLogSocket()
|
||||
} else {
|
||||
disconnectLogSocket()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -551,6 +603,7 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", handleKeydown)
|
||||
window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
|
||||
disconnectLogSocket()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -868,4 +921,31 @@ onUnmounted(() => {
|
||||
font-size: 0.8rem;
|
||||
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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
v-for="entry in flagEntries"
|
||||
:key="entry.countryCode"
|
||||
class="language-flag"
|
||||
:title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`"
|
||||
:title="formatLanguageFlagTitle(entry, externalCodes)"
|
||||
v-html="entry.svg"
|
||||
></span>
|
||||
<span
|
||||
@@ -22,11 +22,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { buildLanguageFlags } from "../utils/languageFlags"
|
||||
import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags"
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
codes: string[] | null | undefined
|
||||
externalCodes?: string[] | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<LanguageFlags
|
||||
class="language-flags-subs"
|
||||
:codes="torrent.subtitle_languages"
|
||||
:external-codes="torrent.external_subtitle_languages"
|
||||
:compact="compactFlags"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface Torrent {
|
||||
audio: string | null
|
||||
audio_languages: string[] | null
|
||||
subtitle_languages: string[] | null
|
||||
external_subtitle_languages?: string[] | null
|
||||
hdr?: boolean
|
||||
dovi?: boolean
|
||||
atmos?: boolean
|
||||
|
||||
@@ -16,17 +16,18 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
||||
// Spanish (including LATAM variants collapsed to Spain flag)
|
||||
es: "ES",
|
||||
spa: "ES",
|
||||
esp: "ES",
|
||||
esl: "ES",
|
||||
spl: "ES",
|
||||
"es-es": "ES",
|
||||
"es-419": "ES",
|
||||
"spa-la": "ES",
|
||||
|
||||
// Portuguese
|
||||
// Portuguese (Brazilian variant collapses to Portugal flag)
|
||||
pt: "PT",
|
||||
por: "PT",
|
||||
"pt-pt": "PT",
|
||||
"pt-br": "BR",
|
||||
"pt-br": "PT",
|
||||
|
||||
// Major European languages
|
||||
fr: "FR",
|
||||
@@ -49,7 +50,7 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
||||
fi: "FI",
|
||||
fin: "FI",
|
||||
pl: "PL",
|
||||
पोल: "PL",
|
||||
pol: "PL",
|
||||
cs: "CZ",
|
||||
ces: "CZ",
|
||||
cze: "CZ",
|
||||
@@ -121,6 +122,113 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
|
||||
eu: "ES",
|
||||
baq: "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 {
|
||||
@@ -265,9 +373,13 @@ export function mapLanguageToCountry(code: string): string | null {
|
||||
const direct = LANGUAGE_TO_COUNTRY[normalized]
|
||||
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("-")
|
||||
if (hyphenParts.length >= 2) {
|
||||
const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]]
|
||||
if (base) return base
|
||||
const region = hyphenParts[hyphenParts.length - 1]
|
||||
if (/^[a-z]{2}$/i.test(region)) {
|
||||
return region.toUpperCase()
|
||||
@@ -341,10 +453,15 @@ export function buildLanguageFlags(codes: string[] | null | undefined): {
|
||||
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
|
||||
eng: "English",
|
||||
spa: "Spanish",
|
||||
esp: "Spanish",
|
||||
"spa-la": "Spanish",
|
||||
"es-419": "Spanish",
|
||||
esl: "Spanish",
|
||||
spl: "Spanish",
|
||||
por: "Portuguese",
|
||||
"pt-br": "Portuguese",
|
||||
nob: "Norwegian",
|
||||
nno: "Norwegian",
|
||||
fre: "French",
|
||||
fra: "French",
|
||||
ger: "German",
|
||||
@@ -431,6 +548,53 @@ function summarizeLanguageCodes(codes: string[] | null | undefined): string {
|
||||
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(
|
||||
audioCodes: string[] | null | undefined,
|
||||
subtitleCodes: string[] | null | undefined,
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
* paths - Array of paths to proxy (default: ['/api'])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421"
|
||||
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-mediahive",
|
||||
name: 'vite-plugin-fastapi-mediahive',
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../mediahive/frontend-build",
|
||||
outDir: '../mediahive/frontend-build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
|
||||
+17
-7
@@ -1,16 +1,20 @@
|
||||
"""MediaHive CLI entrypoint."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||
os.environ["FASTAPI_VUE"] = "MEDIAHIVE"
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue import env, server
|
||||
|
||||
from mediahive.config import config
|
||||
|
||||
DEFAULT_PORT = 8420
|
||||
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
||||
|
||||
|
||||
def _configure_windows_event_loop_policy() -> None:
|
||||
@@ -140,10 +144,11 @@ def main() -> None:
|
||||
name = f"{base_name}{suffix}"
|
||||
suffix += 1
|
||||
roots[name] = p.as_posix()
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
||||
# Teleported to the server process by fastapi-vue's server.run().
|
||||
config.roots = roots
|
||||
|
||||
if (
|
||||
DEVMODE
|
||||
env.dev
|
||||
and sys.platform == "win32"
|
||||
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
||||
):
|
||||
@@ -156,7 +161,12 @@ def main() -> None:
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
loop="none" if sys.platform == "win32" else "auto",
|
||||
reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False,
|
||||
reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False,
|
||||
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
|
||||
# keep our own loggers visible in production too.
|
||||
log_config={
|
||||
"loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Welcome to the MediaHive installer.
|
||||
|
||||
During installation and on first launch, macOS may ask you to allow
|
||||
permissions (for example, access to your media folders or the local
|
||||
network). Please allow these so MediaHive can find and play your media.
|
||||
|
||||
Click Continue to begin.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
+25
-29
@@ -1,57 +1,53 @@
|
||||
r"""Platform-appropriate config persistence for MediaHive.
|
||||
|
||||
Config file location:
|
||||
Windows: %APPDATA%\mediahive\config.toml
|
||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
|
||||
Locations (via platformdirs):
|
||||
Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
|
||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml
|
||||
Logs — Windows: %LOCALAPPDATA%\mediahive\mediahive.log
|
||||
macOS: ~/Library/Logs/mediahive/mediahive.log
|
||||
Linux: $XDG_STATE_HOME/mediahive/mediahive.log
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
import msgspec.toml
|
||||
from fastapi_vue import env
|
||||
from platformdirs import user_config_path, user_log_path
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
media_folder: str | None = None
|
||||
roots: dict[str, str] | None = None
|
||||
|
||||
|
||||
# Runtime config shared between the CLI entrypoint and the server process via
|
||||
# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take
|
||||
# precedence over the persisted config file.
|
||||
config = env(Config)
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA") or Path.home())
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
||||
return base / "mediahive"
|
||||
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||
# roaming=False: config is machine-specific state, not something to sync
|
||||
# across a domain profile.
|
||||
return user_config_path("mediahive", appauthor=False, roaming=False)
|
||||
|
||||
|
||||
def log_dir() -> Path:
|
||||
# opinion=False: no extra Logs/ subdir; mediahive.log sits beside config.
|
||||
return user_log_path("mediahive", appauthor=False, opinion=False)
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
return config_dir() / "config.toml"
|
||||
|
||||
|
||||
def _migrate_legacy_media_folder(cfg: Config) -> Config:
|
||||
"""If roots is empty but media_folder exists, seed roots with it."""
|
||||
if cfg.roots:
|
||||
return cfg
|
||||
if not cfg.media_folder:
|
||||
return cfg
|
||||
path = Path(cfg.media_folder)
|
||||
name = path.name or path.anchor.strip("/\\").lower() or "media"
|
||||
# Resolve collisions simply by using the basename; if user had weird layout
|
||||
# they can rename via the UI later.
|
||||
return msgspec.structs.replace(cfg, roots={name: cfg.media_folder})
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
path = config_path()
|
||||
if path.exists():
|
||||
try:
|
||||
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||
return _migrate_legacy_media_folder(cfg)
|
||||
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
||||
return Config()
|
||||
return Config()
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""Hivescan CLI entrypoint."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from mediahive.config import config
|
||||
|
||||
|
||||
def _configure_windows_event_loop_policy() -> None:
|
||||
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
||||
@@ -55,11 +60,9 @@ The server exposes a unified endpoint:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Defer filesystem validation to the server; pass raw path via env.
|
||||
# Defer filesystem validation to the server; pass raw path via env config.
|
||||
media_root = Path(args.media_folder).expanduser()
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
|
||||
media_root.name or "media": media_root.as_posix()
|
||||
})
|
||||
config.roots = {media_root.name or "media": media_root.as_posix()}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
||||
@@ -16,6 +16,7 @@ from mediahive.hivescan.models import ContentType, ParsedContent
|
||||
from mediahive.hivescan.scanning import (
|
||||
find_cover_image,
|
||||
find_episode_files,
|
||||
find_external_subtitle_languages,
|
||||
find_metadata_probe_file,
|
||||
find_playable_file,
|
||||
)
|
||||
@@ -61,6 +62,18 @@ def _infer_hdr10plus(*values: str | None) -> bool:
|
||||
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:
|
||||
"""Store playable paths compactly relative to the file key when possible."""
|
||||
if not playable_file:
|
||||
@@ -99,6 +112,7 @@ async def _build_torrent_info(
|
||||
probe_target = await find_metadata_probe_file(playable_file)
|
||||
if 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:
|
||||
item.content_hash.size = await asyncio.to_thread(
|
||||
@@ -125,7 +139,11 @@ async def _build_torrent_info(
|
||||
codec=item.codec,
|
||||
audio=item.audio,
|
||||
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,
|
||||
dovi=probe_info.dovi 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] = []
|
||||
for file_path, file_size in files:
|
||||
probe = await get_probe(file_path)
|
||||
external_subs = await find_external_subtitle_languages(file_path)
|
||||
all_episode_files[key].append({
|
||||
"path": file_path,
|
||||
"size": file_size,
|
||||
"probed_resolution": probe.resolution,
|
||||
"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,
|
||||
"dovi": probe.dovi,
|
||||
"atmos": probe.atmos,
|
||||
@@ -255,12 +277,18 @@ async def _collect_episode_files(
|
||||
item.content_hash.path,
|
||||
)
|
||||
size = item.content_hash.size if item.content_hash else 0
|
||||
external_subs = await find_external_subtitle_languages(
|
||||
playable
|
||||
)
|
||||
all_episode_files[key].append({
|
||||
"path": playable,
|
||||
"size": size,
|
||||
"probed_resolution": probe.resolution,
|
||||
"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,
|
||||
"dovi": probe.dovi,
|
||||
"atmos": probe.atmos,
|
||||
@@ -343,6 +371,7 @@ def _build_episodes_data(
|
||||
audio=f.get("audio"),
|
||||
audio_languages=f.get("audio_languages"),
|
||||
subtitle_languages=f.get("subtitle_languages"),
|
||||
external_subtitle_languages=f.get("external_subtitle_languages"),
|
||||
hdr=bool(f.get("hdr")),
|
||||
dovi=bool(f.get("dovi")),
|
||||
atmos=bool(f.get("atmos")),
|
||||
|
||||
@@ -28,6 +28,23 @@ VIDEO_EXTENSIONS = {
|
||||
".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
|
||||
# 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
|
||||
@@ -322,6 +339,56 @@ async def find_playable_file(path: Path) -> str | None:
|
||||
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:
|
||||
"""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_languages: list[str] | None = None
|
||||
subtitle_languages: list[str] | None = None
|
||||
external_subtitle_languages: list[str] | None = None
|
||||
hdr: bool = False
|
||||
dovi: bool = False
|
||||
atmos: bool = False
|
||||
|
||||
+85
-21
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
@@ -30,11 +31,15 @@ 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_vue import Frontend
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
PlainTextResponse,
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from fastapi_vue import Frontend, env
|
||||
|
||||
from mediahive.__main__ import DEVMODE
|
||||
from mediahive.config import load_config
|
||||
from mediahive.config import config, 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
|
||||
@@ -968,23 +973,14 @@ async def _activate_all_roots() -> None:
|
||||
"""
|
||||
desired: dict[str, str] = {}
|
||||
|
||||
# 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict)
|
||||
env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS")
|
||||
env_roots: dict[str, str] | None = None
|
||||
if env_roots_raw:
|
||||
try:
|
||||
parsed = json.loads(env_roots_raw)
|
||||
if isinstance(parsed, dict):
|
||||
env_roots = parsed
|
||||
except Exception:
|
||||
logger.exception("Failed to parse MEDIAHIVE_ROOTS")
|
||||
|
||||
# 1. CLI roots (teleported via fastapi-vue's env config) take precedence
|
||||
# 2. Persisted config roots (used only when CLI roots are not provided)
|
||||
cfg = load_config()
|
||||
if env_roots is not None:
|
||||
desired.update(env_roots)
|
||||
elif cfg.roots:
|
||||
desired.update(cfg.roots)
|
||||
if config.roots:
|
||||
desired.update(config.roots)
|
||||
else:
|
||||
cfg = load_config()
|
||||
if cfg.roots:
|
||||
desired.update(cfg.roots)
|
||||
|
||||
if not desired:
|
||||
logger.info("No roots configured; waiting for PUT /api/config/roots")
|
||||
@@ -1050,7 +1046,7 @@ async def lifespan(_app: FastAPI):
|
||||
await close_image_client()
|
||||
|
||||
|
||||
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
|
||||
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=env.dev)
|
||||
|
||||
# Allow CORS for development
|
||||
app.add_middleware(
|
||||
@@ -1073,6 +1069,74 @@ 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}
|
||||
|
||||
|
||||
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")
|
||||
async def get_config():
|
||||
"""Return current server configuration."""
|
||||
|
||||
+87
-31
@@ -24,11 +24,20 @@ import urllib.request
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config);
|
||||
# this module is the PyInstaller entry point and may run without __main__.
|
||||
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
|
||||
|
||||
import msgspec.structs
|
||||
import uvicorn
|
||||
import velopack
|
||||
import webview
|
||||
from fastapi_vue import env
|
||||
from fastapi_vue.logging import patch_log_config
|
||||
from fastapi_vue.startupbox import print_box
|
||||
from tracerite.html import html_traceback
|
||||
|
||||
from mediahive.config import load_config, save_config
|
||||
from mediahive.config import config, load_config, log_dir, save_config
|
||||
from mediahive.volume_control import get_volume, set_volume, volume_max
|
||||
|
||||
logger = logging.getLogger("mediahive.winmain")
|
||||
@@ -39,6 +48,7 @@ HEALTH_TIMEOUT = 2 # seconds
|
||||
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
|
||||
BACKEND_HEALTH_POLL_SECONDS = 0.25
|
||||
MPC_BE_URL = "http://127.0.0.1:13579"
|
||||
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||
GAMEPAD_REPEAT_SECONDS = 0.008
|
||||
GAMEPAD_POLL_SECONDS = 0.008
|
||||
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
|
||||
@@ -871,18 +881,16 @@ def _wait_for_previous_instance(log_path: Path, timeout: float = 15.0):
|
||||
|
||||
|
||||
def _setup_logging() -> Path:
|
||||
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
|
||||
"""Redirect stdout/stderr and configure logging to a file in the platform log dir.
|
||||
|
||||
In a PyInstaller --windowed build there is no console, so any print() or
|
||||
unhandled exception traceback would be lost. This ensures everything ends
|
||||
up in a persistent log file the user can send for bug reports.
|
||||
Returns the path to the log file.
|
||||
"""
|
||||
from mediahive.config import config_dir
|
||||
|
||||
log_dir = config_dir()
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "mediahive.log"
|
||||
log_directory = log_dir()
|
||||
log_directory.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_directory / "mediahive.log"
|
||||
|
||||
try:
|
||||
log_file = _rotate_and_open_log(log_path)
|
||||
@@ -899,7 +907,7 @@ def _setup_logging() -> Path:
|
||||
log_file = _wait_for_previous_instance(log_path)
|
||||
if log_file is None:
|
||||
# Never fail startup over logging: fall back to a per-process file.
|
||||
log_path = log_dir / f"mediahive-{os.getpid()}.log"
|
||||
log_path = log_directory / f"mediahive-{os.getpid()}.log"
|
||||
with contextlib.suppress(OSError):
|
||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
|
||||
@@ -958,26 +966,53 @@ def _show_fatal_error(exc: BaseException) -> None:
|
||||
Frozen --windowed builds otherwise surface crashes only as PyInstaller's
|
||||
plain-text error dialog (or nothing at all).
|
||||
"""
|
||||
try:
|
||||
from tracerite.html import html_traceback
|
||||
|
||||
fragment = str(html_traceback(exc))
|
||||
except Exception: # noqa: BLE001 - error reporting must never raise
|
||||
return
|
||||
fragment = str(html_traceback(exc))
|
||||
page = (
|
||||
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||
"<title>MediaHive — Error</title></head>"
|
||||
f"<body style='margin:1.5rem'>{fragment}</body></html>"
|
||||
)
|
||||
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
||||
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
||||
|
||||
|
||||
def _velopack_startup() -> None:
|
||||
"""Handle Velopack install/update/uninstall hooks and pending updates.
|
||||
|
||||
Must be the first thing at startup: when Velopack launches the app with
|
||||
--veloapp-* hook arguments (during install/update/uninstall), run()
|
||||
executes the hook and exits the process, so the GUI never starts.
|
||||
Applies downloaded-but-pending updates. No-op in development and
|
||||
portable-ZIP runs.
|
||||
"""
|
||||
velopack.App().run()
|
||||
|
||||
|
||||
def _check_for_updates() -> None:
|
||||
"""Download available updates in the background.
|
||||
|
||||
Downloaded updates are applied automatically by Velopack on the next app
|
||||
start (via _velopack_startup), so the running session is never
|
||||
interrupted. Not a Velopack install (dev/portable) and network failures
|
||||
are expected and skipped quietly.
|
||||
"""
|
||||
try:
|
||||
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
||||
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
||||
except Exception:
|
||||
logger.exception("Could not display the error window")
|
||||
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
||||
info = mgr.check_for_updates()
|
||||
if info is None:
|
||||
logger.info("Velopack: no update available")
|
||||
return
|
||||
version = info.TargetFullRelease.Version
|
||||
logger.info("Velopack: downloading update %s", version)
|
||||
mgr.download_updates(info)
|
||||
logger.info("Velopack: update %s staged, applies on next launch", version)
|
||||
except (RuntimeError, OSError) as exc:
|
||||
logger.info("Velopack update check skipped: %s", exc)
|
||||
|
||||
|
||||
def gui_main() -> None:
|
||||
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
|
||||
_velopack_startup()
|
||||
try:
|
||||
winmain()
|
||||
except Exception as exc:
|
||||
@@ -1126,8 +1161,26 @@ def _configure_windows_event_loop_policy() -> None:
|
||||
asyncio.set_event_loop_policy(policy_cls())
|
||||
|
||||
|
||||
def _strip_mark_of_the_web() -> None:
|
||||
"""Remove Zone.Identifier streams from bundled DLLs (frozen Windows only).
|
||||
|
||||
Files extracted from a downloaded ZIP carry the Mark-of-the-Web, and the
|
||||
.NET Framework CLR refuses to load such assemblies — pythonnet then fails
|
||||
with "Failed to resolve Python.Runtime.Loader.Initialize from
|
||||
.../Python.Runtime.dll". Strip the mark from the bundled DLLs before
|
||||
pywebview loads the CLR.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False) or sys.platform != "win32":
|
||||
return
|
||||
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
for dll in meipass.rglob("*.dll"):
|
||||
with contextlib.suppress(OSError):
|
||||
Path(f"{dll}:Zone.Identifier").unlink()
|
||||
|
||||
|
||||
def winmain() -> None:
|
||||
_configure_windows_event_loop_policy()
|
||||
_strip_mark_of_the_web()
|
||||
|
||||
parser = argparse.ArgumentParser(description="MediaHive")
|
||||
parser.add_argument(
|
||||
@@ -1154,10 +1207,6 @@ def winmain() -> None:
|
||||
initial_roots[name] = p.as_posix()
|
||||
elif cfg.roots:
|
||||
initial_roots = cfg.roots
|
||||
elif cfg.media_folder:
|
||||
p = _normalize_media_root_input(cfg.media_folder)
|
||||
name = p.name or "media"
|
||||
initial_roots[name] = p.as_posix()
|
||||
|
||||
if not initial_roots:
|
||||
folder = _run_initial_setup()
|
||||
@@ -1171,8 +1220,9 @@ def winmain() -> None:
|
||||
if cfg.roots != initial_roots:
|
||||
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
|
||||
|
||||
# Pass roots to the server via env (validation deferred to server startup)
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
|
||||
# Pass roots to the in-process server via the shared env config
|
||||
# (validation deferred to server startup)
|
||||
config.roots = initial_roots
|
||||
|
||||
backend_port = _reserve_backend_port()
|
||||
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
||||
@@ -1180,8 +1230,6 @@ def winmain() -> None:
|
||||
|
||||
# Startup banner, same as fastapi-vue's server.run() prints in CLI mode.
|
||||
# Goes to stderr, which frozen builds redirect to the log file.
|
||||
from fastapi_vue.startupbox import print_box
|
||||
|
||||
try:
|
||||
version = importlib.metadata.version("mediahive")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
@@ -1192,9 +1240,13 @@ def winmain() -> None:
|
||||
# log config wires up its access-log middleware, emoji level prefixes and
|
||||
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
|
||||
# e.g. redirected to the log file in frozen builds).
|
||||
from fastapi_vue.logging import patch_log_config
|
||||
|
||||
config = uvicorn.Config(
|
||||
log_config = patch_log_config(uvicorn.config.LOGGING_CONFIG)
|
||||
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
|
||||
# keep our own loggers visible in production too.
|
||||
log_config.setdefault("loggers", {})["mediahive"] = {
|
||||
"level": "DEBUG" if env.dev else "INFO"
|
||||
}
|
||||
uvicorn_config = uvicorn.Config(
|
||||
"mediahive.server:app",
|
||||
host=BACKEND_HOST,
|
||||
port=backend_port,
|
||||
@@ -1202,9 +1254,9 @@ def winmain() -> None:
|
||||
server_header=False,
|
||||
timeout_graceful_shutdown=0,
|
||||
access_log=False, # fastapi-vue's middleware replaces uvicorn's
|
||||
log_config=patch_log_config(uvicorn.config.LOGGING_CONFIG),
|
||||
log_config=log_config,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server = uvicorn.Server(uvicorn_config)
|
||||
backend_thread = threading.Thread(
|
||||
target=server.run, daemon=True, name="mediahive-backend"
|
||||
)
|
||||
@@ -1228,6 +1280,10 @@ def winmain() -> None:
|
||||
server.should_exit = True
|
||||
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
|
||||
|
||||
threading.Thread(
|
||||
target=_check_for_updates, daemon=True, name="mediahive-update-check"
|
||||
).start()
|
||||
|
||||
api = JsApi()
|
||||
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
|
||||
window = webview.create_window(
|
||||
|
||||
+5
-3
@@ -8,11 +8,12 @@ dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"aiopathlib>=0.6.0",
|
||||
"bencodepy>=0.9.5",
|
||||
"fastapi-vue>=1.4.1",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"msgspec>=0.19",
|
||||
"parse-torrent-title>=2.8.1",
|
||||
"platformdirs>=4.0",
|
||||
"tomli-w>=1.2.0",
|
||||
"uvicorn[standard]>=0.40.0",
|
||||
]
|
||||
@@ -50,10 +51,11 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
|
||||
|
||||
[project.optional-dependencies]
|
||||
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>=6.2.1; platform_system == 'Windows'",
|
||||
"qtpy>=2.4.1; platform_system == 'Darwin'",
|
||||
"PyQt5>=5.15.11; platform_system == 'Darwin'",
|
||||
"velopack>=1.2",
|
||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||
"pyinstaller>=6.0",
|
||||
]
|
||||
|
||||
+22
-8
@@ -5,7 +5,7 @@
|
||||
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||
#
|
||||
# Or use the build script (recommended—handles versioning and packaging):
|
||||
# uv run scripts/winbuild.py
|
||||
# uv run scripts/guibuild.py
|
||||
|
||||
import sys
|
||||
import mediahive.winmain
|
||||
@@ -20,7 +20,15 @@ _frontend_build = _pkg / "frontend-build"
|
||||
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
# ffmpeg staging lives in the persistent build cache (same logic as
|
||||
# scripts/guibuild.py); fall back to the legacy build/ffmpeg location.
|
||||
from platformdirs import user_cache_path
|
||||
|
||||
_tools_dir = (
|
||||
user_cache_path("mediahive-build", appauthor=False, opinion=False) / "ffmpeg"
|
||||
)
|
||||
if not _tools_dir.exists():
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
||||
|
||||
_binaries = []
|
||||
@@ -80,11 +88,12 @@ if sys.platform == "darwin":
|
||||
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
|
||||
"webview.platforms.qt",
|
||||
"qtpy",
|
||||
"PyQt5",
|
||||
"PyQt5.QtCore",
|
||||
"PyQt5.QtGui",
|
||||
"PyQt5.QtWidgets",
|
||||
"PyQt5.QtWebEngineWidgets",
|
||||
"PyQt6",
|
||||
"PyQt6.QtCore",
|
||||
"PyQt6.QtGui",
|
||||
"PyQt6.QtWidgets",
|
||||
"PyQt6.QtWebEngineCore",
|
||||
"PyQt6.QtWebEngineWidgets",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -123,6 +132,11 @@ exe = EXE(
|
||||
windowed=True,
|
||||
)
|
||||
|
||||
# UPX breaks .NET assemblies: packing Python.Runtime.dll strips/corrupts its
|
||||
# CLR metadata and pythonnet then fails with "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize from .../Python.Runtime.dll".
|
||||
_upx_exclude = ["Python.Runtime.dll"] if sys.platform == "win32" else []
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
@@ -130,7 +144,7 @@ coll = COLLECT(
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
upx_exclude=_upx_exclude,
|
||||
name="MediaHive",
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import tracerite
|
||||
@@ -48,11 +48,11 @@ async def run_devserver(
|
||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
pg.create_task(check_ports_free(viteurl, backurl))
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*mediahive, *(extra_args or []))
|
||||
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
|
||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
await pg.spawn(*vite, cwd=front, vital=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -75,8 +75,12 @@ def main() -> None:
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
with suppress(KeyboardInterrupt):
|
||||
try:
|
||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||
except* KeyboardInterrupt:
|
||||
pass # user stopped the devserver: normal exit
|
||||
except* subprocess.SubprocessError, RuntimeError:
|
||||
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
|
||||
@@ -10,20 +10,27 @@ from pathlib import Path
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||
|
||||
INFO and below pass through unprefixed so messages can use their own
|
||||
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.ERROR:
|
||||
return f"🛑 {record.getMessage()}"
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return f"💣 {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
|
||||
@@ -1,110 +1,89 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from asyncio.subprocess import Process
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Awaitable
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
class ProcessGroup(asyncio.TaskGroup):
|
||||
"""TaskGroup with structured ownership of async subprocesses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||
"""Set the grace period before terminate() escalates to kill()."""
|
||||
super().__init__()
|
||||
self._terminate_timeout = terminate_timeout
|
||||
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*cmd: str,
|
||||
cwd: str | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||
) -> Process:
|
||||
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
async def run() -> None:
|
||||
name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._cmds[proc] = cmd
|
||||
started.set_result(proc)
|
||||
except Exception as e: # ruff: ignore[blind-except]
|
||||
started.set_exception(e)
|
||||
return
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
returncode = await proc.wait()
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
),
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
with suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
if vital:
|
||||
logger.warning("Vital process %s exited", name)
|
||||
raise CalledProcessError(returncode, cmd)
|
||||
|
||||
started = asyncio.get_running_loop().create_future()
|
||||
self.create_task(run())
|
||||
return await asyncio.shield(started)
|
||||
|
||||
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||
"""Wait concurrently and return results in argument order."""
|
||||
|
||||
async def task(w: Process | Awaitable) -> Any:
|
||||
if not isinstance(w, Process):
|
||||
return await w
|
||||
if retcode := await w.wait():
|
||||
cmd = self._cmds[w]
|
||||
logger.warning(
|
||||
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||
)
|
||||
raise CalledProcessError(retcode, cmd)
|
||||
return retcode
|
||||
|
||||
async with asyncio.TaskGroup() as group:
|
||||
tasks = [group.create_task(task(w)) for w in waitables]
|
||||
|
||||
return tuple(task.result() for task in tasks)
|
||||
|
||||
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout]
|
||||
"""GET url with plain asyncio streams, return the response Server header.
|
||||
|
||||
Returns an empty string when the server responds without a Server header,
|
||||
@@ -127,42 +106,43 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
||||
writer.close()
|
||||
except OSError, EOFError, ValueError, TimeoutError:
|
||||
return None
|
||||
for line in data.decode("latin-1").split("\r\n"):
|
||||
for line in data.decode(errors="replace").split("\r\n"):
|
||||
if line.lower().startswith("server:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return line[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
|
||||
async def check(url: str) -> None:
|
||||
server = await http_get_server(url, timeout=0.1)
|
||||
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||
RuntimeError (handled like a failed process) if any URL responds.
|
||||
"""
|
||||
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||
for url, server in zip(urls, servers, strict=True):
|
||||
if server is not None:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
await asyncio.gather(*[check(url) for url in urls])
|
||||
raise RuntimeError(url)
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Use empty path to disable the check and make this return immediately.
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||
logger.info("✓ Backend ready!")
|
||||
logger.info("🟢 Backend ready!")
|
||||
return
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
logger.error("Backend at %s didn't start in time", url)
|
||||
raise RuntimeError(url)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
+329
-30
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Build the desktop GUI application and package it as a version-numbered ZIP.
|
||||
"""Build the desktop GUI application and package it with Velopack.
|
||||
|
||||
Usage:
|
||||
uv run scripts/winbuild.py
|
||||
uv run scripts/guibuild.py
|
||||
|
||||
This runs in the project environment where dependencies
|
||||
are available via pyproject.toml.
|
||||
@@ -10,14 +10,18 @@ are available via pyproject.toml.
|
||||
This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
2. Runs `uv build` to produce the wheel/sdist
|
||||
3. On Windows, downloads the latest ffmpeg.exe for bundling
|
||||
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
|
||||
3. On Windows/macOS, downloads the ffmpeg binary for bundling
|
||||
4. Builds MediaHive using PyInstaller
|
||||
5. Creates a ZIP file with the version number
|
||||
5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
|
||||
.AppImage (Linux), plus the update feed in build/velopack/
|
||||
that release.py uploads for in-app auto-updates
|
||||
6. On Windows, also creates a portable ZIP (no auto-updates)
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -25,8 +29,10 @@ import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import setuptools_scm
|
||||
from platformdirs import user_cache_path
|
||||
|
||||
# BtbN automated builds always publish a 'latest' tag with this asset.
|
||||
_FFMPEG_URL = (
|
||||
@@ -36,29 +42,62 @@ _FFMPEG_URL = (
|
||||
_MACOS_ARM64_TOOL_URLS = {
|
||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||
}
|
||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||
|
||||
|
||||
def _platform_zip_suffix() -> str:
|
||||
machine = platform.machine().lower()
|
||||
arch = {
|
||||
"x86_64": "x64",
|
||||
"amd64": "x64",
|
||||
"arm64": "arm64",
|
||||
"aarch64": "arm64",
|
||||
}.get(machine, machine or "unknown")
|
||||
def _build_cache_dir() -> Path:
|
||||
"""Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
|
||||
return user_cache_path("mediahive-build", appauthor=False, opinion=False)
|
||||
|
||||
|
||||
_FFMPEG_STAGING = _build_cache_dir() / "ffmpeg"
|
||||
|
||||
# Velopack CLI (dotnet tool package). Runs on the machine's .NET runtime; the
|
||||
# produced Setup.exe/Update.exe are native and need no runtime on end-user
|
||||
# machines. Pin a version whose tools target an installed .NET major.
|
||||
_VPK_VERSION = "1.2.158"
|
||||
_VPK_URL = (
|
||||
f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}"
|
||||
f"/vpk.{_VPK_VERSION}.nupkg"
|
||||
)
|
||||
_VPK_STAGING = _build_cache_dir() / f"vpk-{_VPK_VERSION}"
|
||||
|
||||
|
||||
class _Platform(NamedTuple):
|
||||
"""Per-platform naming/packaging constants.
|
||||
|
||||
tag is the release artifact suffix. Only Windows keeps an arch marker
|
||||
(win64); macOS builds are arm64-only and we ship one Linux flavor.
|
||||
"""
|
||||
|
||||
tag: str # win64 / macos / linux
|
||||
channel: str # Velopack update channel: win / osx / linux
|
||||
rid: str # Velopack runtime id
|
||||
dist_dir: str # PyInstaller output dir under build/
|
||||
icon: str # file in mediahive/assets
|
||||
main_exe: str
|
||||
setup_ext: str
|
||||
|
||||
|
||||
def _platform() -> _Platform:
|
||||
if sys.platform == "win32":
|
||||
return "win64"
|
||||
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe")
|
||||
if sys.platform == "darwin":
|
||||
return f"macos-{arch}"
|
||||
return f"linux-{arch}"
|
||||
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
|
||||
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
|
||||
|
||||
|
||||
def setup_artifact_name() -> str:
|
||||
"""Versionless name so releases/download/latest/<name> links stay valid."""
|
||||
p = _platform()
|
||||
# Windows keeps the -setup suffix: a bare .exe isn't self-explanatory.
|
||||
suffix = "-setup" if sys.platform == "win32" else ""
|
||||
return f"MediaHive-{p.tag}{suffix}{p.setup_ext}"
|
||||
|
||||
|
||||
def fetch_ffmpeg() -> Path:
|
||||
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
|
||||
"""Download latest ffmpeg.exe from BtbN builds into the persistent build cache."""
|
||||
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
||||
if dest.exists():
|
||||
print(f"ffmpeg already staged at {dest}, skipping download.")
|
||||
@@ -83,7 +122,7 @@ def fetch_ffmpeg() -> Path:
|
||||
|
||||
|
||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/."""
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache."""
|
||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||
"arm64",
|
||||
"aarch64",
|
||||
@@ -180,6 +219,262 @@ def ensure_macos_icon() -> Path:
|
||||
return icon_icns
|
||||
|
||||
|
||||
_VPK_TFM = "net10.0"
|
||||
_VPK_REQUIRED_DOTNET_MAJOR = int(re.fullmatch(r"net(\d+)\.0", _VPK_TFM).group(1))
|
||||
|
||||
|
||||
def fetch_vpk() -> Path:
|
||||
"""Download the Velopack CLI package into the persistent build cache.
|
||||
|
||||
Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`.
|
||||
"""
|
||||
vpk_dll = _VPK_STAGING / "tools" / _VPK_TFM / "any" / "vpk.dll"
|
||||
if vpk_dll.exists():
|
||||
print(f"vpk already staged at {_VPK_STAGING}, skipping download.")
|
||||
return vpk_dll
|
||||
|
||||
_VPK_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading vpk from {_VPK_URL} ...")
|
||||
with urllib.request.urlopen(_VPK_URL) as resp:
|
||||
data = resp.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
zf.extractall(_VPK_STAGING)
|
||||
|
||||
if not vpk_dll.exists():
|
||||
raise RuntimeError(f"vpk.dll not found in package at {vpk_dll}")
|
||||
print(f"vpk staged at {_VPK_STAGING}")
|
||||
return vpk_dll
|
||||
|
||||
|
||||
def _dotnet_runtime_major(exe: Path) -> int | None:
|
||||
"""Return the highest installed Microsoft.NETCore.App major version, or None."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
majors = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
|
||||
try:
|
||||
majors.append(int(parts[1].split(".")[0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return max(majors, default=None)
|
||||
|
||||
|
||||
def fetch_dotnet() -> str:
|
||||
"""Resolve a system dotnet host able to run vpk (needs .NET >= 10).
|
||||
|
||||
The .NET SDK is a build prerequisite installed on the build machine —
|
||||
downloading a runtime per build is slow and flaky. Several dotnet
|
||||
installations may coexist (PATH may resolve to a runtime-only .NET 8
|
||||
while scoop holds the SDK 10), so probe known locations and pick the
|
||||
newest runtime rather than the first that runs.
|
||||
"""
|
||||
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
|
||||
candidates: list[Path] = []
|
||||
root = os.environ.get("DOTNET_ROOT")
|
||||
if root:
|
||||
candidates.append(Path(root) / exe_name)
|
||||
which = shutil.which("dotnet")
|
||||
if which:
|
||||
candidates.append(Path(which))
|
||||
if sys.platform == "win32":
|
||||
candidates += [
|
||||
Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name,
|
||||
Path(r"C:\Program Files\dotnet") / exe_name,
|
||||
]
|
||||
elif sys.platform == "darwin":
|
||||
candidates += [
|
||||
Path("/opt/homebrew/bin") / exe_name,
|
||||
Path("/usr/local/share/dotnet") / exe_name,
|
||||
]
|
||||
else:
|
||||
candidates += [
|
||||
Path("/usr/share/dotnet") / exe_name,
|
||||
Path("/usr/lib/dotnet") / exe_name,
|
||||
Path.home() / ".dotnet" / exe_name,
|
||||
]
|
||||
|
||||
best: tuple[int, Path] | None = None
|
||||
for exe in candidates:
|
||||
if not exe.exists():
|
||||
continue
|
||||
major = _dotnet_runtime_major(exe)
|
||||
if major is not None and (best is None or major > best[0]):
|
||||
best = (major, exe)
|
||||
|
||||
if best is not None and best[0] >= _VPK_REQUIRED_DOTNET_MAJOR:
|
||||
print(f"Using dotnet at {best[1]} (.NET {best[0]})")
|
||||
return str(best[1])
|
||||
|
||||
found = f"newest found is .NET {best[0]} at {best[1]}" if best else "none found"
|
||||
raise RuntimeError(
|
||||
f"vpk requires Microsoft.NETCore.App >= {_VPK_REQUIRED_DOTNET_MAJOR} ({found}). "
|
||||
"Install the current .NET SDK on this build machine "
|
||||
"(Windows: `scoop install dotnet-sdk`; macOS: `brew install dotnet-sdk`; "
|
||||
"Linux: distro `dotnet-sdk` package or the dotnet-install script)."
|
||||
)
|
||||
|
||||
|
||||
def build_velopack(version: str) -> Path:
|
||||
"""Build the Velopack installer/bundle for this platform.
|
||||
|
||||
Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
|
||||
Also produces the update feed (releases.<channel>.json, *.nupkg) in
|
||||
build/velopack/ for release.py to upload — in-app auto-updates read it
|
||||
from the Gitea release. Velopack installs carry no Mark-of-the-Web, so
|
||||
the .NET CLR loads pythonnet/pywebview assemblies that it refuses from
|
||||
a downloaded ZIP.
|
||||
"""
|
||||
plat = _platform()
|
||||
dist_folder = _REPO_ROOT / "build" / plat.dist_dir
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
vpk_dll = fetch_vpk()
|
||||
releases_dir = _REPO_ROOT / "build" / "velopack"
|
||||
|
||||
cmd = [
|
||||
fetch_dotnet(),
|
||||
str(vpk_dll),
|
||||
"pack",
|
||||
"--packId",
|
||||
"MediaHive",
|
||||
"--packVersion",
|
||||
version,
|
||||
"--packDir",
|
||||
str(dist_folder),
|
||||
"--mainExe",
|
||||
plat.main_exe,
|
||||
"--packAuthors",
|
||||
"MediaHive",
|
||||
"--packTitle",
|
||||
"MediaHive",
|
||||
"--icon",
|
||||
str(_ASSETS_DIR / plat.icon),
|
||||
"--runtime",
|
||||
plat.rid,
|
||||
"--outputDir",
|
||||
str(releases_dir),
|
||||
]
|
||||
if sys.platform == "darwin":
|
||||
cmd += ["--instWelcome", str(_ASSETS_DIR / "macos-installer-welcome.txt")]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
try:
|
||||
result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"vpk failed to start: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"vpk pack failed with exit code {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{plat.setup_ext}"))), None)
|
||||
if setup is None:
|
||||
setup = next(iter(sorted(releases_dir.glob(f"*{plat.setup_ext}"))), None)
|
||||
if setup is None:
|
||||
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
|
||||
if sys.platform == "darwin":
|
||||
force_macos_user_install(setup)
|
||||
artifact = _REPO_ROOT / "build" / setup_artifact_name()
|
||||
artifact.unlink(missing_ok=True)
|
||||
setup.rename(artifact)
|
||||
rename_feed_package(releases_dir, version, plat.channel)
|
||||
return artifact
|
||||
|
||||
|
||||
def rename_feed_package(releases_dir: Path, version: str, channel: str) -> None:
|
||||
"""Rename this platform's update-feed nupkg in place.
|
||||
|
||||
vpk hardcodes MediaHive-{ver}[-{channel}]-full.nupkg (Windows, the legacy
|
||||
default channel, gets no marker). Rename all to the uniform
|
||||
mediahive-{ver}-{channel}-full.nupkg: lowercase groups them with the
|
||||
wheel/sdist below the capitalized user downloads on the release page,
|
||||
and every platform carries its channel. releases.<channel>.json
|
||||
references the filename, so patch it too.
|
||||
"""
|
||||
old_name = f"MediaHive-{version}-full.nupkg"
|
||||
if not (releases_dir / old_name).exists():
|
||||
old_name = f"MediaHive-{version}-{channel}-full.nupkg"
|
||||
nupkg = releases_dir / old_name
|
||||
if not nupkg.exists():
|
||||
raise RuntimeError(f"vpk produced no {old_name} in {releases_dir}")
|
||||
|
||||
new_name = f"mediahive-{version}-{channel}-full.nupkg"
|
||||
manifest = releases_dir / f"releases.{channel}.json"
|
||||
text = manifest.read_text()
|
||||
if old_name not in text:
|
||||
raise RuntimeError(f"{manifest.name} does not reference {old_name}")
|
||||
manifest.write_text(text.replace(old_name, new_name))
|
||||
nupkg.rename(nupkg.with_name(new_name))
|
||||
|
||||
|
||||
def force_macos_user_install(pkg: Path) -> None:
|
||||
"""Restrict the Velopack-generated pkg to per-user installs (~/Applications).
|
||||
|
||||
Velopack hardcodes two install domains (currentUserHome + localSystem) in
|
||||
the distribution XML. System installs land in /Applications, which the
|
||||
user may not own — Velopack's UpdateMac then cannot replace the .app on
|
||||
auto-update. With a single domain, macOS Installer skips the Destination
|
||||
Select page and installs to ~/Applications without admin rights.
|
||||
|
||||
Also drops the `sudo -u "$USER"` prefix from Velopack's postinstall
|
||||
script: under a per-user install the script already runs as the
|
||||
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")
|
||||
shutil.rmtree(expanded, ignore_errors=True)
|
||||
subprocess.run(["pkgutil", "--expand", str(pkg), str(expanded)], check=True)
|
||||
|
||||
dist_xml = expanded / "Distribution"
|
||||
xml = dist_xml.read_text()
|
||||
new_xml, count = re.subn(
|
||||
r"<domains [^>]*/>",
|
||||
'<domains enable_anywhere="false" enable_currentUserHome="true" enable_localSystem="false" />',
|
||||
xml,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError("Unexpected distribution.xml: <domains> not found")
|
||||
dist_xml.write_text(new_xml)
|
||||
|
||||
# Edit postinstall inside the component pkg. Depending on the macOS
|
||||
# version, --expand leaves the component as an archived file (needs a
|
||||
# nested expand/flatten round) or as an already-expanded directory.
|
||||
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()
|
||||
if 'sudo -u "$USER" ' not in script:
|
||||
raise RuntimeError("Unexpected postinstall script: sudo prefix not found")
|
||||
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)
|
||||
shutil.rmtree(expanded)
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||
return setuptools_scm.get_version(root=str(_REPO_ROOT))
|
||||
@@ -217,18 +512,18 @@ def build_executable() -> None:
|
||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def create_zip(version: str) -> Path:
|
||||
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
|
||||
repo_root = _REPO_ROOT
|
||||
dist_folder = repo_root / "build" / "MediaHive"
|
||||
def create_portable_zip() -> Path:
|
||||
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||
|
||||
Velopack-less plain-folder distribution for users who cannot or do not
|
||||
want to run Setup.exe. No auto-updates; the app strips Mark-of-the-Web
|
||||
from bundled DLLs at first run instead.
|
||||
"""
|
||||
dist_folder = _REPO_ROOT / "build" / "MediaHive"
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
|
||||
zip_path = repo_root / "build" / zip_name
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
|
||||
print(f"Creating {zip_path}...")
|
||||
shutil.make_archive(
|
||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||
@@ -258,10 +553,14 @@ def main() -> None:
|
||||
)
|
||||
build_wheel()
|
||||
build_executable()
|
||||
zip_path = create_zip(version)
|
||||
|
||||
print(f"✓ Built successfully: {zip_path}")
|
||||
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
artifacts = [build_velopack(version)]
|
||||
if sys.platform == "win32":
|
||||
artifacts.append(create_portable_zip())
|
||||
|
||||
for artifact_path in artifacts:
|
||||
print(f"✓ Built successfully: {artifact_path}")
|
||||
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
|
||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
+108
-40
@@ -9,10 +9,15 @@ Reads from [project.urls] Repository in pyproject.toml.
|
||||
Token: GITEA_TOKEN environment variable
|
||||
|
||||
Steps:
|
||||
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing for a found ZIP version
|
||||
3. Create a Gitea release for each version and upload all assets
|
||||
1. Read the clean tag version via setuptools_scm, find platform artifacts
|
||||
in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing
|
||||
3. Create a Gitea release for each version (or reuse the existing one
|
||||
for the tag, skipping already-uploaded assets) and upload all assets
|
||||
4. Remind the user to run: uv publish
|
||||
|
||||
Parallel CI platform builds converge on one release per tag; pass --no-dist
|
||||
on all but one platform so only it uploads the wheel/sdist.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -24,6 +29,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import setuptools_scm
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
@@ -63,20 +69,27 @@ def load_token() -> str:
|
||||
# ZIP + dist helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
|
||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
|
||||
# Installer artifacts are versionless (MediaHive-win64-setup.exe,
|
||||
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
|
||||
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
|
||||
# stay valid. The version comes from setuptools_scm instead.
|
||||
_ARTIFACT_RE = re.compile(r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$")
|
||||
|
||||
|
||||
def find_releasable_zips() -> list[tuple[Path, str, str]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
|
||||
def read_version() -> str:
|
||||
"""Read version via setuptools_scm, refusing dev/dirty versions."""
|
||||
version = setuptools_scm.get_version(root=str(REPO_ROOT))
|
||||
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
|
||||
raise RuntimeError(
|
||||
f"Refusing to release non-clean version {version!r}. Tag a release first."
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def find_releasable_artifacts() -> list[Path]:
|
||||
"""Return platform artifact paths in build/."""
|
||||
build_dir = REPO_ROOT / "build"
|
||||
results = []
|
||||
for p in sorted(build_dir.glob("MediaHive-*.zip")):
|
||||
m = _CLEAN_ZIP_RE.match(p.name)
|
||||
if m:
|
||||
results.append((p, m.group(1), m.group(2)))
|
||||
return results
|
||||
return [p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)]
|
||||
|
||||
|
||||
def find_dist_files(version: str) -> list[Path]:
|
||||
@@ -108,6 +121,23 @@ def find_dist_files(version: str) -> list[Path]:
|
||||
return [wheel, sdist]
|
||||
|
||||
|
||||
def find_velopack_feed_files() -> list[Path]:
|
||||
"""Velopack update feed files produced by vpk pack in build/velopack/.
|
||||
|
||||
Only what the in-app updater (GiteaSource) reads from the latest
|
||||
release: this channel's releases.<channel>.json index and the nupkg
|
||||
payload it points to. The legacy RELEASES and assets.*.json manifests
|
||||
(Squirrel compat / setup bootstrap) are not uploaded.
|
||||
"""
|
||||
releases_dir = REPO_ROOT / "build" / "velopack"
|
||||
if not releases_dir.exists():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for pattern in ("releases.*.json", "*.nupkg"):
|
||||
files.extend(sorted(releases_dir.glob(pattern)))
|
||||
return files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -117,6 +147,18 @@ def gitea_headers(token: str) -> dict:
|
||||
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
|
||||
|
||||
def get_release_by_tag(
|
||||
client: httpx.Client, base_url: str, repo: str, tag: str
|
||||
) -> dict | None:
|
||||
"""Return the existing release for a tag, or None."""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{tag}"
|
||||
resp = client.get(url)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def create_release(
|
||||
client: httpx.Client,
|
||||
base_url: str,
|
||||
@@ -125,8 +167,12 @@ def create_release(
|
||||
version: str,
|
||||
notes: str,
|
||||
draft: bool,
|
||||
) -> int:
|
||||
"""Create a Gitea release and return its id."""
|
||||
) -> tuple[int, set[str]]:
|
||||
"""Create a Gitea release, or reuse the existing one for the tag.
|
||||
|
||||
Returns (release_id, names of assets already attached), so parallel
|
||||
platform builds can converge on one release without conflicts.
|
||||
"""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
@@ -137,11 +183,17 @@ def create_release(
|
||||
}
|
||||
resp = client.post(url, json=payload)
|
||||
if resp.status_code == 409:
|
||||
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
|
||||
existing = get_release_by_tag(client, base_url, repo, tag)
|
||||
if existing is None:
|
||||
raise RuntimeError(f"Release for tag '{tag}' conflicts but cannot be read.")
|
||||
release_id = existing["id"]
|
||||
assets = {a["name"] for a in existing.get("assets", [])}
|
||||
print(f"Release for tag '{tag}' already exists (id={release_id}), reusing it.")
|
||||
return release_id, assets
|
||||
resp.raise_for_status()
|
||||
release_id = resp.json()["id"]
|
||||
print(f"Created release id={release_id} (draft={draft})")
|
||||
return release_id
|
||||
return release_id, set()
|
||||
|
||||
|
||||
def upload_asset(
|
||||
@@ -154,7 +206,10 @@ def upload_asset(
|
||||
"""Upload a file to the release and return the download URL."""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
|
||||
mime = {
|
||||
".zip": "application/zip",
|
||||
".dmg": "application/x-apple-diskimage",
|
||||
}.get(path.suffix, "application/octet-stream")
|
||||
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
||||
with Path(path).open("rb") as fh:
|
||||
resp = client.post(
|
||||
@@ -185,46 +240,59 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--notes", default="", metavar="TEXT", help="Release notes body"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-dist",
|
||||
action="store_true",
|
||||
help="Skip wheel/sdist upload (for parallel platform builds; one job uploads them)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
cfg = load_gitea_config()
|
||||
token = load_token()
|
||||
version = read_version()
|
||||
|
||||
zips = find_releasable_zips()
|
||||
if not zips:
|
||||
artifacts = find_releasable_artifacts()
|
||||
if not artifacts:
|
||||
print(
|
||||
"No clean-versioned ZIPs found in build/.\n"
|
||||
"No platform artifacts found in build/.\n"
|
||||
"Run scripts/guibuild.py first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate all dist files exist before touching Gitea
|
||||
dist_files: dict[str, list[Path]] = {}
|
||||
for _, version, _platform_tag in zips:
|
||||
dist_files[version] = find_dist_files(version)
|
||||
dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
|
||||
|
||||
base_url = cfg["url"].rstrip("/")
|
||||
repo = cfg["repo"]
|
||||
|
||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||
release_ids_by_version: dict[str, int] = {}
|
||||
for zip_path, version, platform_tag in zips:
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
release_id = release_ids_by_version.get(version)
|
||||
if release_id is None:
|
||||
release_id = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
release_ids_by_version[version] = release_id
|
||||
for path in dist_files[version]:
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
release_id, uploaded = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
for path in dist_files:
|
||||
if path.name in uploaded:
|
||||
print(f"Skipping {path.name}, already on the release.")
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
|
||||
print(f"Uploading platform artifact: {platform_tag}")
|
||||
upload_asset(client, base_url, repo, release_id, zip_path)
|
||||
print(f" ✓ {tag} published")
|
||||
for artifact_path in artifacts:
|
||||
if artifact_path.name in uploaded:
|
||||
print(f"Skipping {artifact_path.name}, already on the release.")
|
||||
continue
|
||||
print(f"Uploading platform artifact: {artifact_path.name}")
|
||||
upload_asset(client, base_url, repo, release_id, artifact_path)
|
||||
uploaded.add(artifact_path.name)
|
||||
for feed_file in find_velopack_feed_files():
|
||||
if feed_file.name in uploaded:
|
||||
print(f"Skipping {feed_file.name}, already on the release.")
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, feed_file)
|
||||
uploaded.add(feed_file.name)
|
||||
print(f" ✓ {tag} published")
|
||||
|
||||
print("\nDone. To publish to PyPI, run:")
|
||||
print(" uv publish")
|
||||
|
||||
Reference in New Issue
Block a user