diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 452837e..bcb4bd8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { + // 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}`); + } +} diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 4c10ed4..617b0ea 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -56,6 +56,18 @@ @keydown.escape="handleEscape" /> + +
+ +
@@ -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(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'; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index bf8fcea..abb88d7 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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); diff --git a/mediahive/__main__.py b/mediahive/__main__.py index 78e7e3b..40e5fda 100644 --- a/mediahive/__main__.py +++ b/mediahive/__main__.py @@ -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 diff --git a/mediahive/assets/mediahive.ico b/mediahive/assets/mediahive.ico new file mode 100644 index 0000000..eaceab1 Binary files /dev/null and b/mediahive/assets/mediahive.ico differ diff --git a/mediahive/config.py b/mediahive/config.py new file mode 100644 index 0000000..20ff159 --- /dev/null +++ b/mediahive/config.py @@ -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)) diff --git a/mediahive/hivescan/showreel.py b/mediahive/hivescan/showreel.py index 43d9807..049ef78 100644 --- a/mediahive/hivescan/showreel.py +++ b/mediahive/hivescan/showreel.py @@ -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, diff --git a/mediahive/models/protocol.py b/mediahive/models/protocol.py index 95a23f2..a407c72 100644 --- a/mediahive/models/protocol.py +++ b/mediahive/models/protocol.py @@ -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 # --------------------------------------------------------------------------- diff --git a/mediahive/server.py b/mediahive/server.py index 831dbaf..3ea6543 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -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)]) diff --git a/mediahive/winmain.py b/mediahive/winmain.py new file mode 100644 index 0000000..767e7ce --- /dev/null +++ b/mediahive/winmain.py @@ -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 = """ + +

MediaHive

Choose a folder that contains your media…

+""" + + +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() diff --git a/pyproject.toml b/pyproject.toml index 4a88007..1709e21 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", + "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", diff --git a/scripts/MediaHive.spec b/scripts/MediaHive.spec new file mode 100644 index 0000000..6a3aa79 --- /dev/null +++ b/scripts/MediaHive.spec @@ -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", +) diff --git a/scripts/winbuild.py b/scripts/winbuild.py new file mode 100644 index 0000000..05b8eaf --- /dev/null +++ b/scripts/winbuild.py @@ -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()