From ead38fdb13abb406065cef0743d7e411cb27eaf3 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Wed, 6 May 2026 18:05:07 -0700 Subject: Add genre tracking and year-in-review improvements Adds genre field to Film model with TMDB enrichment. Genres populate from TMDB detail fetch during add/edit and bulk enrichment. Genre metadata displays on film cards, detail page (Production section), stats page (top genres panel), and year-in-review (by decade and genre breakdowns). Auto-detects rewatches when adding films via TMDB autocomplete - if a film with the same TMDB ID already exists in diary, pre-fills rewatch checkbox and count. Rewatch count now displays on film cards as "Rewatch #N". Stats page now shows: - Top genres (most watched) - Film decades (sorted chronologically) - Already shows: directors, companions, star distribution, rewatch rate Year-in-review shows decades and genres alongside monthly activity and companions. Bulk enrichment endpoint (/data/enrich-posters) now fetches missing genre metadata along with posters and TMDB IDs. Co-Authored-By: Claude Haiku 4.5 --- routers/films.py | 7 +++++++ routers/imports.py | 10 ++++++++-- routers/stats.py | 37 +++++++++++++++++++++++++++++++++++++ routers/tmdb.py | 20 +++++++++++++++++++- 4 files changed, 71 insertions(+), 3 deletions(-) (limited to 'routers') diff --git a/routers/films.py b/routers/films.py index 4ac023c..e432a5c 100644 --- a/routers/films.py +++ b/routers/films.py @@ -123,6 +123,7 @@ def _film_payload(form) -> dict: "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")), @@ -332,6 +333,12 @@ def abandoned_feed( ) +@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( diff --git a/routers/imports.py b/routers/imports.py index f6a202d..10c0f49 100644 --- a/routers/imports.py +++ b/routers/imports.py @@ -202,7 +202,7 @@ async def _enrich_films_from_tmdb(films: list[Film]) -> int: async with httpx.AsyncClient(timeout=10.0) as client: for film in films: - if film.poster_url and film.tmdb_id: + if film.poster_url and film.tmdb_id and film.genre: continue try: metadata = await find_movie(film.title, year=film.year, client=client) @@ -262,7 +262,13 @@ def clear_duplicates(db: Session = Depends(get_db)): 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))) + .filter(or_( + Film.poster_url.is_(None), + Film.poster_url == "", + Film.tmdb_id.is_(None), + Film.genre.is_(None), + Film.genre == "", + )) .order_by(Film.year.asc(), Film.title.asc()) .all() ) diff --git a/routers/stats.py b/routers/stats.py index 0604646..67b5141 100644 --- a/routers/stats.py +++ b/routers/stats.py @@ -15,6 +15,11 @@ from services.countries import ( ) from services.film_people import split_credit_names +def split_genre_names(genre_str: str | None) -> list[str]: + if not genre_str: + return [] + return [name.strip() for name in genre_str.split(",") if name.strip()] + router = APIRouter(tags=["stats"]) templates = Jinja2Templates(directory="templates") @@ -22,7 +27,9 @@ templates = Jinja2Templates(directory="templates") def _build_stats_payload(films: list[Film]) -> dict: countries = Counter() country_codes = Counter() + decades = Counter() directors = Counter() + genres = Counter() star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0}) months = Counter() days = Counter() @@ -36,6 +43,12 @@ def _build_stats_payload(films: list[Film]) -> dict: if iso_numeric is not None: country_codes[iso_numeric] += 1 + if film.year: + decade = (film.year // 10) * 10 + decades[f"{decade}s"] += 1 + + genres.update(split_genre_names(film.genre)) + directors.update(split_credit_names(film.director)) stars = film.stars if film.stars in {0, 1, 2, 3} else 0 @@ -83,6 +96,14 @@ def _build_stats_payload(films: list[Film]) -> dict: {"director": director, "count": count} for director, count in sorted(directors.items(), key=lambda item: (-item[1], item[0])) ], + "films_per_decade": [ + {"decade": decade, "count": count} + for decade, count in sorted(decades.items(), key=lambda item: (item[0], -item[1])) + ], + "films_per_genre": [ + {"genre": genre, "count": count} + for genre, count in sorted(genres.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} @@ -170,6 +191,8 @@ def _year_review_payload(db: Session, year: int | None) -> dict: "total_watched": 0, "average_stars": 0, "most_watched_directors": [], + "films_per_decade": [], + "films_per_genre": [], "star_distribution": [{"stars": stars, "count": 0} for stars in (0, 1, 2, 3)], "films_per_month": [{"month": month_name[index], "count": 0} for index in range(1, 13)], "rewatch_rate": {"rewatched": 0, "total_watched": 0, "rate": 0}, @@ -186,13 +209,19 @@ def _year_review_payload(db: Session, year: int | None) -> dict: year_films = _films_for_year(diary_films, selected_year) countries = Counter() + decades = Counter() directors = Counter() + genres = Counter() star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0}) months = Counter({month_index: 0 for month_index in range(1, 13)}) watched_with = Counter() for film in year_films: countries.update(split_country_names(film.country)) + if film.year: + decade = (film.year // 10) * 10 + decades[f"{decade}s"] += 1 + genres.update(split_genre_names(film.genre)) directors.update(split_credit_names(film.director)) stars = film.stars if film.stars in {0, 1, 2, 3} else 0 star_counts[stars] += 1 @@ -272,6 +301,14 @@ def _year_review_payload(db: Session, year: int | None) -> dict: {"month": month_name[index], "count": months[index]} for index in range(1, 13) ], + "films_per_decade": [ + {"decade": decade, "count": count} + for decade, count in sorted(decades.items(), key=lambda item: (item[0], -item[1])) + ], + "films_per_genre": [ + {"genre": genre, "count": count} + for genre, count in sorted(genres.items(), key=lambda item: (-item[1], item[0])) + ], "star_distribution": [{"stars": stars, "count": star_counts[stars]} for stars in (0, 1, 2, 3)], "most_watched_directors": [ {"director": director, "count": count} diff --git a/routers/tmdb.py b/routers/tmdb.py index 5f7943f..f56a9df 100644 --- a/routers/tmdb.py +++ b/routers/tmdb.py @@ -3,7 +3,7 @@ from dotenv import load_dotenv from fastapi import APIRouter, Query from fastapi.responses import JSONResponse -from services.tmdb import TMDBNotConfiguredError, search_movies, movie_images +from services.tmdb import TMDBNotConfiguredError, search_movies, movie_images, movie_detail, movie_payload load_dotenv() @@ -32,6 +32,24 @@ async def search_tmdb(q: str = Query(..., min_length=2)): ) +@router.get("/detail/{tmdb_id}") +async def tmdb_detail(tmdb_id: int): + try: + async with httpx.AsyncClient(timeout=10.0) as client: + detail = await movie_detail(client, tmdb_id) + return movie_payload(detail) + except TMDBNotConfiguredError: + return JSONResponse( + status_code=503, + content={"error": "TMDB_API_KEY is not configured."}, + ) + except httpx.HTTPError: + return JSONResponse( + status_code=502, + content={"error": "Failed to fetch TMDB details."}, + ) + + @router.get("/posters") async def tmdb_posters(tmdb_id: int = Query(...)): try: -- cgit v1.3-2-g0d8e