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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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))
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)])
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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