29 Commits
Author SHA1 Message Date
LeoVasanko 039824f236 Upload only the consumed feed files; uniform nupkg names
release.py no longer uploads the legacy RELEASES and assets.*.json
manifests — the in-app updater reads only releases.<channel>.json plus
the nupkg it references. The Windows feed nupkg gains its -win channel
marker (vpk omits it for the legacy default channel), so all platforms
are uniformly mediahive-{ver}-{channel}-full.nupkg. The -full suffix is
Velopack's asset type and stays.
2026-09-23 02:37:59 +00:00
LeoVasanko f078e9bec0 Simplify release workflow; node and .NET are runner prerequisites
Drop the per-job uv-installed nodejs-wheel steps — runner hosts must
have git, uv, node/npm and the .NET SDK installed system-wide.
2026-09-23 02:32:14 +00:00
LeoVasanko ff493506ba Rewrite platform naming around a single descriptor
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Successful in 1m19s
release / gui-build (macos, bash) (push) Successful in 2m57s
Replace _platform_zip_suffix() and its overrides with a _Platform
NamedTuple (tag/rid/dist_dir/icon/main_exe/setup_ext) plus
setup_artifact_name(). create_zip() is Windows-only in practice, so it
is now create_portable_zip() with a fixed -win64-portable.zip name
instead of a generic name patched by string replace.
2026-09-23 02:22:18 +00:00
LeoVasanko 5f3ba47098 Simplify artifact names: -macos-setup.pkg, -linux-setup.AppImage
Arch markers dropped except win64: macOS builds are arm64-only and we
ship a single Linux flavor.
2026-09-23 02:15:21 +00:00
LeoVasanko 01c353502c Lowercase update-feed nupkgs in release assets
MediaHive-*-full.nupkg sorted between the capitalized Setup/AppImage
user downloads on the Gitea release page. Renamed to mediahive-* they
group with the wheel/sdist below the downloads; feed manifests
(RELEASES, releases.*.json, assets.*.json) are patched to match.
2026-09-23 02:08:33 +00:00
LeoVasanko 65fdae1546 Make macOS pkg per-user only (~/Applications)
System installs land in /Applications where Velopack's UpdateMac may not
be able to replace the .app during auto-update. Restricting the
distribution domains to currentUserHome skips the Destination Select
page and needs no admin rights; the postinstall script's sudo prefixes
are dropped since it already runs as the installing user.
2026-09-23 01:54:42 +00:00
LeoVasanko 832da6c315 Keep both macOS install domains (system and per-user)
Collapsing <domains> to localSystem did not remove the Destination
Select page (Installer shows it regardless) and only removed the
~/Applications option. Restore Velopack's default of both domains.
2026-09-23 01:52:18 +00:00
LeoVasanko 3142bc2cb2 Fix doubled platformdirs paths; pick newest dotnet runtime
release / gui-build (linux, bash) (push) Successful in 54s
release / gui-build (windows, cmd) (push) Successful in 1m24s
release / gui-build (macos, bash) (push) Successful in 1m47s
appauthor=False/opinion=False avoids mediahive\mediahive\Cache style
paths on Windows. fetch_dotnet now probes all known dotnet locations and
selects the highest Microsoft.NETCore.App major, requiring the version
vpk's TFM targets (10) — PATH on the Windows CI runner resolves to a
runtime-only .NET 8 while scoop holds the SDK 10.
2026-09-23 00:49:38 +00:00
LeoVasanko e514829737 Top-level imports; catch only expected exceptions
release / gui-build (linux, bash) (push) Successful in 56s
release / gui-build (windows, cmd) (push) Failing after 1m37s
release / gui-build (macos, bash) (push) Failing after 13m21s
velopack/tracerite/fastapi-vue are always available in the GUI build, so
drop the in-function import guards. The update checker now catches only
RuntimeError (not a Velopack install: dev/portable) and OSError
(network). Build script and PyInstaller spec use platformdirs for the
persistent tool cache.
2026-09-23 00:44:10 +00:00
LeoVasanko 50420984f1 Use platformdirs for config and log locations
Config and logs now live in local (non-roaming) app data on Windows
(%LOCALAPPDATA%\mediahive) instead of roaming. Existing files in the
old roaming location are left untouched; no migration.
2026-09-23 00:44:10 +00:00
LeoVasanko f9002b19a2 Require system .NET SDK; cache vpk/ffmpeg persistently
release / gui-build (linux, bash) (push) Failing after 50s
release / gui-build (macos, bash) (push) Successful in 1m39s
release / gui-build (windows, cmd) (push) Failing after 1m40s
Per-build downloads of the dotnet runtime (~80 MB) were slow and flaky
(IncompleteRead killed a CI run). The .NET SDK is now a build-host
prerequisite; vpk and ffmpeg download once into a user-level cache
(~/.cache/mediahive-build, %LOCALAPPDATA%\mediahive-build) that
survives the per-run build dir.
2026-09-23 00:28:11 +00:00
LeoVasanko 37f786f5ed Add welcome page text to macOS pkg installer
release / gui-build (linux, bash) (push) Failing after 51s
release / gui-build (macos, bash) (push) Successful in 1m43s
release / gui-build (windows, cmd) (push) Successful in 1m48s
Tells the user to allow the permission prompts macOS may show during
install and first launch. License page remains absent; Destination
Select was already stripped, leaving Introduction, Install, Summary.
2026-09-23 00:17:51 +00:00
LeoVasanko c64f54fe0f Strip Destination Select page from macOS pkg installer
release / gui-build (linux, bash) (push) Successful in 55s
release / gui-build (windows, cmd) (push) Successful in 1m39s
release / gui-build (macos, bash) (push) Successful in 1m36s
Collapse the Velopack-generated distribution.xml to a single install
domain so macOS Installer skips Destination Select, leaving Apple's
minimum pages (Introduction, Install, Summary). Welcome/license/readme/
conclusion pages are already absent (no --inst* options passed).
2026-09-23 00:12:32 +00:00
LeoVasanko bb88ab7d98 Fix tarfile.open call in dotnet bootstrap (fileobj=)
release / gui-build (linux, bash) (push) Successful in 1m4s
release / gui-build (windows, cmd) (push) Successful in 1m43s
release / gui-build (macos, bash) (push) Successful in 1m42s
2026-09-22 23:52:52 +00:00
LeoVasanko 303aabc181 Velopack packaging on all platforms with in-app auto-updates
release / gui-build (linux, bash) (push) Failing after 50s
release / gui-build (macos, bash) (push) Successful in 1m44s
release / gui-build (windows, cmd) (push) Successful in 1m48s
- macOS: .pkg installer replaces the DMG; Linux: .AppImage replaces the ZIP
- winmain runs velopack.App() first (proper hook handling) and checks for
  updates in the background; downloads are applied on next launch
- release.py uploads the vpk update feed (releases.<channel>.json, nupkgs)
  so GiteaSource finds updates on the latest release
- guibuild.py bootstraps a .NET runtime into build/dotnet when the runner
  host lacks one
2026-09-22 23:50:50 +00:00
LeoVasanko e8d2c5773a Exit fast on Velopack hook args; name ZIP artifact win64-portable
release / gui-build (macos, bash) (push) Successful in 1m14s
release / gui-build (linux, bash) (push) Successful in 1m13s
release / gui-build (windows, cmd) (push) Successful in 1m39s
Velopack runs the app with --veloapp-install/-updated/-uninstall during
those operations; without fast-exit handling the full GUI booted
mid-install (also clobbering config via the first-run setup flow) and
the installer reported hook errors. The post-install launch has no hook
args and still starts the app normally.
2026-09-22 23:28:19 +00:00
LeoVasanko 59d2f157eb Probe scoop dotnet-sdk path explicitly (runner service has stale env)
release / gui-build (linux, bash) (push) Successful in 1m3s
release / gui-build (macos, bash) (push) Successful in 1m5s
release / gui-build (windows, cmd) (push) Successful in 1m34s
2026-09-22 23:14:08 +00:00
LeoVasanko 3377702e98 Build Windows installer with Velopack instead of WiX
release / gui-build (linux, bash) (push) Successful in 1m13s
release / gui-build (windows, cmd) (push) Failing after 1m34s
release / gui-build (macos, bash) (push) Successful in 1m13s
vpk runs on the machine's modern .NET runtime (WiX 3.11's .NET Framework
shim fails under the SYSTEM account) and produces a per-user Setup.exe
that needs no runtime on end-user machines.
2026-09-22 23:11:30 +00:00
LeoVasanko 6dee65b4c6 Remove temporary Windows host probe workflow 2026-09-22 22:40:37 +00:00
LeoVasanko c59040ce52 Surface WiX tool stdout/stderr on MSI build failure
release / gui-build (linux, bash) (push) Successful in 1m11s
release / gui-build (windows, cmd) (push) Failing after 1m31s
release / gui-build (macos, bash) (push) Failing after 11m44s
2026-09-22 22:38:14 +00:00
LeoVasanko 734b7993a2 Add per-user MSI installer for Windows; strip Mark-of-the-Web in frozen ZIP builds
release / gui-build (linux, bash) (push) Successful in 1m12s
release / gui-build (macos, bash) (push) Successful in 1m14s
release / gui-build (windows, cmd) (push) Failing after 1m31s
Files extracted from a downloaded ZIP carry a Zone.Identifier stream and the
.NET Framework CLR refuses to load such assemblies, so pythonnet failed with
'Failed to resolve Python.Runtime.Loader.Initialize'. MSI-installed files have
no MOTW; the winmain strip fixes the ZIP distribution.
2026-09-22 22:28:16 +00:00
LeoVasanko b01e8a5c3d Exclude Python.Runtime.dll from UPX (packing corrupts CLR metadata)
release / gui-build (macos, bash) (push) Successful in 1m14s
release / gui-build (linux, bash) (push) Successful in 1m13s
release / gui-build (windows, cmd) (push) Successful in 1m21s
2026-09-22 22:00:57 +00:00
LeoVasanko b231f450bf Temporary Windows host probe workflow
release / gui-build (linux, bash) (push) Successful in 1m2s
release / gui-build (macos, bash) (push) Successful in 1m2s
release / gui-build (windows, cmd) (push) Successful in 1m42s
2026-09-22 04:59:53 +00:00
LeoVasanko 67489e05bc Use cmd shell for Windows steps; powershell is blocked by execution policy under SYSTEM
release / gui-build (windows, cmd) (push) Failing after 0s
release / gui-build (macos, bash) (push) Successful in 1m0s
release / gui-build (linux, bash) (push) Successful in 1m2s
2026-09-22 04:26:30 +00:00
LeoVasanko 3ae4ce9f9f Fix Windows checkout (powershell, not WSL bash) and install node per-job via uv
release / gui-build (windows) (push) Failing after 0s
release / gui-build (macos) (push) Successful in 1m12s
release / gui-build (linux) (push) Successful in 1m15s
2026-09-22 04:23:48 +00:00
LeoVasanko 99e757bc6f Checkout with plain git clone; JS actions need node on host runners
release / gui-build (windows) (push) Failing after 1s
release / gui-build (macos) (push) Successful in 1m8s
release / gui-build (linux) (push) Successful in 1m11s
2026-09-22 04:13:21 +00:00
LeoVasanko 5300fd0c9c Bundle PyQtWebEngine on macOS and ship the app as a DMG
release / gui-build (linux) (push) Successful in 1m13s
release / gui-build (macos) (push) Successful in 1m15s
release / gui-build (windows) (push) Failing after 1s
pywebview[qt] no longer depends on PyQtWebEngine, so fresh CI builds
produced a macOS app without its Chromium engine that crashed on launch.
Package MediaHive.app as a compressed DMG instead of zipping the raw
onedir folder; release.py accepts .dmg artifacts.
2026-09-22 04:07:30 +00:00
LeoVasanko ff10c86e84 Add Gitea Actions cross-platform release workflow
Build the GUI app on macos/windows/linux host runners on v* tag pushes.
All platform jobs converge on one Gitea release; release.py now reuses an
existing release, skips duplicate assets, and supports --no-dist so only
the linux job uploads the wheel/sdist.
2026-09-22 03:54:05 +00:00
LeoVasanko c11cbd4250 Fix console encoding crash in release script summary output 2026-09-10 21:33:43 +00:00
12 changed files with 581 additions and 102 deletions
+51
View File
@@ -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
+1
View File
@@ -19,3 +19,4 @@ package-lock.json
# Dotfiles
.*
!.gitignore
!.gitea/
+4 -3
View File
@@ -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
View File
@@ -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`.
@@ -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

+17 -13
View File
@@ -1,17 +1,19 @@
r"""Platform-appropriate config persistence for MediaHive.
Config file location:
Windows: %APPDATA%\mediahive\config.toml
macOS: ~/Library/Application Support/mediahive/config.toml
Linux: $XDG_CONFIG_HOME/mediahive/config.toml (~/.config/mediahive/config.toml)
Locations (via platformdirs):
Config — Windows: %LOCALAPPDATA%\mediahive\config.toml
macOS: ~/Library/Application Support/mediahive/config.toml
Linux: $XDG_CONFIG_HOME/mediahive/config.toml
Logs — Windows: %LOCALAPPDATA%\mediahive\mediahive.log
macOS: ~/Library/Logs/mediahive/mediahive.log
Linux: $XDG_STATE_HOME/mediahive/mediahive.log
"""
import os
import sys
from pathlib import Path
import msgspec
import msgspec.toml
from 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:
+70 -22
View File
@@ -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
fragment = str(html_traceback(exc))
page = (
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<title>MediaHive — Error</title></head>"
f"<body style='margin:1.5rem'>{fragment}</body></html>"
)
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
webview.start(icon=_icon_path(), **_webview_start_kwargs())
def _velopack_startup() -> None:
"""Handle Velopack install/update/uninstall hooks and pending updates.
Must be the first thing at startup: when Velopack launches the app with
--veloapp-* hook arguments (during install/update/uninstall), run()
executes the hook and exits the process, so the GUI never starts.
Applies downloaded-but-pending updates. No-op in development and
portable-ZIP runs.
"""
velopack.App().run()
def _check_for_updates() -> None:
"""Download available updates in the background.
Downloaded updates are applied automatically by Velopack on the next app
start (via _velopack_startup), so the running session is never
interrupted. Not a Velopack install (dev/portable) and network failures
are expected and skipped quietly.
"""
try:
webview.create_window("MediaHive — Error", html=page, width=1100, height=750)
webview.start(icon=_icon_path(), **_webview_start_kwargs())
except Exception:
logger.exception("Could not display the error window")
mgr = velopack.UpdateManager(velopack.GiteaSource(VELOPACK_REPO_URL))
info = mgr.check_for_updates()
if info is None:
logger.info("Velopack: no update available")
return
version = info.TargetFullRelease.Version
logger.info("Velopack: downloading update %s", version)
mgr.download_updates(info)
logger.info("Velopack: update %s staged, applies on next launch", version)
except (RuntimeError, OSError) as exc:
logger.info("Velopack update check skipped: %s", exc)
def gui_main() -> None:
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
_velopack_startup()
try:
winmain()
except Exception as exc:
@@ -1126,8 +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
View File
@@ -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",
]
@@ -52,8 +53,11 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
gui = [
"pywebview[qt]>=6.2.1; platform_system != 'Windows'",
"pywebview>=6.2.1; platform_system == 'Windows'",
"velopack>=1.2",
"qtpy>=2.4.1; platform_system == 'Darwin'",
"PyQt5>=5.15.11; platform_system == 'Darwin'",
# pywebview[qt] no longer pulls this in; the macOS Qt backend needs it
"PyQtWebEngine>=5.15.7; platform_system == 'Darwin'",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0",
]
+16 -3
View File
@@ -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 = []
@@ -123,6 +131,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 +143,7 @@ coll = COLLECT(
a.datas,
strip=False,
upx=True,
upx_exclude=[],
upx_exclude=_upx_exclude,
name="MediaHive",
)
+308 -30
View File
@@ -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,244 @@ 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.
"""
expanded = pkg.with_name(pkg.stem + "-expanded")
shutil.rmtree(expanded, ignore_errors=True)
# --expand-full also expands the component pkg, exposing its Scripts dir
subprocess.run(["pkgutil", "--expand-full", 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)
postinstalls = list(expanded.glob("*.pkg/Scripts/postinstall"))
if len(postinstalls) != 1:
raise RuntimeError(f"Unexpected pkg layout: postinstalls={postinstalls}")
postinstall = postinstalls[0]
script = postinstall.read_text()
postinstall.write_text(script.replace('sudo -u "$USER" ', ""))
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 +491,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 +532,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)
+100 -28
View File
@@ -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(
@@ -174,6 +223,10 @@ def upload_asset(
def main() -> None:
# Windows consoles default to cp1252, which can't encode ✓/✗
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
parser.add_argument(
"--draft", action="store_true", help="Create as a draft release"
@@ -181,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,
)
@@ -198,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:
dist_files[version] = find_dist_files(version)
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:")