""" main.py — FastAPI application entry point. """ import logging import mimetypes import time from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from config import load_settings from database import check_connection, init_db, get_session from routers import scan, search, stats from scheduler import start_scheduler, stop_scheduler logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) logger = logging.getLogger(__name__) _MAX_DB_RETRIES = 15 _DB_RETRY_DELAY = 4 def _migrate_db() -> None: """Add columns introduced after initial schema creation.""" session = get_session() migrations = [ "ALTER TABLE movies ADD COLUMN poster_url VARCHAR(512) NULL", "ALTER TABLE series ADD COLUMN poster_url VARCHAR(512) NULL", "ALTER TABLE movies ADD COLUMN imdb_id VARCHAR(20) NULL", "ALTER TABLE series ADD COLUMN imdb_id VARCHAR(20) NULL", """CREATE TABLE IF NOT EXISTS alt_titles ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, media_type ENUM('movie','series') NOT NULL, media_id INT UNSIGNED NOT NULL, title VARCHAR(512) NOT NULL, lang VARCHAR(10) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_alt_title (media_type, media_id, lang, title(255)), INDEX idx_alt_title_search (title(255)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", ] try: for sql in migrations: try: session.execute(__import__("sqlalchemy").text(sql)) session.commit() except Exception: session.rollback() finally: session.close() def _wait_for_db() -> None: for attempt in range(1, _MAX_DB_RETRIES + 1): if check_connection(): logger.info("Database connection established") return logger.warning("Database not ready (attempt %d/%d) — retrying in %ds", attempt, _MAX_DB_RETRIES, _DB_RETRY_DELAY) time.sleep(_DB_RETRY_DELAY) raise RuntimeError("Could not connect to the database after multiple attempts") @asynccontextmanager async def lifespan(app: FastAPI): settings = load_settings() app.state.settings = settings logger.info("Initializing database connection to %s:%s/%s", settings.db_host, settings.db_port, settings.db_name) init_db(settings.db_url) _wait_for_db() _migrate_db() logger.info("Poster source: IMDB public suggestion API (no key required)") start_scheduler(settings) logger.info("MediaTracker backend ready") yield stop_scheduler() logger.info("MediaTracker backend shutdown") app = FastAPI(title="MediaTracker API", version="1.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) app.include_router(search.router) app.include_router(scan.router) app.include_router(stats.router) @app.get("/api/health") def health(): return {"status": "ok"} @app.get("/api/local-poster") def local_poster(path: str = Query(...)): """ Serve a local poster image file from the NAS. Path must be under /nas/ (read-only mount — no traversal possible outside). """ resolved = Path(path).resolve() nas_root = Path("/nas").resolve() if not str(resolved).startswith(str(nas_root)): raise HTTPException(status_code=403, detail="Access denied") if not resolved.is_file(): raise HTTPException(status_code=404, detail="Poster file not found") mime, _ = mimetypes.guess_type(str(resolved)) return FileResponse(str(resolved), media_type=mime or "image/jpeg")