"""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 ` 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()