Add Windows GUI

- mediahive/winmain.py: pywebview launcher with threaded uvicorn backend,
- scripts/winbuild.py: build script — downloads ffmpeg, runs PyInstaller, makes zip
- mediahive/config.py: TOML config persistence in %APPDATA%/mediahive/
- server.py: POST /api/change-folder switches media root
- showreel.py: suppress console windows for ffmpeg subprocesses on Windows
- protocol.py: add ChangeFolderRequest struct
This commit is contained in:
2026-05-14 04:27:46 +00:00
parent c831537cef
commit 5063bbc8c8
13 changed files with 687 additions and 13 deletions
+25
View File
@@ -87,3 +87,28 @@ export function getCoverUrl(coverPath: string | null): string {
return `/api/media${encodedPath}`;
}
/**
* Invoke the native OS folder picker via pywebview, then switch the server's
* media folder in-place and reload the page. Only works inside the packaged
* desktop app.
*/
export async function pickFolderAndRestart(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api;
if (!api) return;
const folder: string | null = await api.pick_folder();
if (!folder) return;
const res = await fetch('/api/change-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder }),
});
if (res.ok) {
// Give the server a moment to complete the background folder switch before reloading
setTimeout(() => window.location.reload(), 500);
} else {
const err = await res.json().catch(() => ({ detail: res.statusText }));
alert(`Failed to change folder: ${err.detail || res.statusText}`);
}
}
+25
View File
@@ -56,6 +56,18 @@
@keydown.escape="handleEscape"
/>
</div>
<div v-if="isDesktopApp" class="header-settings">
<button
class="header-settings-btn"
title="Change media folder"
@click="changeFolder"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</button>
</div>
</header>
</template>
@@ -64,6 +76,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { navAttrs } from '../composables/useKeyboardNavigation';
import logoUrl from '../assets/mediahive.webp';
import { pickFolderAndRestart } from '../api';
const props = defineProps<{
currentView: 'movies' | 'series';
@@ -82,6 +95,18 @@ const router = useRouter();
const searchInputRef = ref<HTMLInputElement | null>(null);
const localSearch = ref(props.searchQuery);
// True only when running inside the packaged pywebview desktop app.
// pywebview injects window.pywebview asynchronously, so we listen for the
// 'pywebviewready' event rather than checking at component creation time.
const isDesktopApp = ref(typeof (window as any).pywebview !== 'undefined');
function _onPywebviewReady() { isDesktopApp.value = true; }
window.addEventListener('pywebviewready', _onPywebviewReady, { once: true });
onUnmounted(() => window.removeEventListener('pywebviewready', _onPywebviewReady));
async function changeFolder() {
await pickFolderAndRestart();
}
// Check if we're on a detail page
const isDetailPage = computed(() => {
return props.position === 'after-movie-header' || props.position === 'after-series-hero';
+22
View File
@@ -152,6 +152,28 @@ html, body {
gap: 12px;
}
.header-settings {
display: flex;
align-items: center;
margin-left: 8px;
}
.header-settings-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
transition: color var(--transition-fast);
}
.header-settings-btn:hover {
color: var(--text-primary);
}
.search-input {
background: rgba(20, 20, 20, 0.9);
border: 2px solid var(--border-color);
+1 -1
View File
@@ -21,7 +21,7 @@ def resolve_media_root(path: str | None = None) -> Path:
mediaroot = Path(*rest).resolve()
if not mediaroot.exists() or not mediaroot.is_dir():
sys.stderr.write(f"Error: Folder does not exist: {mediaroot}\n")
exit(1)
sys.exit(1)
return mediaroot
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+48
View File
@@ -0,0 +1,48 @@
"""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)
"""
import os
import sys
from pathlib import Path
import msgspec
import msgspec.toml
class Config(msgspec.Struct):
media_folder: str | None = None
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"
def config_path() -> Path:
return config_dir() / "config.toml"
def load_config() -> Config:
path = config_path()
if path.exists():
try:
return msgspec.toml.decode(path.read_bytes(), type=Config)
except Exception:
return Config()
return Config()
def save_config(cfg: Config) -> None:
path = config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(msgspec.toml.encode(cfg))
+19 -10
View File
@@ -11,6 +11,8 @@ import json
import logging
import re
import shlex
import subprocess
import sys
from collections import Counter
from pathlib import Path
from typing import Optional
@@ -19,6 +21,13 @@ from aiopathlib import AsyncPath
logger = logging.getLogger("hivescan.showreel")
# Suppress console windows when spawning subprocesses on Windows
async def _subprocess_exec(*args, **kwargs):
"""Wrap asyncio.create_subprocess_exec to hide console windows on Windows."""
if sys.platform == "win32":
kwargs.setdefault("creationflags", subprocess.CREATE_NO_WINDOW)
return await asyncio.create_subprocess_exec(*args, **kwargs)
# Showreel timestamp positions in seconds (5, 10, 15, 20, 25 minutes)
SHOWREEL_TIMESTAMPS = [5 * 60, 10 * 60, 15 * 60, 20 * 60, 25 * 60]
@@ -142,7 +151,7 @@ async def get_av1_encoder() -> str:
# Check for NVIDIA AV1 encoder
try:
proc = await asyncio.create_subprocess_exec(
proc = await _subprocess_exec(
"ffmpeg",
"-hide_banner",
"-encoders",
@@ -152,7 +161,7 @@ async def get_av1_encoder() -> str:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
if b"av1_nvenc" in stdout:
# Verify it actually works (driver support)
test_proc = await asyncio.create_subprocess_exec(
test_proc = await _subprocess_exec(
"ffmpeg",
"-f",
"lavfi",
@@ -220,7 +229,7 @@ async def detect_dovi_profile(video_path: str) -> Optional[int]:
video_path,
]
logger.debug(" $ %s", shlex.join(cmd))
proc = await asyncio.create_subprocess_exec(
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -259,7 +268,7 @@ async def detect_dovi_profile(video_path: str) -> Optional[int]:
"csv=p=0",
video_path,
]
codec_proc = await asyncio.create_subprocess_exec(
codec_proc = await _subprocess_exec(
*codec_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -299,7 +308,7 @@ async def is_hdr_video(video_path: str) -> bool:
Returns True if the video has HDR metadata (bt2020, SMPTE ST 2084, etc.)
"""
try:
proc = await asyncio.create_subprocess_exec(
proc = await _subprocess_exec(
"ffprobe",
"-v",
"quiet",
@@ -368,7 +377,7 @@ async def detect_crop(video_path: str) -> Optional[str]:
video_path,
]
logger.debug(" $ %s", shlex.join(dim_cmd))
dim_proc = await asyncio.create_subprocess_exec(
dim_proc = await _subprocess_exec(
*dim_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -409,7 +418,7 @@ async def detect_crop(video_path: str) -> Optional[str]:
"-",
]
logger.debug(" $ %s", shlex.join(cmd))
proc = await asyncio.create_subprocess_exec(
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -494,7 +503,7 @@ async def get_video_duration(video_path: str) -> Optional[float]:
Get the duration of a video file in seconds using ffprobe.
"""
try:
proc = await asyncio.create_subprocess_exec(
proc = await _subprocess_exec(
"ffprobe",
"-v",
"quiet",
@@ -664,7 +673,7 @@ async def generate_showreel_images(
logger.debug(" $ %s", shlex.join(cmd))
try:
proc = await asyncio.create_subprocess_exec(
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -837,7 +846,7 @@ async def generate_episode_reel(
logger.debug(" $ %s", shlex.join(cmd))
try:
proc = await asyncio.create_subprocess_exec(
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
+6
View File
@@ -79,6 +79,12 @@ class OpenFolderRequest(msgspec.Struct):
folder_path: str = ""
class ChangeFolderRequest(msgspec.Struct):
"""POST /api/change-folder body."""
folder: str
# ---------------------------------------------------------------------------
# FastAPI response helper
# ---------------------------------------------------------------------------
+82 -2
View File
@@ -15,16 +15,26 @@ import sys
from contextlib import asynccontextmanager
from pathlib import Path
# Suppress console windows when spawning subprocesses on Windows
_POPEN_KWARGS: dict = (
{"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {}
)
import aiofiles
import msgspec
import msgspec.structs
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi_vue import Frontend
from mediahive.config import load_config, save_config
from mediahive.hivescan.scanner import start as start_scanner
from mediahive.hivescan.scanner import stop as stop_scanner
from mediahive.index_store import IndexStore
from mediahive.models.events import ScanEvent, Task, Upsert
from mediahive.models.protocol import (
ChangeFolderRequest,
MsgspecResponse,
PlayMediaRequest,
OpenFolderRequest,
@@ -145,6 +155,76 @@ async def health_check():
return {"status": "ok"}
@app.get("/api/config")
async def get_config():
"""Return current server configuration."""
return {"media_folder": str(MEDIAROOT) if MEDIAROOT else None}
@app.post("/api/change-folder")
async def change_folder_endpoint(request: Request):
"""Switch the media root folder without restarting the server.
Validates and persists the new folder, then returns immediately.
The actual in-memory switch runs as a background task so the HTTP
response is not held up by the (potentially slow) scanner teardown.
The client should poll /api/config or reload after a short delay.
"""
body = msgspec.json.decode(await request.body(), type=ChangeFolderRequest)
new_root = Path(body.folder).resolve()
if not new_root.exists() or not new_root.is_dir():
raise HTTPException(status_code=400, detail=f"Folder does not exist: {new_root}")
# Persist first — if the background switch crashes, the next launch still uses the new path
cfg = load_config()
save_config(msgspec.structs.replace(cfg, media_folder=str(new_root)))
logger.info("Config saved: media_folder=%s", new_root)
# Schedule the in-memory switch without blocking this response
asyncio.create_task(_switch_folder(new_root))
return {"status": "ok"}
async def _switch_folder(new_root: Path) -> None:
global MEDIAROOT, store, _consumer_task, _scan_events
try:
# Cancel scanner tasks immediately — no need to wait 30 s
await stop_scanner()
# Tear down the old event consumer
if _consumer_task and not _consumer_task.done():
_consumer_task.cancel()
try:
await _consumer_task
except asyncio.CancelledError:
pass
# Flush the old index snapshot
if store:
await store.flush_snapshot()
# Update env and module globals
os.environ["MEDIAHIVE_PATH"] = str(new_root)
MEDIAROOT = new_root
# Fresh event queue — discard any stale events from the old folder
_scan_events = asyncio.Queue()
# Re-initialise the index store
snapshot_path = MEDIAROOT / ".mediahive" / "index.json"
store = IndexStore(snapshot_path, media_root=str(MEDIAROOT))
await store.load_snapshot()
# Restart consumer and scanner
_consumer_task = asyncio.create_task(_consume_scan_events())
await start_scanner(_send_event)
logger.info("Switched media folder to %s", MEDIAROOT)
except Exception:
logger.exception("Error switching media folder to %s", new_root)
@app.get("/api/index")
async def get_index():
"""Return the full media index from the in-memory store."""
@@ -250,10 +330,10 @@ async def open_folder(request: Request):
if sys.platform == "win32":
if target_path.is_file():
# Open parent folder and select the file
subprocess.Popen(["explorer", "/select,", str(target_path)])
subprocess.Popen(["explorer", "/select,", str(target_path)], **_POPEN_KWARGS)
else:
# Open the folder directly
subprocess.Popen(["explorer", str(target_path)])
subprocess.Popen(["explorer", str(target_path)], **_POPEN_KWARGS)
elif sys.platform == "darwin":
if target_path.is_file():
subprocess.Popen(["open", "-R", str(target_path)])
+220
View File
@@ -0,0 +1,220 @@
"""GUI launcher for MediaHive using pywebview.
Run with: python -m mediahive.winmain [media_folder]
Or from PyInstaller: MediaHive.exe [media_folder]
"""
import argparse
import logging
import os
import sys
import threading
import time
import urllib.request
from pathlib import Path
import uvicorn
import webview
import msgspec.structs
from mediahive.__main__ import DEFAULT_PORT, resolve_media_root
from mediahive.config import Config, load_config, save_config
BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = 8420
BACKEND_URL = f"http://{BACKEND_HOST}:{BACKEND_PORT}"
HEALTH_TIMEOUT = 2 # seconds
def _setup_logging() -> Path:
"""Redirect stdout/stderr and configure logging to a log file in %APPDATA%/mediahive/.
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"
# Rotate: keep previous run as .log.1
prev = log_path.with_suffix(".log.1")
if log_path.exists():
if prev.exists():
prev.unlink()
log_path.rename(prev)
log_file = open(log_path, "w", encoding="utf-8", buffering=1) # line-buffered
# Redirect raw stdout/stderr so print() and tracebacks go to the file
sys.stdout = log_file
sys.stderr = log_file
# force=True removes handlers added by uvicorn/fastapi during import so that
# basicConfig actually takes effect (without it, it's a silent no-op)
logging.basicConfig(
force=True,
handlers=[logging.FileHandler(log_path, encoding="utf-8")],
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
logging.getLogger("mediahive.winmain").info("MediaHive started")
return log_path
# Minimal branded setup page shown while the native folder dialog is open.
_SETUP_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #141414; color: #fff;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; align-items: center; justify-content: center;
height: 100vh; text-align: center; }
h1 { font-size: 2rem; color: #e50914; margin-bottom: .5rem; }
p { color: #aaa; }
</style></head><body>
<div><h1>MediaHive</h1><p>Choose a folder that contains your media…</p></div>
</body></html>"""
class JsApi:
"""Python methods exposed to the frontend via window.pywebview.api."""
def __init__(self) -> None:
self._window: webview.Window | None = None
def pick_folder(self) -> str | None:
"""Open a native OS folder picker and return the chosen path (or None)."""
if not self._window:
return None
result = self._window.create_file_dialog(webview.FOLDER_DIALOG)
return result[0] if result else None
def _prepend_meipass_to_path() -> None:
"""When frozen, ensure bundled binaries (ffmpeg) are found first on PATH."""
if getattr(sys, "frozen", False):
meipass = sys._MEIPASS # type: ignore[attr-defined]
os.environ["PATH"] = meipass + os.pathsep + os.environ.get("PATH", "")
def _wait_for_backend(timeout: int = HEALTH_TIMEOUT) -> bool:
url = BACKEND_URL + "/api/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2):
return True
except Exception:
time.sleep(0.25)
return False
def _icon_path() -> str | None:
"""Locate the application icon at runtime (frozen or development)."""
if getattr(sys, "frozen", False):
base = Path(sys._MEIPASS) # type: ignore[attr-defined]
else:
base = Path(__file__).parent
ico = base / "assets" / "mediahive.ico"
return str(ico) if ico.exists() else None
def _run_initial_setup() -> str | None:
"""Show a setup window, prompt for a folder, then close and return the path.
Returns the chosen folder path, or None if the user cancelled.
The window is always destroyed before this function returns so winmain()
can continue without a process restart.
"""
chosen: list[str] = []
window = webview.create_window(
"MediaHive — Setup",
html=_SETUP_HTML,
width=520,
height=300,
resizable=False,
)
def on_shown() -> None:
result = window.create_file_dialog(webview.FOLDER_DIALOG)
if result:
chosen.append(result[0])
window.destroy()
webview.start(func=on_shown, icon=_icon_path())
return chosen[0] if chosen else None
def winmain() -> None:
parser = argparse.ArgumentParser(description="MediaHive")
parser.add_argument(
"media_folder",
nargs="?",
help="Path to the media folder (default: saved config, MEDIAHIVE_PATH, or cwd)",
)
args = parser.parse_args()
_prepend_meipass_to_path()
# In a frozen (windowed) build there is no console — redirect output to a log file
if getattr(sys, "frozen", False):
_setup_logging()
# Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user
folder = args.media_folder or os.environ.get("MEDIAHIVE_PATH") or load_config().media_folder
if not folder:
folder = _run_initial_setup()
if not folder:
return # user cancelled the folder picker
mediaroot = resolve_media_root(folder)
os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix()
# Persist the resolved path so subsequent launches remember it.
cfg = load_config()
if cfg.media_folder != mediaroot.as_posix():
save_config(msgspec.structs.replace(cfg, media_folder=mediaroot.as_posix()))
# Run the FastAPI backend on a background thread so the main thread is
# free for pywebview (Edge WebView2 requires the GUI on the main thread).
config = uvicorn.Config(
"mediahive.server:app",
host=BACKEND_HOST,
port=DEFAULT_PORT,
loop="asyncio",
log_level="warning",
)
server = uvicorn.Server(config)
backend_thread = threading.Thread(
target=server.run, daemon=True, name="mediahive-backend"
)
backend_thread.start()
if not _wait_for_backend():
server.should_exit = True
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
api = JsApi()
window = webview.create_window(
title="MediaHive",
url=BACKEND_URL,
fullscreen=True,
js_api=api,
)
def on_shown() -> None:
api._window = window
webview.start(func=on_shown, icon=_icon_path())
server.should_exit = True
backend_thread.join(timeout=10)
if __name__ == "__main__":
winmain()
+8
View File
@@ -13,6 +13,7 @@ dependencies = [
"httpx[http2]>=0.28.1",
"msgspec>=0.19",
"parse-torrent-title>=2.8.1",
"tomli-w>=1.2.0",
"uvicorn[standard]>=0.40.0",
]
@@ -37,6 +38,13 @@ package = true
[tool.uv.sources]
parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-title.git" }
[project.optional-dependencies]
gui = [
"pywebview>=6.2.1",
"pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'",
"pyinstaller>=6.0",
]
[dependency-groups]
dev = [
"httpx>=0.28.1",
+98
View File
@@ -0,0 +1,98 @@
# MediaHive.spec — PyInstaller build for the Windows GUI application
#
# Build manually (from repo root):
# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller ^
# pyinstaller --noconfirm --clean scripts/MediaHive.spec
#
# Or use the build script (recommended—handles versioning and packaging):
# uv run scripts/build_windows_gui.py
import mediahive.winmain
import mediahive.server
from pathlib import Path
block_cipher = None
_pkg = Path(mediahive.server.__file__).parent
_frontend_build = _pkg / "frontend-build"
_icon = _pkg / "assets" / "mediahive.ico"
_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe"
a = Analysis(
[mediahive.winmain.__file__],
pathex=[],
binaries=[
# Bundle ffmpeg so showreel generation works without a system install.
# Populated by build_windows_gui.py before PyInstaller runs.
(str(_ffmpeg), "."),
],
datas=[
# Bundled Vue frontend served by the FastAPI backend
(str(_frontend_build), "mediahive/frontend-build"),
(str(_icon), "mediahive/assets"),
],
hiddenimports=[
# uvicorn dynamic imports
"uvicorn.logging",
"uvicorn.loops",
"uvicorn.loops.auto",
"uvicorn.loops.asyncio",
"uvicorn.protocols",
"uvicorn.protocols.http",
"uvicorn.protocols.http.auto",
"uvicorn.protocols.http.h11_impl",
"uvicorn.protocols.websockets",
"uvicorn.protocols.websockets.auto",
"uvicorn.protocols.websockets.websockets_impl",
"uvicorn.lifespan",
"uvicorn.lifespan.on",
# mediahive & hivescan modules imported at runtime
"mediahive.server",
"mediahive.hivescan.scanner",
"mediahive.hivescan.indexer",
"mediahive.hivescan.scanning",
"mediahive.hivescan.images",
"mediahive.hivescan.showreel",
"mediahive.hivescan.tmdb_client",
# async / ASGI internals
"anyio",
"anyio._backends._asyncio",
"starlette.routing",
# msgspec TOML write backend
"tomli_w",
],
hookspath=[],
runtime_hooks=[],
excludes=[],
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="MediaHive",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
icon=str(_icon),
# windowed=True hides the console; the backend subprocess inherits this
console=False,
windowed=True,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name="MediaHive",
)
+133
View File
@@ -0,0 +1,133 @@
"""Build the Windows GUI application and package it as a version-numbered ZIP.
Usage:
uv run scripts/build_windows_gui.py
This runs in the project environment where dependencies are available via pyproject.toml.
This script:
1. Reads the version from pyproject.toml
2. Runs `uv build` to produce the wheel/sdist
3. Downloads the latest ffmpeg.exe
4. Builds MediaHive.exe using PyInstaller
5. Creates a ZIP file with the version number
"""
import io
import shutil
import subprocess
import sys
import tomllib
import urllib.request
import zipfile
from pathlib import Path
# BtbN automated builds always publish a 'latest' tag with this asset.
_FFMPEG_URL = (
"https://github.com/BtbN/ffmpeg-builds/releases/download/latest"
"/ffmpeg-master-latest-win64-gpl.zip"
)
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
def fetch_ffmpeg() -> Path:
"""Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/."""
dest = _FFMPEG_STAGING / "ffmpeg.exe"
if dest.exists():
print(f"ffmpeg already staged at {dest}, skipping download.")
return dest
_FFMPEG_STAGING.mkdir(parents=True, exist_ok=True)
print(f"Downloading ffmpeg from {_FFMPEG_URL} ...")
with urllib.request.urlopen(_FFMPEG_URL) as resp:
data = resp.read()
print("Extracting ffmpeg.exe ...")
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# The zip contains a top-level folder; ffmpeg.exe is under .../bin/
ffmpeg_entry = next(
name for name in zf.namelist()
if name.endswith("/bin/ffmpeg.exe")
)
with zf.open(ffmpeg_entry) as src, open(dest, "wb") as out:
out.write(src.read())
print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)")
return dest
def read_version() -> str:
"""Read version from pyproject.toml."""
pyproject = Path(__file__).parent.parent / "pyproject.toml"
with open(pyproject, "rb") as f:
data = tomllib.load(f)
return data["project"]["version"]
def build_wheel() -> None:
"""Run uv build to produce the wheel and sdist."""
repo_root = Path(__file__).parent.parent
cmd = ["uv", "build"]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"uv build failed with exit code {result.returncode}")
def build_exe() -> None:
"""Run PyInstaller to build the executable."""
repo_root = Path(__file__).parent.parent
spec_file = Path(__file__).parent / "MediaHive.spec"
cmd = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--distpath", str(repo_root / "build"),
"--workpath", str(repo_root / "build" / ".pyinstaller-work"),
str(spec_file),
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=repo_root)
if result.returncode != 0:
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
def create_zip(version: str) -> Path:
"""Create a version-numbered ZIP file of the dist/MediaHive folder."""
repo_root = Path(__file__).parent.parent
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}-win64.zip"
zip_path = repo_root / "build" / zip_name
zip_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Creating {zip_path}...")
shutil.make_archive(
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
"zip",
root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself
)
return zip_path
def main() -> None:
try:
version = read_version()
print(f"MediaHive version: {version}")
fetch_ffmpeg()
build_wheel()
build_exe()
zip_path = create_zip(version)
print(f"✓ Built successfully: {zip_path}")
print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB")
except Exception as e:
print(f"✗ Build failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()