diff --git a/README.md b/README.md index c4904e1..f63c41a 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-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 diff --git a/docs/development.md b/docs/development.md index 8878e54..4bd3d95 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). `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, 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/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/winmain.py b/mediahive/winmain.py index d89534a..1ea75c9 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -39,6 +39,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 @@ -976,12 +977,51 @@ def _show_fatal_error(exc: BaseException) -> None: 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: """Run the GUI, rendering fatal exceptions as a TraceRite HTML window.""" - # Velopack runs the app with --veloapp-* hook arguments during install, - # update, and uninstall; these must exit fast instead of starting the GUI. - if any(arg.startswith("--veloapp-") for arg in sys.argv[1:]): - return + _velopack_startup() try: winmain() except Exception as exc: @@ -1250,6 +1290,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 8ba6e34..619d5c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ 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 diff --git a/scripts/guibuild.py b/scripts/guibuild.py index e567a0b..85e1177 100755 --- a/scripts/guibuild.py +++ b/scripts/guibuild.py @@ -1,5 +1,5 @@ #!/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: uv run scripts/winbuild.py @@ -10,10 +10,12 @@ 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 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 @@ -23,6 +25,7 @@ import shutil import stat import subprocess import sys +import tarfile import urllib.request import zipfile from pathlib import Path @@ -215,36 +218,119 @@ def fetch_vpk() -> Path: return vpk_dll -def _dotnet() -> str: - """Resolve the dotnet host with an SDK/modern runtime. +_DOTNET_STAGING = _REPO_ROOT / "build" / "dotnet" +# 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 - carry a stale environment without DOTNET_ROOT or scoop paths — so probe - known locations explicitly. + +def _dotnet_has_runtime(exe: Path) -> bool: + """Check that `exe` runs and has Microsoft.NETCore.App >= 8.""" + 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") if root: - candidates.append(Path(root)) + candidates.append(Path(root) / exe_name) if sys.platform == "win32": - candidates.append(Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current")) - exe_name = "dotnet.exe" if sys.platform == "win32" else "dotnet" - for base in candidates: - exe = base / exe_name - if exe.exists(): + candidates.append( + Path(r"C:\ProgramData\scoop\apps\dotnet-sdk\current") / exe_name + ) + which = shutil.which("dotnet") + 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 "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: - """Build a Velopack per-user Setup.exe from the PyInstaller output folder. +def build_velopack(version: str) -> Path: + """Build the Velopack installer/bundle for this platform. - Velopack installs to %LOCALAPPDATA% (no admin) and installed files carry - no Mark-of-the-Web, so the .NET CLR loads pythonnet/pywebview assemblies - that it refuses from a downloaded ZIP. + 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. """ - dist_folder = _REPO_ROOT / "build" / "MediaHive" + 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" + 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(): 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" cmd = [ - _dotnet(), + fetch_dotnet(), str(vpk_dll), "pack", "--packId", @@ -262,13 +348,15 @@ def build_setup(version: str) -> Path: "--packDir", str(dist_folder), "--mainExe", - "MediaHive.exe", + main_exe, "--packAuthors", "MediaHive", "--packTitle", "MediaHive", "--icon", - str(_ASSETS_DIR / "mediahive.ico"), + str(icon), + "--runtime", + rid, "--outputDir", str(releases_dir), ] @@ -283,10 +371,16 @@ def build_setup(version: str) -> Path: 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: - raise RuntimeError(f"vpk produced no Setup.exe in {releases_dir}") - artifact = _REPO_ROOT / "build" / f"MediaHive-{version}-{_platform_zip_suffix()}-setup.exe" + setup = next(iter(sorted(releases_dir.glob(f"*{artifact_ext}"))), None) + 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) setup.rename(artifact) return artifact @@ -353,33 +447,6 @@ def create_zip(version: str) -> 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: # Windows consoles default to cp1252, which can't encode ✓/✗ sys.stdout.reconfigure(errors="replace") @@ -400,18 +467,15 @@ def main() -> None: ) build_wheel() build_executable() - if sys.platform == "darwin": - artifact_path = create_dmg(version) - else: - artifact_path = create_zip(version) - - print(f"✓ Built successfully: {artifact_path}") - print(f" Size: {artifact_path.stat().st_size / (1024 * 1024):.1f} MB") + artifacts = [build_velopack(version)] 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") + # 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" 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 21059be..d098b0f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -67,10 +67,12 @@ def load_token() -> str: # ZIP + dist helpers # --------------------------------------------------------------------------- -# Matches MediaHive-1.2.3-win64.zip, MediaHive-1.2.3-win64-setup.exe, -# MediaHive-1.2.3-macos-arm64.dmg, etc. -# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64.zip -_CLEAN_ARTIFACT_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe)$") +# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe, +# 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-portable.zip +_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]]: @@ -113,6 +115,21 @@ 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/. + + 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 # --------------------------------------------------------------------------- @@ -266,6 +283,13 @@ def main() -> None: continue print(f"Uploading platform artifact: {platform_tag}") 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:")