6 Commits
Author SHA1 Message Date
LeoVasanko 5fc20c5578 Versionless installer asset names, stable latest-download README links
release / gui-build (linux, bash) (push) Successful in 1m0s
release / gui-build (windows, cmd) (push) Successful in 1m18s
release / gui-build (macos, bash) (push) Successful in 1m41s
2026-09-23 19:29:02 +00:00
LeoVasanko 550131d43b Publish wheel/sdist to PyPI from the linux release job 2026-09-23 18:56:33 +00:00
LeoVasanko f2fc6f657f Updated fastapi-vue-setup 1.7.2
release / gui-build (linux, bash) (push) Successful in 55s
release / gui-build (windows, cmd) (push) Successful in 1m19s
release / gui-build (macos, bash) (push) Failing after 1m14s
- DEVMODE removed; FASTAPI_VUE=MEDIAHIVE is set at entrypoints and
  fastapi_vue.env drives dev-mode checks
- CLI roots now reach the server via fastapi-vue's env(Config) teleport
  (mediahive.config.config) instead of MEDIAHIVE_ROOTS
- Legacy media_folder config field and migration removed
- mediahive.* logger level set via log_config (DEBUG in dev, INFO in prod)
  in both server.run() and winmain's patched uvicorn config
2026-09-23 18:41:51 +00:00
LeoVasanko 1477c240a1 Installers for all platforms and related fixes (#1)
- Windows portable ZIP remains, but has been fixed so that it isn't poisoned by being a web download
- Velopack installer offered on all platforms: Linux AppImage, Windows setup.exe, Mac setup.pkg
- Mac updated to Qt6 which has a modern Chromium as opposed to Qt5 that didn't
- Application log now shown on settings panel
- Automated cross-platform builds via Gitea actions

Reviewed-on: #1
2026-09-23 17:41:21 +00:00
LeoVasanko c891bc84a8 Detect sidecar subtitle languages and show their flags in the UI 2026-09-23 01:45:04 +00:00
LeoVasanko c11cbd4250 Fix console encoding crash in release script summary output 2026-09-10 21:33:43 +00:00
30 changed files with 1271 additions and 309 deletions
+60
View File
@@ -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
+1
View File
@@ -19,3 +19,4 @@ package-lock.json
# Dotfiles # Dotfiles
.* .*
!.gitignore !.gitignore
!.gitea/
+17 -5
View File
@@ -4,12 +4,24 @@
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player. 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
- Linux and other platforms: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
```
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 ## 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 - Remembers per-episode playback positions and offers series continue points
- Hand off playback to your preferred system player - 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. 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
View File
@@ -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/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/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
- `GET /api/player/status` returns `{ "remote": true|false }`. - `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. - 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 ## WebSocket
+3 -3
View File
@@ -1,6 +1,6 @@
# Development # 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 ## 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`): 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/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. - `./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`. 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
View File
@@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise<Record<string, ResumePosit
const rawEpisodes = (entry as { episodes?: unknown }).episodes const rawEpisodes = (entry as { episodes?: unknown }).episodes
if (rawEpisodes && typeof rawEpisodes === "object") { if (rawEpisodes && typeof rawEpisodes === "object") {
const watches: Record<string, EpisodeWatchEntry> = {} const watches: Record<string, EpisodeWatchEntry> = {}
for (const [key, watch] of Object.entries( for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
rawEpisodes as Record<string, unknown>,
)) {
if (!watch || typeof watch !== "object") continue if (!watch || typeof watch !== "object") continue
const w = watch as { pos?: unknown; done?: unknown } const w = watch as { pos?: unknown; done?: unknown }
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
+83 -3
View File
@@ -29,7 +29,9 @@
<!-- Detail mode: show current category + Details --> <!-- Detail mode: show current category + Details -->
<template v-else> <template v-else>
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory"> <button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
{{ currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series" }} {{
currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series"
}}
</button> </button>
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button> <button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
</template> </template>
@@ -208,7 +210,9 @@
<section class="settings-section"> <section class="settings-section">
<h2 class="settings-section-title">Preferred Format</h2> <h2 class="settings-section-title">Preferred Format</h2>
<p class="settings-section-desc">Preferred format when multiple versions are available.</p> <p class="settings-section-desc">
Preferred format when multiple versions are available.
</p>
<div class="format-grid"> <div class="format-grid">
<div class="format-row format-row-stack"> <div class="format-row format-row-stack">
@@ -295,6 +299,16 @@
</div> </div>
</div> </div>
</section> </section>
<section class="settings-section">
<h2 class="settings-section-title">Diagnostics</h2>
<p class="settings-section-desc">Application log for troubleshooting.</p>
<div class="diag-log-header">
<span class="diag-label">Application log</span>
</div>
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -302,7 +316,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from "vue" import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue"
import { useRouter, useRoute } from "vue-router" import { useRouter, useRoute } from "vue-router"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs } from "../composables/useKeyboardNavigation"
import logoUrl from "../assets/mediahive.webp" import logoUrl from "../assets/mediahive.webp"
@@ -409,6 +423,41 @@ async function refreshPlayers() {
} }
} }
const appLog = ref("")
const logEl = ref<HTMLElement | null>(null)
let logSocket: WebSocket | null = null
let pinnedToBottom = true
function onLogScroll() {
const el = logEl.value
if (!el) return
pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
function connectLogSocket() {
if (logSocket) return
const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
logSocket = ws
pinnedToBottom = true
ws.onmessage = async (ev) => {
appLog.value = String(ev.data)
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
}
ws.onclose = () => {
if (logSocket === ws) logSocket = null
if (showSettings.value) setTimeout(connectLogSocket, 3000)
}
}
function disconnectLogSocket() {
const ws = logSocket
logSocket = null
ws?.close()
}
async function removeRoot(rootId: string) { async function removeRoot(rootId: string) {
const filtered = roots.value.filter((r) => r.root_id !== rootId) const filtered = roots.value.filter((r) => r.root_id !== rootId)
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path])) const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
@@ -438,6 +487,9 @@ async function addRoot() {
watch(showSettings, (visible) => { watch(showSettings, (visible) => {
if (visible) { if (visible) {
void refreshPlayers() void refreshPlayers()
connectLogSocket()
} else {
disconnectLogSocket()
} }
}) })
@@ -551,6 +603,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("keydown", handleKeydown) window.removeEventListener("keydown", handleKeydown)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction) window.removeEventListener("mediahive:gamepad-action", onGamepadAction)
disconnectLogSocket()
}) })
</script> </script>
@@ -868,4 +921,31 @@ onUnmounted(() => {
font-size: 0.8rem; font-size: 0.8rem;
color: #22c55e; color: #22c55e;
} }
.diag-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.diag-log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
width: 100%;
max-height: 320px;
overflow-y: auto;
margin: 0;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
color: var(--text-secondary);
}
</style> </style>
+3 -2
View File
@@ -6,7 +6,7 @@
v-for="entry in flagEntries" v-for="entry in flagEntries"
:key="entry.countryCode" :key="entry.countryCode"
class="language-flag" class="language-flag"
:title="`${entry.countryCode}: ${entry.sourceCodes.join(', ')}`" :title="formatLanguageFlagTitle(entry, externalCodes)"
v-html="entry.svg" v-html="entry.svg"
></span> ></span>
<span <span
@@ -22,11 +22,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed } from "vue"
import { buildLanguageFlags } from "../utils/languageFlags" import { buildLanguageFlags, formatLanguageFlagTitle } from "../utils/languageFlags"
const props = defineProps<{ const props = defineProps<{
label?: string label?: string
codes: string[] | null | undefined codes: string[] | null | undefined
externalCodes?: string[] | null
compact?: boolean compact?: boolean
}>() }>()
@@ -44,6 +44,7 @@
<LanguageFlags <LanguageFlags
class="language-flags-subs" class="language-flags-subs"
:codes="torrent.subtitle_languages" :codes="torrent.subtitle_languages"
:external-codes="torrent.external_subtitle_languages"
:compact="compactFlags" :compact="compactFlags"
/> />
</div> </div>
+33
View File
@@ -6,6 +6,38 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
import { installGamepadNavigation } from "./composables/useGamepadNavigation" import { installGamepadNavigation } from "./composables/useGamepadNavigation"
import { installInputModalityTracking } from "./composables/useInputModality" import { installInputModalityTracking } from "./composables/useInputModality"
function postClientError(payload: {
message: string
stack: string | null
source: string | null
}) {
fetch("/api/client-log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => {})
}
function installErrorCapture() {
window.addEventListener("error", (event) => {
const source =
event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null
postClientError({
message: event.message || String(event.error ?? "Unknown error"),
stack: event.error?.stack ?? null,
source,
})
})
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason
postClientError({
message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`,
stack: reason instanceof Error ? (reason.stack ?? null) : null,
source: "unhandledrejection",
})
})
}
function installReloadShortcut() { function installReloadShortcut() {
document.addEventListener( document.addEventListener(
"keydown", "keydown",
@@ -27,6 +59,7 @@ installInputModalityTracking()
installKeyboardNavigation() installKeyboardNavigation()
installGamepadNavigation() installGamepadNavigation()
installReloadShortcut() installReloadShortcut()
installErrorCapture()
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW. // Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
+1
View File
@@ -53,6 +53,7 @@ export interface Torrent {
audio: string | null audio: string | null
audio_languages: string[] | null audio_languages: string[] | null
subtitle_languages: string[] | null subtitle_languages: string[] | null
external_subtitle_languages?: string[] | null
hdr?: boolean hdr?: boolean
dovi?: boolean dovi?: boolean
atmos?: boolean atmos?: boolean
+168 -4
View File
@@ -16,17 +16,18 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
// Spanish (including LATAM variants collapsed to Spain flag) // Spanish (including LATAM variants collapsed to Spain flag)
es: "ES", es: "ES",
spa: "ES", spa: "ES",
esp: "ES",
esl: "ES", esl: "ES",
spl: "ES", spl: "ES",
"es-es": "ES", "es-es": "ES",
"es-419": "ES", "es-419": "ES",
"spa-la": "ES", "spa-la": "ES",
// Portuguese // Portuguese (Brazilian variant collapses to Portugal flag)
pt: "PT", pt: "PT",
por: "PT", por: "PT",
"pt-pt": "PT", "pt-pt": "PT",
"pt-br": "BR", "pt-br": "PT",
// Major European languages // Major European languages
fr: "FR", fr: "FR",
@@ -49,7 +50,7 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
fi: "FI", fi: "FI",
fin: "FI", fin: "FI",
pl: "PL", pl: "PL",
: "PL", pol: "PL",
cs: "CZ", cs: "CZ",
ces: "CZ", ces: "CZ",
cze: "CZ", cze: "CZ",
@@ -121,6 +122,113 @@ const LANGUAGE_TO_COUNTRY: Record<string, string> = {
eu: "ES", eu: "ES",
baq: "ES", baq: "ES",
eus: "ES", eus: "ES",
gl: "ES",
glg: "ES",
// Additional ISO 639-2 codes (bibliographic + terminology)
mk: "MK",
mkd: "MK",
mac: "MK",
et: "EE",
est: "EE",
lv: "LV",
lav: "LV",
lt: "LT",
lit: "LT",
is: "IS",
isl: "IS",
ice: "IS",
ga: "IE",
gle: "IE",
cy: "GB",
cym: "GB",
wel: "GB",
gd: "GB",
gla: "GB",
mt: "MT",
mlt: "MT",
sq: "AL",
sqi: "AL",
alb: "AL",
be: "BY",
bel: "BY",
bs: "BA",
bos: "BA",
scc: "RS",
scr: "HR",
nb: "NO",
nob: "NO",
nn: "NO",
nno: "NO",
kk: "KZ",
kaz: "KZ",
az: "AZ",
aze: "AZ",
hy: "AM",
hye: "AM",
arm: "AM",
ka: "GE",
kat: "GE",
geo: "GE",
uz: "UZ",
uzb: "UZ",
tk: "TM",
tuk: "TM",
tg: "TJ",
tgk: "TJ",
ky: "KG",
kir: "KG",
mn: "MN",
mon: "MN",
bo: "CN",
bod: "CN",
tib: "CN",
my: "MM",
mya: "MM",
bur: "MM",
km: "KH",
khm: "KH",
lo: "LA",
lao: "LA",
si: "LK",
sin: "LK",
ne: "NP",
nep: "NP",
bn: "BD",
ben: "BD",
ta: "IN",
tam: "IN",
te: "IN",
tel: "IN",
kn: "IN",
kan: "IN",
ml: "IN",
mal: "IN",
mr: "IN",
mar: "IN",
gu: "IN",
guj: "IN",
pa: "IN",
pan: "IN",
tl: "PH",
tgl: "PH",
fil: "PH",
af: "ZA",
afr: "ZA",
am: "ET",
amh: "ET",
so: "SO",
som: "SO",
ha: "NG",
hau: "NG",
yo: "NG",
yor: "NG",
ig: "NG",
ibo: "NG",
ku: "TR",
kur: "TR",
ps: "AF",
pus: "AF",
} }
function normalizeLanguageCode(code: string): string { function normalizeLanguageCode(code: string): string {
@@ -265,9 +373,13 @@ export function mapLanguageToCountry(code: string): string | null {
const direct = LANGUAGE_TO_COUNTRY[normalized] const direct = LANGUAGE_TO_COUNTRY[normalized]
if (direct) return direct if (direct) return direct
// region-tag style code like en-us / pt-br / es-mx // region-tag style code like en-us / pt-br / es-mx: variants collapse to
// the base language's host-country flag; only fall back to the region
// itself when the base language is unmapped.
const hyphenParts = normalized.split("-") const hyphenParts = normalized.split("-")
if (hyphenParts.length >= 2) { if (hyphenParts.length >= 2) {
const base = LANGUAGE_TO_COUNTRY[hyphenParts[0]]
if (base) return base
const region = hyphenParts[hyphenParts.length - 1] const region = hyphenParts[hyphenParts.length - 1]
if (/^[a-z]{2}$/i.test(region)) { if (/^[a-z]{2}$/i.test(region)) {
return region.toUpperCase() return region.toUpperCase()
@@ -341,10 +453,15 @@ export function buildLanguageFlags(codes: string[] | null | undefined): {
const LANGUAGE_NAME_OVERRIDES: Record<string, string> = { const LANGUAGE_NAME_OVERRIDES: Record<string, string> = {
eng: "English", eng: "English",
spa: "Spanish", spa: "Spanish",
esp: "Spanish",
"spa-la": "Spanish", "spa-la": "Spanish",
"es-419": "Spanish",
esl: "Spanish", esl: "Spanish",
spl: "Spanish", spl: "Spanish",
por: "Portuguese", por: "Portuguese",
"pt-br": "Portuguese",
nob: "Norwegian",
nno: "Norwegian",
fre: "French", fre: "French",
fra: "French", fra: "French",
ger: "German", ger: "German",
@@ -431,6 +548,53 @@ function summarizeLanguageCodes(codes: string[] | null | undefined): string {
return names.join(", ") return names.join(", ")
} }
const REGION_NAME_OVERRIDES: Record<string, string> = {
GB: "UK",
US: "US",
}
function toRegionName(countryCode: string): string {
const override = REGION_NAME_OVERRIDES[countryCode]
if (override) return override
const display = new Intl.DisplayNames(["en"], { type: "region" })
return display.of(countryCode) ?? countryCode
}
export function formatLanguageFlagTitle(
entry: LanguageFlagEntry,
externalCodes?: string[] | null,
): string {
const names: string[] = []
const variants: string[] = []
const external = new Set(
(externalCodes ?? []).map((c) => resolveLanguageIdentifier(c)),
)
let hasExternal = false
for (const code of entry.sourceCodes) {
const normalized = resolveLanguageIdentifier(code)
const base = normalized.split("-", 1)[0]
const name = toLanguageName(base)
if (!names.includes(name)) names.push(name)
// Explicit region tags (en-us, es-419) become parenthesized variants;
// plain codes contribute their host country.
const suffix = normalized.split("-").pop() ?? ""
const region = /^[a-z]{2}$|^\d{3}$/i.test(suffix) && normalized.includes("-")
? suffix.toUpperCase()
: mapLanguageToCountry(code)
const regionName = region ? toRegionName(region) : null
const variant = external.has(normalized)
? regionName
? `${regionName} srt`
: "srt"
: regionName
if (variant && !variants.includes(variant)) variants.push(variant)
if (external.has(normalized)) hasExternal = true
}
const title = names.join(" / ")
if (variants.length > 1 || hasExternal) return `${title} (${variants.join(", ")})`
return title
}
export function formatAudioSubtitleSummary( export function formatAudioSubtitleSummary(
audioCodes: string[] | null | undefined, audioCodes: string[] | null | undefined,
subtitleCodes: string[] | null | undefined, subtitleCodes: string[] | null | undefined,
+5 -5
View File
@@ -8,11 +8,11 @@
* - Disables Vite's screen clearing on startup * - Disables Vite's screen clearing on startup
* *
* Options: * Options:
* paths - Array of paths to proxy (default: ["/api"]) * paths - Array of paths to proxy (default: ['/api'])
*/ */
export default function fastapiVue({ paths = ["/api"] } = {}) { export default function fastapiVue({ paths = ['/api'] } = {}) {
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421" const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
// Build proxy configuration for each path // Build proxy configuration for each path
const proxy = {} const proxy = {}
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
} }
return { return {
name: "vite-plugin-fastapi-mediahive", name: 'vite-plugin-fastapi-mediahive',
config: () => ({ config: () => ({
clearScreen: false, clearScreen: false,
server: { proxy }, server: { proxy },
build: { build: {
outDir: "../mediahive/frontend-build", outDir: '../mediahive/frontend-build',
emptyOutDir: true, emptyOutDir: true,
}, },
}), }),
+17 -7
View File
@@ -1,16 +1,20 @@
"""MediaHive CLI entrypoint.""" """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 argparse
import asyncio import asyncio
import json
import os
import sys import sys
from pathlib import Path from pathlib import Path
from fastapi_vue import server from fastapi_vue import env, server
from mediahive.config import config
DEFAULT_PORT = 8420 DEFAULT_PORT = 8420
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
def _configure_windows_event_loop_policy() -> None: def _configure_windows_event_loop_policy() -> None:
@@ -140,10 +144,11 @@ def main() -> None:
name = f"{base_name}{suffix}" name = f"{base_name}{suffix}"
suffix += 1 suffix += 1
roots[name] = p.as_posix() 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 ( if (
DEVMODE env.dev
and sys.platform == "win32" and sys.platform == "win32"
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1" and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
): ):
@@ -156,7 +161,12 @@ def main() -> None:
default_port=DEFAULT_PORT, default_port=DEFAULT_PORT,
server_header=False, server_header=False,
loop="none" if sys.platform == "win32" else "auto", 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
View File
@@ -1,57 +1,53 @@
r"""Platform-appropriate config persistence for MediaHive. r"""Platform-appropriate config persistence for MediaHive.
Config file location: Locations (via platformdirs):
Windows: %APPDATA%\mediahive\config.toml Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
macOS: ~/Library/Application Support/mediahive/config.toml macOS: ~/Library/Application Support/mediahive/config.toml
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/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 from pathlib import Path
import msgspec import msgspec
import msgspec.toml import msgspec.toml
from fastapi_vue import env
from platformdirs import user_config_path, user_log_path
class Config(msgspec.Struct, omit_defaults=True): class Config(msgspec.Struct, omit_defaults=True):
media_folder: str | None = None
roots: dict[str, 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: def config_dir() -> Path:
if sys.platform == "win32": # appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
base = Path(os.environ.get("APPDATA") or Path.home()) # roaming=False: config is machine-specific state, not something to sync
elif sys.platform == "darwin": # across a domain profile.
base = Path.home() / "Library" / "Application Support" return user_config_path("mediahive", appauthor=False, roaming=False)
else:
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
return base / "mediahive" 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: def config_path() -> Path:
return config_dir() / "config.toml" 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: def load_config() -> Config:
path = config_path() path = config_path()
if path.exists(): if path.exists():
try: try:
cfg = msgspec.toml.decode(path.read_bytes(), type=Config) return msgspec.toml.decode(path.read_bytes(), type=Config)
return _migrate_legacy_media_folder(cfg)
except OSError, msgspec.DecodeError, msgspec.ValidationError: except OSError, msgspec.DecodeError, msgspec.ValidationError:
return Config() return Config()
return Config() return Config()
+9 -6
View File
@@ -1,13 +1,18 @@
"""Hivescan CLI entrypoint.""" """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 argparse
import asyncio import asyncio
import json
import logging import logging
import os
import sys import sys
from pathlib import Path from pathlib import Path
from mediahive.config import config
def _configure_windows_event_loop_policy() -> None: def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available.""" """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() 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() media_root = Path(args.media_folder).expanduser()
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({ config.roots = {media_root.name or "media": media_root.as_posix()}
media_root.name or "media": media_root.as_posix()
})
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
+32 -3
View File
@@ -16,6 +16,7 @@ from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.scanning import ( from mediahive.hivescan.scanning import (
find_cover_image, find_cover_image,
find_episode_files, find_episode_files,
find_external_subtitle_languages,
find_metadata_probe_file, find_metadata_probe_file,
find_playable_file, find_playable_file,
) )
@@ -61,6 +62,18 @@ def _infer_hdr10plus(*values: str | None) -> bool:
return bool(_HDR10PLUS_RE.search(text)) return bool(_HDR10PLUS_RE.search(text))
def _merge_subtitle_languages(
probed: list[str] | None,
external: list[str],
) -> list[str] | None:
"""Union embedded subtitle languages with sidecar-subtitle languages."""
merged = list(probed or [])
for lang in external:
if lang not in merged:
merged.append(lang)
return merged or None
def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None: def _compact_playable_file(file_key: str, playable_file: str | None) -> str | None:
"""Store playable paths compactly relative to the file key when possible.""" """Store playable paths compactly relative to the file key when possible."""
if not playable_file: if not playable_file:
@@ -99,6 +112,7 @@ async def _build_torrent_info(
probe_target = await find_metadata_probe_file(playable_file) probe_target = await find_metadata_probe_file(playable_file)
if probe_target: if probe_target:
probe_info = await probe_media_info(str(probe_target)) probe_info = await probe_media_info(str(probe_target))
external_subs = await find_external_subtitle_languages(playable_file)
if item.content_hash and item.content_hash.size == 0: if item.content_hash and item.content_hash.size == 0:
item.content_hash.size = await asyncio.to_thread( item.content_hash.size = await asyncio.to_thread(
@@ -125,7 +139,11 @@ async def _build_torrent_info(
codec=item.codec, codec=item.codec,
audio=item.audio, audio=item.audio,
audio_languages=probe_info.audio_languages if probe_info else None, audio_languages=probe_info.audio_languages if probe_info else None,
subtitle_languages=probe_info.subtitle_languages if probe_info else None, subtitle_languages=_merge_subtitle_languages(
probe_info.subtitle_languages if probe_info else None,
external_subs,
),
external_subtitle_languages=external_subs or None,
hdr=probe_info.hdr if probe_info else False, hdr=probe_info.hdr if probe_info else False,
dovi=probe_info.dovi if probe_info else False, dovi=probe_info.dovi if probe_info else False,
atmos=probe_info.atmos if probe_info else False, atmos=probe_info.atmos if probe_info else False,
@@ -207,12 +225,16 @@ async def _collect_episode_files(
all_episode_files[key] = [] all_episode_files[key] = []
for file_path, file_size in files: for file_path, file_size in files:
probe = await get_probe(file_path) probe = await get_probe(file_path)
external_subs = await find_external_subtitle_languages(file_path)
all_episode_files[key].append({ all_episode_files[key].append({
"path": file_path, "path": file_path,
"size": file_size, "size": file_size,
"probed_resolution": probe.resolution, "probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages, "audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages, "subtitle_languages": _merge_subtitle_languages(
probe.subtitle_languages, external_subs
),
"external_subtitle_languages": external_subs or None,
"hdr": probe.hdr, "hdr": probe.hdr,
"dovi": probe.dovi, "dovi": probe.dovi,
"atmos": probe.atmos, "atmos": probe.atmos,
@@ -255,12 +277,18 @@ async def _collect_episode_files(
item.content_hash.path, item.content_hash.path,
) )
size = item.content_hash.size if item.content_hash else 0 size = item.content_hash.size if item.content_hash else 0
external_subs = await find_external_subtitle_languages(
playable
)
all_episode_files[key].append({ all_episode_files[key].append({
"path": playable, "path": playable,
"size": size, "size": size,
"probed_resolution": probe.resolution, "probed_resolution": probe.resolution,
"audio_languages": probe.audio_languages, "audio_languages": probe.audio_languages,
"subtitle_languages": probe.subtitle_languages, "subtitle_languages": _merge_subtitle_languages(
probe.subtitle_languages, external_subs
),
"external_subtitle_languages": external_subs or None,
"hdr": probe.hdr, "hdr": probe.hdr,
"dovi": probe.dovi, "dovi": probe.dovi,
"atmos": probe.atmos, "atmos": probe.atmos,
@@ -343,6 +371,7 @@ def _build_episodes_data(
audio=f.get("audio"), audio=f.get("audio"),
audio_languages=f.get("audio_languages"), audio_languages=f.get("audio_languages"),
subtitle_languages=f.get("subtitle_languages"), subtitle_languages=f.get("subtitle_languages"),
external_subtitle_languages=f.get("external_subtitle_languages"),
hdr=bool(f.get("hdr")), hdr=bool(f.get("hdr")),
dovi=bool(f.get("dovi")), dovi=bool(f.get("dovi")),
atmos=bool(f.get("atmos")), atmos=bool(f.get("atmos")),
+67
View File
@@ -28,6 +28,23 @@ VIDEO_EXTENSIONS = {
".m2ts", ".m2ts",
} }
# External subtitle file extensions
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt", ".sub"}
# Non-language tokens that may follow the language in a sidecar filename
_SUBTITLE_FLAG_TOKENS = {"forced", "sdh", "cc", "hi", "dhi", "commentary", "signs"}
# ISO 639-1 -> ISO 639-2/B for common sidecar language tags, so they merge
# with the codes ffmpeg reports for embedded tracks.
_ISO_639_1_TO_639_2 = {
"ar": "ara", "cs": "ces", "da": "dan", "de": "deu", "el": "ell",
"en": "eng", "es": "esp", "fi": "fin", "fr": "fra", "he": "heb",
"hi": "hin", "hu": "hun", "id": "ind", "it": "ita", "ja": "jpn",
"ko": "kor", "nl": "nld", "no": "nor", "pl": "pol", "pt": "por",
"ru": "rus", "sv": "swe", "th": "tha", "tr": "tur", "uk": "ukr",
"vi": "vie", "zh": "zho",
}
# Caches for expensive operations. These are per-scan only: the scanner # Caches for expensive operations. These are per-scan only: the scanner
# clears them at the start of every scan. Caching across scans is wrong — # clears them at the start of every scan. Caching across scans is wrong —
# an empty result recorded before a download finished (or during a transient # an empty result recorded before a download finished (or during a transient
@@ -322,6 +339,56 @@ async def find_playable_file(path: Path) -> str | None:
return result return result
def _sidecar_subtitle_language(video_stem: str, filename: str) -> str | None:
"""Language tag from a sidecar subtitle name like `<stem>.esp.srt`, if any."""
if not filename.startswith(video_stem + "."):
return None
suffix = Path(filename).suffix.lower()
if suffix not in SUBTITLE_EXTENSIONS:
return None
middle = filename[len(video_stem) + 1 : -len(suffix)]
tokens = [t for t in middle.split(".") if t]
while tokens and tokens[-1].lower() in _SUBTITLE_FLAG_TOKENS:
tokens.pop()
if not tokens:
return None
code = tokens[-1].lower()
if not code.isalpha() or not 2 <= len(code) <= 3:
return None
code = _ISO_639_1_TO_639_2.get(code, code)
return None if code == "und" else code
def _scan_external_subtitle_languages(video_path: Path) -> list[str]:
languages: list[str] = []
with os.scandir(video_path.parent) as entries:
for entry in entries:
if not entry.is_file(follow_symlinks=False):
continue
lang = _sidecar_subtitle_language(video_path.stem, entry.name)
if lang and lang not in languages:
languages.append(lang)
return languages
async def find_external_subtitle_languages(video_path: str | None) -> list[str]:
"""Languages of external subtitle files sitting next to a video file.
Matches sidecars named `<stem>.<lang>.<ext>` (e.g. `Movie.esp.srt` ->
``esp``), optionally with flags like ``forced``/``sdh`` after the language.
Bare `<stem>.<ext>` files carry no language tag and are ignored.
"""
if not video_path or "://" in video_path or video_path.startswith("concat:"):
return []
path = Path(video_path)
if path.suffix.lower() not in VIDEO_EXTENSIONS:
return []
try:
return await asyncio.to_thread(_scan_external_subtitle_languages, path)
except OSError, PermissionError:
return []
async def find_metadata_probe_file(playable_path: str | None) -> str | None: async def find_metadata_probe_file(playable_path: str | None) -> str | None:
"""Resolve a path suitable for ffmpeg stream metadata probing. """Resolve a path suitable for ffmpeg stream metadata probing.
+1
View File
@@ -26,6 +26,7 @@ class Torrent(msgspec.Struct, omit_defaults=True):
audio: str | None = None audio: str | None = None
audio_languages: list[str] | None = None audio_languages: list[str] | None = None
subtitle_languages: list[str] | None = None subtitle_languages: list[str] | None = None
external_subtitle_languages: list[str] | None = None
hdr: bool = False hdr: bool = False
dovi: bool = False dovi: bool = False
atmos: bool = False atmos: bool = False
+85 -21
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio import asyncio
import ctypes import ctypes
import importlib.metadata
import json import json
import logging import logging
import mimetypes import mimetypes
@@ -30,11 +31,15 @@ import aiofiles
import msgspec import msgspec
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response, StreamingResponse from fastapi.responses import (
from fastapi_vue import Frontend FileResponse,
PlainTextResponse,
Response,
StreamingResponse,
)
from fastapi_vue import Frontend, env
from mediahive.__main__ import DEVMODE from mediahive.config import config, load_config, log_dir
from mediahive.config import load_config
from mediahive.hivescan.images import close_image_client from mediahive.hivescan.images import close_image_client
from mediahive.hivescan.scanner import RootScanner from mediahive.hivescan.scanner import RootScanner
from mediahive.hivescan.tmdb_client import close_http_client from mediahive.hivescan.tmdb_client import close_http_client
@@ -968,23 +973,14 @@ async def _activate_all_roots() -> None:
""" """
desired: dict[str, str] = {} desired: dict[str, str] = {}
# 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict) # 1. CLI roots (teleported via fastapi-vue's env config) take precedence
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")
# 2. Persisted config roots (used only when CLI roots are not provided) # 2. Persisted config roots (used only when CLI roots are not provided)
cfg = load_config() if config.roots:
if env_roots is not None: desired.update(config.roots)
desired.update(env_roots) else:
elif cfg.roots: cfg = load_config()
desired.update(cfg.roots) if cfg.roots:
desired.update(cfg.roots)
if not desired: if not desired:
logger.info("No roots configured; waiting for PUT /api/config/roots") logger.info("No roots configured; waiting for PUT /api/config/roots")
@@ -1050,7 +1046,7 @@ async def lifespan(_app: FastAPI):
await close_image_client() 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 # Allow CORS for development
app.add_middleware( app.add_middleware(
@@ -1073,6 +1069,74 @@ async def health_check():
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/version")
async def get_version():
"""Return the installed MediaHive package version."""
try:
version = importlib.metadata.version("mediahive")
except importlib.metadata.PackageNotFoundError:
version = "dev"
return {"version": version}
def _read_log() -> str:
"""Return the full application log file."""
path = log_dir() / "mediahive.log"
if not path.exists():
return ""
try:
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
@app.get("/api/log")
async def get_log():
"""Return the full application log file."""
return PlainTextResponse(_read_log())
@app.websocket("/api/log/ws")
async def ws_log(ws: WebSocket) -> None:
"""Stream the application log: full log on connect and on every change."""
await ws.accept()
last_sent: str | None = None
try:
while True:
current = _read_log()
if current != last_sent:
last_sent = current
await ws.send_text(current)
await asyncio.sleep(1.0)
except WebSocketDisconnect, OSError, RuntimeError:
pass
@app.post("/api/client-log", status_code=204)
async def post_client_log(request: Request):
"""Append a client-side (webview) error report to client-errors.log."""
try:
payload = msgspec.json.decode(await request.body())
except msgspec.DecodeError:
payload = {}
message = str(payload.get("message") or "")
stack = payload.get("stack")
source = payload.get("source")
try:
dirpath = log_dir()
dirpath.mkdir(parents=True, exist_ok=True)
with (dirpath / "client-errors.log").open("a", encoding="utf-8") as f:
timestamp = datetime.now().isoformat(timespec="seconds")
f.write(f"[{timestamp}] {message}\n")
if source:
f.write(f" source: {source}\n")
if stack:
f.write(f" stack: {stack}\n")
except OSError:
pass
return Response(status_code=204)
@app.get("/api/config") @app.get("/api/config")
async def get_config(): async def get_config():
"""Return current server configuration.""" """Return current server configuration."""
+87 -31
View File
@@ -24,11 +24,20 @@ import urllib.request
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path 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 msgspec.structs
import uvicorn import uvicorn
import velopack
import webview 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 from mediahive.volume_control import get_volume, set_volume, volume_max
logger = logging.getLogger("mediahive.winmain") logger = logging.getLogger("mediahive.winmain")
@@ -39,6 +48,7 @@ HEALTH_TIMEOUT = 2 # seconds
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25 BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579" MPC_BE_URL = "http://127.0.0.1:13579"
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
GAMEPAD_REPEAT_SECONDS = 0.008 GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008 GAMEPAD_POLL_SECONDS = 0.008
MPC_BE_FRAME_REPEAT_SECONDS = 0.016 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: 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 In a PyInstaller --windowed build there is no console, so any print() or
unhandled exception traceback would be lost. This ensures everything ends unhandled exception traceback would be lost. This ensures everything ends
up in a persistent log file the user can send for bug reports. up in a persistent log file the user can send for bug reports.
Returns the path to the log file. Returns the path to the log file.
""" """
from mediahive.config import config_dir log_directory = log_dir()
log_directory.mkdir(parents=True, exist_ok=True)
log_dir = config_dir() log_path = log_directory / "mediahive.log"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / "mediahive.log"
try: try:
log_file = _rotate_and_open_log(log_path) log_file = _rotate_and_open_log(log_path)
@@ -899,7 +907,7 @@ def _setup_logging() -> Path:
log_file = _wait_for_previous_instance(log_path) log_file = _wait_for_previous_instance(log_path)
if log_file is None: if log_file is None:
# Never fail startup over logging: fall back to a per-process file. # 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): with contextlib.suppress(OSError):
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) 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) 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 Frozen --windowed builds otherwise surface crashes only as PyInstaller's
plain-text error dialog (or nothing at all). plain-text error dialog (or nothing at all).
""" """
try: fragment = str(html_traceback(exc))
from tracerite.html import html_traceback
fragment = str(html_traceback(exc))
except Exception: # noqa: BLE001 - error reporting must never raise
return
page = ( page = (
"<!DOCTYPE html><html><head><meta charset='utf-8'>" "<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<title>MediaHive — Error</title></head>" "<title>MediaHive — Error</title></head>"
f"<body style='margin:1.5rem'>{fragment}</body></html>" 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: try:
webview.create_window("MediaHive — Error", html=page, width=1100, height=750) mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
webview.start(icon=_icon_path(), **_webview_start_kwargs()) info = mgr.check_for_updates()
except Exception: if info is None:
logger.exception("Could not display the error window") 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: def gui_main() -> None:
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window.""" """Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
_velopack_startup()
try: try:
winmain() winmain()
except Exception as exc: except Exception as exc:
@@ -1126,8 +1161,26 @@ def _configure_windows_event_loop_policy() -> None:
asyncio.set_event_loop_policy(policy_cls()) 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: def winmain() -> None:
_configure_windows_event_loop_policy() _configure_windows_event_loop_policy()
_strip_mark_of_the_web()
parser = argparse.ArgumentParser(description="MediaHive") parser = argparse.ArgumentParser(description="MediaHive")
parser.add_argument( parser.add_argument(
@@ -1154,10 +1207,6 @@ def winmain() -> None:
initial_roots[name] = p.as_posix() initial_roots[name] = p.as_posix()
elif cfg.roots: elif cfg.roots:
initial_roots = 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: if not initial_roots:
folder = _run_initial_setup() folder = _run_initial_setup()
@@ -1171,8 +1220,9 @@ def winmain() -> None:
if cfg.roots != initial_roots: if cfg.roots != initial_roots:
save_config(msgspec.structs.replace(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) # Pass roots to the in-process server via the shared env config
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots) # (validation deferred to server startup)
config.roots = initial_roots
backend_port = _reserve_backend_port() backend_port = _reserve_backend_port()
backend_url = f"http://{BACKEND_HOST}:{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. # Startup banner, same as fastapi-vue's server.run() prints in CLI mode.
# Goes to stderr, which frozen builds redirect to the log file. # Goes to stderr, which frozen builds redirect to the log file.
from fastapi_vue.startupbox import print_box
try: try:
version = importlib.metadata.version("mediahive") version = importlib.metadata.version("mediahive")
except importlib.metadata.PackageNotFoundError: except importlib.metadata.PackageNotFoundError:
@@ -1192,9 +1240,13 @@ def winmain() -> None:
# log config wires up its access-log middleware, emoji level prefixes and # log config wires up its access-log middleware, emoji level prefixes and
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty, # tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
# e.g. redirected to the log file in frozen builds). # e.g. redirected to the log file in frozen builds).
from fastapi_vue.logging import patch_log_config log_config = patch_log_config(uvicorn.config.LOGGING_CONFIG)
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
config = uvicorn.Config( # 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", "mediahive.server:app",
host=BACKEND_HOST, host=BACKEND_HOST,
port=backend_port, port=backend_port,
@@ -1202,9 +1254,9 @@ def winmain() -> None:
server_header=False, server_header=False,
timeout_graceful_shutdown=0, timeout_graceful_shutdown=0,
access_log=False, # fastapi-vue's middleware replaces uvicorn's 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( backend_thread = threading.Thread(
target=server.run, daemon=True, name="mediahive-backend" target=server.run, daemon=True, name="mediahive-backend"
) )
@@ -1228,6 +1280,10 @@ def winmain() -> None:
server.should_exit = True server.should_exit = True
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s") 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() api = JsApi()
logger.info("Configured pywebview backend: %s", _selected_webview_backend()) logger.info("Configured pywebview backend: %s", _selected_webview_backend())
window = webview.create_window( window = webview.create_window(
+5 -3
View File
@@ -8,11 +8,12 @@ dependencies = [
"aiofiles>=25.1.0", "aiofiles>=25.1.0",
"aiopathlib>=0.6.0", "aiopathlib>=0.6.0",
"bencodepy>=0.9.5", "bencodepy>=0.9.5",
"fastapi-vue>=1.4.1", "fastapi-vue~=1.7.2",
"fastapi[standard]>=0.128.0", "fastapi[standard]>=0.128.0",
"httpx[http2]>=0.28.1", "httpx[http2]>=0.28.1",
"msgspec>=0.19", "msgspec>=0.19",
"parse-torrent-title>=2.8.1", "parse-torrent-title>=2.8.1",
"platformdirs>=4.0",
"tomli-w>=1.2.0", "tomli-w>=1.2.0",
"uvicorn[standard]>=0.40.0", "uvicorn[standard]>=0.40.0",
] ]
@@ -50,10 +51,11 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
[project.optional-dependencies] [project.optional-dependencies]
gui = [ gui = [
# pywebview's qt extra is Qt6-only (QtPy + PyQt6 + PyQt6-WebEngine);
# Qt5 would come from its separate qt5 extra, which we do not use.
"pywebview[qt]>=6.2.1; platform_system != 'Windows'", "pywebview[qt]>=6.2.1; platform_system != 'Windows'",
"pywebview>=6.2.1; platform_system == 'Windows'", "pywebview>=6.2.1; platform_system == 'Windows'",
"qtpy>=2.4.1; platform_system == 'Darwin'", "velopack>=1.2",
"PyQt5>=5.15.11; platform_system == 'Darwin'",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0", "pyinstaller>=6.0",
] ]
+22 -8
View File
@@ -5,7 +5,7 @@
# pyinstaller --noconfirm --clean scripts/MediaHive.spec # pyinstaller --noconfirm --clean scripts/MediaHive.spec
# #
# Or use the build script (recommended—handles versioning and packaging): # Or use the build script (recommended—handles versioning and packaging):
# uv run scripts/winbuild.py # uv run scripts/guibuild.py
import sys import sys
import mediahive.winmain import mediahive.winmain
@@ -20,7 +20,15 @@ _frontend_build = _pkg / "frontend-build"
_logo_webp = _pkg / "assets" / "mediahive.webp" _logo_webp = _pkg / "assets" / "mediahive.webp"
_icon_win = _pkg / "assets" / "mediahive.ico" _icon_win = _pkg / "assets" / "mediahive.ico"
_icon_mac = _pkg / "assets" / "mediahive.icns" _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"] _tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
_binaries = [] _binaries = []
@@ -80,11 +88,12 @@ if sys.platform == "darwin":
# pywebview Qt backend selected dynamically via webview.start(gui="qt") # pywebview Qt backend selected dynamically via webview.start(gui="qt")
"webview.platforms.qt", "webview.platforms.qt",
"qtpy", "qtpy",
"PyQt5", "PyQt6",
"PyQt5.QtCore", "PyQt6.QtCore",
"PyQt5.QtGui", "PyQt6.QtGui",
"PyQt5.QtWidgets", "PyQt6.QtWidgets",
"PyQt5.QtWebEngineWidgets", "PyQt6.QtWebEngineCore",
"PyQt6.QtWebEngineWidgets",
] ]
) )
@@ -123,6 +132,11 @@ exe = EXE(
windowed=True, 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( coll = COLLECT(
exe, exe,
a.binaries, a.binaries,
@@ -130,7 +144,7 @@ coll = COLLECT(
a.datas, a.datas,
strip=False, strip=False,
upx=True, upx=True,
upx_exclude=[], upx_exclude=_upx_exclude,
name="MediaHive", name="MediaHive",
) )
+9 -5
View File
@@ -5,8 +5,8 @@
import argparse import argparse
import asyncio import asyncio
import os import os
import subprocess
import sys import sys
from contextlib import suppress
from pathlib import Path from pathlib import Path
import tracerite import tracerite
@@ -48,11 +48,11 @@ async def run_devserver(
os.environ["MEDIAHIVE_DEV"] = "1" os.environ["MEDIAHIVE_DEV"] = "1"
async with ProcessGroup() as pg: async with ProcessGroup() as pg:
pg.create_task(check_ports_free(viteurl, backurl))
npm_i = await pg.spawn(*npm_install, cwd=front) npm_i = await pg.spawn(*npm_install, cwd=front)
await check_ports_free(viteurl, backurl) await pg.spawn(*mediahive, *(extra_args or []), vital=True)
await pg.spawn(*mediahive, *(extra_args or []))
await pg.wait(npm_i, ready(backurl, path=HEALTH)) 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: def main() -> None:
@@ -75,8 +75,12 @@ def main() -> None:
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})", help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
) )
args, extra_args = parser.parse_known_args() args, extra_args = parser.parse_known_args()
with suppress(KeyboardInterrupt): try:
asyncio.run(run_devserver(args.listen, args.backend, extra_args)) 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 = """ HELP_EPILOG = """
+11 -4
View File
@@ -10,20 +10,27 @@ from pathlib import Path
MIN_NODE_VERSION = 20 MIN_NODE_VERSION = 20
class _PrefixFormatter(logging.Formatter): class _Formatter(logging.Formatter):
"""Formatter that adds prefix based on log level.""" """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: def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.ERROR:
return f"🛑 {record.getMessage()}"
if record.levelno >= logging.WARNING: if record.levelno >= logging.WARNING:
return f"⚠️ {record.getMessage()}" return f"💣 {record.getMessage()}"
return record.getMessage() return record.getMessage()
_handler = logging.StreamHandler() _handler = logging.StreamHandler()
_handler.setFormatter(_PrefixFormatter()) _handler.setFormatter(_Formatter())
logger = logging.getLogger("fastapi-vue") logger = logging.getLogger("fastapi-vue")
logger.addHandler(_handler) logger.addHandler(_handler)
logger.setLevel(logging.INFO) 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: def _check_node_version(node_path: str) -> None:
+76 -96
View File
@@ -1,110 +1,89 @@
"""Utilities meant for devserver script, used only in source repository with dev deps.""" """Utilities meant for devserver script, used only in source repository with dev deps."""
from __future__ import annotations
import asyncio import asyncio
import subprocess
import sys import sys
from asyncio.subprocess import Process
from contextlib import suppress from contextlib import suppress
from pathlib import Path 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 urllib.parse import urlsplit
from buildutil import find_dev_tool, find_install_tool, logger from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine from collections.abc import Awaitable
class ProcessGroup: class ProcessGroup(asyncio.TaskGroup):
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" """TaskGroup with structured ownership of async subprocesses."""
def __init__(self) -> None: def __init__(self, *, terminate_timeout: float = 10) -> None:
"""Initialize empty process tracking.""" """Set the grace period before terminate() escalates to kill()."""
self._procs: list[asyncio.subprocess.Process] = [] super().__init__()
self._cmds: dict[int, str] = {} # pid -> command name self._terminate_timeout = terminate_timeout
self._cmds: dict[Process, tuple[str, ...]] = {}
async def spawn( async def spawn(
self, self, *cmd: str, cwd: str | None = None, vital: bool = False
*cmd: str, ) -> Process:
cwd: str | None = None, """Spawn and own a subprocess. If a vital process exits, the group cancels."""
) -> 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
async def wait( async def run() -> None:
self, name = Path(cmd[0]).stem
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any], logger.info(">>> %s", " ".join([name, *cmd[1:]]))
) -> None: try:
"""Wait for processes/coroutines to complete, raise SystemExit on failure.""" 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: try:
returncode = await proc.wait() returncode = await proc.wait()
if returncode != 0: finally:
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:
with suppress(ProcessLookupError): with suppress(ProcessLookupError):
p.terminate() proc.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):
try: try:
await asyncio.shield( await asyncio.wait_for(proc.wait(), self._terminate_timeout)
asyncio.wait_for(
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
),
)
except TimeoutError: except TimeoutError:
for p in self._procs: with suppress(ProcessLookupError):
if p.returncode is None: proc.kill()
with suppress(ProcessLookupError): await proc.wait()
p.kill()
await p.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. """GET url with plain asyncio streams, return the response Server header.
Returns an empty string when the server responds without a 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() writer.close()
except OSError, EOFError, ValueError, TimeoutError: except OSError, EOFError, ValueError, TimeoutError:
return None 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:"): if line.lower().startswith("server:"):
return line.split(":", 1)[1].strip() return line[7:].strip()
return "" return ""
async def check_ports_free(*urls: str) -> None: 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: Meant to run as a task inside a TaskGroup. Logs the conflict and raises
server = await http_get_server(url, timeout=0.1) 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: if server is not None:
logger.warning( logger.error(
"Conflicting %s already running at %s", server or "server", url "Conflicting %s already running at %s", server or "server", url
) )
raise SystemExit(1) raise RuntimeError(url)
await asyncio.gather(*[check(url) for url in urls])
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint. """Wait for the server to be ready by polling an endpoint.
Use empty path to disable the check and make this return immediately. 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: if not path:
return return
for attempt in range(max_attempts): for attempt in range(max_attempts):
if await http_get_server(f"{url}{path}", timeout=1.0) is not None: if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
logger.info(" Backend ready!") logger.info("🟢 Backend ready!")
return return
if attempt == max_attempts - 1: if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time") logger.error("Backend at %s didn't start in time", url)
raise SystemExit(1) raise RuntimeError(url)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
+329 -30
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env -S uv run #!/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: Usage:
uv run scripts/winbuild.py uv run scripts/guibuild.py
This runs in the project environment where dependencies This runs in the project environment where dependencies
are available via pyproject.toml. are available via pyproject.toml.
@@ -10,14 +10,18 @@ are available via pyproject.toml.
This script: This script:
1. Reads the version from pyproject.toml 1. Reads the version from pyproject.toml
2. Runs `uv build` to produce the wheel/sdist 2. Runs `uv build` to produce the wheel/sdist
3. On Windows, downloads the latest ffmpeg.exe for bundling 3. On Windows/macOS, downloads the ffmpeg binary for bundling
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
4. Builds MediaHive using PyInstaller 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 io
import os
import platform import platform
import re
import shutil import shutil
import stat import stat
import subprocess import subprocess
@@ -25,8 +29,10 @@ import sys
import urllib.request import urllib.request
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import NamedTuple
import setuptools_scm import setuptools_scm
from platformdirs import user_cache_path
# BtbN automated builds always publish a 'latest' tag with this asset. # BtbN automated builds always publish a 'latest' tag with this asset.
_FFMPEG_URL = ( _FFMPEG_URL = (
@@ -36,29 +42,62 @@ _FFMPEG_URL = (
_MACOS_ARM64_TOOL_URLS = { _MACOS_ARM64_TOOL_URLS = {
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip", "ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
} }
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
_REPO_ROOT = Path(__file__).parent.parent _REPO_ROOT = Path(__file__).parent.parent
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets" _ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
def _platform_zip_suffix() -> str: def _build_cache_dir() -> Path:
machine = platform.machine().lower() """Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
arch = { return user_cache_path("mediahive-build", appauthor=False, opinion=False)
"x86_64": "x64",
"amd64": "x64",
"arm64": "arm64",
"aarch64": "arm64",
}.get(machine, machine or "unknown")
_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": if sys.platform == "win32":
return "win64" return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe")
if sys.platform == "darwin": if sys.platform == "darwin":
return f"macos-{arch}" return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
return f"linux-{arch}" 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: 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" dest = _FFMPEG_STAGING / "ffmpeg.exe"
if dest.exists(): if dest.exists():
print(f"ffmpeg already staged at {dest}, skipping download.") 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]: 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 { if sys.platform != "darwin" or platform.machine().lower() not in {
"arm64", "arm64",
"aarch64", "aarch64",
@@ -180,6 +219,262 @@ def ensure_macos_icon() -> Path:
return icon_icns 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: def read_version() -> str:
"""Read version via setuptools_scm (same logic as hatch-vcs).""" """Read version via setuptools_scm (same logic as hatch-vcs)."""
return setuptools_scm.get_version(root=str(_REPO_ROOT)) 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}") raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
def create_zip(version: str) -> Path: def create_portable_zip() -> Path:
"""Create a version-numbered ZIP file of the build/MediaHive folder.""" """Create the Windows portable ZIP of the build/MediaHive folder.
repo_root = _REPO_ROOT
dist_folder = repo_root / "build" / "MediaHive"
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(): if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}") raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip" zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
zip_path = repo_root / "build" / zip_name
zip_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Creating {zip_path}...") print(f"Creating {zip_path}...")
shutil.make_archive( shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
@@ -258,10 +553,14 @@ def main() -> None:
) )
build_wheel() build_wheel()
build_executable() build_executable()
zip_path = create_zip(version)
print(f"✓ Built successfully: {zip_path}") artifacts = [build_velopack(version)]
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB") 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: except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
print(f"✗ Build failed: {e}", file=sys.stderr) print(f"✗ Build failed: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+112 -40
View File
@@ -9,10 +9,15 @@ Reads from [project.urls] Repository in pyproject.toml.
Token: GITEA_TOKEN environment variable Token: GITEA_TOKEN environment variable
Steps: Steps:
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists 1. Read the clean tag version via setuptools_scm, find platform artifacts
2. Abort if any dist files are missing for a found ZIP version in build/ and matching dist/ wheels/sdists
3. Create a Gitea release for each version and upload all assets 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 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 import argparse
@@ -24,6 +29,7 @@ from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
import setuptools_scm
REPO_ROOT = Path(__file__).parent.parent REPO_ROOT = Path(__file__).parent.parent
@@ -63,20 +69,27 @@ def load_token() -> str:
# ZIP + dist helpers # ZIP + dist helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc. # Installer artifacts are versionless (MediaHive-win64-setup.exe,
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip # MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$") # 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]]: def read_version() -> str:
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/.""" """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" build_dir = REPO_ROOT / "build"
results = [] return [p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)]
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
def find_dist_files(version: str) -> list[Path]: def find_dist_files(version: str) -> list[Path]:
@@ -108,6 +121,23 @@ def find_dist_files(version: str) -> list[Path]:
return [wheel, sdist] 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 # Gitea API helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -117,6 +147,18 @@ def gitea_headers(token: str) -> dict:
return {"Authorization": f"token {token}", "Accept": "application/json"} 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( def create_release(
client: httpx.Client, client: httpx.Client,
base_url: str, base_url: str,
@@ -125,8 +167,12 @@ def create_release(
version: str, version: str,
notes: str, notes: str,
draft: bool, draft: bool,
) -> int: ) -> tuple[int, set[str]]:
"""Create a Gitea release and return its id.""" """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" url = f"{base_url}/api/v1/repos/{repo}/releases"
payload = { payload = {
"tag_name": tag, "tag_name": tag,
@@ -137,11 +183,17 @@ def create_release(
} }
resp = client.post(url, json=payload) resp = client.post(url, json=payload)
if resp.status_code == 409: 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() resp.raise_for_status()
release_id = resp.json()["id"] release_id = resp.json()["id"]
print(f"Created release id={release_id} (draft={draft})") print(f"Created release id={release_id} (draft={draft})")
return release_id return release_id, set()
def upload_asset( def upload_asset(
@@ -154,7 +206,10 @@ def upload_asset(
"""Upload a file to the release and return the download URL.""" """Upload a file to the release and return the download URL."""
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets" url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
size_mb = path.stat().st_size / (1024 * 1024) 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) ...") print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
with Path(path).open("rb") as fh: with Path(path).open("rb") as fh:
resp = client.post( resp = client.post(
@@ -174,6 +229,10 @@ def upload_asset(
def main() -> None: def main() -> None:
# Windows consoles default to cp1252, which can't encode ✓/✗
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea") parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
parser.add_argument( parser.add_argument(
"--draft", action="store_true", help="Create as a draft release" "--draft", action="store_true", help="Create as a draft release"
@@ -181,46 +240,59 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--notes", default="", metavar="TEXT", help="Release notes body" "--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() args = parser.parse_args()
try: try:
cfg = load_gitea_config() cfg = load_gitea_config()
token = load_token() token = load_token()
version = read_version()
zips = find_releasable_zips() artifacts = find_releasable_artifacts()
if not zips: if not artifacts:
print( print(
"No clean-versioned ZIPs found in build/.\n" "No platform artifacts found in build/.\n"
"Run scripts/guibuild.py first.", "Run scripts/guibuild.py first.",
file=sys.stderr, file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
# Validate all dist files exist before touching Gitea # Validate all dist files exist before touching Gitea
dist_files: dict[str, list[Path]] = {} dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
for _, version, _platform_tag in zips:
dist_files[version] = find_dist_files(version)
base_url = cfg["url"].rstrip("/") base_url = cfg["url"].rstrip("/")
repo = cfg["repo"] repo = cfg["repo"]
with httpx.Client(headers=gitea_headers(token)) as client: with httpx.Client(headers=gitea_headers(token)) as client:
release_ids_by_version: dict[str, int] = {} print(f"\nReleasing {version} ...")
for zip_path, version, platform_tag in zips: tag = f"v{version}"
print(f"\nReleasing {version} ...") release_id, uploaded = create_release(
tag = f"v{version}" client, base_url, repo, tag, version, args.notes, args.draft
release_id = release_ids_by_version.get(version) )
if release_id is None: for path in dist_files:
release_id = create_release( if path.name in uploaded:
client, base_url, repo, tag, version, args.notes, args.draft print(f"Skipping {path.name}, already on the release.")
) continue
release_ids_by_version[version] = release_id upload_asset(client, base_url, repo, release_id, path)
for path in dist_files[version]:
upload_asset(client, base_url, repo, release_id, path)
print(f"Uploading platform artifact: {platform_tag}") for artifact_path in artifacts:
upload_asset(client, base_url, repo, release_id, zip_path) if artifact_path.name in uploaded:
print(f" {tag} published") 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("\nDone. To publish to PyPI, run:")
print(" uv publish") print(" uv publish")