summaryrefslogtreecommitdiff
path: root/routers
diff options
context:
space:
mode:
Diffstat (limited to 'routers')
-rw-r--r--routers/films.py1
-rw-r--r--routers/stats.py207
2 files changed, 207 insertions, 1 deletions
diff --git a/routers/films.py b/routers/films.py
index 0a6491f..fc087b1 100644
--- a/routers/films.py
+++ b/routers/films.py
@@ -6,7 +6,6 @@ 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
diff --git a/routers/stats.py b/routers/stats.py
index 78e9621..0604646 100644
--- a/routers/stats.py
+++ b/routers/stats.py
@@ -1,4 +1,5 @@
from collections import Counter
+from calendar import month_name
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request
@@ -112,6 +113,190 @@ def _diary_films(db: Session) -> list[Film]:
)
+def _available_years(films: list[Film]) -> list[int]:
+ years = {film.date_watched.year for film in films if film.date_watched}
+ return sorted(years, reverse=True)
+
+
+def _format_film_excerpt(notes: str | None, limit: int = 140) -> str | None:
+ if not notes:
+ return None
+ excerpt = " ".join(notes.split())
+ if len(excerpt) <= limit:
+ return excerpt
+ return excerpt[: limit - 1].rstrip() + "..."
+
+
+def _film_highlight_payload(film: Film) -> dict:
+ return {
+ "id": film.id,
+ "title": film.title,
+ "poster_url": film.poster_url,
+ "director": film.director,
+ "year": film.year,
+ "date_watched": film.date_watched.isoformat() if film.date_watched else None,
+ "stars": film.stars,
+ "notes_excerpt": _format_film_excerpt(film.notes),
+ "runtime": film.runtime,
+ "country": film.country,
+ "language": film.language,
+ }
+
+
+def _select_year(selected_year: int | None, available_years: list[int]) -> int | None:
+ if selected_year is not None:
+ return selected_year
+ if available_years:
+ return available_years[0]
+ return None
+
+
+def _films_for_year(films: list[Film], year: int) -> list[Film]:
+ return [
+ film
+ for film in films
+ if film.date_watched and film.date_watched.year == year
+ ]
+
+
+def _year_review_payload(db: Session, year: int | None) -> dict:
+ diary_films = _diary_films(db)
+ available_years = _available_years(diary_films)
+ selected_year = _select_year(year, available_years)
+ if selected_year is None:
+ return {
+ "selected_year": date.today().year if year is None else year,
+ "available_years": [],
+ "total_watched": 0,
+ "average_stars": 0,
+ "most_watched_directors": [],
+ "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},
+ "watched_with_breakdown": [],
+ "top_director": None,
+ "top_month": None,
+ "highlight_films": {
+ "highest_rated": [],
+ "first_watch": None,
+ "last_watch": None,
+ "most_rewatched": None,
+ },
+ }
+
+ year_films = _films_for_year(diary_films, selected_year)
+ countries = Counter()
+ directors = 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))
+ directors.update(split_credit_names(film.director))
+ stars = film.stars if film.stars in {0, 1, 2, 3} else 0
+ star_counts[stars] += 1
+ if film.date_watched:
+ months[film.date_watched.month] += 1
+ companions = split_credit_names(film.watched_with)
+ if companions:
+ watched_with.update(companions)
+ else:
+ watched_with["solo"] += 1
+
+ total_watched = len(year_films)
+ rewatched = sum(1 for film in year_films if film.rewatch or film.rewatch_count > 0)
+ average_stars = round(sum(film.stars for film in year_films) / total_watched, 1) if total_watched else 0
+
+ top_director = None
+ if directors:
+ top_director_name, top_director_count = sorted(directors.items(), key=lambda item: (-item[1], item[0]))[0]
+ top_director = {"director": top_director_name, "count": top_director_count}
+
+ top_month = None
+ if total_watched:
+ top_month_index, top_month_count = sorted(months.items(), key=lambda item: (-item[1], item[0]))[0]
+ top_month = {
+ "month": month_name[top_month_index],
+ "count": top_month_count,
+ }
+
+ year_films_sorted = sorted(
+ year_films,
+ key=lambda film: (
+ -(film.stars or 0),
+ -(film.date_watched.toordinal()) if film.date_watched else 0,
+ film.title.casefold(),
+ film.id,
+ ),
+ )
+ highest_rated = [_film_highlight_payload(film) for film in year_films_sorted[:4]]
+
+ first_watch = None
+ last_watch = None
+ if year_films:
+ first_watch_film = min(
+ year_films,
+ key=lambda film: (film.date_watched or date.max, film.id),
+ )
+ last_watch_film = max(
+ year_films,
+ key=lambda film: (film.date_watched or date.min, film.id),
+ )
+ first_watch = _film_highlight_payload(first_watch_film)
+ last_watch = _film_highlight_payload(last_watch_film)
+
+ most_rewatched = None
+ rewatched_candidates = [
+ film
+ for film in year_films
+ if film.rewatch_count > 0 or film.rewatch
+ ]
+ if rewatched_candidates:
+ rewatched_candidates.sort(
+ key=lambda film: (
+ -film.rewatch_count,
+ -(film.date_watched.toordinal()) if film.date_watched else 0,
+ film.title.casefold(),
+ film.id,
+ )
+ )
+ most_rewatched = _film_highlight_payload(rewatched_candidates[0])
+
+ return {
+ "selected_year": selected_year,
+ "available_years": available_years,
+ "total_watched": total_watched,
+ "average_stars": average_stars,
+ "films_per_month": [
+ {"month": month_name[index], "count": months[index]}
+ for index in range(1, 13)
+ ],
+ "star_distribution": [{"stars": stars, "count": star_counts[stars]} for stars in (0, 1, 2, 3)],
+ "most_watched_directors": [
+ {"director": director, "count": count}
+ for director, count in sorted(directors.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "watched_with_breakdown": [
+ {"watched_with": watched_with_value, "count": count}
+ for watched_with_value, count in sorted(watched_with.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "rewatch_rate": {
+ "rewatched": rewatched,
+ "total_watched": total_watched,
+ "rate": round(rewatched / total_watched, 4) if total_watched else 0,
+ },
+ "top_director": top_director,
+ "top_month": top_month,
+ "highlight_films": {
+ "highest_rated": highest_rated,
+ "first_watch": first_watch,
+ "last_watch": last_watch,
+ "most_rewatched": most_rewatched,
+ },
+ }
+
+
@router.get("/stats")
def stats_page(request: Request):
return templates.TemplateResponse(
@@ -124,3 +309,25 @@ def stats_page(request: Request):
@router.get("/stats/data")
def stats_data(db: Session = Depends(get_db)):
return _build_stats_payload(_diary_films(db))
+
+
+@router.get("/stats/year-in-review")
+def year_in_review_page(
+ request: Request,
+ year: int | None = None,
+ db: Session = Depends(get_db),
+):
+ review = _year_review_payload(db, year)
+ return templates.TemplateResponse(
+ request=request,
+ name="year_review.html",
+ context={"request": request, "active_page": "stats", **review},
+ )
+
+
+@router.get("/stats/year-in-review/data")
+def year_in_review_data(
+ year: int | None = None,
+ db: Session = Depends(get_db),
+):
+ return _year_review_payload(db, year)