diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..e1bd971 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 74241ef..0dfef6f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ package-lock.json # Dotfiles .* !.gitignore +!.gitea/ diff --git a/README.md b/README.md index c4904e1..062c21b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/development.md b/docs/development.md index 8878e54..ceeff21 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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/.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`. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b155b7c..a07f23e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise = {} - for (const [key, watch] of Object.entries( - rawEpisodes as Record, - )) { + for (const [key, watch] of Object.entries(rawEpisodes as Record)) { if (!watch || typeof watch !== "object") continue const w = watch as { pos?: unknown; done?: unknown } if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 44e434e..90eebff 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -29,7 +29,9 @@ @@ -208,7 +210,9 @@

Preferred Format

-

Preferred format when multiple versions are available.

+

+ Preferred format when multiple versions are available. +

@@ -295,6 +299,16 @@
+ +
+

Diagnostics

+

Application log for troubleshooting.

+ +
+ Application log +
+
{{ appLog }}
+
@@ -302,7 +316,7 @@ @@ -868,4 +921,31 @@ onUnmounted(() => { font-size: 0.8rem; color: #22c55e; } + +.diag-label { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.diag-log-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.diag-log { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-all; + width: 100%; + max-height: 320px; + overflow-y: auto; + margin: 0; + padding: 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + color: var(--text-secondary); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 55bb80b..10d20d3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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) { diff --git a/mediahive/assets/macos-installer-welcome.txt b/mediahive/assets/macos-installer-welcome.txt new file mode 100644 index 0000000..9a3482d --- /dev/null +++ b/mediahive/assets/macos-installer-welcome.txt @@ -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. diff --git a/mediahive/assets/mediahive.png b/mediahive/assets/mediahive.png new file mode 100644 index 0000000..c1eef81 Binary files /dev/null and b/mediahive/assets/mediahive.png differ diff --git a/mediahive/config.py b/mediahive/config.py index c74f98d..32e27a0 100644 --- a/mediahive/config.py +++ b/mediahive/config.py @@ -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: diff --git a/mediahive/server.py b/mediahive/server.py index 00af587..a169ca8 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -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,74 @@ async def health_check(): return {"status": "ok"} +@app.get("/api/version") +async def get_version(): + """Return the installed MediaHive package version.""" + try: + version = importlib.metadata.version("mediahive") + except importlib.metadata.PackageNotFoundError: + version = "dev" + return {"version": version} + + +def _read_log() -> str: + """Return the full application log file.""" + path = log_dir() / "mediahive.log" + if not path.exists(): + return "" + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + +@app.get("/api/log") +async def get_log(): + """Return the full application log file.""" + return PlainTextResponse(_read_log()) + + +@app.websocket("/api/log/ws") +async def ws_log(ws: WebSocket) -> None: + """Stream the application log: full log on connect and on every change.""" + await ws.accept() + last_sent: str | None = None + try: + while True: + current = _read_log() + if current != last_sent: + last_sent = current + await ws.send_text(current) + await asyncio.sleep(1.0) + except WebSocketDisconnect, OSError, RuntimeError: + pass + + +@app.post("/api/client-log", status_code=204) +async def post_client_log(request: Request): + """Append a client-side (webview) error report to client-errors.log.""" + try: + payload = msgspec.json.decode(await request.body()) + except msgspec.DecodeError: + payload = {} + message = str(payload.get("message") or "") + stack = payload.get("stack") + source = payload.get("source") + try: + dirpath = log_dir() + dirpath.mkdir(parents=True, exist_ok=True) + with (dirpath / "client-errors.log").open("a", encoding="utf-8") as f: + timestamp = datetime.now().isoformat(timespec="seconds") + f.write(f"[{timestamp}] {message}\n") + if source: + f.write(f" source: {source}\n") + if stack: + f.write(f" stack: {stack}\n") + except OSError: + pass + return Response(status_code=204) + + @app.get("/api/config") async def get_config(): """Return current server configuration.""" diff --git a/mediahive/winmain.py b/mediahive/winmain.py index e5e30b3..9b0d55e 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -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 = ( "" "MediaHive — Error" f"{fragment}" ) + 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( diff --git a/pyproject.toml b/pyproject.toml index ea38a64..fd56f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/scripts/MediaHive.spec b/scripts/MediaHive.spec index 30ae419..01bc923 100644 --- a/scripts/MediaHive.spec +++ b/scripts/MediaHive.spec @@ -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", ) diff --git a/scripts/guibuild.py b/scripts/guibuild.py index fb1bbfc..1474220 100755 --- a/scripts/guibuild.py +++ b/scripts/guibuild.py @@ -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..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..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"]*/>", + '', + xml, + ) + if count != 1: + raise RuntimeError("Unexpected distribution.xml: 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) diff --git a/scripts/release.py b/scripts/release.py index 053a712..bf4496a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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..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: - 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:")