Files
mediahive/mediahive/winmain.py
T

1042 lines
36 KiB
Python

"""GUI launcher for MediaHive using pywebview.
Run with: python -m mediahive.winmain [media_folder]
Or from PyInstaller: MediaHive.exe [media_folder]
"""
import argparse
import asyncio
import contextlib
import ctypes
import html
import json
import logging
import os
import re
import socket
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
import msgspec.structs
import uvicorn
import webview
from mediahive.config import load_config, save_config
from mediahive.volume_control import get_volume, set_volume, volume_max
logger = logging.getLogger("mediahive.winmain")
BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = 8420
HEALTH_TIMEOUT = 2 # seconds
BACKEND_HEALTH_REQUEST_TIMEOUT = 2 # seconds
BACKEND_HEALTH_POLL_SECONDS = 0.25
MPC_BE_URL = "http://127.0.0.1:13579"
GAMEPAD_REPEAT_SECONDS = 0.008
GAMEPAD_POLL_SECONDS = 0.008
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
MPC_BE_SEEK_BEGIN_HOLD_SECONDS = 1.0
MPC_BE_REQUEST_TIMEOUT = 0.15
MPC_BE_MAX_INFLIGHT_REQUESTS = 12
MPC_BE_REQUEST_WORKERS = 4
MPC_BE_STATUS_POLL_SECONDS = 0.1
MPC_BE_STATUS_MISS_THRESHOLD = 5
MPC_BE_STATE_STOPPED = 0
MPC_BE_STATE_PAUSED = 1
MPC_BE_STATE_RUNNING = 2
MPC_BE_SEEK_BEGIN_COMMAND = 1085
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000
MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
VOLUME_MIN = 0.0
VOLUME_MAX = 1.5
VOLUME_STEP = 0.01
VOLUME_REPEAT_SECONDS = 0.02
class _XINPUT_GAMEPAD(ctypes.Structure):
_fields_ = [
("wButtons", ctypes.c_ushort),
("bLeftTrigger", ctypes.c_ubyte),
("bRightTrigger", ctypes.c_ubyte),
("sThumbLX", ctypes.c_short),
("sThumbLY", ctypes.c_short),
("sThumbRX", ctypes.c_short),
("sThumbRY", ctypes.c_short),
]
class _XINPUT_STATE(ctypes.Structure):
_fields_ = [
("dwPacketNumber", ctypes.c_ulong),
("Gamepad", _XINPUT_GAMEPAD),
]
_XINPUT_BUTTONS = {
0x0001: "DPAD_UP",
0x0002: "DPAD_DOWN",
0x0004: "DPAD_LEFT",
0x0008: "DPAD_RIGHT",
0x0010: "START",
0x0020: "BACK",
0x0040: "L3",
0x0080: "R3",
0x0100: "LB",
0x0200: "RB",
0x1000: "A",
0x2000: "B",
0x4000: "X",
0x8000: "Y",
}
_MPC_BE_COMMANDS = {
0x1000: 889,
0x2000: 816,
0x8000: 909,
}
_MPC_BE_SEEK_MASK_TO_COMMANDS = {
0x0004: (892, 901),
0x0008: (891, 902),
}
_MPC_BE_REPEATABLE_MASKS = {
*_MPC_BE_SEEK_MASK_TO_COMMANDS,
}
_STATE_RE = re.compile(r'<p id="state">(\d+)</p>')
_FILEPATH_RE = re.compile(r'<p id="filepath">(.*?)</p>', re.DOTALL)
_POSITION_RE = re.compile(r'<p id="position">(\d+)</p>')
_DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
def _default_playback_state() -> dict[str, object]:
return {
"current": None,
}
def _normalize_media_path(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _expand_playable_file(file_key: str, playable_file: str | None) -> str:
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
if playable_file.startswith(f"{file_key}/"):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
def _fetch_resume_positions(backend_url: str) -> dict[str, int]:
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=2) as resp:
raw = json.loads(resp.read().decode("utf-8"))
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
return {}
data = raw.get("data") if isinstance(raw, dict) else None
positions = data.get("resume_positions") if isinstance(data, dict) else None
if not isinstance(positions, dict):
return {}
cleaned: dict[str, int] = {}
for slug, value in positions.items():
if not isinstance(slug, str) or not isinstance(value, dict):
continue
pos = value.get("pos")
if isinstance(pos, int) and pos > 0:
cleaned[slug] = pos * 1000
return cleaned
def _post_resume_position(
backend_url: str,
root_id: str,
file_path: str,
pos: int | None,
) -> bool:
body = json.dumps({
"root_id": root_id,
"file_path": file_path,
"pos": pos,
}).encode("utf-8")
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=2):
return True
except OSError, TimeoutError, urllib.error.URLError:
logger.warning("Failed to post playback-state update for %s", file_path)
return False
def _load_movie_slug_map(index_path: Path) -> dict[str, str]:
try:
raw = json.loads(index_path.read_text(encoding="utf-8"))
except OSError, TypeError, json.JSONDecodeError:
return {}
movies = raw.get("movies") if isinstance(raw, dict) else None
if not isinstance(movies, dict):
return {}
mapping: dict[str, str] = {}
for movie_id, movie in movies.items():
if not isinstance(movie_id, str) or not isinstance(movie, dict):
continue
files = movie.get("files")
if not isinstance(files, dict):
continue
for file_key, torrent in files.items():
if not isinstance(file_key, str):
continue
normalized_key = _normalize_media_path(file_key)
mapping[normalized_key] = movie_id
playable_file = (
torrent.get("playable_file") if isinstance(torrent, dict) else None
)
expanded = _expand_playable_file(
file_key, playable_file if isinstance(playable_file, str) else None
)
mapping[_normalize_media_path(expanded)] = movie_id
return mapping
def _media_key_for_filepath(
filepath: str, roots: dict[str, Path]
) -> tuple[str | None, str, str] | None:
"""Resolve a filepath to a (movie_slug, root_id, relative_key) tuple."""
for root_id, root in roots.items():
try:
relative = Path(filepath).resolve().relative_to(root.resolve())
relative_key = relative.as_posix()
index_path = root / ".mediahive" / "index.json"
movie_slug = _load_movie_slug_map(index_path).get(
_normalize_media_path(relative_key)
)
return movie_slug, root_id, relative_key
except OSError, RuntimeError, ValueError:
continue
return None
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
if duration_ms <= 0:
return False
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
def _load_xinput_get_state():
"""Load XInputGetState from available XInput DLLs (XInput only)."""
candidates = ["xinput1_4.dll", "xinput9_1_0.dll", "xinput1_3.dll"]
for dll_name in candidates:
try:
dll = ctypes.WinDLL(dll_name)
fn = dll.XInputGetState
fn.argtypes = [ctypes.c_uint, ctypes.POINTER(_XINPUT_STATE)]
fn.restype = ctypes.c_ulong
return fn
except AttributeError, OSError:
continue
raise RuntimeError("XInput DLL not found")
def _mpcbe_request(path: str, timeout: float = MPC_BE_REQUEST_TIMEOUT) -> bool:
"""Call MPC-BE's local web interface and return True on HTTP success."""
req = urllib.request.Request(url=f"{MPC_BE_URL}{path}", method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except urllib.error.URLError, TimeoutError, OSError:
return False
def _send_mpcbe_command(command_id: int) -> bool:
return _mpcbe_request(f"/command.html?wm_command={command_id}")
def _format_mpcbe_position(position_ms: int) -> str:
total_seconds = max(0, position_ms // 1000)
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def _seek_mpcbe_to_position(position_ms: int) -> bool:
query = urllib.parse.urlencode({
"wm_command": -1,
"position": _format_mpcbe_position(position_ms),
})
return _mpcbe_request(f"/command.html?{query}")
def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
"""Fetch current file path, position, duration, and playback state from MPC-BE."""
req = urllib.request.Request(url=f"{MPC_BE_URL}/variables.html", method="GET")
try:
with urllib.request.urlopen(req, timeout=MPC_BE_REQUEST_TIMEOUT) as resp:
response_html = resp.read().decode("utf-8", errors="replace")
except urllib.error.URLError, TimeoutError, OSError:
return None
state_match = _STATE_RE.search(response_html)
filepath_match = _FILEPATH_RE.search(response_html)
position_match = _POSITION_RE.search(response_html)
duration_match = _DURATION_RE.search(response_html)
if not state_match or not position_match or not duration_match:
return None
filepath = html.unescape(filepath_match.group(1)).strip() if filepath_match else ""
return (
filepath,
int(position_match.group(1)),
int(duration_match.group(1)),
int(state_match.group(1)),
)
def _start_gamepad_remote(
stop_event: threading.Event, roots: dict[str, Path], backend_url: str
) -> threading.Thread:
"""Start background XInput polling and send mapped commands to MPC-BE."""
get_state = _load_xinput_get_state()
last_connected = [False, False, False, False]
last_pressed_masks = [0, 0, 0, 0]
seek_begin_hold_started_at: list[float | None] = [None, None, None, None]
seek_begin_fired = [False, False, False, False]
last_volume_repeat_up_at = [0.0, 0.0, 0.0, 0.0]
last_volume_repeat_down_at = [0.0, 0.0, 0.0, 0.0]
last_repeat_at = [
dict.fromkeys(
(*_MPC_BE_COMMANDS.keys(), *_MPC_BE_SEEK_MASK_TO_COMMANDS.keys()), 0.0
)
for _ in range(4)
]
request_pool = ThreadPoolExecutor(
max_workers=MPC_BE_REQUEST_WORKERS,
thread_name_prefix="mediahive-mpcbe",
)
pending_requests: list[Future[bool]] = []
status_lock = threading.Lock()
status_future: Future[tuple[str, int, int, int] | None] | None = None
player_filepath = ""
player_position_ms: int | None = None
player_duration_ms: int | None = None
player_state: int | None = None
status_updated_at = 0.0
status_miss_count = 0
playback_state = _default_playback_state()
resume_positions = _fetch_resume_positions(backend_url)
tracked_media_key: str | None = None
tracked_root_id: str | None = None
tracked_relative_path = ""
tracked_filepath = ""
resume_applied_for_key: str | None = None
last_playback_state_flush_at = 0.0
def queue_command(command_id: int) -> None:
pending_requests.append(request_pool.submit(_send_mpcbe_command, command_id))
def queue_seek_to_position(position_ms: int) -> None:
pending_requests.append(
request_pool.submit(_seek_mpcbe_to_position, position_ms)
)
def clear_tracked_current(*, clear_resume_applied: bool) -> None:
nonlocal \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
last_playback_state_flush_at, \
resume_applied_for_key
if tracked_media_key is None and playback_state.get("current") is None:
if clear_resume_applied:
resume_applied_for_key = None
return
tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = ""
playback_state["current"] = None
last_playback_state_flush_at = 0.0
if clear_resume_applied:
resume_applied_for_key = None
def finalize_tracked_current() -> None:
nonlocal \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
resume_applied_for_key, \
last_playback_state_flush_at
if tracked_media_key is None:
if playback_state.get("current") is not None:
playback_state["current"] = None
return
position_ms = player_position_ms or 0
duration_ms = player_duration_ms or 0
if _should_clear_resume(position_ms, duration_ms):
resume_positions.pop(tracked_media_key, None)
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url, tracked_root_id, tracked_relative_path, None
)
elif position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
# Ignore brief starts; keep the previous saved resume position.
pass
else:
position_seconds = max(0, position_ms // 1000)
resume_positions[tracked_media_key] = position_ms
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url,
tracked_root_id,
tracked_relative_path,
position_seconds,
)
tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = ""
playback_state["current"] = None
resume_applied_for_key = None
last_playback_state_flush_at = 0.0
def persist_tracked_current(now: float, *, force: bool = False) -> None:
nonlocal last_playback_state_flush_at
if tracked_media_key is None:
return
if (
not force
and now - last_playback_state_flush_at < MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS
):
return
playback_state["current"] = {
"file_key": tracked_media_key,
"file_path": tracked_filepath,
"position_ms": player_position_ms or 0,
"duration_ms": player_duration_ms or 0,
"updated_at": int(time.time()),
}
last_playback_state_flush_at = now
def maybe_apply_resume(now: float) -> None:
nonlocal player_position_ms, resume_applied_for_key
if tracked_media_key is None:
return
if resume_applied_for_key == tracked_media_key:
return
saved_position = resume_positions.get(tracked_media_key)
if not isinstance(saved_position, int):
resume_applied_for_key = tracked_media_key
return
if player_position_ms is None or player_duration_ms is None:
return
if player_position_ms > MPC_BE_RESUME_APPLY_THRESHOLD_MS:
resume_applied_for_key = tracked_media_key
return
if _should_clear_resume(saved_position, player_duration_ms):
resume_positions.pop(tracked_media_key, None)
resume_applied_for_key = tracked_media_key
return
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
return
target_ms = min(saved_position, max(player_duration_ms - 1000, 0))
queue_seek_to_position(target_ms)
player_position_ms = target_ms
resume_applied_for_key = tracked_media_key
persist_tracked_current(now, force=True)
def update_status_from_future() -> None:
nonlocal \
status_future, \
player_filepath, \
player_position_ms, \
player_duration_ms, \
player_state, \
status_updated_at, \
status_miss_count, \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
resume_applied_for_key
if status_future is None or not status_future.done():
return
try:
status = status_future.result()
except OSError, RuntimeError, ValueError:
status = None
status_future = None
if status is None:
status_miss_count += 1
if status_miss_count >= MPC_BE_STATUS_MISS_THRESHOLD:
finalize_tracked_current()
with status_lock:
player_filepath = ""
player_position_ms = None
player_duration_ms = None
player_state = None
status_updated_at = 0.0
return
status_miss_count = 0
filepath, position_ms, duration_ms, state = status
resolved = _media_key_for_filepath(filepath, roots) if filepath else None
media_key = resolved[0] if resolved else None
root_id = resolved[1] if resolved else None
relative_path = resolved[2] if resolved else ""
if tracked_media_key is not None and media_key != tracked_media_key:
finalize_tracked_current()
if media_key is None:
clear_tracked_current(clear_resume_applied=True)
elif tracked_media_key != media_key:
tracked_media_key = media_key
tracked_root_id = root_id
tracked_relative_path = relative_path
tracked_filepath = filepath
resume_applied_for_key = None
player_filepath = filepath
with status_lock:
player_position_ms = position_ms
player_duration_ms = duration_ms
player_state = state
status_updated_at = time.monotonic()
maybe_apply_resume(status_updated_at)
persist_tracked_current(status_updated_at)
def queue_status_refresh(now: float, *, force: bool = False) -> None:
nonlocal status_future
if status_future is not None:
return
with status_lock:
is_stale = now - status_updated_at >= MPC_BE_STATUS_POLL_SECONDS
if force or is_stale:
status_future = request_pool.submit(_mpcbe_fetch_status)
def command_for_seek(mask: int) -> int:
paused_command, seek_command = _MPC_BE_SEEK_MASK_TO_COMMANDS[mask]
with status_lock:
is_paused = player_state == MPC_BE_STATE_PAUSED
return paused_command if is_paused else seek_command
def repeat_seconds_for_seek(mask: int) -> float:
paused_command, _seek_command = _MPC_BE_SEEK_MASK_TO_COMMANDS[mask]
with status_lock:
active_command = (
paused_command if player_state == MPC_BE_STATE_PAUSED else None
)
return (
MPC_BE_FRAME_REPEAT_SECONDS
if active_command == paused_command
else GAMEPAD_REPEAT_SECONDS
)
def _run() -> None:
try:
while not stop_event.is_set():
now = time.monotonic()
pending_requests[:] = [
future for future in pending_requests if not future.done()
]
update_status_from_future()
queue_status_refresh(now)
for slot in range(4):
state = _XINPUT_STATE()
rc = get_state(slot, ctypes.byref(state))
is_connected = rc == 0
current_mask = state.Gamepad.wButtons if is_connected else 0
is_seek_begin_pressed = bool(current_mask & 0x4000)
if is_seek_begin_pressed:
if seek_begin_hold_started_at[slot] is None:
seek_begin_hold_started_at[slot] = now
seek_begin_fired[slot] = False
elif (
not seek_begin_fired[slot]
and now - seek_begin_hold_started_at[slot]
>= MPC_BE_SEEK_BEGIN_HOLD_SECONDS
and len(pending_requests) < MPC_BE_MAX_INFLIGHT_REQUESTS
):
queue_command(MPC_BE_SEEK_BEGIN_COMMAND)
seek_begin_fired[slot] = True
else:
seek_begin_hold_started_at[slot] = None
seek_begin_fired[slot] = False
if is_connected != last_connected[slot]:
last_connected[slot] = is_connected
# Volume control via D-pad up/down with fixed repeat cadence
is_vol_up_pressed = bool(current_mask & 0x0001)
was_vol_up_pressed = bool(last_pressed_masks[slot] & 0x0001)
if is_vol_up_pressed and (
not was_vol_up_pressed
or now - last_volume_repeat_up_at[slot] >= VOLUME_REPEAT_SECONDS
):
set_volume(min(volume_max(), get_volume() + VOLUME_STEP))
last_volume_repeat_up_at[slot] = now
elif not is_vol_up_pressed:
last_volume_repeat_up_at[slot] = 0.0
is_vol_down_pressed = bool(current_mask & 0x0002)
was_vol_down_pressed = bool(last_pressed_masks[slot] & 0x0002)
if is_vol_down_pressed and (
not was_vol_down_pressed
or now - last_volume_repeat_down_at[slot]
>= VOLUME_REPEAT_SECONDS
):
set_volume(max(VOLUME_MIN, get_volume() - VOLUME_STEP))
last_volume_repeat_down_at[slot] = now
elif not is_vol_down_pressed:
last_volume_repeat_down_at[slot] = 0.0
for mask, command_id in _MPC_BE_COMMANDS.items():
is_pressed = bool(current_mask & mask)
was_pressed = bool(last_pressed_masks[slot] & mask)
should_fire = is_pressed and not was_pressed
if (
not should_fire
and is_pressed
and mask in _MPC_BE_REPEATABLE_MASKS
and now - last_repeat_at[slot][mask]
>= GAMEPAD_REPEAT_SECONDS
):
should_fire = True
if not should_fire:
continue
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
continue
queue_command(command_id)
last_repeat_at[slot][mask] = now
for mask in _MPC_BE_SEEK_MASK_TO_COMMANDS:
is_pressed = bool(current_mask & mask)
was_pressed = bool(last_pressed_masks[slot] & mask)
should_fire = is_pressed and not was_pressed
repeat_seconds = repeat_seconds_for_seek(mask)
if (
not should_fire
and is_pressed
and now - last_repeat_at[slot][mask] >= repeat_seconds
):
should_fire = True
if not should_fire:
continue
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
continue
queue_command(command_for_seek(mask))
last_repeat_at[slot][mask] = now
last_pressed_masks[slot] = current_mask
stop_event.wait(GAMEPAD_POLL_SECONDS)
finally:
finalize_tracked_current()
request_pool.shutdown(wait=False, cancel_futures=True)
thread = threading.Thread(target=_run, daemon=True, name="mediahive-gamepad-remote")
thread.start()
return thread
def _setup_logging() -> Path:
"""Redirect stdout/stderr and configure logging to a 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)
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
log_file = os.fdopen(fd, "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 set_volume(self, x: float) -> None:
"""Set system master volume from slider position ``x`` (0.0 .. 1.5)."""
# Clamp to the platform's maximum so the slider never exceeds what
# the OS can actually apply (1.0 on Windows/macOS, 1.5 on Linux).
clamped = max(VOLUME_MIN, min(volume_max(), float(x)))
set_volume(clamped)
def get_volume(self) -> float:
"""Return current volume slider position (0.0 .. 1.5)."""
return get_volume()
def volume_max(self) -> float:
"""Return the maximum volume slider position for this platform."""
return volume_max()
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 | None = None) -> bool:
url = os.environ["MEDIAHIVE_BACKEND_URL"] + "/api/health"
deadline = time.monotonic() + timeout if timeout is not None else None
while True:
if deadline is not None and time.monotonic() >= deadline:
return False
try:
with urllib.request.urlopen(url, timeout=BACKEND_HEALTH_REQUEST_TIMEOUT):
return True
except urllib.error.URLError, TimeoutError, OSError:
time.sleep(BACKEND_HEALTH_POLL_SECONDS)
def _icon_path() -> str | None:
"""Locate the application icon at runtime (frozen or development)."""
if getattr(sys, "frozen", False):
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
base = meipass / "mediahive" if (meipass / "mediahive").exists() else meipass
else:
base = Path(__file__).parent
ico = base / "assets" / "mediahive.ico"
return str(ico) if ico.exists() else None
def _webview_start_kwargs() -> dict[str, str]:
"""Return platform-specific pywebview startup kwargs."""
if sys.platform == "darwin":
return {"gui": "qt"}
return {}
def _selected_webview_backend() -> str:
"""Return the configured pywebview GUI backend name for logging."""
return _webview_start_kwargs().get("gui", "default")
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(), **_webview_start_kwargs())
return chosen[0] if chosen else None
def _normalize_media_root_input(path: str) -> Path:
"""Normalize configured media path without touching filesystem.
This intentionally avoids exists()/is_dir()/resolve() checks so startup can
continue even if macOS shows a permission dialog for the selected folder.
"""
parts = Path(path).expanduser().parts
match parts:
case (*rest, ".mediahive", "index.json"):
...
case (*rest, ".mediahive"):
...
case rest:
...
base = Path(*rest)
if not base.is_absolute():
base = Path.cwd() / base
return base
def _supports_gamepad_remote() -> bool:
return sys.platform == "win32"
def _reserve_backend_port() -> int:
"""Reserve an ephemeral localhost port for the embedded backend."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((BACKEND_HOST, 0))
sock.listen(1)
return int(sock.getsockname()[1])
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
if sys.platform != "win32":
return
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
if policy_cls is None:
return
asyncio.set_event_loop_policy(policy_cls())
def winmain() -> None:
_configure_windows_event_loop_policy()
parser = argparse.ArgumentParser(description="MediaHive")
parser.add_argument(
"media_folder",
nargs="?",
help="Path to the media folder (default: saved config or initial setup dialog)",
)
args, _unknown = parser.parse_known_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()
cfg = load_config()
# Build initial roots dict (filesystem is NOT touched here — validation is
# deferred to the server's background activation task).
initial_roots: dict[str, str] = {}
if args.media_folder:
p = _normalize_media_root_input(args.media_folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
elif cfg.roots:
initial_roots = cfg.roots
elif cfg.media_folder:
p = _normalize_media_root_input(cfg.media_folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
if not initial_roots:
folder = _run_initial_setup()
if not folder:
return # user cancelled
p = _normalize_media_root_input(folder)
name = p.name or "media"
initial_roots[name] = p.as_posix()
# Persist resolved roots
if cfg.roots != initial_roots:
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
# Pass roots to the server via env (validation deferred to server startup)
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
backend_port = _reserve_backend_port()
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
os.environ["MEDIAHIVE_BACKEND_URL"] = backend_url
# Run the FastAPI backend on a background thread
config = uvicorn.Config(
"mediahive.server:app",
host=BACKEND_HOST,
port=backend_port,
loop="asyncio",
log_level="warning",
timeout_graceful_shutdown=0,
)
server = uvicorn.Server(config)
backend_thread = threading.Thread(
target=server.run, daemon=True, name="mediahive-backend"
)
backend_thread.start()
def _activate_initial_roots() -> None:
body = json.dumps({"roots": initial_roots}).encode("utf-8")
req = urllib.request.Request(
url=f"{backend_url}/api/config/roots",
data=body,
method="PUT",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10):
logger.info("Requested initial roots activation")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
logger.warning("Initial roots activation request failed: %s", exc)
if not _wait_for_backend(timeout=HEALTH_TIMEOUT):
server.should_exit = True
raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s")
api = JsApi()
logger.info("Configured pywebview backend: %s", _selected_webview_backend())
window = webview.create_window(
title="MediaHive",
url=backend_url,
fullscreen=True,
js_api=api,
)
poll_stop = threading.Event()
poll_thread: threading.Thread | None = None
# Resolve all root paths for gamepad remote
gamepad_roots = {root_id: Path(p) for root_id, p in initial_roots.items()}
def on_shown() -> None:
api._window = window
try:
user_agent = window.evaluate_js("navigator.userAgent")
if isinstance(user_agent, str):
logger.info("Embedded webview user agent: %s", user_agent)
except (OSError, RuntimeError, ValueError) as exc:
logger.warning("Could not read embedded user agent: %s", exc)
nonlocal poll_thread
if poll_thread is None and _supports_gamepad_remote():
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots, backend_url)
threading.Thread(
target=_activate_initial_roots,
daemon=True,
name="mediahive-initial-roots-activation",
).start()
def on_closing() -> None:
# Begin backend shutdown as soon as the window starts closing so that
# by the time webview.start() returns the backend is already done.
server.should_exit = True
window.events.closing += on_closing
webview.start(func=on_shown, icon=_icon_path(), **_webview_start_kwargs())
# Ensure backend shutdown has been requested (in case closing event
# was not fired or we are on a platform that does not support it).
server.should_exit = True
backend_thread.join(timeout=2)
poll_stop.set()
if poll_thread is not None:
poll_thread.join(timeout=1)
# Close log file handles so mediahive.log is not left locked.
if getattr(sys, "frozen", False):
logging.shutdown()
for handler in logging.root.handlers[:]:
handler.close()
logging.root.removeHandler(handler)
if sys.stdout is not sys.__stdout__:
with contextlib.suppress(Exception):
sys.stdout.close()
if sys.stderr is not sys.__stderr__:
with contextlib.suppress(Exception):
sys.stderr.close()
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
if __name__ == "__main__":
winmain()