summaryrefslogtreecommitdiff
path: root/routers
diff options
context:
space:
mode:
Diffstat (limited to 'routers')
-rw-r--r--routers/__init__.py1
-rw-r--r--routers/films.py544
-rw-r--r--routers/imports.py343
-rw-r--r--routers/stats.py126
-rw-r--r--routers/tmdb.py32
5 files changed, 1046 insertions, 0 deletions
diff --git a/routers/__init__.py b/routers/__init__.py
new file mode 100644
index 0000000..44df2a9
--- /dev/null
+++ b/routers/__init__.py
@@ -0,0 +1 @@
+"""Route modules for Lumiere."""
diff --git a/routers/films.py b/routers/films.py
new file mode 100644
index 0000000..0a6491f
--- /dev/null
+++ b/routers/films.py
@@ -0,0 +1,544 @@
+from collections import Counter
+from datetime import date, datetime
+from itertools import groupby
+from types import SimpleNamespace
+
+import httpx
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import RedirectResponse
+from fastapi.responses import JSONResponse
+from sqlalchemy import func
+from fastapi.templating import Jinja2Templates
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import Film
+from services.film_people import director_href, normalize_name, split_credit_names
+from services.tmdb import TMDBNotConfiguredError, detail_context as tmdb_detail_context, movie_detail
+
+router = APIRouter()
+templates = Jinja2Templates(directory="templates")
+templates.env.globals.update(director_href=director_href, split_credit_names=split_credit_names)
+
+ALLOWED_SHELVES = {"diary", "queue", "abandoned"}
+SHELF_META = {
+ "diary": {
+ "path": "/diary",
+ "eyebrow": "Film Diary",
+ "title": "Recently watched",
+ "empty_title": "No diary entries yet",
+ "empty_text": "Your diary is ready for its first screening.",
+ },
+ "queue": {
+ "path": "/queue",
+ "eyebrow": "Queue",
+ "title": "To watch",
+ "empty_title": "Nothing queued",
+ "empty_text": "Search TMDB or add a film manually to start a watch queue.",
+ },
+ "abandoned": {
+ "path": "/abandoned",
+ "eyebrow": "Abandoned",
+ "title": "Left unfinished",
+ "empty_title": "No abandoned films",
+ "empty_text": "Films you set aside will collect here.",
+ },
+}
+
+
+def _empty_to_none(value: str) -> str | None:
+ value = value.strip()
+ return value or None
+
+
+def _parse_int(value: str, field_name: str) -> int | None:
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError as exc:
+ raise ValueError(f"{field_name} must be a whole number.") from exc
+
+
+def _parse_nonnegative_int(value: str, field_name: str) -> int:
+ parsed = _parse_int(value, field_name)
+ if parsed is None:
+ return 0
+ if parsed < 0:
+ raise ValueError(f"{field_name} cannot be negative.")
+ return parsed
+
+
+def _parse_optional_nonnegative_int(value: str, field_name: str) -> int | None:
+ parsed = _parse_int(value, field_name)
+ if parsed is None:
+ return None
+ if parsed < 0:
+ raise ValueError(f"{field_name} cannot be negative.")
+ return parsed
+
+
+def _parse_stars(value: str) -> int:
+ stars = _parse_nonnegative_int(value, "Stars")
+ if stars not in {0, 1, 2, 3}:
+ raise ValueError("Stars must be 0, 1, 2, or 3.")
+ return stars
+
+
+def _parse_bool(value: str) -> bool:
+ return value.lower() in {"1", "true", "on", "yes"}
+
+
+def _parse_date(value: str) -> date | None:
+ value = value.strip()
+ if not value:
+ return None
+ for date_format in ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y"):
+ try:
+ return datetime.strptime(value, date_format).date()
+ except ValueError:
+ continue
+ raise ValueError("Date watched must be a valid date.")
+
+
+def _form_value(form, key: str) -> str:
+ value = form.get(key, "")
+ return str(value).strip()
+
+
+def _film_payload(form) -> dict:
+ title = _form_value(form, "title")
+ if not title:
+ raise ValueError("Title is required.")
+
+ shelf = _form_value(form, "shelf") or "diary"
+ if shelf not in ALLOWED_SHELVES:
+ raise ValueError("Shelf must be diary, queue, or abandoned.")
+
+ return {
+ "tmdb_id": _parse_int(_form_value(form, "tmdb_id"), "TMDB ID"),
+ "poster_url": _empty_to_none(_form_value(form, "poster_url")),
+ "title": title,
+ "original_title": _empty_to_none(_form_value(form, "original_title")),
+ "director": _empty_to_none(_form_value(form, "director")),
+ "year": _parse_int(_form_value(form, "year"), "Year"),
+ "country": _empty_to_none(_form_value(form, "country")),
+ "language": _empty_to_none(_form_value(form, "language")),
+ "runtime": _parse_optional_nonnegative_int(_form_value(form, "runtime"), "Runtime"),
+ "date_watched": _parse_date(_form_value(form, "date_watched")),
+ "rewatch": _parse_bool(_form_value(form, "rewatch")),
+ "rewatch_count": _parse_nonnegative_int(_form_value(form, "rewatch_count"), "Rewatch count"),
+ "stars": _parse_stars(_form_value(form, "stars")),
+ "watched_with": _empty_to_none(_form_value(form, "watched_with")),
+ "shelf": shelf,
+ "how_found": _empty_to_none(_form_value(form, "how_found")),
+ "context": _empty_to_none(_form_value(form, "context")),
+ "notes": _empty_to_none(_form_value(form, "notes")),
+ }
+
+
+def _film_from_form(form) -> SimpleNamespace:
+ data = {key: _form_value(form, key) for key in form.keys()}
+ data.setdefault("id", None)
+ data.setdefault("rewatch", False)
+ data.setdefault("rewatch_count", "0")
+ data.setdefault("stars", "0")
+ data.setdefault("shelf", "diary")
+ return SimpleNamespace(**data)
+
+
+def _shelf_path(shelf: str) -> str:
+ return SHELF_META.get(shelf, SHELF_META["diary"])["path"]
+
+
+def _get_film_or_404(db: Session, film_id: int) -> Film:
+ film = db.get(Film, film_id)
+ if film is None:
+ raise HTTPException(status_code=404, detail="Film not found.")
+ return film
+
+
+def _notice_context(
+ imported: int | None,
+ skipped: int | None,
+ cleared: int | None,
+ enriched: int | None,
+ deduped: int | None,
+ empty_queue: int | None = None,
+) -> dict:
+ return {
+ "imported": imported,
+ "skipped": skipped,
+ "cleared": cleared,
+ "enriched": enriched,
+ "deduped": deduped,
+ "empty_queue": empty_queue,
+ }
+
+
+def _group_films_by_month(films: list[Film]) -> list[dict]:
+ def month_key(film: Film) -> str:
+ return film.date_watched.strftime("%B %Y") if film.date_watched else "Unknown"
+
+ grouped = []
+ for month, group in groupby(films, key=month_key):
+ grouped.append({"month": month, "films": list(group)})
+ return grouped
+
+
+def _render_shelf(
+ shelf: str,
+ request: Request,
+ db: Session,
+ notices: dict,
+):
+ query = db.query(Film).filter(Film.shelf == shelf)
+ if shelf == "diary":
+ films = query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc()).all()
+ grouped_films = _group_films_by_month(films)
+ elif shelf == "queue":
+ films = query.order_by(Film.created_at.desc(), Film.id.desc()).all()
+ grouped_films = None
+ else:
+ films = query.order_by(Film.updated_at.desc(), Film.created_at.desc(), Film.id.desc()).all()
+ grouped_films = None
+
+ return templates.TemplateResponse(
+ request=request,
+ name="index.html",
+ context={
+ "request": request,
+ "films": films,
+ "grouped_films": grouped_films,
+ "active_shelf": shelf,
+ "shelf_meta": SHELF_META[shelf],
+ **notices,
+ },
+ )
+
+
+def _director_films(db: Session, director_name: str) -> list[Film]:
+ target_name = normalize_name(director_name)
+ films = db.query(Film).filter(Film.director.is_not(None), Film.director != "").all()
+ matches = []
+ for film in films:
+ if any(normalize_name(name) == target_name for name in split_credit_names(film.director)):
+ matches.append(film)
+ return matches
+
+
+@router.get("/")
+def home(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "diary",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped),
+ )
+
+
+@router.get("/diary")
+def diary_feed(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "diary",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped),
+ )
+
+
+@router.get("/queue")
+def queue_feed(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ empty_queue: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "queue",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped, empty_queue),
+ )
+
+
+@router.get("/abandoned")
+def abandoned_feed(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "abandoned",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped),
+ )
+
+
+@router.get("/films/new")
+def new_film(request: Request):
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": None,
+ "shelf_override": None,
+ "action": "/films",
+ "page_title": "Add Film",
+ "submit_label": "Add entry",
+ "error": None,
+ },
+ )
+
+
+@router.post("/films")
+async def create_film(request: Request, db: Session = Depends(get_db)):
+ form = await request.form()
+ try:
+ film = Film(**_film_payload(form))
+ except ValueError as exc:
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": _film_from_form(form),
+ "shelf_override": None,
+ "active_shelf": _form_value(form, "shelf") or "diary",
+ "action": "/films",
+ "page_title": "Add Film",
+ "submit_label": "Add entry",
+ "error": str(exc),
+ },
+ status_code=400,
+ )
+
+ db.add(film)
+ db.commit()
+ db.refresh(film)
+ return RedirectResponse(f"/films/{film.id}", status_code=303)
+
+
+async def _film_tmdb_context(film: Film) -> dict | None:
+ if not film.tmdb_id:
+ return None
+
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ try:
+ movie = await movie_detail(client, film.tmdb_id)
+ except (TMDBNotConfiguredError, httpx.HTTPError):
+ return None
+
+ context = tmdb_detail_context(movie)
+ if not context["overview"] and not context["cast"] and not context["tagline"]:
+ return None
+ return context
+
+
+@router.get("/films/{film_id}")
+async def film_detail(film_id: int, request: Request, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ tmdb_context = await _film_tmdb_context(film)
+ return templates.TemplateResponse(
+ request=request,
+ name="detail.html",
+ context={"request": request, "film": film, "active_shelf": film.shelf, "tmdb_context": tmdb_context},
+ )
+
+
+@router.post("/films/{film_id}/stars")
+async def update_film_stars(film_id: int, request: Request, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ if film.shelf != "diary":
+ raise HTTPException(status_code=400, detail="Stars can only be updated for diary entries.")
+
+ try:
+ payload = await request.json()
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(status_code=400, detail="Request body must be JSON.") from exc
+
+ try:
+ stars = int(payload.get("stars"))
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="Stars must be 0, 1, 2, or 3.") from exc
+
+ if stars not in {0, 1, 2, 3}:
+ raise HTTPException(status_code=400, detail="Stars must be 0, 1, 2, or 3.")
+
+ film.stars = stars
+ db.commit()
+ return {"film_id": film.id, "stars": film.stars}
+
+
+@router.get("/director/{director_name}")
+def director_detail(director_name: str, request: Request, db: Session = Depends(get_db)):
+ films = _director_films(db, director_name)
+ if not films:
+ raise HTTPException(status_code=404, detail="Director not found.")
+
+ display_name = next(
+ (
+ name
+ for film in films
+ for name in split_credit_names(film.director)
+ if normalize_name(name) == normalize_name(director_name)
+ ),
+ director_name,
+ )
+
+ films.sort(
+ key=lambda film: (
+ film.date_watched is None,
+ -(film.date_watched.toordinal()) if film.date_watched else 0,
+ film.year is None,
+ film.year or 0,
+ film.title.casefold(),
+ film.id,
+ )
+ )
+
+ shelf_counts = Counter(film.shelf for film in films)
+ most_common_shelf = sorted(
+ shelf_counts.items(),
+ key=lambda item: (-item[1], {"diary": 0, "queue": 1, "abandoned": 2}.get(item[0], 99)),
+ )[0][0]
+ average_stars = round(sum(film.stars for film in films) / len(films), 1)
+
+ return templates.TemplateResponse(
+ request=request,
+ name="director.html",
+ context={
+ "request": request,
+ "director_name": display_name,
+ "films": films,
+ "director_summary": {
+ "total_films_logged": len(films),
+ "average_stars": average_stars,
+ "most_common_shelf": most_common_shelf,
+ },
+ },
+ )
+
+
+@router.get("/queue/random")
+def queue_random(db: Session = Depends(get_db)):
+ film = db.query(Film).filter(Film.shelf == "queue").order_by(func.random()).first()
+ if film is None:
+ return RedirectResponse("/queue?empty_queue=1", status_code=303)
+ return RedirectResponse(f"/films/{film.id}", status_code=303)
+
+
+@router.get("/films/{film_id}/edit")
+def edit_film(
+ film_id: int,
+ request: Request,
+ shelf: str | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ film = _get_film_or_404(db, film_id)
+ shelf_override = shelf if shelf in ALLOWED_SHELVES else None
+ page_title = "Edit Film"
+ submit_label = "Save changes"
+ if shelf_override == "diary" and film.shelf != "diary":
+ page_title = "Mark Watched"
+ submit_label = "Save diary entry"
+
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": film,
+ "shelf_override": shelf_override,
+ "active_shelf": shelf_override or film.shelf,
+ "action": f"/films/{film.id}/edit",
+ "page_title": page_title,
+ "submit_label": submit_label,
+ "error": None,
+ },
+ )
+
+
+@router.post("/films/{film_id}/edit")
+async def update_film(film_id: int, request: Request, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ form = await request.form()
+ try:
+ payload = _film_payload(form)
+ except ValueError as exc:
+ form_film = _film_from_form(form)
+ form_film.id = film.id
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": form_film,
+ "shelf_override": None,
+ "active_shelf": _form_value(form, "shelf") or film.shelf,
+ "action": f"/films/{film.id}/edit",
+ "page_title": "Edit Film",
+ "submit_label": "Save changes",
+ "error": str(exc),
+ },
+ status_code=400,
+ )
+
+ for key, value in payload.items():
+ setattr(film, key, value)
+ db.commit()
+ return RedirectResponse(f"/films/{film.id}", status_code=303)
+
+
+@router.post("/films/{film_id}/shelf/{shelf}")
+def move_film_to_shelf(
+ film_id: int,
+ shelf: str,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ if shelf not in ALLOWED_SHELVES:
+ raise HTTPException(status_code=400, detail="Shelf must be diary, queue, or abandoned.")
+
+ film = _get_film_or_404(db, film_id)
+ film.shelf = shelf
+ db.commit()
+
+ redirect_to = request.headers.get("referer") or _shelf_path(shelf)
+ return RedirectResponse(redirect_to, status_code=303)
+
+
+@router.post("/films/{film_id}/delete")
+def delete_film(film_id: int, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ shelf = film.shelf
+ db.delete(film)
+ db.commit()
+ return RedirectResponse(_shelf_path(shelf), status_code=303)
diff --git a/routers/imports.py b/routers/imports.py
new file mode 100644
index 0000000..f6a202d
--- /dev/null
+++ b/routers/imports.py
@@ -0,0 +1,343 @@
+import csv
+import io
+from datetime import datetime
+
+import httpx
+from fastapi import APIRouter, Depends, File, Request, UploadFile
+from fastapi.responses import RedirectResponse
+from fastapi.templating import Jinja2Templates
+from sqlalchemy import or_, text
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import Film
+from services.tmdb import TMDBNotConfiguredError, apply_metadata_to_film, find_movie
+
+router = APIRouter(tags=["imports"])
+templates = Jinja2Templates(directory="templates")
+
+
+def _value(row: dict, *names: str) -> str:
+ for name in names:
+ value = row.get(name)
+ if value:
+ return value.strip()
+ return ""
+
+
+def _parse_int(value: str) -> int | None:
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError:
+ return None
+
+
+def _parse_bool(value: str) -> bool:
+ return value.strip().lower() in {"1", "true", "yes", "y", "on", "rewatched"}
+
+
+def _parse_explicit_stars(value: str) -> int | None:
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ stars = int(value)
+ except ValueError:
+ return None
+ return stars if stars in {0, 1, 2, 3} else None
+
+
+def _parse_rating_to_stars(value: str) -> int:
+ value = value.strip()
+ if not value:
+ return 0
+ try:
+ rating = float(value)
+ except ValueError:
+ return 0
+
+ if rating < 0 or rating > 5:
+ return 0
+ if rating >= 5:
+ return 3
+ if rating >= 4.5:
+ return 2
+ if rating >= 3.5:
+ return 1
+ return 0
+
+
+def _stars_from_row(row: dict) -> int:
+ explicit_stars = _parse_explicit_stars(_value(row, "Stars"))
+ if explicit_stars is not None:
+ return explicit_stars
+ return _parse_rating_to_stars(_value(row, "Rating"))
+
+
+def _parse_shelf(value: str) -> str:
+ shelf = value.strip().lower()
+ return shelf if shelf in {"diary", "queue", "abandoned"} else "diary"
+
+
+def _parse_date(value: str):
+ value = value.strip()
+ if not value:
+ return None
+ for date_format in ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y"):
+ try:
+ return datetime.strptime(value, date_format).date()
+ except ValueError:
+ continue
+ return None
+
+
+def _normalize_title(value: str | None) -> str:
+ return (value or "").casefold().strip()
+
+
+def _date_key(value) -> str:
+ return value.isoformat() if value else ""
+
+
+def _duplicate_keys_for_film(film: Film) -> set[tuple]:
+ keys = set()
+ watched_date = _date_key(film.date_watched)
+ if film.tmdb_id:
+ keys.add(("tmdb", str(film.tmdb_id), watched_date))
+
+ title = _normalize_title(film.title)
+ if title:
+ keys.add(("title", title, str(film.year or ""), watched_date))
+
+ return keys
+
+
+def _existing_duplicate_keys(db: Session) -> set[tuple]:
+ keys = set()
+ for film in db.query(Film).all():
+ keys.update(_duplicate_keys_for_film(film))
+ return keys
+
+
+def _decode_csv(content: bytes) -> str:
+ for encoding in ("utf-8-sig", "utf-8", "latin-1"):
+ try:
+ return content.decode(encoding)
+ except UnicodeDecodeError:
+ continue
+ return content.decode("utf-8", errors="replace")
+
+
+def _film_from_row(row: dict, shelf: str) -> Film | None:
+ title = _value(row, "Name", "Title")
+ if not title:
+ return None
+
+ is_diary = shelf == "diary"
+ return Film(
+ title=title,
+ original_title=_value(row, "Original Title") or None,
+ director=_value(row, "Director") or None,
+ year=_parse_int(_value(row, "Year")),
+ country=_value(row, "Country") or None,
+ language=_value(row, "Language") or None,
+ runtime=_parse_int(_value(row, "Runtime", "Runtime (mins)")),
+ date_watched=_parse_date(_value(row, "Watched Date", "Date")) if is_diary else None,
+ rewatch=_parse_bool(_value(row, "Rewatch")) if is_diary else False,
+ rewatch_count=(_parse_int(_value(row, "Rewatch Count")) or 0) if is_diary else 0,
+ stars=_stars_from_row(row) if is_diary else 0,
+ watched_with=_value(row, "Watched With", "Watched_with") or None,
+ shelf=shelf,
+ how_found=_value(row, "How Found", "How_found") or None,
+ context=_value(row, "Context", "Tags") or None,
+ notes=_value(row, "Review", "Notes") or None,
+ poster_url=_value(row, "Poster URL", "Poster") or None,
+ tmdb_id=_parse_int(_value(row, "TMDB ID", "tmdb_id")),
+ )
+
+
+async def _dedupe_enrich_and_save(
+ films: list[Film],
+ db: Session,
+) -> tuple[list[Film], int, int]:
+ skipped = 0
+ existing_keys = _existing_duplicate_keys(db)
+ pending_keys = set()
+ deduped_before_enrichment = []
+
+ for film in films:
+ film_keys = _duplicate_keys_for_film(film)
+ if film_keys & (existing_keys | pending_keys):
+ skipped += 1
+ continue
+
+ deduped_before_enrichment.append(film)
+ pending_keys.update(film_keys)
+
+ enriched = await _enrich_films_from_tmdb(deduped_before_enrichment)
+ deduped_after_enrichment = []
+ final_keys = set(existing_keys)
+ for film in deduped_before_enrichment:
+ film_keys = _duplicate_keys_for_film(film)
+ if film_keys & final_keys:
+ skipped += 1
+ continue
+ deduped_after_enrichment.append(film)
+ final_keys.update(film_keys)
+
+ if deduped_after_enrichment:
+ db.add_all(deduped_after_enrichment)
+ db.commit()
+
+ return deduped_after_enrichment, skipped, enriched
+
+
+async def _enrich_films_from_tmdb(films: list[Film]) -> int:
+ enriched = 0
+ if not films:
+ return enriched
+
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ for film in films:
+ if film.poster_url and film.tmdb_id:
+ continue
+ try:
+ metadata = await find_movie(film.title, year=film.year, client=client)
+ except TMDBNotConfiguredError:
+ return enriched
+ except httpx.HTTPError:
+ continue
+ if metadata and apply_metadata_to_film(film, metadata):
+ enriched += 1
+
+ return enriched
+
+
+@router.get("/import")
+def import_page(request: Request):
+ return templates.TemplateResponse(
+ request=request,
+ name="import.html",
+ context={"request": request, "error": None, "active_page": "import"},
+ )
+
+
+@router.post("/data/clear")
+def clear_data(db: Session = Depends(get_db)):
+ deleted = db.query(Film).delete(synchronize_session=False)
+ bind = db.get_bind()
+ if bind.dialect.name == "sqlite":
+ db.execute(text("DELETE FROM sqlite_sequence WHERE name = 'films'"))
+ db.commit()
+ return RedirectResponse(f"/?cleared={deleted}", status_code=303)
+
+
+@router.post("/data/clear-duplicates")
+def clear_duplicates(db: Session = Depends(get_db)):
+ seen_keys = set()
+ duplicate_ids = []
+ for film in db.query(Film).order_by(Film.id.asc()).all():
+ film_keys = _duplicate_keys_for_film(film)
+ if film_keys & seen_keys:
+ duplicate_ids.append(film.id)
+ continue
+ seen_keys.update(film_keys)
+
+ deleted = 0
+ if duplicate_ids:
+ deleted = (
+ db.query(Film)
+ .filter(Film.id.in_(duplicate_ids))
+ .delete(synchronize_session=False)
+ )
+ db.commit()
+
+ return RedirectResponse(f"/?deduped={deleted}", status_code=303)
+
+
+@router.post("/data/enrich-posters")
+async def enrich_missing_posters(db: Session = Depends(get_db)):
+ films = (
+ db.query(Film)
+ .filter(or_(Film.poster_url.is_(None), Film.poster_url == "", Film.tmdb_id.is_(None)))
+ .order_by(Film.year.asc(), Film.title.asc())
+ .all()
+ )
+ enriched = await _enrich_films_from_tmdb(films)
+ if enriched:
+ db.commit()
+ return RedirectResponse(f"/?enriched={enriched}", status_code=303)
+
+
+@router.post("/import/letterboxd")
+async def import_letterboxd(
+ request: Request,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ if not file.filename.lower().endswith(".csv"):
+ return templates.TemplateResponse(
+ request=request,
+ name="import.html",
+ context={"request": request, "error": "Upload a CSV file.", "active_page": "import"},
+ status_code=400,
+ )
+
+ content = await file.read()
+ reader = csv.DictReader(io.StringIO(_decode_csv(content)))
+ films = []
+
+ for row in reader:
+ if not any(value.strip() for value in row.values() if value):
+ continue
+
+ film = _film_from_row(row, _parse_shelf(_value(row, "Shelf")))
+ if film is None:
+ continue
+ films.append(film)
+
+ films, skipped, enriched = await _dedupe_enrich_and_save(films, db)
+
+ return RedirectResponse(
+ f"/?imported={len(films)}&skipped={skipped}&enriched={enriched}",
+ status_code=303,
+ )
+
+
+@router.post("/import/watchlist")
+async def import_watchlist(
+ request: Request,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ if not file.filename.lower().endswith(".csv"):
+ return templates.TemplateResponse(
+ request=request,
+ name="import.html",
+ context={"request": request, "error": "Upload a CSV file.", "active_page": "import"},
+ status_code=400,
+ )
+
+ content = await file.read()
+ reader = csv.DictReader(io.StringIO(_decode_csv(content)))
+ films = []
+
+ for row in reader:
+ if not any(value.strip() for value in row.values() if value):
+ continue
+
+ film = _film_from_row(row, "queue")
+ if film is None:
+ continue
+
+ films.append(film)
+
+ films, skipped, enriched = await _dedupe_enrich_and_save(films, db)
+
+ return RedirectResponse(
+ f"/queue?imported={len(films)}&skipped={skipped}&enriched={enriched}",
+ status_code=303,
+ )
diff --git a/routers/stats.py b/routers/stats.py
new file mode 100644
index 0000000..78e9621
--- /dev/null
+++ b/routers/stats.py
@@ -0,0 +1,126 @@
+from collections import Counter
+from datetime import date, timedelta
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.templating import Jinja2Templates
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import Film
+from services.countries import (
+ ISO_NUMERIC_TO_COUNTRY_NAME,
+ country_name_to_iso_numeric,
+ split_country_names,
+)
+from services.film_people import split_credit_names
+
+router = APIRouter(tags=["stats"])
+templates = Jinja2Templates(directory="templates")
+
+
+def _build_stats_payload(films: list[Film]) -> dict:
+ countries = Counter()
+ country_codes = Counter()
+ directors = Counter()
+ star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0})
+ months = Counter()
+ days = Counter()
+ watched_with = Counter()
+
+ for film in films:
+ country_names = split_country_names(film.country)
+ countries.update(country_names)
+ for country in country_names:
+ iso_numeric = country_name_to_iso_numeric(country)
+ if iso_numeric is not None:
+ country_codes[iso_numeric] += 1
+
+ directors.update(split_credit_names(film.director))
+
+ stars = film.stars if film.stars in {0, 1, 2, 3} else 0
+ star_counts[stars] += 1
+
+ if film.date_watched:
+ months[film.date_watched.strftime("%Y-%m")] += 1
+ days[film.date_watched.isoformat()] += 1
+
+ companions = split_credit_names(film.watched_with)
+ if companions:
+ watched_with.update(companions)
+ else:
+ watched_with["solo"] += 1
+
+ total_watched = len(films)
+ rewatched = sum(1 for film in films if film.rewatch or film.rewatch_count > 0)
+
+ today = date.today()
+ start_day = today - timedelta(days=364)
+ trailing_days = []
+ cursor = start_day
+ while cursor <= today:
+ trailing_days.append({"date": cursor.isoformat(), "count": days[cursor.isoformat()]})
+ cursor += timedelta(days=1)
+
+ return {
+ "scope": {
+ "shelf": "diary",
+ "requires_date_watched": True,
+ },
+ "total_watched": total_watched,
+ "films_per_country": [
+ {"country": country, "count": count}
+ for country, count in sorted(countries.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "films_per_country_codes": [
+ {"code": code, "count": count}
+ for code, count in sorted(country_codes.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "country_labels_by_code": {
+ str(code): ISO_NUMERIC_TO_COUNTRY_NAME.get(code, str(code)) for code in country_codes
+ },
+ "most_watched_directors": [
+ {"director": director, "count": count}
+ for director, count in sorted(directors.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "star_distribution": [{"stars": stars, "count": star_counts[stars]} for stars in (0, 1, 2, 3)],
+ "films_per_month": [
+ {"month": month, "count": count}
+ for month, count in sorted(months.items())
+ ],
+ "films_per_day": [
+ {"date": watched_date, "count": count}
+ for watched_date, count in sorted(days.items())
+ ],
+ "films_per_day_365": trailing_days,
+ "rewatch_rate": {
+ "rewatched": rewatched,
+ "total_watched": total_watched,
+ "rate": round(rewatched / total_watched, 4) if total_watched else 0,
+ },
+ "watched_with_breakdown": [
+ {"watched_with": watched_with_value, "count": count}
+ for watched_with_value, count in sorted(watched_with.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ }
+
+
+def _diary_films(db: Session) -> list[Film]:
+ return (
+ db.query(Film)
+ .filter(Film.shelf == "diary", Film.date_watched.is_not(None))
+ .all()
+ )
+
+
+@router.get("/stats")
+def stats_page(request: Request):
+ return templates.TemplateResponse(
+ request=request,
+ name="stats.html",
+ context={"request": request, "active_page": "stats"},
+ )
+
+
+@router.get("/stats/data")
+def stats_data(db: Session = Depends(get_db)):
+ return _build_stats_payload(_diary_films(db))
diff --git a/routers/tmdb.py b/routers/tmdb.py
new file mode 100644
index 0000000..522c1d0
--- /dev/null
+++ b/routers/tmdb.py
@@ -0,0 +1,32 @@
+import httpx
+from dotenv import load_dotenv
+from fastapi import APIRouter, Query
+from fastapi.responses import JSONResponse
+
+from services.tmdb import TMDBNotConfiguredError, search_movies
+
+load_dotenv()
+
+router = APIRouter(prefix="/tmdb", tags=["tmdb"])
+
+
+@router.get("/search")
+async def search_tmdb(q: str = Query(..., min_length=2)):
+ try:
+ return {"results": await search_movies(q, limit=8, include_details=True)}
+ except TMDBNotConfiguredError:
+ return JSONResponse(
+ status_code=503,
+ content={
+ "error": "TMDB_API_KEY is not configured.",
+ "results": [],
+ },
+ )
+ except httpx.HTTPError:
+ return JSONResponse(
+ status_code=502,
+ content={
+ "error": "TMDB search failed. Check your API key and try again.",
+ "results": [],
+ },
+ )