From 4279408876268f4960c98492d3814f5475e36e38 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Tue, 12 May 2026 03:15:17 -0700 Subject: Add stats totals, runtime summary, and duplicate detection on add form - Stats page now shows total films watched and total runtime (formatted as Xd Yh) in an overview panel above the world map - /stats/data endpoint includes total_runtime_minutes in payload - New GET /films/find endpoint returns all shelf matches for a tmdb_id - Add film form shows an inline notice when the selected TMDB film is already logged, with shelf name, date, and a link to the entry - Update CLAUDE.md and README to reflect current auth, OMDb, and router/service structure Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 22 ++++++++++--- README.md | 87 ++++++++++++++++++++++++++++++---------------------- routers/films.py | 16 ++++++++++ routers/stats.py | 2 ++ static/app.js | 25 ++++++++++++--- static/styles.css | 11 +++++++ templates/form.html | 1 + templates/stats.html | 31 +++++++++++++++++++ 8 files changed, 149 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a646370..ed960f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What This Is -Lumiere is a personal cinema diary — a server-rendered FastAPI web app backed by SQLite. Users track films across three shelves: `diary`, `queue`, and `abandoned`. TMDB enriches film metadata (posters, directors, runtime, countries). +Lumière is a personal cinema diary — a server-rendered FastAPI web app backed by SQLite. A single owner logs in with a password; the `/tyler` public profile is read-only for guests. Films live on three shelves: `diary`, `queue`, and `abandoned`. TMDB enriches metadata (posters, directors, runtime, countries, cast, overview); OMDb supplies third-party ratings (IMDb, Rotten Tomatoes, Metacritic) on the detail page. ## Commands @@ -22,22 +22,34 @@ python -m venv .venv .venv/bin/pytest ``` -Requires a `.env` file with `TMDB_API_KEY=...`. App runs at `http://127.0.0.1:8000`. +Requires a `.env` file with: +``` +TMDB_API_KEY=... +OMDB_API_KEY=... # for IMDb/RT/Metacritic ratings +OWNER_PASSWORD_HASH=... # argon2 hash; generate with: python -c "from argon2 import PasswordHasher; print(PasswordHasher().hash('your-password'))" +SESSION_SECRET=... # random secret for cookie signing +``` +App runs at `http://127.0.0.1:8000`. ## Architecture **Request flow:** Browser → FastAPI router → SQLAlchemy (SQLite) → Jinja2 template → HTML response. `static/app.js` handles star ratings (JSON PATCH) and TMDB search autocomplete without a build step. **Key files:** -- `main.py` — app entry, router registration +- `main.py` — app entry, router registration, `AuthMiddleware` (session-based, password protected) - `database.py` — SQLite engine, sessions, self-healing schema migration (no Alembic; renames old table on column mismatch and migrates data) - `models.py` — single `Film` ORM model with a `shelf` column -- `routers/films.py` — shelf listing, CRUD, director page, queue random pick, star rating API +- `routers/films.py` — shelf listing, CRUD, director page, queue random pick, star rating API, infinite-scroll partial - `routers/imports.py` — Letterboxd and watchlist CSV import, deduplication, enrichment - `routers/stats.py` — all-time stats and year-in-review (pure Python aggregation, no SQL GROUP BY) +- `routers/auth.py` — `/login` / `/logout` using argon2 password hashing +- `routers/profile.py` — public `/tyler` profile page (no auth required) +- `routers/about.py` — static about page - `routers/tmdb.py` — `/tmdb/search?q=` proxy for JS autocomplete -- `services/tmdb.py` — TMDB API client: search, detail fetch, metadata application +- `services/tmdb.py` — TMDB API client: search, detail fetch, metadata application, director bio/image +- `services/omdb.py` — OMDb API client: fetches IMDb/Rotten Tomatoes/Metacritic ratings for detail page - `services/film_people.py` — director name normalization and URL slug helpers +- `services/countries.py` — ISO numeric country code mapping for stats world map **Patterns:** - Route logic stays in `routers/`, shared service logic in `services/` diff --git a/README.md b/README.md index 6c1807f..7dad3ff 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,88 @@ # Lumière -Lumière is a personal cinema diary and film catalog application. Built with FastAPI and SQLAlchemy, it allows you to track movies you've watched, manage a queue of films to watch, and maintain a "deserted" list of films you no longer intend to see. +Lumière is a personal cinema diary. Track films you've watched, queue films to watch, and record ones you've set aside. Built with FastAPI and SQLite — no build step, no framework overhead, just a fast server-rendered app. ## Features -- **Personal Diary:** Keep a detailed record of movies you've watched, including dates, notes, and ratings. -- **Film Shelves:** Organise films into three distinct categories: - - **Diary:** Films you have watched. - - **Queue:** Films you intend to watch. - - **Abandoned:** Films you have decided not to watch. -- **TMDB Integration:** Seamlessly import movie data from The Movie Database (TMDB) to populate your collection. -- **Statistics:** Track your viewing habits and film statistics. -- **Responsive Design:** A cinematic, dark-themed interface optimized for both desktop and mobile. +- **Three shelves** — Diary (watched), Queue (to watch), Abandoned (set aside) +- **TMDB integration** — search and auto-populate poster, director, runtime, genre, cast, overview +- **Third-party ratings** — IMDb, Rotten Tomatoes, and Metacritic scores via OMDb on each film detail page +- **Director pages** — all films by a director, with bio and photo from TMDB +- **Statistics** — all-time stats (country map, decade breakdown, star distribution, activity heatmap) and year-in-review +- **CSV import** — Letterboxd diary and watchlist imports with deduplication and TMDB enrichment +- **Password-protected** — single-owner login via argon2; public read-only profile at `/tyler` +- **Infinite scroll + search** — live search and load-more across all shelves +- **Responsive** — cinematic dark theme, works on desktop and mobile ## Tech Stack - **Backend:** [FastAPI](https://fastapi.tiangolo.com/) (Python) -- **Database:** [SQLite](https://www.sqlite.org/) with [SQLAlchemy](https://www.sqlalchemy.org/) ORM -- **Frontend:** Jinja2 templates, CSS (Modern CSS Variables), and Vanilla JavaScript -- **Data Source:** [The Movie Database (TMDB) API](https://www.themoviedb.org/documentation/api) +- **Database:** [SQLite](https://www.sqlite.org/) via [SQLAlchemy](https://www.sqlalchemy.org/) ORM +- **Frontend:** Jinja2 templates, CSS custom properties, vanilla JS (no build step) +- **APIs:** [TMDB](https://www.themoviedb.org/documentation/api) for metadata, [OMDb](https://www.omdbapi.com/) for ratings ## Getting Started ### Prerequisites - Python 3.10+ -- A TMDB API Key +- TMDB API key (free at themoviedb.org) +- OMDb API key (free tier at omdbapi.com) ### Installation -1. **Clone the repository:** +1. Clone and enter the repo: ```bash git clone cd lumiere ``` -2. **Create a virtual environment:** +2. Create a virtual environment and install dependencies: ```bash python -m venv .venv - source .venv/bin/activate # On Windows use: .venv\Scripts\activate - ``` - -3. **Install dependencies:** - ```bash + source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt ``` -4. **Set up environment variables:** - Create a `.env` file in the root directory and add your TMDB API key: +3. Create a `.env` file: ```env - TMDB_API_KEY=your_api_key_here + TMDB_API_KEY=your_tmdb_key + OMDB_API_KEY=your_omdb_key + SESSION_SECRET=some-long-random-string + OWNER_PASSWORD_HASH=... + ``` + + Generate the password hash: + ```bash + python -c "from argon2 import PasswordHasher; print(PasswordHasher().hash('your-password'))" ``` -5. **Run the application:** +4. Run the app: ```bash uvicorn main:app --reload ``` -The application will be available at `http://127.0.0.1:8000`. +Open `http://127.0.0.1:8000` and log in. ## Project Structure -- `main.py`: Application entry point and FastAPI configuration. -- `models.py`: SQLAlchemy database models. -- `database.py`: Database connection and initialization. -- `routers/`: API route handlers (films, imports, stats, tmdb). -- `services/`: Business logic and external API integrations (TMDB). -- `templates/`: Jinja2 HTML templates. -- `static/`: Static assets (CSS, JS, images). -- `lumiere.db`: SQLite database file. - -## License - -[Specify License, e.g., MIT] +``` +main.py # App entry, middleware, router registration +models.py # Film ORM model +database.py # SQLite engine, self-healing schema migration +routers/ + films.py # Shelf views, CRUD, director page, ratings API + imports.py # Letterboxd/watchlist CSV import + stats.py # All-time stats and year-in-review + auth.py # Login/logout + profile.py # Public profile page + tmdb.py # TMDB search proxy for autocomplete + about.py # About page +services/ + tmdb.py # TMDB API client + omdb.py # OMDb ratings client + film_people.py # Name normalization, slug helpers + countries.py # ISO country code mapping +templates/ # Jinja2 HTML templates +static/ # CSS, JS, icons +``` diff --git a/routers/films.py b/routers/films.py index 449fd44..56f50c5 100644 --- a/routers/films.py +++ b/routers/films.py @@ -335,6 +335,22 @@ def abandoned_feed( ) +@router.get("/films/find") +def find_films_by_tmdb_id(tmdb_id: int, db: Session = Depends(get_db)): + films = db.query(Film).filter(Film.tmdb_id == tmdb_id).all() + return { + "matches": [ + { + "id": f.id, + "shelf": f.shelf, + "title": f.title, + "date_watched": f.date_watched.isoformat() if f.date_watched else None, + } + for f in films + ] + } + + @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() diff --git a/routers/stats.py b/routers/stats.py index 15063cb..d600e5a 100644 --- a/routers/stats.py +++ b/routers/stats.py @@ -65,6 +65,7 @@ def _build_stats_payload(films: list[Film]) -> dict: watched_with["solo"] += 1 total_watched = len(films) + total_runtime_minutes = sum(film.runtime for film in films if film.runtime) title_groups = defaultdict(list) for film in films: @@ -103,6 +104,7 @@ def _build_stats_payload(films: list[Film]) -> dict: "requires_date_watched": True, }, "total_watched": total_watched, + "total_runtime_minutes": total_runtime_minutes, "films_per_country": [ {"country": country, "count": count} for country, count in sorted(countries.items(), key=lambda item: (-item[1], item[0])) diff --git a/static/app.js b/static/app.js index 938cf70..04997a0 100644 --- a/static/app.js +++ b/static/app.js @@ -111,15 +111,17 @@ const applyResult = async (film) => { setValue("#tmdb_id", film.tmdb_id || ""); setPoster(film.poster_url); - // Check if this is the add form (not edit) + const duplicateNotice = document.getElementById("duplicate-notice"); + if (duplicateNotice) duplicateNotice.hidden = true; + 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([ + const [detailResponse, rewatchResponse, findResponse] = await Promise.all([ fetch(`/tmdb/detail/${film.tmdb_id}`), isAddForm ? fetch(`/films/check-rewatch?tmdb_id=${film.tmdb_id}`) : Promise.resolve(null), + isAddForm ? fetch(`/films/find?tmdb_id=${film.tmdb_id}`) : Promise.resolve(null), ]); if (detailResponse.ok) { @@ -137,8 +139,23 @@ const applyResult = async (film) => { } } } + + if (findResponse && findResponse.ok && duplicateNotice) { + const found = await findResponse.json(); + if (found.matches && found.matches.length > 0) { + const shelfLabel = { diary: "Diary", queue: "Queue", abandoned: "Abandoned" }; + const parts = found.matches.map((m) => { + const label = shelfLabel[m.shelf] || m.shelf; + const date = m.date_watched + ? ` · ${new Date(m.date_watched + "T00:00:00").toLocaleDateString(undefined, { month: "short", year: "numeric" })}` + : ""; + return `${label}${date}`; + }); + duplicateNotice.innerHTML = `Already logged — ${parts.join(", ")}`; + duplicateNotice.hidden = false; + } + } } catch (error) { - // Fail silently if detail/rewatch fetch fails console.error("Failed to fetch details", error); } } diff --git a/static/styles.css b/static/styles.css index 9f8619b..2a6cd04 100644 --- a/static/styles.css +++ b/static/styles.css @@ -687,6 +687,11 @@ textarea:focus { margin-top: 12px; } +#duplicate-notice { + margin-top: 12px; + margin-bottom: 0; +} + .tmdb-result { display: grid; grid-template-columns: 38px minmax(0, 1fr); @@ -927,6 +932,12 @@ textarea:focus { background: linear-gradient(90deg, rgba(240, 184, 77, 0.4), var(--accent)); } +.stats-overview-row { + display: flex; + gap: 40px; + flex-wrap: wrap; +} + .stats-metric { display: grid; gap: 8px; diff --git a/templates/form.html b/templates/form.html index 83e888e..cd29e67 100644 --- a/templates/form.html +++ b/templates/form.html @@ -38,6 +38,7 @@
+
diff --git a/templates/stats.html b/templates/stats.html index 797054a..6e3a987 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -14,6 +14,19 @@
+
+
+
+ Films watched + +
+
+ Total runtime + +
+
+
+
@@ -124,7 +137,25 @@ return String(value).padStart(3, "0"); } + function formatRuntime(minutes) { + if (!minutes) return "0h"; + const totalHours = Math.floor(minutes / 60); + if (totalHours >= 24) { + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + } + const mins = minutes % 60; + return mins > 0 ? `${totalHours}h ${mins}m` : `${totalHours}h`; + } + function renderLists(data) { + const totalFilms = document.getElementById("stats-total-films"); + if (totalFilms) totalFilms.textContent = data.total_watched; + + const totalRuntime = document.getElementById("stats-total-runtime"); + if (totalRuntime) totalRuntime.textContent = formatRuntime(data.total_runtime_minutes); + const topDirectors = document.getElementById("top-directors"); topDirectors.innerHTML = data.most_watched_directors.slice(0, 8).map((item) => `
  • ${item.director}${item.count}
  • -- cgit v1.3-2-g0d8e