summaryrefslogtreecommitdiff
path: root/routers
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 18:05:07 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 18:05:07 -0700
commitead38fdb13abb406065cef0743d7e411cb27eaf3 (patch)
tree56f5bc31be37833505726ac61cec0cfd19a7be92 /routers
parent2d298f982408f222ad344b2aa9c18bbe7dc70f12 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'routers')
-rw-r--r--routers/films.py7
-rw-r--r--routers/imports.py10
-rw-r--r--routers/stats.py37
-rw-r--r--routers/tmdb.py20
4 files changed, 71 insertions, 3 deletions
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: