ddf4a6cb29
- Add `linux` (dbus-python, PyGObject, python-xlib) and `macos`
(pyobjc) extras to pyproject.toml with sys_platform markers; move
cross-platform screen-brightness-control + monitorcontrol to base deps.
- build-dist-linux.sh: install `.[linux]`, pkg-config pre-flight for
dbus-1/glib-2.0, emit a systemd unit with DBUS_SESSION_BUS_ADDRESS +
XDG_RUNTIME_DIR + ReadWritePaths for ~/.config and ~/.cache so MPRIS
works and audit-log / thumbnail writes aren't blocked by ProtectHome.
- New build-dist-macos.sh + per-user LaunchAgent installer producing
MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz.
- Templated media-server.service updated to match the dist layout with
proper session-bus env vars and a writable state-dir grant.
- install_linux.sh: drop dead requirements.txt path; install via
`pip install ".[linux]"` and pre-create the writable state dirs.
- Cross-platform album artwork: abstract MediaController.get_album_art()
with Linux (mpris:artUrl, file:// + http(s)://) and macOS (Spotify URL)
impls; routes/media artwork endpoint now awaits the controller.
- LinuxMediaController connects to the session bus lazily — failure no
longer crashes lifespan startup; MPRIS calls return idle until the bus
is reachable. Logged once at INFO with a hint about
`loginctl enable-linger`.
- Startup preflight on Linux warns if DBUS_SESSION_BUS_ADDRESS or
XDG_RUNTIME_DIR is unset and informs the user when Wayland disables
the foreground probe.
- /api/media/visualizer/status now reports a per-OS unavailable_reason.
- tray._confirm guarded against ctypes.windll on non-Windows.
- config.example.yaml: per-OS commented script examples; on_turn_off
default is now a no-op echo (used to silently fail off Windows).
- README: replace stale `pip install -r requirements.txt` instructions
with the new extras; add systemd lingering doc + troubleshooting
section; add macOS LaunchAgent section.
- CI: new linux-smoke job (installs `.[linux]`, boots the server under
dbus-run-session, asserts /api/health). Release workflow gains
apt-deps step for the Linux build and a best-effort macOS build job.
613 lines
22 KiB
Python
613 lines
22 KiB
Python
"""Media Server - FastAPI application entry point."""
|
|
|
|
import argparse
|
|
import logging
|
|
import socket
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from . import __version__
|
|
from .auth import get_token_label, request_id_var, token_label_var
|
|
from .config import generate_default_config, get_config_dir, settings
|
|
from .routes import (
|
|
audio_router,
|
|
browser_router,
|
|
callbacks_router,
|
|
display_router,
|
|
foreground_router,
|
|
health_router,
|
|
links_router,
|
|
media_router,
|
|
scripts_router,
|
|
)
|
|
from .services import get_media_controller
|
|
from .services.websocket_manager import ws_manager
|
|
|
|
|
|
class TokenLabelFilter(logging.Filter):
|
|
"""Add token label + request_id to log records."""
|
|
|
|
def filter(self, record):
|
|
record.token_label = token_label_var.get("unknown")
|
|
record.request_id = request_id_var.get("-")
|
|
return True
|
|
|
|
|
|
class _StripTokenQueryFilter(logging.Filter):
|
|
"""Strip `token=...` from query strings before they hit the access log.
|
|
|
|
uvicorn's default access log format includes the full request line, so
|
|
`/api/media/artwork?token=SECRET` would otherwise be persisted verbatim
|
|
in stdout/journald/file sinks.
|
|
"""
|
|
|
|
import re as _re
|
|
|
|
_TOKEN_RE = _re.compile(r"([?&])token=[^&\s\"']+")
|
|
|
|
def filter(self, record): # type: ignore[override]
|
|
if isinstance(record.args, tuple):
|
|
record.args = tuple(
|
|
self._TOKEN_RE.sub(r"\1token=REDACTED", a) if isinstance(a, str) else a
|
|
for a in record.args
|
|
)
|
|
if isinstance(record.msg, str) and "token=" in record.msg:
|
|
record.msg = self._TOKEN_RE.sub(r"\1token=REDACTED", record.msg)
|
|
return True
|
|
|
|
|
|
def setup_logging():
|
|
"""Configure application logging with token labels."""
|
|
# Create filter and handler
|
|
token_filter = TokenLabelFilter()
|
|
handler = logging.StreamHandler(sys.stdout)
|
|
handler.addFilter(token_filter)
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, settings.log_level.upper()),
|
|
format=(
|
|
"%(asctime)s - %(name)s - [%(token_label)s] [%(request_id)s]"
|
|
" - %(levelname)s - %(message)s"
|
|
),
|
|
handlers=[handler],
|
|
)
|
|
|
|
# Suppress noisy third-party loggers
|
|
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
|
|
|
|
# Make sure the uvicorn access log never persists tokens leaked into the
|
|
# query string (the artwork + WS endpoints accept `?token=` for browser
|
|
# compatibility — see verify_token_or_query).
|
|
strip_filter = _StripTokenQueryFilter()
|
|
for name in ("uvicorn.access", "uvicorn"):
|
|
logging.getLogger(name).addFilter(strip_filter)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Application lifespan handler.
|
|
|
|
All long-lived resources started during startup are kept in local refs and
|
|
torn down in a `finally:` so a partial-startup failure cannot orphan tasks
|
|
or thread pools.
|
|
"""
|
|
import asyncio
|
|
|
|
setup_logging()
|
|
logger = logging.getLogger(__name__)
|
|
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
|
|
|
# Log authentication status — never log full or partial token material.
|
|
if settings.api_tokens:
|
|
labels = ", ".join(settings.api_tokens.keys())
|
|
logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
|
|
else:
|
|
logger.warning("No API tokens configured — authentication is DISABLED")
|
|
|
|
# Linux preflight: most MPRIS / PulseAudio failures are environmental
|
|
# (no DBUS_SESSION_BUS_ADDRESS, missing XDG_RUNTIME_DIR, systemd service
|
|
# started before logind). Surface that early so the failure mode is a
|
|
# warning at boot instead of silent "/api/media/status returns idle".
|
|
import os
|
|
import platform as _platform
|
|
if _platform.system() == "Linux":
|
|
missing = [
|
|
v for v in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR")
|
|
if not os.environ.get(v)
|
|
]
|
|
if missing:
|
|
logger.warning(
|
|
"Linux preflight: %s not set — MPRIS / PulseAudio may be unavailable."
|
|
" Under systemd, run `loginctl enable-linger <user>` and ensure the"
|
|
" service unit sets DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR.",
|
|
", ".join(missing),
|
|
)
|
|
if os.environ.get("WAYLAND_DISPLAY"):
|
|
logger.info(
|
|
"Wayland session detected — foreground-window probe is intentionally"
|
|
" disabled (Wayland hides window info from unprivileged clients)."
|
|
)
|
|
|
|
update_checker = None
|
|
cleanup_task: asyncio.Task | None = None
|
|
analyzer = None
|
|
status_monitor_started = False
|
|
|
|
try:
|
|
# Start WebSocket status monitor
|
|
controller = get_media_controller()
|
|
await ws_manager.start_status_monitor(controller.get_status)
|
|
status_monitor_started = True
|
|
logger.info("WebSocket status monitor started")
|
|
|
|
# Start update checker
|
|
if settings.update_check_enabled:
|
|
from .services.gitea_release_provider import GiteaReleaseProvider
|
|
from .services.update_checker import UpdateChecker
|
|
|
|
provider = GiteaReleaseProvider()
|
|
update_checker = UpdateChecker(provider, __version__)
|
|
await update_checker.start(settings.update_check_interval)
|
|
# Store globally so health endpoint can access cached result
|
|
app.state.update_checker = update_checker
|
|
|
|
# Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually
|
|
# enforced. Runs once at startup and then hourly until shutdown.
|
|
from .services.thumbnail_service import ThumbnailService
|
|
|
|
async def _thumbnail_cleanup_loop() -> None:
|
|
while True:
|
|
try:
|
|
await asyncio.to_thread(ThumbnailService.cleanup_cache)
|
|
except Exception as e:
|
|
logger.warning("Thumbnail cache cleanup failed: %s", e)
|
|
try:
|
|
await asyncio.sleep(3600)
|
|
except asyncio.CancelledError:
|
|
break
|
|
|
|
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
|
|
|
|
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
|
if settings.visualizer_enabled:
|
|
from .services.audio_analyzer import get_audio_analyzer
|
|
|
|
analyzer = get_audio_analyzer(
|
|
num_bins=settings.visualizer_bins,
|
|
target_fps=settings.visualizer_fps,
|
|
device_name=settings.visualizer_device,
|
|
)
|
|
if analyzer.available:
|
|
await ws_manager.start_audio_monitor(analyzer)
|
|
logger.info("Audio visualizer available (capture on-demand)")
|
|
else:
|
|
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
|
|
|
yield
|
|
finally:
|
|
# Stop update checker
|
|
if update_checker is not None:
|
|
try:
|
|
await update_checker.stop()
|
|
except Exception:
|
|
logger.exception("Error stopping update checker")
|
|
|
|
# Cancel periodic thumbnail cleanup
|
|
if cleanup_task is not None:
|
|
cleanup_task.cancel()
|
|
try:
|
|
await cleanup_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
logger.exception("Error awaiting thumbnail cleanup task")
|
|
|
|
# Stop audio visualizer
|
|
try:
|
|
await ws_manager.stop_audio_monitor()
|
|
except Exception:
|
|
logger.exception("Error stopping audio monitor")
|
|
if analyzer and analyzer.running:
|
|
try:
|
|
analyzer.stop()
|
|
except Exception:
|
|
logger.exception("Error stopping audio analyzer")
|
|
|
|
# Stop WebSocket status monitor
|
|
if status_monitor_started:
|
|
try:
|
|
await ws_manager.stop_status_monitor()
|
|
except Exception:
|
|
logger.exception("Error stopping status monitor")
|
|
|
|
# Shut down dedicated thread pools so pending scripts don't leak threads
|
|
try:
|
|
from .routes.callbacks import shutdown_callback_executor
|
|
from .routes.scripts import shutdown_script_executor
|
|
|
|
shutdown_script_executor()
|
|
shutdown_callback_executor()
|
|
except Exception:
|
|
logger.exception("Error shutting down script/callback executors")
|
|
|
|
# Flush audit log writer
|
|
try:
|
|
from .services.audit_log import shutdown_audit_log
|
|
shutdown_audit_log()
|
|
except Exception:
|
|
logger.exception("Error flushing audit log")
|
|
|
|
# Clean up platform-specific resources
|
|
import platform as _platform
|
|
if _platform.system() == "Windows":
|
|
try:
|
|
from .services.windows_media import shutdown_executor
|
|
shutdown_executor()
|
|
except Exception:
|
|
logger.exception("Error shutting down windows_media executor")
|
|
|
|
logger.info("Media Server shutting down")
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
"""Create and configure the FastAPI application."""
|
|
app = FastAPI(
|
|
title="Media Server",
|
|
description="REST API for controlling system media playback",
|
|
version=__version__,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Compress responses > 1KB
|
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
|
|
# CORS — restrict to same-origin by default; users that integrate the API
|
|
# from another origin (e.g. Home Assistant on a different host) can set
|
|
# cors_origins in config.yaml. Refuse "*" outright: combined with the
|
|
# admin endpoints this would let any origin in the universe run
|
|
# arbitrary shell. If users genuinely need every origin, they can list
|
|
# them explicitly.
|
|
if any(o.strip() == "*" for o in settings.cors_origins):
|
|
raise RuntimeError(
|
|
"cors_origins must not contain '*' — list exact origins instead. "
|
|
"This protects the script-execution endpoints from any-origin abuse."
|
|
)
|
|
cors_origins = settings.cors_origins or [
|
|
f"http://localhost:{settings.port}",
|
|
f"http://127.0.0.1:{settings.port}",
|
|
]
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=cors_origins,
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers=["Authorization", "Content-Type"],
|
|
)
|
|
|
|
# Request correlation ID — accept upstream X-Request-ID if it's a sane
|
|
# ASCII id, otherwise mint a fresh UUID4. Emitted on the response so
|
|
# clients can quote it back in bug reports.
|
|
import re
|
|
import uuid as _uuid
|
|
|
|
_REQ_ID_RE = re.compile(r"^[A-Za-z0-9._\-]{1,128}$")
|
|
|
|
@app.middleware("http")
|
|
async def request_id_middleware(request: Request, call_next):
|
|
incoming = request.headers.get("x-request-id", "")
|
|
req_id = incoming if _REQ_ID_RE.match(incoming) else _uuid.uuid4().hex[:16]
|
|
request_id_var.set(req_id)
|
|
response = await call_next(request)
|
|
response.headers["X-Request-ID"] = req_id
|
|
return response
|
|
|
|
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
|
|
@app.middleware("http")
|
|
async def security_headers_middleware(request: Request, call_next):
|
|
response = await call_next(request)
|
|
response.headers.setdefault(
|
|
"Content-Security-Policy",
|
|
(
|
|
"default-src 'self'; "
|
|
"img-src 'self' data: blob: https://api.iconify.design; "
|
|
"connect-src 'self' https://api.iconify.design ws: wss:; "
|
|
"script-src 'self'; "
|
|
"style-src 'self' 'unsafe-inline'; "
|
|
"font-src 'self' data:; "
|
|
"frame-ancestors 'none'; "
|
|
"form-action 'self'; "
|
|
"worker-src 'self'; "
|
|
"manifest-src 'self'; "
|
|
"base-uri 'self'"
|
|
),
|
|
)
|
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
|
return response
|
|
|
|
# Add token logging middleware + auth-failure rate limit
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from .services.rate_limit import check as ratelimit_check
|
|
from .services.rate_limit import get_peer
|
|
|
|
@app.middleware("http")
|
|
async def token_logging_middleware(request: Request, call_next):
|
|
"""Extract token label, set in context, and rate-limit failed auths."""
|
|
if not settings.api_tokens:
|
|
token_label_var.set("anonymous")
|
|
else:
|
|
token_label = "unknown"
|
|
token_present = False
|
|
token_valid = False
|
|
|
|
# Try Authorization header
|
|
auth_header = request.headers.get("authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
token_present = True
|
|
token = auth_header[7:]
|
|
label = get_token_label(token)
|
|
if label:
|
|
token_label = label
|
|
token_valid = True
|
|
|
|
# Try query parameter (for artwork endpoint)
|
|
elif "token" in request.query_params:
|
|
token_present = True
|
|
token = request.query_params["token"]
|
|
label = get_token_label(token)
|
|
if label:
|
|
token_label = label
|
|
token_valid = True
|
|
|
|
token_label_var.set(token_label)
|
|
|
|
# Brute-force gate: a peer that produces a wrong/missing token gets
|
|
# 5 failures per minute before being throttled. Static-asset
|
|
# requests (GET /static/*, /, /sw.js) and the docs endpoint are
|
|
# exempt — they're served unauthenticated by design.
|
|
if token_present and not token_valid:
|
|
path = request.url.path
|
|
if not (
|
|
path == "/" or path == "/sw.js"
|
|
or path.startswith("/static/")
|
|
or path.startswith("/docs") or path.startswith("/openapi")
|
|
or path.startswith("/redoc")
|
|
):
|
|
allowed, retry_after = ratelimit_check("auth", get_peer(request))
|
|
if not allowed:
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={"detail": "Too many authentication failures"},
|
|
headers={"Retry-After": str(int(retry_after or 60))},
|
|
)
|
|
|
|
response = await call_next(request)
|
|
return response
|
|
|
|
# Register routers
|
|
app.include_router(audio_router)
|
|
app.include_router(browser_router)
|
|
app.include_router(callbacks_router)
|
|
app.include_router(display_router)
|
|
app.include_router(foreground_router)
|
|
app.include_router(health_router)
|
|
app.include_router(links_router)
|
|
app.include_router(media_router)
|
|
app.include_router(scripts_router)
|
|
|
|
# Mount static files and serve UI at root
|
|
static_dir = Path(__file__).parent / "static"
|
|
if static_dir.exists():
|
|
@app.get("/sw.js", include_in_schema=False)
|
|
async def serve_service_worker():
|
|
"""Serve service worker from root scope for PWA installability."""
|
|
return FileResponse(
|
|
static_dir / "sw.js",
|
|
media_type="application/javascript",
|
|
headers={"Cache-Control": "no-cache"},
|
|
)
|
|
|
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def serve_ui():
|
|
"""Serve the Web UI."""
|
|
return FileResponse(static_dir / "index.html")
|
|
else:
|
|
logging.getLogger(__name__).warning(
|
|
"static_dir not found at %s — Web UI disabled (API only)",
|
|
static_dir,
|
|
)
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
def main():
|
|
"""Main entry point for running the server."""
|
|
parser = argparse.ArgumentParser(description="Media Server")
|
|
parser.add_argument(
|
|
"--host",
|
|
default=settings.host,
|
|
help=f"Host to bind to (default: {settings.host})",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=settings.port,
|
|
help=f"Port to bind to (default: {settings.port})",
|
|
)
|
|
parser.add_argument(
|
|
"--generate-config",
|
|
action="store_true",
|
|
help="Generate a default configuration file and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--show-token",
|
|
action="store_true",
|
|
help="Show the current API token and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--no-tray",
|
|
action="store_true",
|
|
help="Disable system tray icon (for headless/service mode)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.generate_config:
|
|
config_path = generate_default_config()
|
|
print(f"Configuration file generated at: {config_path}")
|
|
print("A random API token was generated under api_tokens.default.")
|
|
print("Run `python -m media_server.main --show-token` to view it.")
|
|
return
|
|
|
|
if args.show_token:
|
|
print(f"Config directory: {get_config_dir()}")
|
|
if settings.api_tokens:
|
|
print("\nAPI Tokens:")
|
|
for label, spec in settings.api_tokens.items():
|
|
scope_str = ",".join(spec.scopes)
|
|
print(f" {label:20} {spec.token} [scopes: {scope_str}]")
|
|
else:
|
|
print("\nAuthentication is DISABLED (no tokens configured)")
|
|
return
|
|
|
|
# Stderr is invisible when launched via wscript / pythonw (Start Menu shortcut,
|
|
# autostart). Mirror pre-uvicorn failures to a file in the config dir so the
|
|
# next silent boot failure is diagnosable.
|
|
def _fatal(msg: str, exit_code: int = 1) -> None:
|
|
print(msg, file=sys.stderr)
|
|
try:
|
|
log_path = get_config_dir() / "startup-errors.log"
|
|
from datetime import datetime
|
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
|
|
except OSError:
|
|
pass
|
|
sys.exit(exit_code)
|
|
|
|
# First-run bootstrap: if no config has ever been written, generate one
|
|
# with a random token instead of starting in the insecure "no-auth" mode.
|
|
config_path = get_config_dir() / "config.yaml"
|
|
if not config_path.exists() and not settings.api_tokens:
|
|
try:
|
|
generate_default_config(config_path)
|
|
_fatal(
|
|
f"\nFirst run: generated default config at {config_path}.\n"
|
|
"Run --show-token to retrieve the API token, then restart.",
|
|
exit_code=0,
|
|
)
|
|
except OSError as e:
|
|
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
|
|
|
|
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
|
|
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
|
|
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
|
|
_fatal(
|
|
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
|
|
"Either set api_tokens in config.yaml, bind to 127.0.0.1,"
|
|
" or set allow_lan_without_auth: true in config.yaml to override."
|
|
)
|
|
|
|
# Check if port is available before starting
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
try:
|
|
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
|
except OSError:
|
|
_fatal(
|
|
f"ERROR: Port {args.port} is already in use. "
|
|
f"Another instance of Media Server may be running.\n"
|
|
f"Stop the other process or use --port to pick a different port."
|
|
)
|
|
|
|
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
|
|
|
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
|
|
|
# Validate TLS pair consistency before either path so we don't fail late.
|
|
if bool(settings.ssl_certfile) ^ bool(settings.ssl_keyfile):
|
|
_fatal(
|
|
"ERROR: ssl_certfile and ssl_keyfile must both be set, or both unset."
|
|
)
|
|
|
|
def _uvicorn_kwargs() -> dict:
|
|
kw: dict = {
|
|
"host": args.host,
|
|
"port": args.port,
|
|
"log_level": settings.log_level.lower(),
|
|
"proxy_headers": settings.proxy_headers,
|
|
"forwarded_allow_ips": settings.forwarded_allow_ips,
|
|
}
|
|
if settings.ssl_certfile and settings.ssl_keyfile:
|
|
kw["ssl_certfile"] = settings.ssl_certfile
|
|
kw["ssl_keyfile"] = settings.ssl_keyfile
|
|
if settings.ssl_keyfile_password:
|
|
kw["ssl_keyfile_password"] = settings.ssl_keyfile_password
|
|
return kw
|
|
|
|
if use_tray:
|
|
import asyncio
|
|
import threading
|
|
|
|
# Run uvicorn in a background thread so tray owns the main thread message loop
|
|
uv_config = uvicorn.Config(
|
|
"media_server.main:app",
|
|
**_uvicorn_kwargs(),
|
|
)
|
|
server = uvicorn.Server(uv_config)
|
|
|
|
def run_server():
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
loop.run_until_complete(server.serve())
|
|
|
|
server_thread = threading.Thread(target=run_server, daemon=True)
|
|
server_thread.start()
|
|
|
|
# Tray on main thread (blocking)
|
|
tray = TrayManager(
|
|
port=args.port,
|
|
on_exit=lambda: setattr(server, "should_exit", True),
|
|
)
|
|
tray.run()
|
|
|
|
# Tray exited — wait for server to finish graceful shutdown
|
|
server_thread.join(timeout=10)
|
|
|
|
if tray.restart_requested:
|
|
import subprocess
|
|
|
|
# Always restart via `python -m media_server.main` — this works
|
|
# regardless of how we were originally started (console_script,
|
|
# python -m, or direct script invocation).
|
|
cmd = [sys.executable, "-m", "media_server.main"]
|
|
|
|
subprocess.Popen(
|
|
cmd,
|
|
cwd=Path.cwd(),
|
|
start_new_session=True,
|
|
)
|
|
else:
|
|
uvicorn.run(
|
|
"media_server.main:app",
|
|
reload=False,
|
|
**_uvicorn_kwargs(),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|