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 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")), "genre": _empty_to_none(_form_value(form, "genre")), "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, } _SORT_COLUMNS = { "date_watched_desc": [Film.date_watched.desc().nullslast(), Film.id.desc()], "date_watched_asc": [Film.date_watched.asc().nullslast(), Film.id.asc()], "title_asc": [Film.title.asc(), Film.id.asc()], "title_desc": [Film.title.desc(), Film.id.desc()], "year_desc": [Film.year.desc().nullslast(), Film.id.desc()], "year_asc": [Film.year.asc().nullslast(), Film.id.asc()], "stars_desc": [Film.stars.desc(), Film.id.desc()], "stars_asc": [Film.stars.asc(), Film.id.asc()], } def _get_shelf_query(db: Session, shelf: str, q: str | None = None, sort: str | None = None): """Get a query for films on a shelf, ordered appropriately.""" query = db.query(Film).filter(Film.shelf == shelf) if q: term = f"%{q}%" query = query.filter(Film.title.ilike(term) | Film.director.ilike(term)) if sort and sort in _SORT_COLUMNS: return query.order_by(*_SORT_COLUMNS[sort]) if shelf == "diary": return query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc()) elif shelf == "queue": return query.order_by(Film.created_at.desc(), Film.id.desc()) else: # abandoned return query.order_by(Film.updated_at.desc(), Film.created_at.desc(), Film.id.desc()) 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 = _get_shelf_query(db, shelf) total_films = query.count() films = query.limit(20).all() has_more = total_films > 20 if shelf == "diary": grouped_films = _group_films_by_month(films) else: 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], "total_films": total_films, "has_more": has_more, **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/check-rewatch") def check_rewatch(tmdb_id: int, db: Session = Depends(get_db)): count = db.query(Film).filter(Film.tmdb_id == tmdb_id, Film.shelf == "diary").count() return {"count": count} @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/partial") def get_films_partial( request: Request, shelf: str = Query("diary"), offset: int = Query(0), limit: int = Query(20), q: str | None = Query(None), sort: str | None = Query(None), db: Session = Depends(get_db), ): """Returns partial HTML for pagination. Used by infinite scroll.""" if shelf not in ALLOWED_SHELVES: raise HTTPException(status_code=400, detail="Invalid shelf.") search_active = bool(q and q.strip()) query = _get_shelf_query(db, shelf, q=q or None, sort=sort or None) total = query.count() films = query.offset(offset).limit(limit).all() has_more = (offset + limit) < total if shelf == "diary" and not search_active: grouped_films = _group_films_by_month(films) else: grouped_films = None from fastapi.responses import HTMLResponse html = templates.get_template("_feed_partial.html").render( request=request, films=films, grouped_films=grouped_films, active_shelf=shelf, search_active=search_active, ) response = HTMLResponse(html) response.headers["X-Has-More"] = "true" if has_more else "false" return response @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) rewatch_history = ( db.query(Film) .filter(Film.title == film.title, Film.shelf == "diary") .order_by(Film.date_watched.asc()) .all() ) return templates.TemplateResponse( request=request, name="detail.html", context={ "request": request, "film": film, "active_shelf": film.shelf, "tmdb_context": tmdb_context, "rewatch_history": rewatch_history, }, ) @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.post("/films/{film_id}/poster") async def update_film_poster(film_id: int, request: Request, db: Session = Depends(get_db)): film = _get_film_or_404(db, film_id) try: payload = await request.json() except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail="Request body must be JSON.") from exc poster_url = payload.get("poster_url", "").strip() if not poster_url: raise HTTPException(status_code=400, detail="poster_url is required.") film.poster_url = poster_url db.commit() return {"film_id": film.id, "poster_url": film.poster_url} @router.get("/director/{director_name}") async 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) from services.tmdb import director_info director_data = await director_info(display_name) return templates.TemplateResponse( request=request, name="director.html", context={ "request": request, "director_name": display_name, "director_image": director_data["image"], "director_biography": director_data["biography"], "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)