summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-12 03:15:17 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-12 03:15:17 -0700
commit4279408876268f4960c98492d3814f5475e36e38 (patch)
tree9fc4828768534368a575c2e60d39d02de0973b79
parent61d68b339fee628c258e15c8664b6bcad2e70ab1 (diff)
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 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md22
-rw-r--r--README.md87
-rw-r--r--routers/films.py16
-rw-r--r--routers/stats.py2
-rw-r--r--static/app.js25
-rw-r--r--static/styles.css11
-rw-r--r--templates/form.html1
-rw-r--r--templates/stats.html31
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 <your-repo-url>
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 `<a href="/films/${m.id}" class="inline-link">${label}${date}</a>`;
+ });
+ 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 @@
<button id="tmdb-search" type="button">Search</button>
</div>
<div id="tmdb-results" class="tmdb-results" aria-live="polite"></div>
+ <div id="duplicate-notice" class="notice" hidden></div>
</div>
<div class="form-grid">
diff --git a/templates/stats.html b/templates/stats.html
index 797054a..6e3a987 100644
--- a/templates/stats.html
+++ b/templates/stats.html
@@ -15,6 +15,19 @@
<section class="stats-layout">
<section class="stats-panel stats-panel-wide">
+ <div class="stats-overview-row">
+ <div class="stats-metric">
+ <span class="eyebrow">Films watched</span>
+ <strong id="stats-total-films">—</strong>
+ </div>
+ <div class="stats-metric">
+ <span class="eyebrow">Total runtime</span>
+ <strong id="stats-total-runtime">—</strong>
+ </div>
+ </div>
+ </section>
+
+ <section class="stats-panel stats-panel-wide">
<div class="stats-panel-header">
<div>
<p class="eyebrow">World Map</p>
@@ -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) => `
<li><span>${item.director}</span><strong>${item.count}</strong></li>