From e708bec6cd76c2686de4158dde4d04f72a3c300d Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Wed, 6 May 2026 12:21:26 -0700 Subject: init: lumiere film diary --- routers/__init__.py | 1 + routers/films.py | 544 ++++++++++++++++++++++++++++++++++++++++++++++++++++ routers/imports.py | 343 +++++++++++++++++++++++++++++++++ routers/stats.py | 126 ++++++++++++ routers/tmdb.py | 32 ++++ 5 files changed, 1046 insertions(+) create mode 100644 routers/__init__.py create mode 100644 routers/films.py create mode 100644 routers/imports.py create mode 100644 routers/stats.py create mode 100644 routers/tmdb.py (limited to 'routers') 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": [], + }, + ) -- cgit v1.3-2-g0d8e