Installers for all platforms and related fixes #1

Merged
LeoVasanko merged 38 commits from installer into main 2026-09-23 17:41:21 +00:00
7 changed files with 215 additions and 81 deletions
Showing only changes of commit 303aabc181 - Show all commits
+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. Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
**[Windows and Mac portable ZIP downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)** **[Windows, Mac and Linux downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
## Getting Started ## Getting Started
- Windows and macOS: Download the portable ZIP from the releases page, extract it anywhere, and run `MediaHive`. - Windows: Download `*-win64-setup.exe` from the releases page and run it (no admin needed; auto-updates included). A `-win64-portable.zip` is also available.
- Linux and other platforms: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`. - macOS: Download `*-macos-arm64-setup.pkg` and install (auto-updates included).
- Linux: Download the `.AppImage`, `chmod +x` it, and run. Alternatively install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
## What It Does ## What It Does
+3 -3
View File
@@ -1,6 +1,6 @@
# Development # Development
This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (portable ZIPs on Windows/macOS, `uvx --from mediahive[gui] mediahive` on Linux/other). This document covers the developer-facing ways to run MediaHive locally. The main [README.md](../README.md) covers end-user startup across platforms (installer/AppImage downloads, `uvx --from mediahive[gui] mediahive` on Linux/other).
## Requirements ## Requirements
@@ -44,8 +44,8 @@ This launches the same pywebview-based desktop flow used by the Windows build.
The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`): The helper scripts are directly executable via their `uv run` shebang (on Windows, run them with `uv run scripts/<name>.py`):
- `./scripts/guibuild.py` builds the PyInstaller desktop app and a versioned portable ZIP under `build/`. - `./scripts/guibuild.py` builds the PyInstaller desktop app and packages it with Velopack under `build/`: per-user `Setup.exe` (Windows), `.pkg` installer (macOS), `.AppImage` (Linux), plus the update feed in `build/velopack/`. On Windows it also creates a `-win64-portable.zip` (no auto-updates). `vpk` (and a .NET runtime, if the host lacks one) are downloaded into `build/` automatically.
- `./scripts/release.py` publishes a release to the Gitea releases page. - `./scripts/release.py` publishes a release to the Gitea releases page, uploading the platform artifacts and the Velopack update feed files — installed apps auto-update from the latest release.
Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`. Python packaging builds the frontend automatically through the hatch build hook `scripts/fastapi-vue/buildhook.py` (see `pyproject.toml`), so wheels and sdists always ship a fresh `mediahive/frontend-build`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

+48 -4
View File
@@ -39,6 +39,7 @@ HEALTH_TIMEOUT = 2 # seconds
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25 BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579" MPC_BE_URL = "http://127.0.0.1:13579"
VELOPACK_REPO_URL = "https://git.zi.fi/LeoVasanko/mediahive"
GAMEPAD_REPEAT_SECONDS = 0.008 GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008 GAMEPAD_POLL_SECONDS = 0.008
MPC_BE_FRAME_REPEAT_SECONDS = 0.016 MPC_BE_FRAME_REPEAT_SECONDS = 0.016
@@ -976,12 +977,51 @@ def _show_fatal_error(exc: BaseException) -> None:
logger.exception("Could not display the error window") logger.exception("Could not display the error window")
def _velopack_startup() -> None:
"""Handle Velopack install/update/uninstall hooks and pending updates.
Must be the first thing at startup: when Velopack launches the app with
--veloapp-* hook arguments (during install/update/uninstall), run()
executes the hook and exits the process, so the GUI never starts.
Applies downloaded-but-pending updates. No-op in development and
portable-ZIP runs.
"""
try:
import velopack
except ImportError:
return
try:
velopack.App().run()
except Exception:
logger.exception("Velopack startup hook failed")
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.
"""
try:
import velopack
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 Exception as exc: # not a Velopack install (dev/portable), network, ...
logger.info("Velopack update check skipped: %s", exc)
def gui_main() -> None: def gui_main() -> None:
"""Run the GUI, rendering fatal exceptions as a TraceRite HTML window.""" """Run the GUI, rendering fatal exceptions as a TraceRite HTML window."""
# Velopack runs the app with --veloapp-* hook arguments during install, _velopack_startup()
# update, and uninstall; these must exit fast instead of starting the GUI.
if any(arg.startswith("--veloapp-") for arg in sys.argv[1:]):
return
try: try:
winmain() winmain()
except Exception as exc: except Exception as exc:
@@ -1250,6 +1290,10 @@ def winmain() -> None:
server.should_exit = True server.should_exit = True
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s") raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
threading.Thread(
target=_check_for_updates, daemon=True, name="mediahive-update-check"
).start()
api = JsApi() api = JsApi()
logger.info("Configured pywebview backend: %s", _selected_webview_backend()) logger.info("Configured pywebview backend: %s", _selected_webview_backend())
window = webview.create_window( window = webview.create_window(
+1
View File
@@ -52,6 +52,7 @@ parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-titl
gui = [ gui = [
"pywebview[qt]>=6.2.1; platform_system != 'Windows'", "pywebview[qt]>=6.2.1; platform_system != 'Windows'",
"pywebview>=6.2.1; platform_system == 'Windows'", "pywebview>=6.2.1; platform_system == 'Windows'",
"velopack>=1.2",
"qtpy>=2.4.1; platform_system == 'Darwin'", "qtpy>=2.4.1; platform_system == 'Darwin'",
"PyQt5>=5.15.11; platform_system == 'Darwin'", "PyQt5>=5.15.11; platform_system == 'Darwin'",
# pywebview[qt] no longer pulls this in; the macOS Qt backend needs it # pywebview[qt] no longer pulls this in; the macOS Qt backend needs it
+129 -65
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
"""Build the desktop GUI application and package it as a versioned ZIP/DMG. """Build the desktop GUI application and package it with Velopack.
Usage: Usage:
uv run scripts/winbuild.py uv run scripts/winbuild.py
@@ -10,10 +10,12 @@ are available via pyproject.toml.
This script: This script:
1. Reads the version from pyproject.toml 1. Reads the version from pyproject.toml
2. Runs `uv build` to produce the wheel/sdist 2. Runs `uv build` to produce the wheel/sdist
3. On Windows, downloads the latest ffmpeg.exe for bundling 3. On Windows/macOS, downloads the ffmpeg binary for bundling
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
4. Builds MediaHive using PyInstaller 4. Builds MediaHive using PyInstaller
5. Creates a versioned ZIP (Windows/Linux) or DMG (macOS) artifact 5. Packages with Velopack: Setup.exe (Windows), .pkg (macOS),
.AppImage (Linux), plus the update feed in build/velopack/
that release.py uploads for in-app auto-updates
6. On Windows, also creates a portable ZIP (no auto-updates)
""" """
import io import io
@@ -23,6 +25,7 @@ import shutil
import stat import stat
import subprocess import subprocess
import sys import sys
import tarfile
import urllib.request import urllib.request
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -215,36 +218,119 @@ def fetch_vpk() -> Path:
return vpk_dll return vpk_dll
def _dotnet() -> str: _DOTNET_STAGING = _REPO_ROOT / "build" / "dotnet"
"""Resolve the dotnet host with an SDK/modern runtime. # aka.ms latest-runtime archives per platform (vpk needs .NET >= 8).
_DOTNET_RUNTIME_URLS = {
("win32", "x64"): "https://aka.ms/dotnet/10.0/dotnet-runtime-win-x64.zip",
("darwin", "arm64"): "https://aka.ms/dotnet/10.0/dotnet-runtime-osx-arm64.tar.gz",
("linux", "x64"): "https://aka.ms/dotnet/10.0/dotnet-runtime-linux-x64.tar.gz",
}
A plain `dotnet` may resolve to a runtime-only installation (e.g.
C:\\Program Files\\dotnet), and long-running services (CI runners) may def _dotnet_has_runtime(exe: Path) -> bool:
carry a stale environment without DOTNET_ROOT or scoop paths — so probe """Check that `exe` runs and has Microsoft.NETCore.App >= 8."""
known locations explicitly. try:
result = subprocess.run(
[str(exe), "--list-runtimes"], capture_output=True, text=True, timeout=30
)
except (OSError, subprocess.TimeoutExpired):
return False
if result.returncode != 0:
return False
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] == "Microsoft.NETCore.App":
try:
if int(parts[1].split(".")[0]) >= 8:
return True
except ValueError:
continue
return False
def fetch_dotnet() -> str:
"""Resolve a dotnet host with a modern (>= 8) runtime, bootstrapping one
into build/dotnet/ if none is found.
A plain `dotnet` may resolve to a runtime-only installation, and
long-running services (CI runners) may carry a stale environment without
DOTNET_ROOT or scoop paths — so probe known locations explicitly.
""" """
candidates = [] exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet"
candidates: list[Path] = []
root = os.environ.get("DOTNET_ROOT") root = os.environ.get("DOTNET_ROOT")
if root: if root:
candidates.append(Path(root)) candidates.append(Path(root) / exe_name)
if sys.platform == "win32": if sys.platform == "win32":
candidates.append(Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current")) candidates.append(
exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet" Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name
for base in candidates: )
exe = base / exe_name which = shutil.which("dotnet")
if exe.exists(): if which:
candidates.append(Path(which))
staged = _DOTNET_STAGING / exe_name
candidates.append(staged)
for exe in candidates:
if exe.exists() and _dotnet_has_runtime(exe):
print(f"Using dotnet at {exe}")
return str(exe) return str(exe)
return "dotnet"
machine = platform.machine().lower()
arch = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"}.get(
machine, machine
)
url = _DOTNET_RUNTIME_URLS.get((sys.platform, arch))
if url is None:
raise RuntimeError(f"No dotnet runtime download for {sys.platform}/{arch}")
print(f"Downloading dotnet runtime from {url} ...")
with urllib.request.urlopen(url) as resp:
data = resp.read()
_DOTNET_STAGING.mkdir(parents=True, exist_ok=True)
if url.endswith(".zip"):
with zipfile.ZipFile(io.BytesIO(data)) as zf:
zf.extractall(_DOTNET_STAGING)
else:
with tarfile.open(io.BytesIO(data), "r:gz") as tf:
tf.extractall(_DOTNET_STAGING)
staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
if not _dotnet_has_runtime(staged):
raise RuntimeError(f"Bootstrapped dotnet at {staged} failed runtime check")
print(f"dotnet staged at {_DOTNET_STAGING}")
return str(staged)
def build_setup(version: str) -> Path: def build_velopack(version: str) -> Path:
"""Build a Velopack per-user Setup.exe from the PyInstaller output folder. """Build the Velopack installer/bundle for this platform.
Velopack installs to %LOCALAPPDATA% (no admin) and installed files carry Windows: per-user Setup.exe. macOS: .pkg installer. Linux: .AppImage.
no Mark-of-the-Web, so the .NET CLR loads pythonnet/pywebview assemblies Also produces the update feed (releases.<channel>.json, *.nupkg) in
that it refuses from a downloaded ZIP. 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.
""" """
if sys.platform == "darwin":
dist_folder = _REPO_ROOT / "build" / "MediaHive.app"
icon = _ASSETS_DIR / "mediahive.icns"
rid = "osx-arm64"
main_exe = "MediaHive"
artifact_ext = ".pkg"
elif sys.platform == "win32":
dist_folder = _REPO_ROOT / "build" / "MediaHive" dist_folder = _REPO_ROOT / "build" / "MediaHive"
icon = _ASSETS_DIR / "mediahive.ico"
rid = "win-x64"
main_exe = "MediaHive.exe"
artifact_ext = ".exe"
else:
dist_folder = _REPO_ROOT / "build" / "MediaHive"
icon = _ASSETS_DIR / "mediahive.png"
rid = "linux-x64"
main_exe = "MediaHive"
artifact_ext = ".AppImage"
if not dist_folder.exists(): if not dist_folder.exists():
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}") raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
@@ -252,7 +338,7 @@ def build_setup(version: str) -> Path:
releases_dir = _REPO_ROOT / "build" / "velopack" releases_dir = _REPO_ROOT / "build" / "velopack"
cmd = [ cmd = [
_dotnet(), fetch_dotnet(),
str(vpk_dll), str(vpk_dll),
"pack", "pack",
"--packId", "--packId",
@@ -262,13 +348,15 @@ def build_setup(version: str) -> Path:
"--packDir", "--packDir",
str(dist_folder), str(dist_folder),
"--mainExe", "--mainExe",
"MediaHive.exe", main_exe,
"--packAuthors", "--packAuthors",
"MediaHive", "MediaHive",
"--packTitle", "--packTitle",
"MediaHive", "MediaHive",
"--icon", "--icon",
str(_ASSETS_DIR / "mediahive.ico"), str(icon),
"--runtime",
rid,
"--outputDir", "--outputDir",
str(releases_dir), str(releases_dir),
] ]
@@ -283,10 +371,16 @@ def build_setup(version: str) -> Path:
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
) )
setup = next(releases_dir.glob("*-win-Setup.exe"), None) setup = next(iter(sorted(releases_dir.glob(f"*Setup*{artifact_ext}"))), None)
if setup is None: if setup is None:
raise RuntimeError(f"vpk produced no Setup.exe in {releases_dir}") setup = next(iter(sorted(releases_dir.glob(f"*{artifact_ext}"))), None)
artifact = _REPO_ROOT / "build" / f"MediaHive-{version}-{_platform_zip_suffix()}-setup.exe" if setup is None:
raise RuntimeError(f"vpk produced no *{artifact_ext} in {releases_dir}")
artifact = (
_REPO_ROOT
/ "build"
/ f"MediaHive-{version}-{_platform_zip_suffix()}-setup{artifact_ext}"
)
artifact.unlink(missing_ok=True) artifact.unlink(missing_ok=True)
setup.rename(artifact) setup.rename(artifact)
return artifact return artifact
@@ -353,33 +447,6 @@ def create_zip(version: str) -> Path:
return zip_path return zip_path
def create_dmg(version: str) -> Path:
"""Create a version-numbered DMG containing MediaHive.app (macOS only)."""
repo_root = _REPO_ROOT
app_bundle = repo_root / "build" / "MediaHive.app"
if not app_bundle.exists():
raise FileNotFoundError(f"App bundle not found: {app_bundle}")
dmg_path = repo_root / "build" / f"MediaHive-{version}-{_platform_zip_suffix()}.dmg"
dmg_path.parent.mkdir(parents=True, exist_ok=True)
dmg_path.unlink(missing_ok=True)
cmd = [
"hdiutil", "create",
"-volname", "MediaHive",
"-srcfolder", str(app_bundle),
"-ov",
"-format", "UDZO",
str(dmg_path),
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"hdiutil failed with exit code {result.returncode}")
return dmg_path
def main() -> None: def main() -> None:
# Windows consoles default to cp1252, which can't encode ✓/✗ # Windows consoles default to cp1252, which can't encode ✓/✗
sys.stdout.reconfigure(errors="replace") sys.stdout.reconfigure(errors="replace")
@@ -400,18 +467,15 @@ def main() -> None:
) )
build_wheel() build_wheel()
build_executable() build_executable()
if sys.platform == "darwin":
artifact_path = create_dmg(version)
else:
artifact_path = create_zip(version)
artifacts = [build_velopack(version)]
if sys.platform == "win32":
# Velopack-less plain-folder distribution (with MOTW strip)
artifacts.append(create_zip(version))
for artifact_path in artifacts:
print(f"✓ Built successfully: {artifact_path}") print(f"✓ Built successfully: {artifact_path}")
print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB") print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB")
if sys.platform == "win32":
setup_path = build_setup(version)
print(f"✓ Built successfully: {setup_path}")
print(f" Size: {setup_path.stat().st_size / (1024 * 1024):.1f} MB")
except (FileNotFoundError, OSError, RuntimeError, ValueError) as e: except (FileNotFoundError, OSError, RuntimeError, ValueError) as e:
print(f"✗ Build failed: {e}", file=sys.stderr) print(f"✗ Build failed: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+28 -4
View File
@@ -67,10 +67,12 @@ def load_token() -> str:
# ZIP + dist helpers # ZIP + dist helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-win64-setup.exe, # Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
# MediaHive-1.2.3-macos-arm64.dmg, etc. # MediaHive-1.2.3-macos-arm64-setup.pkg, MediaHive-1.2.3-linux-x64.AppImage, etc.
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip # Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64-portable.zip
_CLEAN_ARTIFACT_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe)$") _CLEAN_ARTIFACT_RE = re.compile(
r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe|pkg|AppImage)$"
)
def find_releasable_artifacts() -> list[tuple[Path, str, str]]: def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
@@ -113,6 +115,21 @@ def find_dist_files(version: str) -> list[Path]:
return [wheel, sdist] return [wheel, sdist]
def find_velopack_feed_files() -> list[Path]:
"""Velopack update feed files produced by vpk pack in build/velopack/.
These keep their original names — the feed JSONs reference them — and
in-app updates (GiteaSource) download them from the latest release.
"""
releases_dir = REPO_ROOT / "build" / "velopack"
if not releases_dir.exists():
return []
files: list[Path] = []
for pattern in ("RELEASES", "releases.*.json", "assets.*.json", "*.nupkg"):
files.extend(sorted(releases_dir.glob(pattern)))
return files
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Gitea API helpers # Gitea API helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -266,6 +283,13 @@ def main() -> None:
continue continue
print(f"Uploading platform artifact: {platform_tag}") print(f"Uploading platform artifact: {platform_tag}")
upload_asset(client, base_url, repo, release_id, artifact_path) upload_asset(client, base_url, repo, release_id, artifact_path)
uploaded.add(artifact_path.name)
for feed_file in find_velopack_feed_files():
if feed_file.name in uploaded:
print(f"Skipping {feed_file.name}, already on the release.")
continue
upload_asset(client, base_url, repo, release_id, feed_file)
uploaded.add(feed_file.name)
print(f"{tag} published") print(f"{tag} published")
print("\nDone. To publish to PyPI, run:") print("\nDone. To publish to PyPI, run:")