summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--models.py1
-rw-r--r--routers/films.py7
-rw-r--r--routers/imports.py10
-rw-r--r--routers/stats.py37
-rw-r--r--routers/tmdb.py20
-rw-r--r--services/tmdb.py8
-rw-r--r--static/app.js44
-rw-r--r--templates/_film_card.html5
-rw-r--r--templates/detail.html1
-rw-r--r--templates/form.html5
-rw-r--r--templates/stats.html30
-rw-r--r--templates/year_review.html18
12 files changed, 179 insertions, 7 deletions
diff --git a/models.py b/models.py
index 6643a58..05aa587 100644
--- a/models.py
+++ b/models.py
@@ -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">