Installers for all platforms and related fixes #1
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
- name: Attach platform artifact to the Gitea release
|
||||||
|
if: matrix.os != 'linux'
|
||||||
|
shell: ${{ matrix.shell }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: uv run scripts/release.py --no-dist
|
||||||
@@ -19,3 +19,4 @@ package-lock.json
|
|||||||
# Dotfiles
|
# Dotfiles
|
||||||
.*
|
.*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.gitea/
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
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)**
|
**[Windows, Mac and Linux downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
- Windows and macOS: Download the portable ZIP from the releases page, extract it anywhere, and run `MediaHive`.
|
- Windows: Download `*-win64-setup.exe` from the releases page and run it (no admin needed; auto-updates included). A `-win64-portable.zip` is also available.
|
||||||
- Linux and other platforms: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
|
- macOS: Download `*-macos-setup.pkg` and install (auto-updates included).
|
||||||
|
- Linux: Download the `.AppImage`, `chmod +x` it, and run. Alternatively install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
+24
-3
@@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise<Record<string, ResumePosit
|
|||||||
const rawEpisodes = (entry as { episodes?: unknown }).episodes
|
const rawEpisodes = (entry as { episodes?: unknown }).episodes
|
||||||
if (rawEpisodes && typeof rawEpisodes === "object") {
|
if (rawEpisodes && typeof rawEpisodes === "object") {
|
||||||
const watches: Record<string, EpisodeWatchEntry> = {}
|
const watches: Record<string, EpisodeWatchEntry> = {}
|
||||||
for (const [key, watch] of Object.entries(
|
for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
|
||||||
rawEpisodes as Record<string, unknown>,
|
|
||||||
)) {
|
|
||||||
if (!watch || typeof watch !== "object") continue
|
if (!watch || typeof watch !== "object") continue
|
||||||
const w = watch as { pos?: unknown; done?: unknown }
|
const w = watch as { pos?: unknown; done?: unknown }
|
||||||
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
|
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
|
||||||
@@ -438,3 +436,26 @@ export async function pickFolderAndAddRoot(): Promise<string | null> {
|
|||||||
const folder: string | null = await api.pick_folder()
|
const folder: string | null = await api.pick_folder()
|
||||||
return folder
|
return folder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the installed MediaHive version.
|
||||||
|
*/
|
||||||
|
export async function getVersion(): Promise<string> {
|
||||||
|
const response = await fetch("/api/version")
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load version: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
return data.version || "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the tail of the application log.
|
||||||
|
*/
|
||||||
|
export async function getLog(): Promise<string> {
|
||||||
|
const response = await fetch("/api/log")
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load log: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.text()
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
<!-- Detail mode: show current category + Details -->
|
<!-- Detail mode: show current category + Details -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
|
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
|
||||||
{{ currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series" }}
|
{{
|
||||||
|
currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series"
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
|
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -208,7 +210,9 @@
|
|||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section-title">Preferred Format</h2>
|
<h2 class="settings-section-title">Preferred Format</h2>
|
||||||
<p class="settings-section-desc">Preferred format when multiple versions are available.</p>
|
<p class="settings-section-desc">
|
||||||
|
Preferred format when multiple versions are available.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="format-grid">
|
<div class="format-grid">
|
||||||
<div class="format-row format-row-stack">
|
<div class="format-row format-row-stack">
|
||||||
@@ -295,6 +299,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section-title">Diagnostics</h2>
|
||||||
|
<p class="settings-section-desc">
|
||||||
|
Version info and application log for troubleshooting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="diag-rows">
|
||||||
|
<div class="diag-row">
|
||||||
|
<span class="diag-label">App version</span>
|
||||||
|
<span class="diag-value">{{ appVersion || "…" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diag-row">
|
||||||
|
<span class="diag-label">Browser engine</span>
|
||||||
|
<span class="diag-value">{{ browserEngine }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diag-log-header">
|
||||||
|
<span class="diag-label">Application log</span>
|
||||||
|
<button class="diag-refresh-btn" @click="refreshLog">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<pre class="diag-log">{{ appLog }}</pre>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +334,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue"
|
|||||||
import { useRouter, useRoute } from "vue-router"
|
import { useRouter, useRoute } from "vue-router"
|
||||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||||
import logoUrl from "../assets/mediahive.webp"
|
import logoUrl from "../assets/mediahive.webp"
|
||||||
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api"
|
import { replaceRoots, pickFolderAndAddRoot, fetchPlayers, getVersion, getLog } from "../api"
|
||||||
import type { PlayerInfo } from "../api"
|
import type { PlayerInfo } from "../api"
|
||||||
import HexKeyboard from "./HexKeyboard.vue"
|
import HexKeyboard from "./HexKeyboard.vue"
|
||||||
import {
|
import {
|
||||||
@@ -409,6 +437,30 @@ async function refreshPlayers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appVersion = ref("")
|
||||||
|
const appLog = ref("")
|
||||||
|
const browserEngine = navigator.userAgent
|
||||||
|
let diagnosticsFetched = false
|
||||||
|
|
||||||
|
async function refreshLog() {
|
||||||
|
try {
|
||||||
|
appLog.value = await getLog()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch log:", e)
|
||||||
|
appLog.value = "Failed to load log."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDiagnostics() {
|
||||||
|
try {
|
||||||
|
appVersion.value = await getVersion()
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch version:", e)
|
||||||
|
appVersion.value = "unknown"
|
||||||
|
}
|
||||||
|
await refreshLog()
|
||||||
|
}
|
||||||
|
|
||||||
async function removeRoot(rootId: string) {
|
async function removeRoot(rootId: string) {
|
||||||
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
||||||
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
||||||
@@ -438,6 +490,10 @@ async function addRoot() {
|
|||||||
watch(showSettings, (visible) => {
|
watch(showSettings, (visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
void refreshPlayers()
|
void refreshPlayers()
|
||||||
|
if (!diagnosticsFetched) {
|
||||||
|
diagnosticsFetched = true
|
||||||
|
void refreshDiagnostics()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -868,4 +924,69 @@ onUnmounted(() => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diag-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-refresh-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-refresh-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-log {
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
width: 80ch;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,38 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
|||||||
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
||||||
import { installInputModalityTracking } from "./composables/useInputModality"
|
import { installInputModalityTracking } from "./composables/useInputModality"
|
||||||
|
|
||||||
|
function postClientError(payload: {
|
||||||
|
message: string
|
||||||
|
stack: string | null
|
||||||
|
source: string | null
|
||||||
|
}) {
|
||||||
|
fetch("/api/client-log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function installErrorCapture() {
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
const source =
|
||||||
|
event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null
|
||||||
|
postClientError({
|
||||||
|
message: event.message || String(event.error ?? "Unknown error"),
|
||||||
|
stack: event.error?.stack ?? null,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const reason = event.reason
|
||||||
|
postClientError({
|
||||||
|
message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`,
|
||||||
|
stack: reason instanceof Error ? (reason.stack ?? null) : null,
|
||||||
|
source: "unhandledrejection",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function installReloadShortcut() {
|
function installReloadShortcut() {
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"keydown",
|
"keydown",
|
||||||
@@ -27,6 +59,7 @@ installInputModalityTracking()
|
|||||||
installKeyboardNavigation()
|
installKeyboardNavigation()
|
||||||
installGamepadNavigation()
|
installGamepadNavigation()
|
||||||
installReloadShortcut()
|
installReloadShortcut()
|
||||||
|
installErrorCapture()
|
||||||
|
|
||||||
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
|
|||||||
@@ -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 |
+16
-12
@@ -1,17 +1,19 @@
|
|||||||
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 platformdirs import user_config_path, user_log_path
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct, omit_defaults=True):
|
class Config(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -20,13 +22,15 @@ class Config(msgspec.Struct, omit_defaults=True):
|
|||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
+60
-2
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import importlib.metadata
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -30,11 +31,16 @@ import aiofiles
|
|||||||
import msgspec
|
import msgspec
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
from fastapi.responses import (
|
||||||
|
FileResponse,
|
||||||
|
PlainTextResponse,
|
||||||
|
Response,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
from mediahive.__main__ import DEVMODE
|
from mediahive.__main__ import DEVMODE
|
||||||
from mediahive.config import load_config
|
from mediahive.config import load_config, log_dir
|
||||||
from mediahive.hivescan.images import close_image_client
|
from mediahive.hivescan.images import close_image_client
|
||||||
from mediahive.hivescan.scanner import RootScanner
|
from mediahive.hivescan.scanner import RootScanner
|
||||||
from mediahive.hivescan.tmdb_client import close_http_client
|
from mediahive.hivescan.tmdb_client import close_http_client
|
||||||
@@ -1073,6 +1079,58 @@ async def health_check():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
async def get_version():
|
||||||
|
"""Return the installed MediaHive package version."""
|
||||||
|
try:
|
||||||
|
version = importlib.metadata.version("mediahive")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
version = "dev"
|
||||||
|
return {"version": version}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/log")
|
||||||
|
async def get_log():
|
||||||
|
"""Return the tail of the application log file (last ~64 KB)."""
|
||||||
|
path = log_dir() / "mediahive.log"
|
||||||
|
if not path.exists():
|
||||||
|
return PlainTextResponse("")
|
||||||
|
try:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
size = f.tell()
|
||||||
|
f.seek(max(0, size - 64 * 1024))
|
||||||
|
data = f.read()
|
||||||
|
return PlainTextResponse(data.decode("utf-8", errors="replace"))
|
||||||
|
except OSError:
|
||||||
|
return PlainTextResponse("")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/client-log", status_code=204)
|
||||||
|
async def post_client_log(request: Request):
|
||||||
|
"""Append a client-side (webview) error report to client-errors.log."""
|
||||||
|
try:
|
||||||
|
payload = msgspec.json.decode(await request.body())
|
||||||
|
except msgspec.DecodeError:
|
||||||
|
payload = {}
|
||||||
|
message = str(payload.get("message") or "")
|
||||||
|
stack = payload.get("stack")
|
||||||
|
source = payload.get("source")
|
||||||
|
try:
|
||||||
|
dirpath = log_dir()
|
||||||
|
dirpath.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (dirpath / "client-errors.log").open("a", encoding="utf-8") as f:
|
||||||
|
timestamp = datetime.now().isoformat(timespec="seconds")
|
||||||
|
f.write(f"[{timestamp}] {message}\n")
|
||||||
|
if source:
|
||||||
|
f.write(f" source: {source}\n")
|
||||||
|
if stack:
|
||||||
|
f.write(f" stack: {stack}\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config")
|
@app.get("/api/config")
|
||||||
async def get_config():
|
async def get_config():
|
||||||
"""Return current server configuration."""
|
"""Return current server configuration."""
|
||||||
|
|||||||
+68
-20
@@ -26,9 +26,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
import msgspec.structs
|
import msgspec.structs
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import velopack
|
||||||
import webview
|
import webview
|
||||||
|
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 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 +43,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 +876,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 +902,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 +961,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:
|
|
||||||
from tracerite.html import html_traceback
|
|
||||||
|
|
||||||
fragment = str(html_traceback(exc))
|
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>"
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
||||||
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
||||||
except Exception:
|
|
||||||
logger.exception("Could not display the error window")
|
|
||||||
|
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:
|
||||||
|
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
|
||||||
|
info = mgr.check_for_updates()
|
||||||
|
if info is None:
|
||||||
|
logger.info("Velopack: no update available")
|
||||||
|
return
|
||||||
|
version = info.TargetFullRelease.Version
|
||||||
|
logger.info("Velopack: downloading update %s", version)
|
||||||
|
mgr.download_updates(info)
|
||||||
|
logger.info("Velopack: update %s staged, applies on next launch", version)
|
||||||
|
except (RuntimeError, OSError) as exc:
|
||||||
|
logger.info("Velopack update check skipped: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def gui_main() -> None:
|
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 +1156,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(
|
||||||
@@ -1180,8 +1228,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,8 +1238,6 @@ 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
|
|
||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
"mediahive.server:app",
|
"mediahive.server:app",
|
||||||
host=BACKEND_HOST,
|
host=BACKEND_HOST,
|
||||||
@@ -1228,6 +1272,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(
|
||||||
|
|||||||
+4
-2
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"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
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+326
-30
@@ -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,59 @@ _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(version: str) -> str:
|
||||||
|
p = _platform()
|
||||||
|
return f"MediaHive-{version}-{p.tag}-setup{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 +119,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 +216,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(version)
|
||||||
|
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 +509,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(version: str) -> 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" / f"MediaHive-{version}-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 +550,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(version))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
+95
-27
@@ -9,10 +9,14 @@ 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. Find clean-versioned platform artifacts in build/ and matching dist/ wheels/sdists
|
||||||
2. Abort if any dist files are missing for a found ZIP version
|
2. Abort if any dist files are missing for a found artifact version
|
||||||
3. Create a Gitea release for each version and upload all assets
|
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
|
||||||
@@ -63,17 +67,20 @@ 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.
|
# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
|
||||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
# MediaHive-1.2.3-macos-setup.pkg, MediaHive-1.2.3-linux-setup.AppImage, etc.
|
||||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
|
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64-portable.zip
|
||||||
|
_CLEAN_ARTIFACT_RE = re.compile(
|
||||||
|
r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe|pkg|AppImage)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_releasable_zips() -> list[tuple[Path, str, str]]:
|
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
|
||||||
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
|
"""Return (path, version, platform_tag) for clean-versioned artifacts in build/."""
|
||||||
build_dir = REPO_ROOT / "build"
|
build_dir = REPO_ROOT / "build"
|
||||||
results = []
|
results = []
|
||||||
for p in sorted(build_dir.glob("MediaHive-*.zip")):
|
for p in sorted(build_dir.glob("MediaHive-*")):
|
||||||
m = _CLEAN_ZIP_RE.match(p.name)
|
m = _CLEAN_ARTIFACT_RE.match(p.name)
|
||||||
if m:
|
if m:
|
||||||
results.append((p, m.group(1), m.group(2)))
|
results.append((p, m.group(1), m.group(2)))
|
||||||
return results
|
return results
|
||||||
@@ -108,6 +115,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 +141,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 +161,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 +177,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 +200,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(
|
||||||
@@ -185,16 +234,21 @@ 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()
|
||||||
|
|
||||||
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 clean-versioned platform artifacts found in build/.\n"
|
||||||
"Run scripts/guibuild.py first.",
|
"Run scripts/guibuild.py first.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
@@ -202,28 +256,42 @@ def main() -> None:
|
|||||||
|
|
||||||
# Validate all dist files exist before touching Gitea
|
# Validate all dist files exist before touching Gitea
|
||||||
dist_files: dict[str, list[Path]] = {}
|
dist_files: dict[str, list[Path]] = {}
|
||||||
for _, version, _platform_tag in zips:
|
if not args.no_dist:
|
||||||
|
for _, version, _platform_tag in artifacts:
|
||||||
dist_files[version] = find_dist_files(version)
|
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] = {}
|
releases: dict[str, tuple[int, set[str]]] = {}
|
||||||
for zip_path, version, platform_tag in zips:
|
for artifact_path, version, platform_tag in artifacts:
|
||||||
print(f"\nReleasing {version} ...")
|
print(f"\nReleasing {version} ...")
|
||||||
tag = f"v{version}"
|
tag = f"v{version}"
|
||||||
release_id = release_ids_by_version.get(version)
|
if version not in releases:
|
||||||
if release_id is None:
|
releases[version] = create_release(
|
||||||
release_id = create_release(
|
|
||||||
client, base_url, repo, tag, version, args.notes, args.draft
|
client, base_url, repo, tag, version, args.notes, args.draft
|
||||||
)
|
)
|
||||||
release_ids_by_version[version] = release_id
|
release_id, uploaded = releases[version]
|
||||||
for path in dist_files[version]:
|
for path in dist_files.get(version, []):
|
||||||
|
if path.name in uploaded:
|
||||||
|
print(f"Skipping {path.name}, already on the release.")
|
||||||
|
continue
|
||||||
upload_asset(client, base_url, repo, release_id, path)
|
upload_asset(client, base_url, repo, release_id, path)
|
||||||
|
|
||||||
|
release_id, uploaded = releases[version]
|
||||||
|
if artifact_path.name in uploaded:
|
||||||
|
print(f"Skipping {artifact_path.name}, already on the release.")
|
||||||
|
continue
|
||||||
print(f"Uploading platform artifact: {platform_tag}")
|
print(f"Uploading platform artifact: {platform_tag}")
|
||||||
upload_asset(client, base_url, repo, release_id, zip_path)
|
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(f" ✓ {tag} published")
|
||||||
|
|
||||||
print("\nDone. To publish to PyPI, run:")
|
print("\nDone. To publish to PyPI, run:")
|
||||||
|
|||||||
Reference in New Issue
Block a user