Merge installers: Velopack packaging on all platforms
Per-user installers with in-app auto-updates (Velopack Setup.exe / macOS pkg / Linux AppImage) built by the Gitea release workflow. macOS GUI switched to Qt6 WebEngine (modern Chromium). Diagnostics section in settings (version, browser engine, log view, client error capture).
This commit is contained in:
@@ -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
|
||||
.*
|
||||
!.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.
|
||||
|
||||
**[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
|
||||
|
||||
- Windows and macOS: Download the portable ZIP from the releases page, extract it anywhere, and run `MediaHive`.
|
||||
- Linux and other platforms: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Development
|
||||
|
||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (portable ZIPs on Windows/macOS, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
||||
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (installer/AppImage downloads, `uvx --from mediahive[gui] mediahive` on Linux/other).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -44,8 +44,8 @@ This launches the same pywebview-based desktop flow used by the Windows build.
|
||||
|
||||
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
|
||||
|
||||
- `./scripts/guibuild.py` builds the PyInstaller desktop app and a versioned portable ZIP under `build/`.
|
||||
- `./scripts/release.py` publishes a release to the Gitea releases page.
|
||||
- `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). Requires node/npm and the .NET SDK (>= 10 runtime) installed on the build host; `vpk` and ffmpeg are downloaded once into a persistent user cache (`~/.cache/mediahive-build`, `%LOCALAPPDATA%\mediahive-build` on Windows).
|
||||
- `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release.
|
||||
|
||||
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
|
||||
|
||||
|
||||
+24
-3
@@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise<Record<string, ResumePosit
|
||||
const rawEpisodes = (entry as { episodes?: unknown }).episodes
|
||||
if (rawEpisodes && typeof rawEpisodes === "object") {
|
||||
const watches: Record<string, EpisodeWatchEntry> = {}
|
||||
for (const [key, watch] of Object.entries(
|
||||
rawEpisodes as Record<string, unknown>,
|
||||
)) {
|
||||
for (const [key, watch] of Object.entries(rawEpisodes as Record<string, unknown>)) {
|
||||
if (!watch || typeof watch !== "object") continue
|
||||
const w = watch as { pos?: unknown; done?: unknown }
|
||||
if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue
|
||||
@@ -438,3 +436,26 @@ export async function pickFolderAndAddRoot(): Promise<string | null> {
|
||||
const folder: string | null = await api.pick_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 -->
|
||||
<template v-else>
|
||||
<button class="header-nav-item" v-bind="navAttrs(navRow, 0)" @focus="goToCategory">
|
||||
{{ currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series" }}
|
||||
{{
|
||||
currentView === "search" ? "Search" : currentView === "movies" ? "Movies" : "Series"
|
||||
}}
|
||||
</button>
|
||||
<button class="header-nav-item active" v-bind="navAttrs(navRow, 1, 1)">Details</button>
|
||||
</template>
|
||||
@@ -208,7 +210,9 @@
|
||||
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section-title">Preferred Format</h2>
|
||||
<p class="settings-section-desc">Preferred format when multiple versions are available.</p>
|
||||
<p class="settings-section-desc">
|
||||
Preferred format when multiple versions are available.
|
||||
</p>
|
||||
|
||||
<div class="format-grid">
|
||||
<div class="format-row format-row-stack">
|
||||
@@ -295,6 +299,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -306,7 +334,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { navAttrs } from "../composables/useKeyboardNavigation"
|
||||
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 HexKeyboard from "./HexKeyboard.vue"
|
||||
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) {
|
||||
const filtered = roots.value.filter((r) => r.root_id !== rootId)
|
||||
const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path]))
|
||||
@@ -438,6 +490,10 @@ async function addRoot() {
|
||||
watch(showSettings, (visible) => {
|
||||
if (visible) {
|
||||
void refreshPlayers()
|
||||
if (!diagnosticsFetched) {
|
||||
diagnosticsFetched = true
|
||||
void refreshDiagnostics()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -868,4 +924,69 @@ onUnmounted(() => {
|
||||
font-size: 0.8rem;
|
||||
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>
|
||||
|
||||
@@ -6,6 +6,38 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation"
|
||||
import { installGamepadNavigation } from "./composables/useGamepadNavigation"
|
||||
import { installInputModalityTracking } from "./composables/useInputModality"
|
||||
|
||||
function postClientError(payload: {
|
||||
message: string
|
||||
stack: string | null
|
||||
source: string | null
|
||||
}) {
|
||||
fetch("/api/client-log", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function installErrorCapture() {
|
||||
window.addEventListener("error", (event) => {
|
||||
const source =
|
||||
event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null
|
||||
postClientError({
|
||||
message: event.message || String(event.error ?? "Unknown error"),
|
||||
stack: event.error?.stack ?? null,
|
||||
source,
|
||||
})
|
||||
})
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason
|
||||
postClientError({
|
||||
message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`,
|
||||
stack: reason instanceof Error ? (reason.stack ?? null) : null,
|
||||
source: "unhandledrejection",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function installReloadShortcut() {
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
@@ -27,6 +59,7 @@ installInputModalityTracking()
|
||||
installKeyboardNavigation()
|
||||
installGamepadNavigation()
|
||||
installReloadShortcut()
|
||||
installErrorCapture()
|
||||
|
||||
// Unregister any legacy service workers — MediaHive no longer uses a PWA/SW.
|
||||
if ("serviceWorker" in navigator) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Config file location:
|
||||
Windows: %APPDATA%\mediahive\config.toml
|
||||
Locations (via platformdirs):
|
||||
Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
|
||||
macOS: ~/Library/Application Support/mediahive/config.toml
|
||||
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.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
|
||||
|
||||
import msgspec
|
||||
import msgspec.toml
|
||||
from platformdirs import user_config_path, user_log_path
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
@@ -20,13 +22,15 @@ class Config(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA") or Path.home())
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
||||
return base / "mediahive"
|
||||
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||
# roaming=False: config is machine-specific state, not something to sync
|
||||
# across a domain profile.
|
||||
return user_config_path("mediahive", appauthor=False, roaming=False)
|
||||
|
||||
|
||||
def log_dir() -> Path:
|
||||
# opinion=False: no extra Logs/ subdir; mediahive.log sits beside config.
|
||||
return user_log_path("mediahive", appauthor=False, opinion=False)
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
|
||||
+60
-2
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
@@ -30,11 +31,16 @@ import aiofiles
|
||||
import msgspec
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
PlainTextResponse,
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
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.scanner import RootScanner
|
||||
from mediahive.hivescan.tmdb_client import close_http_client
|
||||
@@ -1073,6 +1079,58 @@ async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
async def get_version():
|
||||
"""Return the installed MediaHive package version."""
|
||||
try:
|
||||
version = importlib.metadata.version("mediahive")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
version = "dev"
|
||||
return {"version": version}
|
||||
|
||||
|
||||
@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")
|
||||
async def get_config():
|
||||
"""Return current server configuration."""
|
||||
|
||||
+68
-20
@@ -26,9 +26,13 @@ from pathlib import Path
|
||||
|
||||
import msgspec.structs
|
||||
import uvicorn
|
||||
import velopack
|
||||
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
|
||||
|
||||
logger = logging.getLogger("mediahive.winmain")
|
||||
@@ -39,6 +43,7 @@ HEALTH_TIMEOUT = 2 # seconds
|
||||
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
|
||||
BACKEND_HEALTH_POLL_SECONDS = 0.25
|
||||
MPC_BE_URL = "http://127.0.0.1:13579"
|
||||
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
|
||||
GAMEPAD_REPEAT_SECONDS = 0.008
|
||||
GAMEPAD_POLL_SECONDS = 0.008
|
||||
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
|
||||
@@ -871,18 +876,16 @@ def _wait_for_previous_instance(log_path: Path, timeout: float = 15.0):
|
||||
|
||||
|
||||
def _setup_logging() -> Path:
|
||||
"""Redirect stdout/stderr and configure logging to a file in %APPDATA%/mediahive/.
|
||||
"""Redirect stdout/stderr and configure logging to a file in the platform log dir.
|
||||
|
||||
In a PyInstaller --windowed build there is no console, so any print() or
|
||||
unhandled exception traceback would be lost. This ensures everything ends
|
||||
up in a persistent log file the user can send for bug reports.
|
||||
Returns the path to the log file.
|
||||
"""
|
||||
from mediahive.config import config_dir
|
||||
|
||||
log_dir = config_dir()
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "mediahive.log"
|
||||
log_directory = log_dir()
|
||||
log_directory.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_directory / "mediahive.log"
|
||||
|
||||
try:
|
||||
log_file = _rotate_and_open_log(log_path)
|
||||
@@ -899,7 +902,7 @@ def _setup_logging() -> Path:
|
||||
log_file = _wait_for_previous_instance(log_path)
|
||||
if log_file is None:
|
||||
# Never fail startup over logging: fall back to a per-process file.
|
||||
log_path = log_dir / f"mediahive-{os.getpid()}.log"
|
||||
log_path = log_directory / f"mediahive-{os.getpid()}.log"
|
||||
with contextlib.suppress(OSError):
|
||||
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||
log_file = os.fdopen(fd, "w", encoding="utf-8", buffering=1)
|
||||
@@ -958,26 +961,53 @@ def _show_fatal_error(exc: BaseException) -> None:
|
||||
Frozen --windowed builds otherwise surface crashes only as PyInstaller's
|
||||
plain-text error dialog (or nothing at all).
|
||||
"""
|
||||
try:
|
||||
from tracerite.html import html_traceback
|
||||
|
||||
fragment = str(html_traceback(exc))
|
||||
except Exception: # noqa: BLE001 - error reporting must never raise
|
||||
return
|
||||
page = (
|
||||
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||
"<title>MediaHive — Error</title></head>"
|
||||
f"<body style='margin:1.5rem'>{fragment}</body></html>"
|
||||
)
|
||||
try:
|
||||
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
|
||||
webview.start(icon=_icon_path(), **_webview_start_kwargs())
|
||||
except Exception:
|
||||
logger.exception("Could not display the error window")
|
||||
|
||||
|
||||
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:
|
||||
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
|
||||
_velopack_startup()
|
||||
try:
|
||||
winmain()
|
||||
except Exception as exc:
|
||||
@@ -1126,8 +1156,26 @@ def _configure_windows_event_loop_policy() -> None:
|
||||
asyncio.set_event_loop_policy(policy_cls())
|
||||
|
||||
|
||||
def _strip_mark_of_the_web() -> None:
|
||||
"""Remove Zone.Identifier streams from bundled DLLs (frozen Windows only).
|
||||
|
||||
Files extracted from a downloaded ZIP carry the Mark-of-the-Web, and the
|
||||
.NET Framework CLR refuses to load such assemblies — pythonnet then fails
|
||||
with "Failed to resolve Python.Runtime.Loader.Initialize from
|
||||
.../Python.Runtime.dll". Strip the mark from the bundled DLLs before
|
||||
pywebview loads the CLR.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False) or sys.platform != "win32":
|
||||
return
|
||||
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
for dll in meipass.rglob("*.dll"):
|
||||
with contextlib.suppress(OSError):
|
||||
Path(f"{dll}:Zone.Identifier").unlink()
|
||||
|
||||
|
||||
def winmain() -> None:
|
||||
_configure_windows_event_loop_policy()
|
||||
_strip_mark_of_the_web()
|
||||
|
||||
parser = argparse.ArgumentParser(description="MediaHive")
|
||||
parser.add_argument(
|
||||
@@ -1180,8 +1228,6 @@ def winmain() -> None:
|
||||
|
||||
# Startup banner, same as fastapi-vue's server.run() prints in CLI mode.
|
||||
# Goes to stderr, which frozen builds redirect to the log file.
|
||||
from fastapi_vue.startupbox import print_box
|
||||
|
||||
try:
|
||||
version = importlib.metadata.version("mediahive")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
@@ -1192,8 +1238,6 @@ def winmain() -> None:
|
||||
# log config wires up its access-log middleware, emoji level prefixes and
|
||||
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
|
||||
# e.g. redirected to the log file in frozen builds).
|
||||
from fastapi_vue.logging import patch_log_config
|
||||
|
||||
config = uvicorn.Config(
|
||||
"mediahive.server:app",
|
||||
host=BACKEND_HOST,
|
||||
@@ -1228,6 +1272,10 @@ def winmain() -> None:
|
||||
server.should_exit = True
|
||||
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
|
||||
|
||||
threading.Thread(
|
||||
target=_check_for_updates, daemon=True, name="mediahive-update-check"
|
||||
).start()
|
||||
|
||||
api = JsApi()
|
||||
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
|
||||
window = webview.create_window(
|
||||
|
||||
+4
-2
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"httpx[http2]>=0.28.1",
|
||||
"msgspec>=0.19",
|
||||
"parse-torrent-title>=2.8.1",
|
||||
"platformdirs>=4.0",
|
||||
"tomli-w>=1.2.0",
|
||||
"uvicorn[standard]>=0.40.0",
|
||||
]
|
||||
@@ -50,10 +51,11 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
|
||||
|
||||
[project.optional-dependencies]
|
||||
gui = [
|
||||
# pywebview's qt extra is Qt6-only (QtPy + PyQt6 + PyQt6-WebEngine);
|
||||
# Qt5 would come from its separate qt5 extra, which we do not use.
|
||||
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
|
||||
"pywebview>=6.2.1; platform_system == 'Windows'",
|
||||
"qtpy>=2.4.1; platform_system == 'Darwin'",
|
||||
"PyQt5>=5.15.11; platform_system == 'Darwin'",
|
||||
"velopack>=1.2",
|
||||
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
|
||||
"pyinstaller>=6.0",
|
||||
]
|
||||
|
||||
+22
-8
@@ -5,7 +5,7 @@
|
||||
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
|
||||
#
|
||||
# Or use the build script (recommended—handles versioning and packaging):
|
||||
# uv run scripts/winbuild.py
|
||||
# uv run scripts/guibuild.py
|
||||
|
||||
import sys
|
||||
import mediahive.winmain
|
||||
@@ -20,7 +20,15 @@ _frontend_build = _pkg / "frontend-build"
|
||||
_logo_webp = _pkg / "assets" / "mediahive.webp"
|
||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
# ffmpeg staging lives in the persistent build cache (same logic as
|
||||
# scripts/guibuild.py); fall back to the legacy build/ffmpeg location.
|
||||
from platformdirs import user_cache_path
|
||||
|
||||
_tools_dir = (
|
||||
user_cache_path("mediahive-build", appauthor=False, opinion=False) / "ffmpeg"
|
||||
)
|
||||
if not _tools_dir.exists():
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
||||
|
||||
_binaries = []
|
||||
@@ -80,11 +88,12 @@ if sys.platform == "darwin":
|
||||
# pywebview Qt backend selected dynamically via webview.start(gui="qt")
|
||||
"webview.platforms.qt",
|
||||
"qtpy",
|
||||
"PyQt5",
|
||||
"PyQt5.QtCore",
|
||||
"PyQt5.QtGui",
|
||||
"PyQt5.QtWidgets",
|
||||
"PyQt5.QtWebEngineWidgets",
|
||||
"PyQt6",
|
||||
"PyQt6.QtCore",
|
||||
"PyQt6.QtGui",
|
||||
"PyQt6.QtWidgets",
|
||||
"PyQt6.QtWebEngineCore",
|
||||
"PyQt6.QtWebEngineWidgets",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -123,6 +132,11 @@ exe = EXE(
|
||||
windowed=True,
|
||||
)
|
||||
|
||||
# UPX breaks .NET assemblies: packing Python.Runtime.dll strips/corrupts its
|
||||
# CLR metadata and pythonnet then fails with "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize from .../Python.Runtime.dll".
|
||||
_upx_exclude = ["Python.Runtime.dll"] if sys.platform == "win32" else []
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
@@ -130,7 +144,7 @@ coll = COLLECT(
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
upx_exclude=_upx_exclude,
|
||||
name="MediaHive",
|
||||
)
|
||||
|
||||
|
||||
+326
-30
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Build the desktop GUI application and package it as a version-numbered ZIP.
|
||||
"""Build the desktop GUI application and package it with Velopack.
|
||||
|
||||
Usage:
|
||||
uv run scripts/winbuild.py
|
||||
uv run scripts/guibuild.py
|
||||
|
||||
This runs in the project environment where dependencies
|
||||
are available via pyproject.toml.
|
||||
@@ -10,14 +10,18 @@ are available via pyproject.toml.
|
||||
This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
2. Runs `uv build` to produce the wheel/sdist
|
||||
3. On Windows, downloads the latest ffmpeg.exe for bundling
|
||||
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
|
||||
3. On Windows/macOS, downloads the ffmpeg binary for bundling
|
||||
4. Builds MediaHive using PyInstaller
|
||||
5. Creates a ZIP file with the version number
|
||||
5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
|
||||
.AppImage (Linux), plus the update feed in build/velopack/
|
||||
that release.py uploads for in-app auto-updates
|
||||
6. On Windows, also creates a portable ZIP (no auto-updates)
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -25,8 +29,10 @@ import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import setuptools_scm
|
||||
from platformdirs import user_cache_path
|
||||
|
||||
# BtbN automated builds always publish a 'latest' tag with this asset.
|
||||
_FFMPEG_URL = (
|
||||
@@ -36,29 +42,59 @@ _FFMPEG_URL = (
|
||||
_MACOS_ARM64_TOOL_URLS = {
|
||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||
}
|
||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
_ASSETS_DIR = _REPO_ROOT / "mediahive" / "assets"
|
||||
|
||||
|
||||
def _platform_zip_suffix() -> str:
|
||||
machine = platform.machine().lower()
|
||||
arch = {
|
||||
"x86_64": "x64",
|
||||
"amd64": "x64",
|
||||
"arm64": "arm64",
|
||||
"aarch64": "arm64",
|
||||
}.get(machine, machine or "unknown")
|
||||
def _build_cache_dir() -> Path:
|
||||
"""Return the persistent cross-build cache dir for downloaded tools (CI wipes build/)."""
|
||||
return user_cache_path("mediahive-build", appauthor=False, opinion=False)
|
||||
|
||||
|
||||
_FFMPEG_STAGING = _build_cache_dir() / "ffmpeg"
|
||||
|
||||
# Velopack CLI (dotnet tool package). Runs on the machine's .NET runtime; the
|
||||
# produced Setup.exe/Update.exe are native and need no runtime on end-user
|
||||
# machines. Pin a version whose tools target an installed .NET major.
|
||||
_VPK_VERSION = "1.2.158"
|
||||
_VPK_URL = (
|
||||
f"https://api.nuget.org/v3-flatcontainer/vpk/{_VPK_VERSION}"
|
||||
f"/vpk.{_VPK_VERSION}.nupkg"
|
||||
)
|
||||
_VPK_STAGING = _build_cache_dir() / f"vpk-{_VPK_VERSION}"
|
||||
|
||||
|
||||
class _Platform(NamedTuple):
|
||||
"""Per-platform naming/packaging constants.
|
||||
|
||||
tag is the release artifact suffix. Only Windows keeps an arch marker
|
||||
(win64); macOS builds are arm64-only and we ship one Linux flavor.
|
||||
"""
|
||||
|
||||
tag: str # win64 / macos / linux
|
||||
channel: str # Velopack update channel: win / osx / linux
|
||||
rid: str # Velopack runtime id
|
||||
dist_dir: str # PyInstaller output dir under build/
|
||||
icon: str # file in mediahive/assets
|
||||
main_exe: str
|
||||
setup_ext: str
|
||||
|
||||
|
||||
def _platform() -> _Platform:
|
||||
if sys.platform == "win32":
|
||||
return "win64"
|
||||
return _Platform("win64", "win", "win-x64", "MediaHive", "mediahive.ico", "MediaHive.exe", ".exe")
|
||||
if sys.platform == "darwin":
|
||||
return f"macos-{arch}"
|
||||
return f"linux-{arch}"
|
||||
return _Platform("macos", "osx", "osx-arm64", "MediaHive.app", "mediahive.icns", "MediaHive", ".pkg")
|
||||
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
|
||||
|
||||
|
||||
def setup_artifact_name(version: str) -> str:
|
||||
p = _platform()
|
||||
return f"MediaHive-{version}-{p.tag}-setup{p.setup_ext}"
|
||||
|
||||
|
||||
def fetch_ffmpeg() -> Path:
|
||||
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
|
||||
"""Download latest ffmpeg.exe from BtbN builds into the persistent build cache."""
|
||||
dest = _FFMPEG_STAGING / "ffmpeg.exe"
|
||||
if dest.exists():
|
||||
print(f"ffmpeg already staged at {dest}, skipping download.")
|
||||
@@ -83,7 +119,7 @@ def fetch_ffmpeg() -> Path:
|
||||
|
||||
|
||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/."""
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into the persistent build cache."""
|
||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||
"arm64",
|
||||
"aarch64",
|
||||
@@ -180,6 +216,262 @@ def ensure_macos_icon() -> Path:
|
||||
return icon_icns
|
||||
|
||||
|
||||
_VPK_TFM = "net10.0"
|
||||
_VPK_REQUIRED_DOTNET_MAJOR = int(re.fullmatch(r"net(\d+)\.0", _VPK_TFM).group(1))
|
||||
|
||||
|
||||
def fetch_vpk() -> Path:
|
||||
"""Download the Velopack CLI package into the persistent build cache.
|
||||
|
||||
Returns the path to vpk.dll, runnable with `dotnet vpk.dll ...`.
|
||||
"""
|
||||
vpk_dll = _VPK_STAGING / "tools" / _VPK_TFM / "any" / "vpk.dll"
|
||||
if vpk_dll.exists():
|
||||
print(f"vpk already staged at {_VPK_STAGING}, skipping download.")
|
||||
return vpk_dll
|
||||
|
||||
_VPK_STAGING.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading vpk from {_VPK_URL} ...")
|
||||
with urllib.request.urlopen(_VPK_URL) as resp:
|
||||
data = resp.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
zf.extractall(_VPK_STAGING)
|
||||
|
||||
if not vpk_dll.exists():
|
||||
raise RuntimeError(f"vpk.dll not found in package at {vpk_dll}")
|
||||
print(f"vpk staged at {_VPK_STAGING}")
|
||||
return vpk_dll
|
||||
|
||||
|
||||
def _dotnet_runtime_major(exe: Path) -> int | None:
|
||||
"""Return the highest installed Microsoft.NETCore.App major version, or None."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
majors = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
|
||||
try:
|
||||
majors.append(int(parts[1].split(".")[0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return max(majors, default=None)
|
||||
|
||||
|
||||
def fetch_dotnet() -> str:
|
||||
"""Resolve a system dotnet host able to run vpk (needs .NET >= 10).
|
||||
|
||||
The .NET SDK is a build prerequisite installed on the build machine —
|
||||
downloading a runtime per build is slow and flaky. Several dotnet
|
||||
installations may coexist (PATH may resolve to a runtime-only .NET 8
|
||||
while scoop holds the SDK 10), so probe known locations and pick the
|
||||
newest runtime rather than the first that runs.
|
||||
"""
|
||||
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
|
||||
candidates: list[Path] = []
|
||||
root = os.environ.get("DOTNET_ROOT")
|
||||
if root:
|
||||
candidates.append(Path(root) / exe_name)
|
||||
which = shutil.which("dotnet")
|
||||
if which:
|
||||
candidates.append(Path(which))
|
||||
if sys.platform == "win32":
|
||||
candidates += [
|
||||
Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name,
|
||||
Path(r"C:\Program Files\dotnet") / exe_name,
|
||||
]
|
||||
elif sys.platform == "darwin":
|
||||
candidates += [
|
||||
Path("/opt/homebrew/bin") / exe_name,
|
||||
Path("/usr/local/share/dotnet") / exe_name,
|
||||
]
|
||||
else:
|
||||
candidates += [
|
||||
Path("/usr/share/dotnet") / exe_name,
|
||||
Path("/usr/lib/dotnet") / exe_name,
|
||||
Path.home() / ".dotnet" / exe_name,
|
||||
]
|
||||
|
||||
best: tuple[int, Path] | None = None
|
||||
for exe in candidates:
|
||||
if not exe.exists():
|
||||
continue
|
||||
major = _dotnet_runtime_major(exe)
|
||||
if major is not None and (best is None or major > best[0]):
|
||||
best = (major, exe)
|
||||
|
||||
if best is not None and best[0] >= _VPK_REQUIRED_DOTNET_MAJOR:
|
||||
print(f"Using dotnet at {best[1]} (.NET {best[0]})")
|
||||
return str(best[1])
|
||||
|
||||
found = f"newest found is .NET {best[0]} at {best[1]}" if best else "none found"
|
||||
raise RuntimeError(
|
||||
f"vpk requires Microsoft.NETCore.App >= {_VPK_REQUIRED_DOTNET_MAJOR} ({found}). "
|
||||
"Install the current .NET SDK on this build machine "
|
||||
"(Windows: `scoop install dotnet-sdk`; macOS: `brew install dotnet-sdk`; "
|
||||
"Linux: distro `dotnet-sdk` package or the dotnet-install script)."
|
||||
)
|
||||
|
||||
|
||||
def build_velopack(version: str) -> Path:
|
||||
"""Build the Velopack installer/bundle for this platform.
|
||||
|
||||
Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
|
||||
Also produces the update feed (releases.<channel>.json, *.nupkg) in
|
||||
build/velopack/ for release.py to upload — in-app auto-updates read it
|
||||
from the Gitea release. Velopack installs carry no Mark-of-the-Web, so
|
||||
the .NET CLR loads pythonnet/pywebview assemblies that it refuses from
|
||||
a downloaded ZIP.
|
||||
"""
|
||||
plat = _platform()
|
||||
dist_folder = _REPO_ROOT / "build" / plat.dist_dir
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
vpk_dll = fetch_vpk()
|
||||
releases_dir = _REPO_ROOT / "build" / "velopack"
|
||||
|
||||
cmd = [
|
||||
fetch_dotnet(),
|
||||
str(vpk_dll),
|
||||
"pack",
|
||||
"--packId",
|
||||
"MediaHive",
|
||||
"--packVersion",
|
||||
version,
|
||||
"--packDir",
|
||||
str(dist_folder),
|
||||
"--mainExe",
|
||||
plat.main_exe,
|
||||
"--packAuthors",
|
||||
"MediaHive",
|
||||
"--packTitle",
|
||||
"MediaHive",
|
||||
"--icon",
|
||||
str(_ASSETS_DIR / plat.icon),
|
||||
"--runtime",
|
||||
plat.rid,
|
||||
"--outputDir",
|
||||
str(releases_dir),
|
||||
]
|
||||
if sys.platform == "darwin":
|
||||
cmd += ["--instWelcome", str(_ASSETS_DIR / "macos-installer-welcome.txt")]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
try:
|
||||
result = subprocess.run(cmd, cwd=_REPO_ROOT, capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"vpk failed to start: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"vpk pack failed with exit code {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
setup = next(iter(sorted(releases_dir.glob(f"*Setup*{plat.setup_ext}"))), None)
|
||||
if setup is None:
|
||||
setup = next(iter(sorted(releases_dir.glob(f"*{plat.setup_ext}"))), None)
|
||||
if setup is None:
|
||||
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
|
||||
if sys.platform == "darwin":
|
||||
force_macos_user_install(setup)
|
||||
artifact = _REPO_ROOT / "build" / setup_artifact_name(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:
|
||||
"""Read version via setuptools_scm (same logic as hatch-vcs)."""
|
||||
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}")
|
||||
|
||||
|
||||
def create_zip(version: str) -> Path:
|
||||
"""Create a version-numbered ZIP file of the build/MediaHive folder."""
|
||||
repo_root = _REPO_ROOT
|
||||
dist_folder = repo_root / "build" / "MediaHive"
|
||||
def create_portable_zip(version: str) -> Path:
|
||||
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||
|
||||
Velopack-less plain-folder distribution for users who cannot or do not
|
||||
want to run Setup.exe. No auto-updates; the app strips Mark-of-the-Web
|
||||
from bundled DLLs at first run instead.
|
||||
"""
|
||||
dist_folder = _REPO_ROOT / "build" / "MediaHive"
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_name = f"MediaHive-{version}-{_platform_zip_suffix()}.zip"
|
||||
zip_path = repo_root / "build" / zip_name
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
zip_path = _REPO_ROOT / "build" / f"MediaHive-{version}-win64-portable.zip"
|
||||
print(f"Creating {zip_path}...")
|
||||
shutil.make_archive(
|
||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||
@@ -258,10 +550,14 @@ def main() -> None:
|
||||
)
|
||||
build_wheel()
|
||||
build_executable()
|
||||
zip_path = create_zip(version)
|
||||
|
||||
print(f"✓ Built successfully: {zip_path}")
|
||||
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
|
||||
artifacts = [build_velopack(version)]
|
||||
if sys.platform == "win32":
|
||||
artifacts.append(create_portable_zip(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:
|
||||
print(f"✗ Build failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
+95
-27
@@ -9,10 +9,14 @@ Reads from [project.urls] Repository in pyproject.toml.
|
||||
Token: GITEA_TOKEN environment variable
|
||||
|
||||
Steps:
|
||||
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing for a found ZIP version
|
||||
3. Create a Gitea release for each version and upload all assets
|
||||
1. Find clean-versioned platform artifacts in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing for a found artifact version
|
||||
3. Create a Gitea release for each version (or reuse the existing one
|
||||
for the tag, skipping already-uploaded assets) and upload all assets
|
||||
4. Remind the user to run: uv publish
|
||||
|
||||
Parallel CI platform builds converge on one release per tag; pass --no-dist
|
||||
on all but one platform so only it uploads the wheel/sdist.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -63,17 +67,20 @@ def load_token() -> str:
|
||||
# ZIP + dist helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-macos-arm64.zip, etc.
|
||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip
|
||||
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.zip$")
|
||||
# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
|
||||
# MediaHive-1.2.3-macos-setup.pkg, MediaHive-1.2.3-linux-setup.AppImage, etc.
|
||||
# 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]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned ZIPs in build/."""
|
||||
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned artifacts in build/."""
|
||||
build_dir = REPO_ROOT / "build"
|
||||
results = []
|
||||
for p in sorted(build_dir.glob("MediaHive-*.zip")):
|
||||
m = _CLEAN_ZIP_RE.match(p.name)
|
||||
for p in sorted(build_dir.glob("MediaHive-*")):
|
||||
m = _CLEAN_ARTIFACT_RE.match(p.name)
|
||||
if m:
|
||||
results.append((p, m.group(1), m.group(2)))
|
||||
return results
|
||||
@@ -108,6 +115,23 @@ def find_dist_files(version: str) -> list[Path]:
|
||||
return [wheel, sdist]
|
||||
|
||||
|
||||
def find_velopack_feed_files() -> list[Path]:
|
||||
"""Velopack update feed files produced by vpk pack in build/velopack/.
|
||||
|
||||
Only what the in-app updater (GiteaSource) reads from the latest
|
||||
release: this channel's releases.<channel>.json index and the nupkg
|
||||
payload it points to. The legacy RELEASES and assets.*.json manifests
|
||||
(Squirrel compat / setup bootstrap) are not uploaded.
|
||||
"""
|
||||
releases_dir = REPO_ROOT / "build" / "velopack"
|
||||
if not releases_dir.exists():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for pattern in ("releases.*.json", "*.nupkg"):
|
||||
files.extend(sorted(releases_dir.glob(pattern)))
|
||||
return files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -117,6 +141,18 @@ def gitea_headers(token: str) -> dict:
|
||||
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
|
||||
|
||||
def get_release_by_tag(
|
||||
client: httpx.Client, base_url: str, repo: str, tag: str
|
||||
) -> dict | None:
|
||||
"""Return the existing release for a tag, or None."""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{tag}"
|
||||
resp = client.get(url)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def create_release(
|
||||
client: httpx.Client,
|
||||
base_url: str,
|
||||
@@ -125,8 +161,12 @@ def create_release(
|
||||
version: str,
|
||||
notes: str,
|
||||
draft: bool,
|
||||
) -> int:
|
||||
"""Create a Gitea release and return its id."""
|
||||
) -> tuple[int, set[str]]:
|
||||
"""Create a Gitea release, or reuse the existing one for the tag.
|
||||
|
||||
Returns (release_id, names of assets already attached), so parallel
|
||||
platform builds can converge on one release without conflicts.
|
||||
"""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
@@ -137,11 +177,17 @@ def create_release(
|
||||
}
|
||||
resp = client.post(url, json=payload)
|
||||
if resp.status_code == 409:
|
||||
raise RuntimeError(f"A release for tag '{tag}' already exists on Gitea.")
|
||||
existing = get_release_by_tag(client, base_url, repo, tag)
|
||||
if existing is None:
|
||||
raise RuntimeError(f"Release for tag '{tag}' conflicts but cannot be read.")
|
||||
release_id = existing["id"]
|
||||
assets = {a["name"] for a in existing.get("assets", [])}
|
||||
print(f"Release for tag '{tag}' already exists (id={release_id}), reusing it.")
|
||||
return release_id, assets
|
||||
resp.raise_for_status()
|
||||
release_id = resp.json()["id"]
|
||||
print(f"Created release id={release_id} (draft={draft})")
|
||||
return release_id
|
||||
return release_id, set()
|
||||
|
||||
|
||||
def upload_asset(
|
||||
@@ -154,7 +200,10 @@ def upload_asset(
|
||||
"""Upload a file to the release and return the download URL."""
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
|
||||
mime = {
|
||||
".zip": "application/zip",
|
||||
".dmg": "application/x-apple-diskimage",
|
||||
}.get(path.suffix, "application/octet-stream")
|
||||
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
|
||||
with Path(path).open("rb") as fh:
|
||||
resp = client.post(
|
||||
@@ -185,16 +234,21 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--notes", default="", metavar="TEXT", help="Release notes body"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-dist",
|
||||
action="store_true",
|
||||
help="Skip wheel/sdist upload (for parallel platform builds; one job uploads them)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
cfg = load_gitea_config()
|
||||
token = load_token()
|
||||
|
||||
zips = find_releasable_zips()
|
||||
if not zips:
|
||||
artifacts = find_releasable_artifacts()
|
||||
if not artifacts:
|
||||
print(
|
||||
"No clean-versioned ZIPs found in build/.\n"
|
||||
"No clean-versioned platform artifacts found in build/.\n"
|
||||
"Run scripts/guibuild.py first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -202,28 +256,42 @@ def main() -> None:
|
||||
|
||||
# Validate all dist files exist before touching Gitea
|
||||
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)
|
||||
|
||||
base_url = cfg["url"].rstrip("/")
|
||||
repo = cfg["repo"]
|
||||
|
||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||
release_ids_by_version: dict[str, int] = {}
|
||||
for zip_path, version, platform_tag in zips:
|
||||
releases: dict[str, tuple[int, set[str]]] = {}
|
||||
for artifact_path, version, platform_tag in artifacts:
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
release_id = release_ids_by_version.get(version)
|
||||
if release_id is None:
|
||||
release_id = create_release(
|
||||
if version not in releases:
|
||||
releases[version] = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
release_ids_by_version[version] = release_id
|
||||
for path in dist_files[version]:
|
||||
release_id, uploaded = releases[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)
|
||||
|
||||
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}")
|
||||
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("\nDone. To publish to PyPI, run:")
|
||||
|
||||
Reference in New Issue
Block a user