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:
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user