Files
Mediatracker/backend/main.py
snoopy 6f551ba439 Initial release — MediaTracker v1.0
Dockerized media library scanner for NAS drives.
Identifies movies and series from filenames, fetches posters
via the public IMDB suggestion API (no key required), and
exposes a dark-themed web UI with multilingual search support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:40:44 +00:00

126 lines
3.9 KiB
Python

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