summaryrefslogtreecommitdiff
path: root/routers/films.py
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 12:21:26 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 12:21:26 -0700
commite708bec6cd76c2686de4158dde4d04f72a3c300d (patch)
tree04b0bc4738e090dd7834d47478c7e652da010f92 /routers/films.py
init: lumiere film diary
Diffstat (limited to 'routers/films.py')
-rw-r--r--routers/films.py544
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)