diff options
| -rw-r--r-- | models.py | 1 | ||||
| -rw-r--r-- | routers/films.py | 7 | ||||
| -rw-r--r-- | routers/imports.py | 10 | ||||
| -rw-r--r-- | routers/stats.py | 37 | ||||
| -rw-r--r-- | routers/tmdb.py | 20 | ||||
| -rw-r--r-- | services/tmdb.py | 8 | ||||
| -rw-r--r-- | static/app.js | 44 | ||||
| -rw-r--r-- | templates/_film_card.html | 5 | ||||
| -rw-r--r-- | templates/detail.html | 1 | ||||
| -rw-r--r-- | templates/form.html | 5 | ||||
| -rw-r--r-- | templates/stats.html | 30 | ||||
| -rw-r--r-- | templates/year_review.html | 18 |
12 files changed, 179 insertions, 7 deletions
@@ -27,6 +27,7 @@ class Film(Base): director: Mapped[str | None] = mapped_column(String(255), nullable=True) year: Mapped[int | None] = mapped_column(Integer, nullable=True) country: Mapped[str | None] = mapped_column(String(255), nullable=True) + genre: Mapped[str | None] = mapped_column(String(255), nullable=True) language: Mapped[str | None] = mapped_column(String(255), nullable=True) runtime: Mapped[int | None] = mapped_column(Integer, nullable=True) date_watched: Mapped[date | None] = mapped_column(Date, nullable=True) 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: diff --git a/services/tmdb.py b/services/tmdb.py index 3574f57..27f9f5b 100644 --- a/services/tmdb.py +++ b/services/tmdb.py @@ -46,6 +46,12 @@ def countries(movie: dict) -> str | None: return ", ".join(names) if names else None +def genres(movie: dict) -> str | None: + names = [genre.get("name") for genre in movie.get("genres", [])] + names = [name for name in names if name] + return ", ".join(names) if names else None + + def languages(movie: dict) -> str | None: names = [ language.get("english_name") or language.get("name") @@ -115,6 +121,7 @@ def movie_payload(movie: dict, fallback: dict | None = None) -> dict: "release_date": release_date, "director": directors(movie), "country": countries(movie), + "genre": genres(movie), "language": languages(movie), "runtime": movie.get("runtime"), "overview": movie.get("overview") or fallback.get("overview"), @@ -275,6 +282,7 @@ def apply_metadata_to_film(film, metadata: dict) -> bool: "director": metadata.get("director"), "year": metadata.get("year"), "country": metadata.get("country"), + "genre": metadata.get("genre"), "language": metadata.get("language"), "runtime": metadata.get("runtime"), } diff --git a/static/app.js b/static/app.js index 7cd501a..fae2dbd 100644 --- a/static/app.js +++ b/static/app.js @@ -95,7 +95,7 @@ const renderMessage = (message) => { tmdbResults.appendChild(node); }; -const applyResult = (film) => { +const applyResult = async (film) => { setValue("#title", film.title || ""); setValue("#original_title", film.original_title || ""); setValue("#director", film.director || ""); @@ -105,6 +105,39 @@ const applyResult = (film) => { setValue("#runtime", film.runtime || ""); setValue("#tmdb_id", film.tmdb_id || ""); setPoster(film.poster_url); + + // Check if this is the add form (not edit) + const isAddForm = window.location.pathname.endsWith("/films/new"); + + if (film.tmdb_id) { + try { + // Fetch full detail for genre and check for rewatches in parallel + const [detailResponse, rewatchResponse] = await Promise.all([ + fetch(`/tmdb/detail/${film.tmdb_id}`), + isAddForm ? fetch(`/films/check-rewatch?tmdb_id=${film.tmdb_id}`) : Promise.resolve(null), + ]); + + if (detailResponse.ok) { + const detail = await detailResponse.json(); + setValue("#genre", detail.genre || ""); + } + + if (rewatchResponse && rewatchResponse.ok) { + const rw = await rewatchResponse.json(); + if (rw.count > 0) { + const rewatchCheckbox = document.getElementById("rewatch"); + if (rewatchCheckbox) { + rewatchCheckbox.checked = true; + setValue("#rewatch_count", String(rw.count)); + } + } + } + } catch (error) { + // Fail silently if detail/rewatch fetch fails + console.error("Failed to fetch details", error); + } + } + clearResults(); }; @@ -120,7 +153,14 @@ const renderResults = (films) => { const button = document.createElement("button"); button.type = "button"; button.className = "tmdb-result"; - button.addEventListener("click", () => applyResult(film)); + button.addEventListener("click", async () => { + button.disabled = true; + try { + await applyResult(film); + } finally { + button.disabled = false; + } + }); if (film.poster_url) { const image = document.createElement("img"); diff --git a/templates/_film_card.html b/templates/_film_card.html index 5e8e6e4..a4e206f 100644 --- a/templates/_film_card.html +++ b/templates/_film_card.html @@ -43,11 +43,12 @@ {% if film.date_watched %}<span>{{ film.date_watched }}</span>{% endif %} {% if film.runtime %}<span>{{ film.runtime }} min</span>{% endif %} {% if film.language %}<span>{{ film.language }}</span>{% endif %} - {% if film.rewatch %}<span>Rewatch</span>{% endif %} + {% if film.rewatch %}<span>Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}</span>{% endif %} </div> - {% if film.context or film.how_found or film.watched_with %} + {% if film.genre or film.context or film.how_found or film.watched_with %} <div class="tag-row"> + {% if film.genre %}<span>{{ film.genre }}</span>{% endif %} {% if film.context %}<span>{{ film.context }}</span>{% endif %} {% if film.how_found %}<span>{{ film.how_found }}</span>{% endif %} {% if film.watched_with %}<span>With {{ film.watched_with }}</span>{% endif %} diff --git a/templates/detail.html b/templates/detail.html index 20897b0..156dec2 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -78,6 +78,7 @@ <article class="detail-panel"> <p class="eyebrow">Production</p> <div class="detail-meta"> + {% if film.genre %}<span>{{ film.genre }}</span>{% endif %} {% if film.country %}<span>{{ film.country }}</span>{% endif %} {% if film.language %}<span>{{ film.language }}</span>{% endif %} {% if film.year %}<span>{{ film.year }}</span>{% endif %} diff --git a/templates/form.html b/templates/form.html index 4009e87..489d9c6 100644 --- a/templates/form.html +++ b/templates/form.html @@ -52,6 +52,11 @@ </div> <div class="field"> + <label for="genre">Genre</label> + <input id="genre" name="genre" value="{{ film.genre if film and film.genre else '' }}"> + </div> + + <div class="field"> <label for="language">Language</label> <input id="language" name="language" value="{{ film.language if film and film.language else '' }}"> </div> diff --git a/templates/stats.html b/templates/stats.html index e0a6a91..9b71679 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -78,6 +78,26 @@ </div> <ol id="watched-with" class="stats-list"></ol> </section> + + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Genres</p> + <h2>Most watched</h2> + </div> + </div> + <ol id="top-genres" class="stats-list"></ol> + </section> + + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Decades</p> + <h2>By era</h2> + </div> + </div> + <ol id="film-decades" class="stats-list"></ol> + </section> </section> {% endblock %} @@ -118,6 +138,16 @@ <li><span>${item.watched_with}</span><strong>${item.count}</strong></li> `).join(""); + const topGenres = document.getElementById("top-genres"); + topGenres.innerHTML = data.films_per_genre.slice(0, 8).map((item) => ` + <li><span>${item.genre}</span><strong>${item.count}</strong></li> + `).join(""); + + const filmDecades = document.getElementById("film-decades"); + filmDecades.innerHTML = data.films_per_decade.map((item) => ` + <li><span>${item.decade}</span><strong>${item.count}</strong></li> + `).join(""); + const starDistribution = document.getElementById("star-distribution"); const maxStars = Math.max(1, ...data.star_distribution.map((item) => item.count)); starDistribution.innerHTML = data.star_distribution.map((item) => ` diff --git a/templates/year_review.html b/templates/year_review.html index 1234e80..3d03cc0 100644 --- a/templates/year_review.html +++ b/templates/year_review.html @@ -89,6 +89,24 @@ {% endfor %} </ol> </article> + + <article class="review-panel"> + <p class="eyebrow">Decades</p> + <ol class="stats-list"> + {% for item in films_per_decade %} + <li><span>{{ item.decade }}</span><strong>{{ item.count }}</strong></li> + {% endfor %} + </ol> + </article> + + <article class="review-panel"> + <p class="eyebrow">Genres</p> + <ol class="stats-list"> + {% for item in films_per_genre %} + <li><span>{{ item.genre }}</span><strong>{{ item.count }}</strong></li> + {% endfor %} + </ol> + </article> </section> <section class="review-panel"> |
