diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 12:21:26 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 12:21:26 -0700 |
| commit | e708bec6cd76c2686de4158dde4d04f72a3c300d (patch) | |
| tree | 04b0bc4738e090dd7834d47478c7e652da010f92 /routers/films.py | |
init: lumiere film diary
Diffstat (limited to 'routers/films.py')
| -rw-r--r-- | routers/films.py | 544 |
1 files changed, 544 insertions, 0 deletions
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) |
