summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--AGENTS.md22
-rw-r--r--README.md75
-rw-r--r--database.py90
-rw-r--r--main.py26
-rw-r--r--models.py53
-rw-r--r--requirements.txt7
-rw-r--r--routers/__init__.py1
-rw-r--r--routers/films.py544
-rw-r--r--routers/imports.py343
-rw-r--r--routers/stats.py126
-rw-r--r--routers/tmdb.py32
-rw-r--r--services/__init__.py1
-rw-r--r--services/countries.py67
-rw-r--r--services/film_people.py17
-rw-r--r--services/tmdb.py226
-rw-r--r--static/app.js131
-rw-r--r--static/styles.css789
-rw-r--r--templates/_film_card.html70
-rw-r--r--templates/base.html30
-rw-r--r--templates/detail.html83
-rw-r--r--templates/director.html31
-rw-r--r--templates/form.html144
-rw-r--r--templates/import.html108
-rw-r--r--templates/index.html64
-rw-r--r--templates/stats.html268
26 files changed, 3354 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b79faa3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.py[cod]
+.env
+.venv/
+venv/
+lumiere.db*
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..bf95f64
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,22 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+`main.py` boots the FastAPI app and includes routers. `database.py` manages the SQLite engine, sessions, and schema setup. `models.py` defines the `Film` ORM model. Route handlers live in `routers/` (`films.py`, `imports.py`, `stats.py`, `tmdb.py`), and TMDB integration logic lives in `services/tmdb.py`. Server-rendered UI templates are in `templates/`; browser assets are in `static/`. Local data files such as `lumiere.db` and backup snapshots stay at the repo root and should not drive code changes.
+
+## Build, Test, and Development Commands
+Create the environment and install dependencies with `python -m venv .venv` and `.venv/bin/pip install -r requirements.txt`.
+Run the app locally with `.venv/bin/uvicorn main:app --reload`.
+Use `.venv/bin/python -m py_compile main.py database.py models.py routers/*.py services/*.py` for a fast syntax check before opening a PR.
+If you need a clean dependency install, prefer `.venv/bin/pip install -r requirements.txt` over global Python packages.
+
+## Coding Style & Naming Conventions
+Use 4-space indentation and keep Python code straightforward and type-friendly. Follow existing naming: `snake_case` for functions, variables, and module names; `PascalCase` for SQLAlchemy models; uppercase for constants such as shelf metadata. Keep route logic in `routers/` and shared external-service behavior in `services/` rather than duplicating API code across handlers. Jinja templates should stay minimal and defer behavior to `static/app.js` when client-side scripting is needed.
+
+## Testing Guidelines
+There is no committed `tests/` package yet, so add targeted tests alongside any non-trivial feature work. Use `pytest` if you introduce automated tests, with filenames like `test_imports.py` and test names such as `test_watchlist_import_sets_queue_shelf`. For now, every change should at least pass the `py_compile` check and a local smoke test of the affected route in the browser.
+
+## Commit & Pull Request Guidelines
+Git history is not readable from this workspace, so use short imperative commit subjects such as `Add queue watchlist import`. Keep commits focused on one behavior change. Pull requests should explain the user-facing change, note any schema or import implications, list verification steps, and include screenshots for template or CSS updates.
+
+## Security & Configuration
+Keep secrets in `.env`, especially `TMDB_API_KEY`, and never hardcode credentials in Python, templates, or JavaScript. Treat `lumiere.db` and backup database files as local artifacts unless a task explicitly requires fixture data.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6c1807f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,75 @@
+# 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.
+
+## 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.
+
+## 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)
+
+## Getting Started
+
+### Prerequisites
+
+- Python 3.10+
+- A TMDB API Key
+
+### Installation
+
+1. **Clone the repository:**
+ ```bash
+ git clone <your-repo-url>
+ cd lumiere
+ ```
+
+2. **Create a virtual environment:**
+ ```bash
+ python -m venv .venv
+ source .venv/bin/activate # On Windows use: .venv\Scripts\activate
+ ```
+
+3. **Install dependencies:**
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+4. **Set up environment variables:**
+ Create a `.env` file in the root directory and add your TMDB API key:
+ ```env
+ TMDB_API_KEY=your_api_key_here
+ ```
+
+5. **Run the application:**
+ ```bash
+ uvicorn main:app --reload
+ ```
+
+The application will be available at `http://127.0.0.1:8000`.
+
+## 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]
diff --git a/database.py b/database.py
new file mode 100644
index 0000000..addcd30
--- /dev/null
+++ b/database.py
@@ -0,0 +1,90 @@
+import os
+from collections.abc import Generator
+
+from dotenv import load_dotenv
+from sqlalchemy import create_engine, inspect
+from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
+
+load_dotenv()
+
+DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./lumiere.db")
+
+connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
+engine = create_engine(DATABASE_URL, connect_args=connect_args)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db() -> Generator[Session, None, None]:
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
+
+def init_db() -> None:
+ import models
+
+ _rebuild_films_table_if_needed(models.Film)
+ Base.metadata.create_all(bind=engine)
+
+
+def _rebuild_films_table_if_needed(film_model) -> None:
+ if not DATABASE_URL.startswith("sqlite"):
+ return
+
+ inspector = inspect(engine)
+ if "films" not in inspector.get_table_names():
+ return
+
+ existing_columns = {column["name"] for column in inspector.get_columns("films")}
+ expected_columns = {column.name for column in film_model.__table__.columns}
+ if existing_columns == expected_columns:
+ return
+
+ legacy_indexes = [index["name"] for index in inspector.get_indexes("films")]
+ legacy_table = "films_legacy_schema"
+ with engine.begin() as connection:
+ connection.exec_driver_sql(f"DROP TABLE IF EXISTS {legacy_table}")
+ connection.exec_driver_sql(f"ALTER TABLE films RENAME TO {legacy_table}")
+ for index_name in legacy_indexes:
+ connection.exec_driver_sql(f"DROP INDEX IF EXISTS {index_name}")
+ film_model.__table__.create(bind=connection)
+
+ target_columns = []
+ select_expressions = []
+ for column in film_model.__table__.columns:
+ name = column.name
+ if name in existing_columns:
+ target_columns.append(name)
+ select_expressions.append(name)
+ elif name == "context" and "mood_tags" in existing_columns:
+ target_columns.append(name)
+ select_expressions.append("mood_tags")
+ elif name == "rewatch":
+ target_columns.append(name)
+ select_expressions.append("0")
+ elif name in {"rewatch_count", "stars"}:
+ target_columns.append(name)
+ select_expressions.append("0")
+ elif name == "shelf":
+ target_columns.append(name)
+ select_expressions.append("'diary'")
+ elif name in {"created_at", "updated_at"}:
+ target_columns.append(name)
+ select_expressions.append("CURRENT_TIMESTAMP")
+
+ if target_columns:
+ connection.exec_driver_sql(
+ f"""
+ INSERT INTO films ({", ".join(target_columns)})
+ SELECT {", ".join(select_expressions)}
+ FROM {legacy_table}
+ """
+ )
+
+ connection.exec_driver_sql(f"DROP TABLE {legacy_table}")
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..257d2b6
--- /dev/null
+++ b/main.py
@@ -0,0 +1,26 @@
+from contextlib import asynccontextmanager
+
+from dotenv import load_dotenv
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+
+from database import init_db
+from routers import films, imports as imports_router, stats, tmdb
+
+load_dotenv()
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ init_db()
+ yield
+
+
+app = FastAPI(title="Lumière", lifespan=lifespan)
+
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+app.include_router(tmdb.router)
+app.include_router(imports_router.router)
+app.include_router(stats.router)
+app.include_router(films.router)
diff --git a/models.py b/models.py
new file mode 100644
index 0000000..6643a58
--- /dev/null
+++ b/models.py
@@ -0,0 +1,53 @@
+from datetime import UTC, date, datetime
+
+from sqlalchemy import Boolean, CheckConstraint, Date, DateTime, Integer, String, Text, text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from database import Base
+
+
+def utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+class Film(Base):
+ __tablename__ = "films"
+ __table_args__ = (
+ CheckConstraint("shelf in ('diary', 'queue', 'abandoned')", name="ck_films_shelf"),
+ CheckConstraint("stars in (0, 1, 2, 3)", name="ck_films_stars"),
+ CheckConstraint("rewatch_count >= 0", name="ck_films_rewatch_count"),
+ {"sqlite_autoincrement": True},
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, index=True)
+ tmdb_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ poster_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+ title: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
+ original_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ 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)
+ 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)
+ rewatch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("0"))
+ rewatch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
+ stars: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
+ watched_with: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ shelf: Mapped[str] = mapped_column(String(32), nullable=False, default="diary", server_default=text("'diary'"))
+ how_found: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ context: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=utc_now,
+ server_default=text("CURRENT_TIMESTAMP"),
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ default=utc_now,
+ onupdate=utc_now,
+ server_default=text("CURRENT_TIMESTAMP"),
+ )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..dc23061
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+fastapi
+uvicorn[standard]
+sqlalchemy
+jinja2
+python-dotenv
+python-multipart
+httpx
diff --git a/routers/__init__.py b/routers/__init__.py
new file mode 100644
index 0000000..44df2a9
--- /dev/null
+++ b/routers/__init__.py
@@ -0,0 +1 @@
+"""Route modules for Lumiere."""
diff --git a/routers/films.py b/routers/films.py
new file mode 100644
index 0000000..0a6491f
--- /dev/null
+++ b/routers/films.py
@@ -0,0 +1,544 @@
+from collections import Counter
+from datetime import date, datetime
+from itertools import groupby
+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
+
+from database import get_db
+from models import Film
+from services.film_people import director_href, normalize_name, split_credit_names
+from services.tmdb import TMDBNotConfiguredError, detail_context as tmdb_detail_context, movie_detail
+
+router = APIRouter()
+templates = Jinja2Templates(directory="templates")
+templates.env.globals.update(director_href=director_href, split_credit_names=split_credit_names)
+
+ALLOWED_SHELVES = {"diary", "queue", "abandoned"}
+SHELF_META = {
+ "diary": {
+ "path": "/diary",
+ "eyebrow": "Film Diary",
+ "title": "Recently watched",
+ "empty_title": "No diary entries yet",
+ "empty_text": "Your diary is ready for its first screening.",
+ },
+ "queue": {
+ "path": "/queue",
+ "eyebrow": "Queue",
+ "title": "To watch",
+ "empty_title": "Nothing queued",
+ "empty_text": "Search TMDB or add a film manually to start a watch queue.",
+ },
+ "abandoned": {
+ "path": "/abandoned",
+ "eyebrow": "Abandoned",
+ "title": "Left unfinished",
+ "empty_title": "No abandoned films",
+ "empty_text": "Films you set aside will collect here.",
+ },
+}
+
+
+def _empty_to_none(value: str) -> str | None:
+ value = value.strip()
+ return value or None
+
+
+def _parse_int(value: str, field_name: str) -> int | None:
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError as exc:
+ raise ValueError(f"{field_name} must be a whole number.") from exc
+
+
+def _parse_nonnegative_int(value: str, field_name: str) -> int:
+ parsed = _parse_int(value, field_name)
+ if parsed is None:
+ return 0
+ if parsed < 0:
+ raise ValueError(f"{field_name} cannot be negative.")
+ return parsed
+
+
+def _parse_optional_nonnegative_int(value: str, field_name: str) -> int | None:
+ parsed = _parse_int(value, field_name)
+ if parsed is None:
+ return None
+ if parsed < 0:
+ raise ValueError(f"{field_name} cannot be negative.")
+ return parsed
+
+
+def _parse_stars(value: str) -> int:
+ stars = _parse_nonnegative_int(value, "Stars")
+ if stars not in {0, 1, 2, 3}:
+ raise ValueError("Stars must be 0, 1, 2, or 3.")
+ return stars
+
+
+def _parse_bool(value: str) -> bool:
+ return value.lower() in {"1", "true", "on", "yes"}
+
+
+def _parse_date(value: str) -> date | None:
+ value = value.strip()
+ if not value:
+ return None
+ for date_format in ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y"):
+ try:
+ return datetime.strptime(value, date_format).date()
+ except ValueError:
+ continue
+ raise ValueError("Date watched must be a valid date.")
+
+
+def _form_value(form, key: str) -> str:
+ value = form.get(key, "")
+ return str(value).strip()
+
+
+def _film_payload(form) -> dict:
+ title = _form_value(form, "title")
+ if not title:
+ raise ValueError("Title is required.")
+
+ shelf = _form_value(form, "shelf") or "diary"
+ if shelf not in ALLOWED_SHELVES:
+ raise ValueError("Shelf must be diary, queue, or abandoned.")
+
+ return {
+ "tmdb_id": _parse_int(_form_value(form, "tmdb_id"), "TMDB ID"),
+ "poster_url": _empty_to_none(_form_value(form, "poster_url")),
+ "title": title,
+ "original_title": _empty_to_none(_form_value(form, "original_title")),
+ "director": _empty_to_none(_form_value(form, "director")),
+ "year": _parse_int(_form_value(form, "year"), "Year"),
+ "country": _empty_to_none(_form_value(form, "country")),
+ "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")),
+ "rewatch": _parse_bool(_form_value(form, "rewatch")),
+ "rewatch_count": _parse_nonnegative_int(_form_value(form, "rewatch_count"), "Rewatch count"),
+ "stars": _parse_stars(_form_value(form, "stars")),
+ "watched_with": _empty_to_none(_form_value(form, "watched_with")),
+ "shelf": shelf,
+ "how_found": _empty_to_none(_form_value(form, "how_found")),
+ "context": _empty_to_none(_form_value(form, "context")),
+ "notes": _empty_to_none(_form_value(form, "notes")),
+ }
+
+
+def _film_from_form(form) -> SimpleNamespace:
+ data = {key: _form_value(form, key) for key in form.keys()}
+ data.setdefault("id", None)
+ data.setdefault("rewatch", False)
+ data.setdefault("rewatch_count", "0")
+ data.setdefault("stars", "0")
+ data.setdefault("shelf", "diary")
+ return SimpleNamespace(**data)
+
+
+def _shelf_path(shelf: str) -> str:
+ return SHELF_META.get(shelf, SHELF_META["diary"])["path"]
+
+
+def _get_film_or_404(db: Session, film_id: int) -> Film:
+ film = db.get(Film, film_id)
+ if film is None:
+ raise HTTPException(status_code=404, detail="Film not found.")
+ return film
+
+
+def _notice_context(
+ imported: int | None,
+ skipped: int | None,
+ cleared: int | None,
+ enriched: int | None,
+ deduped: int | None,
+ empty_queue: int | None = None,
+) -> dict:
+ return {
+ "imported": imported,
+ "skipped": skipped,
+ "cleared": cleared,
+ "enriched": enriched,
+ "deduped": deduped,
+ "empty_queue": empty_queue,
+ }
+
+
+def _group_films_by_month(films: list[Film]) -> list[dict]:
+ def month_key(film: Film) -> str:
+ return film.date_watched.strftime("%B %Y") if film.date_watched else "Unknown"
+
+ grouped = []
+ for month, group in groupby(films, key=month_key):
+ grouped.append({"month": month, "films": list(group)})
+ return grouped
+
+
+def _render_shelf(
+ shelf: str,
+ request: Request,
+ db: Session,
+ notices: dict,
+):
+ query = db.query(Film).filter(Film.shelf == shelf)
+ if shelf == "diary":
+ films = query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc()).all()
+ grouped_films = _group_films_by_month(films)
+ elif shelf == "queue":
+ films = query.order_by(Film.created_at.desc(), Film.id.desc()).all()
+ grouped_films = None
+ else:
+ films = query.order_by(Film.updated_at.desc(), Film.created_at.desc(), Film.id.desc()).all()
+ grouped_films = None
+
+ return templates.TemplateResponse(
+ request=request,
+ name="index.html",
+ context={
+ "request": request,
+ "films": films,
+ "grouped_films": grouped_films,
+ "active_shelf": shelf,
+ "shelf_meta": SHELF_META[shelf],
+ **notices,
+ },
+ )
+
+
+def _director_films(db: Session, director_name: str) -> list[Film]:
+ target_name = normalize_name(director_name)
+ films = db.query(Film).filter(Film.director.is_not(None), Film.director != "").all()
+ matches = []
+ for film in films:
+ if any(normalize_name(name) == target_name for name in split_credit_names(film.director)):
+ matches.append(film)
+ return matches
+
+
+@router.get("/")
+def home(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "diary",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped),
+ )
+
+
+@router.get("/diary")
+def diary_feed(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "diary",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped),
+ )
+
+
+@router.get("/queue")
+def queue_feed(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ empty_queue: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "queue",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped, empty_queue),
+ )
+
+
+@router.get("/abandoned")
+def abandoned_feed(
+ request: Request,
+ imported: int | None = Query(None),
+ skipped: int | None = Query(None),
+ cleared: int | None = Query(None),
+ enriched: int | None = Query(None),
+ deduped: int | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ return _render_shelf(
+ "abandoned",
+ request,
+ db,
+ _notice_context(imported, skipped, cleared, enriched, deduped),
+ )
+
+
+@router.get("/films/new")
+def new_film(request: Request):
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": None,
+ "shelf_override": None,
+ "action": "/films",
+ "page_title": "Add Film",
+ "submit_label": "Add entry",
+ "error": None,
+ },
+ )
+
+
+@router.post("/films")
+async def create_film(request: Request, db: Session = Depends(get_db)):
+ form = await request.form()
+ try:
+ film = Film(**_film_payload(form))
+ except ValueError as exc:
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": _film_from_form(form),
+ "shelf_override": None,
+ "active_shelf": _form_value(form, "shelf") or "diary",
+ "action": "/films",
+ "page_title": "Add Film",
+ "submit_label": "Add entry",
+ "error": str(exc),
+ },
+ status_code=400,
+ )
+
+ db.add(film)
+ db.commit()
+ db.refresh(film)
+ return RedirectResponse(f"/films/{film.id}", status_code=303)
+
+
+async def _film_tmdb_context(film: Film) -> dict | None:
+ if not film.tmdb_id:
+ return None
+
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ try:
+ movie = await movie_detail(client, film.tmdb_id)
+ except (TMDBNotConfiguredError, httpx.HTTPError):
+ return None
+
+ context = tmdb_detail_context(movie)
+ if not context["overview"] and not context["cast"] and not context["tagline"]:
+ return None
+ return context
+
+
+@router.get("/films/{film_id}")
+async def film_detail(film_id: int, request: Request, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ tmdb_context = await _film_tmdb_context(film)
+ return templates.TemplateResponse(
+ request=request,
+ name="detail.html",
+ context={"request": request, "film": film, "active_shelf": film.shelf, "tmdb_context": tmdb_context},
+ )
+
+
+@router.post("/films/{film_id}/stars")
+async def update_film_stars(film_id: int, request: Request, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ if film.shelf != "diary":
+ raise HTTPException(status_code=400, detail="Stars can only be updated for diary entries.")
+
+ try:
+ payload = await request.json()
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(status_code=400, detail="Request body must be JSON.") from exc
+
+ try:
+ stars = int(payload.get("stars"))
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail="Stars must be 0, 1, 2, or 3.") from exc
+
+ if stars not in {0, 1, 2, 3}:
+ raise HTTPException(status_code=400, detail="Stars must be 0, 1, 2, or 3.")
+
+ film.stars = stars
+ db.commit()
+ return {"film_id": film.id, "stars": film.stars}
+
+
+@router.get("/director/{director_name}")
+def director_detail(director_name: str, request: Request, db: Session = Depends(get_db)):
+ films = _director_films(db, director_name)
+ if not films:
+ raise HTTPException(status_code=404, detail="Director not found.")
+
+ display_name = next(
+ (
+ name
+ for film in films
+ for name in split_credit_names(film.director)
+ if normalize_name(name) == normalize_name(director_name)
+ ),
+ director_name,
+ )
+
+ films.sort(
+ key=lambda film: (
+ film.date_watched is None,
+ -(film.date_watched.toordinal()) if film.date_watched else 0,
+ film.year is None,
+ film.year or 0,
+ film.title.casefold(),
+ film.id,
+ )
+ )
+
+ shelf_counts = Counter(film.shelf for film in films)
+ most_common_shelf = sorted(
+ shelf_counts.items(),
+ key=lambda item: (-item[1], {"diary": 0, "queue": 1, "abandoned": 2}.get(item[0], 99)),
+ )[0][0]
+ average_stars = round(sum(film.stars for film in films) / len(films), 1)
+
+ return templates.TemplateResponse(
+ request=request,
+ name="director.html",
+ context={
+ "request": request,
+ "director_name": display_name,
+ "films": films,
+ "director_summary": {
+ "total_films_logged": len(films),
+ "average_stars": average_stars,
+ "most_common_shelf": most_common_shelf,
+ },
+ },
+ )
+
+
+@router.get("/queue/random")
+def queue_random(db: Session = Depends(get_db)):
+ film = db.query(Film).filter(Film.shelf == "queue").order_by(func.random()).first()
+ if film is None:
+ return RedirectResponse("/queue?empty_queue=1", status_code=303)
+ return RedirectResponse(f"/films/{film.id}", status_code=303)
+
+
+@router.get("/films/{film_id}/edit")
+def edit_film(
+ film_id: int,
+ request: Request,
+ shelf: str | None = Query(None),
+ db: Session = Depends(get_db),
+):
+ film = _get_film_or_404(db, film_id)
+ shelf_override = shelf if shelf in ALLOWED_SHELVES else None
+ page_title = "Edit Film"
+ submit_label = "Save changes"
+ if shelf_override == "diary" and film.shelf != "diary":
+ page_title = "Mark Watched"
+ submit_label = "Save diary entry"
+
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": film,
+ "shelf_override": shelf_override,
+ "active_shelf": shelf_override or film.shelf,
+ "action": f"/films/{film.id}/edit",
+ "page_title": page_title,
+ "submit_label": submit_label,
+ "error": None,
+ },
+ )
+
+
+@router.post("/films/{film_id}/edit")
+async def update_film(film_id: int, request: Request, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ form = await request.form()
+ try:
+ payload = _film_payload(form)
+ except ValueError as exc:
+ form_film = _film_from_form(form)
+ form_film.id = film.id
+ return templates.TemplateResponse(
+ request=request,
+ name="form.html",
+ context={
+ "request": request,
+ "film": form_film,
+ "shelf_override": None,
+ "active_shelf": _form_value(form, "shelf") or film.shelf,
+ "action": f"/films/{film.id}/edit",
+ "page_title": "Edit Film",
+ "submit_label": "Save changes",
+ "error": str(exc),
+ },
+ status_code=400,
+ )
+
+ for key, value in payload.items():
+ setattr(film, key, value)
+ db.commit()
+ return RedirectResponse(f"/films/{film.id}", status_code=303)
+
+
+@router.post("/films/{film_id}/shelf/{shelf}")
+def move_film_to_shelf(
+ film_id: int,
+ shelf: str,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ if shelf not in ALLOWED_SHELVES:
+ raise HTTPException(status_code=400, detail="Shelf must be diary, queue, or abandoned.")
+
+ film = _get_film_or_404(db, film_id)
+ film.shelf = shelf
+ db.commit()
+
+ redirect_to = request.headers.get("referer") or _shelf_path(shelf)
+ return RedirectResponse(redirect_to, status_code=303)
+
+
+@router.post("/films/{film_id}/delete")
+def delete_film(film_id: int, db: Session = Depends(get_db)):
+ film = _get_film_or_404(db, film_id)
+ shelf = film.shelf
+ db.delete(film)
+ db.commit()
+ return RedirectResponse(_shelf_path(shelf), status_code=303)
diff --git a/routers/imports.py b/routers/imports.py
new file mode 100644
index 0000000..f6a202d
--- /dev/null
+++ b/routers/imports.py
@@ -0,0 +1,343 @@
+import csv
+import io
+from datetime import datetime
+
+import httpx
+from fastapi import APIRouter, Depends, File, Request, UploadFile
+from fastapi.responses import RedirectResponse
+from fastapi.templating import Jinja2Templates
+from sqlalchemy import or_, text
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import Film
+from services.tmdb import TMDBNotConfiguredError, apply_metadata_to_film, find_movie
+
+router = APIRouter(tags=["imports"])
+templates = Jinja2Templates(directory="templates")
+
+
+def _value(row: dict, *names: str) -> str:
+ for name in names:
+ value = row.get(name)
+ if value:
+ return value.strip()
+ return ""
+
+
+def _parse_int(value: str) -> int | None:
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError:
+ return None
+
+
+def _parse_bool(value: str) -> bool:
+ return value.strip().lower() in {"1", "true", "yes", "y", "on", "rewatched"}
+
+
+def _parse_explicit_stars(value: str) -> int | None:
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ stars = int(value)
+ except ValueError:
+ return None
+ return stars if stars in {0, 1, 2, 3} else None
+
+
+def _parse_rating_to_stars(value: str) -> int:
+ value = value.strip()
+ if not value:
+ return 0
+ try:
+ rating = float(value)
+ except ValueError:
+ return 0
+
+ if rating < 0 or rating > 5:
+ return 0
+ if rating >= 5:
+ return 3
+ if rating >= 4.5:
+ return 2
+ if rating >= 3.5:
+ return 1
+ return 0
+
+
+def _stars_from_row(row: dict) -> int:
+ explicit_stars = _parse_explicit_stars(_value(row, "Stars"))
+ if explicit_stars is not None:
+ return explicit_stars
+ return _parse_rating_to_stars(_value(row, "Rating"))
+
+
+def _parse_shelf(value: str) -> str:
+ shelf = value.strip().lower()
+ return shelf if shelf in {"diary", "queue", "abandoned"} else "diary"
+
+
+def _parse_date(value: str):
+ value = value.strip()
+ if not value:
+ return None
+ for date_format in ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y"):
+ try:
+ return datetime.strptime(value, date_format).date()
+ except ValueError:
+ continue
+ return None
+
+
+def _normalize_title(value: str | None) -> str:
+ return (value or "").casefold().strip()
+
+
+def _date_key(value) -> str:
+ return value.isoformat() if value else ""
+
+
+def _duplicate_keys_for_film(film: Film) -> set[tuple]:
+ keys = set()
+ watched_date = _date_key(film.date_watched)
+ if film.tmdb_id:
+ keys.add(("tmdb", str(film.tmdb_id), watched_date))
+
+ title = _normalize_title(film.title)
+ if title:
+ keys.add(("title", title, str(film.year or ""), watched_date))
+
+ return keys
+
+
+def _existing_duplicate_keys(db: Session) -> set[tuple]:
+ keys = set()
+ for film in db.query(Film).all():
+ keys.update(_duplicate_keys_for_film(film))
+ return keys
+
+
+def _decode_csv(content: bytes) -> str:
+ for encoding in ("utf-8-sig", "utf-8", "latin-1"):
+ try:
+ return content.decode(encoding)
+ except UnicodeDecodeError:
+ continue
+ return content.decode("utf-8", errors="replace")
+
+
+def _film_from_row(row: dict, shelf: str) -> Film | None:
+ title = _value(row, "Name", "Title")
+ if not title:
+ return None
+
+ is_diary = shelf == "diary"
+ return Film(
+ title=title,
+ original_title=_value(row, "Original Title") or None,
+ director=_value(row, "Director") or None,
+ year=_parse_int(_value(row, "Year")),
+ country=_value(row, "Country") or None,
+ language=_value(row, "Language") or None,
+ runtime=_parse_int(_value(row, "Runtime", "Runtime (mins)")),
+ date_watched=_parse_date(_value(row, "Watched Date", "Date")) if is_diary else None,
+ rewatch=_parse_bool(_value(row, "Rewatch")) if is_diary else False,
+ rewatch_count=(_parse_int(_value(row, "Rewatch Count")) or 0) if is_diary else 0,
+ stars=_stars_from_row(row) if is_diary else 0,
+ watched_with=_value(row, "Watched With", "Watched_with") or None,
+ shelf=shelf,
+ how_found=_value(row, "How Found", "How_found") or None,
+ context=_value(row, "Context", "Tags") or None,
+ notes=_value(row, "Review", "Notes") or None,
+ poster_url=_value(row, "Poster URL", "Poster") or None,
+ tmdb_id=_parse_int(_value(row, "TMDB ID", "tmdb_id")),
+ )
+
+
+async def _dedupe_enrich_and_save(
+ films: list[Film],
+ db: Session,
+) -> tuple[list[Film], int, int]:
+ skipped = 0
+ existing_keys = _existing_duplicate_keys(db)
+ pending_keys = set()
+ deduped_before_enrichment = []
+
+ for film in films:
+ film_keys = _duplicate_keys_for_film(film)
+ if film_keys & (existing_keys | pending_keys):
+ skipped += 1
+ continue
+
+ deduped_before_enrichment.append(film)
+ pending_keys.update(film_keys)
+
+ enriched = await _enrich_films_from_tmdb(deduped_before_enrichment)
+ deduped_after_enrichment = []
+ final_keys = set(existing_keys)
+ for film in deduped_before_enrichment:
+ film_keys = _duplicate_keys_for_film(film)
+ if film_keys & final_keys:
+ skipped += 1
+ continue
+ deduped_after_enrichment.append(film)
+ final_keys.update(film_keys)
+
+ if deduped_after_enrichment:
+ db.add_all(deduped_after_enrichment)
+ db.commit()
+
+ return deduped_after_enrichment, skipped, enriched
+
+
+async def _enrich_films_from_tmdb(films: list[Film]) -> int:
+ enriched = 0
+ if not films:
+ return enriched
+
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ for film in films:
+ if film.poster_url and film.tmdb_id:
+ continue
+ try:
+ metadata = await find_movie(film.title, year=film.year, client=client)
+ except TMDBNotConfiguredError:
+ return enriched
+ except httpx.HTTPError:
+ continue
+ if metadata and apply_metadata_to_film(film, metadata):
+ enriched += 1
+
+ return enriched
+
+
+@router.get("/import")
+def import_page(request: Request):
+ return templates.TemplateResponse(
+ request=request,
+ name="import.html",
+ context={"request": request, "error": None, "active_page": "import"},
+ )
+
+
+@router.post("/data/clear")
+def clear_data(db: Session = Depends(get_db)):
+ deleted = db.query(Film).delete(synchronize_session=False)
+ bind = db.get_bind()
+ if bind.dialect.name == "sqlite":
+ db.execute(text("DELETE FROM sqlite_sequence WHERE name = 'films'"))
+ db.commit()
+ return RedirectResponse(f"/?cleared={deleted}", status_code=303)
+
+
+@router.post("/data/clear-duplicates")
+def clear_duplicates(db: Session = Depends(get_db)):
+ seen_keys = set()
+ duplicate_ids = []
+ for film in db.query(Film).order_by(Film.id.asc()).all():
+ film_keys = _duplicate_keys_for_film(film)
+ if film_keys & seen_keys:
+ duplicate_ids.append(film.id)
+ continue
+ seen_keys.update(film_keys)
+
+ deleted = 0
+ if duplicate_ids:
+ deleted = (
+ db.query(Film)
+ .filter(Film.id.in_(duplicate_ids))
+ .delete(synchronize_session=False)
+ )
+ db.commit()
+
+ return RedirectResponse(f"/?deduped={deleted}", status_code=303)
+
+
+@router.post("/data/enrich-posters")
+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)))
+ .order_by(Film.year.asc(), Film.title.asc())
+ .all()
+ )
+ enriched = await _enrich_films_from_tmdb(films)
+ if enriched:
+ db.commit()
+ return RedirectResponse(f"/?enriched={enriched}", status_code=303)
+
+
+@router.post("/import/letterboxd")
+async def import_letterboxd(
+ request: Request,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ if not file.filename.lower().endswith(".csv"):
+ return templates.TemplateResponse(
+ request=request,
+ name="import.html",
+ context={"request": request, "error": "Upload a CSV file.", "active_page": "import"},
+ status_code=400,
+ )
+
+ content = await file.read()
+ reader = csv.DictReader(io.StringIO(_decode_csv(content)))
+ films = []
+
+ for row in reader:
+ if not any(value.strip() for value in row.values() if value):
+ continue
+
+ film = _film_from_row(row, _parse_shelf(_value(row, "Shelf")))
+ if film is None:
+ continue
+ films.append(film)
+
+ films, skipped, enriched = await _dedupe_enrich_and_save(films, db)
+
+ return RedirectResponse(
+ f"/?imported={len(films)}&skipped={skipped}&enriched={enriched}",
+ status_code=303,
+ )
+
+
+@router.post("/import/watchlist")
+async def import_watchlist(
+ request: Request,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ if not file.filename.lower().endswith(".csv"):
+ return templates.TemplateResponse(
+ request=request,
+ name="import.html",
+ context={"request": request, "error": "Upload a CSV file.", "active_page": "import"},
+ status_code=400,
+ )
+
+ content = await file.read()
+ reader = csv.DictReader(io.StringIO(_decode_csv(content)))
+ films = []
+
+ for row in reader:
+ if not any(value.strip() for value in row.values() if value):
+ continue
+
+ film = _film_from_row(row, "queue")
+ if film is None:
+ continue
+
+ films.append(film)
+
+ films, skipped, enriched = await _dedupe_enrich_and_save(films, db)
+
+ return RedirectResponse(
+ f"/queue?imported={len(films)}&skipped={skipped}&enriched={enriched}",
+ status_code=303,
+ )
diff --git a/routers/stats.py b/routers/stats.py
new file mode 100644
index 0000000..78e9621
--- /dev/null
+++ b/routers/stats.py
@@ -0,0 +1,126 @@
+from collections import Counter
+from datetime import date, timedelta
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.templating import Jinja2Templates
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import Film
+from services.countries import (
+ ISO_NUMERIC_TO_COUNTRY_NAME,
+ country_name_to_iso_numeric,
+ split_country_names,
+)
+from services.film_people import split_credit_names
+
+router = APIRouter(tags=["stats"])
+templates = Jinja2Templates(directory="templates")
+
+
+def _build_stats_payload(films: list[Film]) -> dict:
+ countries = Counter()
+ country_codes = Counter()
+ directors = Counter()
+ star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0})
+ months = Counter()
+ days = Counter()
+ watched_with = Counter()
+
+ for film in films:
+ country_names = split_country_names(film.country)
+ countries.update(country_names)
+ for country in country_names:
+ iso_numeric = country_name_to_iso_numeric(country)
+ if iso_numeric is not None:
+ country_codes[iso_numeric] += 1
+
+ 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.strftime("%Y-%m")] += 1
+ days[film.date_watched.isoformat()] += 1
+
+ companions = split_credit_names(film.watched_with)
+ if companions:
+ watched_with.update(companions)
+ else:
+ watched_with["solo"] += 1
+
+ total_watched = len(films)
+ rewatched = sum(1 for film in films if film.rewatch or film.rewatch_count > 0)
+
+ today = date.today()
+ start_day = today - timedelta(days=364)
+ trailing_days = []
+ cursor = start_day
+ while cursor <= today:
+ trailing_days.append({"date": cursor.isoformat(), "count": days[cursor.isoformat()]})
+ cursor += timedelta(days=1)
+
+ return {
+ "scope": {
+ "shelf": "diary",
+ "requires_date_watched": True,
+ },
+ "total_watched": total_watched,
+ "films_per_country": [
+ {"country": country, "count": count}
+ for country, count in sorted(countries.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "films_per_country_codes": [
+ {"code": code, "count": count}
+ for code, count in sorted(country_codes.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "country_labels_by_code": {
+ str(code): ISO_NUMERIC_TO_COUNTRY_NAME.get(code, str(code)) for code in country_codes
+ },
+ "most_watched_directors": [
+ {"director": director, "count": count}
+ for director, count in sorted(directors.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}
+ for month, count in sorted(months.items())
+ ],
+ "films_per_day": [
+ {"date": watched_date, "count": count}
+ for watched_date, count in sorted(days.items())
+ ],
+ "films_per_day_365": trailing_days,
+ "rewatch_rate": {
+ "rewatched": rewatched,
+ "total_watched": total_watched,
+ "rate": round(rewatched / total_watched, 4) if total_watched else 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]))
+ ],
+ }
+
+
+def _diary_films(db: Session) -> list[Film]:
+ return (
+ db.query(Film)
+ .filter(Film.shelf == "diary", Film.date_watched.is_not(None))
+ .all()
+ )
+
+
+@router.get("/stats")
+def stats_page(request: Request):
+ return templates.TemplateResponse(
+ request=request,
+ name="stats.html",
+ context={"request": request, "active_page": "stats"},
+ )
+
+
+@router.get("/stats/data")
+def stats_data(db: Session = Depends(get_db)):
+ return _build_stats_payload(_diary_films(db))
diff --git a/routers/tmdb.py b/routers/tmdb.py
new file mode 100644
index 0000000..522c1d0
--- /dev/null
+++ b/routers/tmdb.py
@@ -0,0 +1,32 @@
+import httpx
+from dotenv import load_dotenv
+from fastapi import APIRouter, Query
+from fastapi.responses import JSONResponse
+
+from services.tmdb import TMDBNotConfiguredError, search_movies
+
+load_dotenv()
+
+router = APIRouter(prefix="/tmdb", tags=["tmdb"])
+
+
+@router.get("/search")
+async def search_tmdb(q: str = Query(..., min_length=2)):
+ try:
+ return {"results": await search_movies(q, limit=8, include_details=True)}
+ except TMDBNotConfiguredError:
+ return JSONResponse(
+ status_code=503,
+ content={
+ "error": "TMDB_API_KEY is not configured.",
+ "results": [],
+ },
+ )
+ except httpx.HTTPError:
+ return JSONResponse(
+ status_code=502,
+ content={
+ "error": "TMDB search failed. Check your API key and try again.",
+ "results": [],
+ },
+ )
diff --git a/services/__init__.py b/services/__init__.py
new file mode 100644
index 0000000..1ac9669
--- /dev/null
+++ b/services/__init__.py
@@ -0,0 +1 @@
+"""Service helpers for Lumiere."""
diff --git a/services/countries.py b/services/countries.py
new file mode 100644
index 0000000..8681e81
--- /dev/null
+++ b/services/countries.py
@@ -0,0 +1,67 @@
+COUNTRY_NAME_TO_ISO_NUMERIC = {
+ "argentina": 32,
+ "australia": 36,
+ "belgium": 56,
+ "brazil": 76,
+ "bulgaria": 100,
+ "canada": 124,
+ "china": 156,
+ "czech republic": 203,
+ "denmark": 208,
+ "finland": 246,
+ "france": 250,
+ "germany": 276,
+ "hong kong": 344,
+ "hungary": 348,
+ "india": 356,
+ "iran": 364,
+ "ireland": 372,
+ "italy": 380,
+ "japan": 392,
+ "luxembourg": 442,
+ "netherlands": 528,
+ "new zealand": 554,
+ "norway": 578,
+ "northern ireland": 826,
+ "russia": 643,
+ "soviet union": 643,
+ "spain": 724,
+ "south africa": 710,
+ "south korea": 410,
+ "korea, south": 410,
+ "republic of korea": 410,
+ "sweden": 752,
+ "taiwan": 158,
+ "thailand": 764,
+ "united arab emirates": 784,
+ "united kingdom": 826,
+ "uk": 826,
+ "great britain": 826,
+ "england": 826,
+ "scotland": 826,
+ "united states": 840,
+ "united states of america": 840,
+ "usa": 840,
+}
+
+ISO_NUMERIC_TO_COUNTRY_NAME = {
+ value: key.title() for key, value in COUNTRY_NAME_TO_ISO_NUMERIC.items()
+}
+ISO_NUMERIC_TO_COUNTRY_NAME[410] = "South Korea"
+ISO_NUMERIC_TO_COUNTRY_NAME[643] = "Russia"
+ISO_NUMERIC_TO_COUNTRY_NAME[826] = "United Kingdom"
+ISO_NUMERIC_TO_COUNTRY_NAME[840] = "United States of America"
+
+
+def split_country_names(value: str | None) -> list[str]:
+ if not value:
+ return []
+
+ normalized = value.replace(";", ",")
+ return [item.strip() for item in normalized.split(",") if item.strip()]
+
+
+def country_name_to_iso_numeric(value: str | None) -> int | None:
+ if not value:
+ return None
+ return COUNTRY_NAME_TO_ISO_NUMERIC.get(value.casefold().strip())
diff --git a/services/film_people.py b/services/film_people.py
new file mode 100644
index 0000000..ef0e2ff
--- /dev/null
+++ b/services/film_people.py
@@ -0,0 +1,17 @@
+from urllib.parse import quote
+
+
+def split_credit_names(value: str | None) -> list[str]:
+ if not value:
+ return []
+
+ normalized = value.replace(";", ",")
+ return [item.strip() for item in normalized.split(",") if item.strip()]
+
+
+def normalize_name(value: str) -> str:
+ return value.casefold().strip()
+
+
+def director_href(name: str) -> str:
+ return f"/director/{quote(name, safe='')}"
diff --git a/services/tmdb.py b/services/tmdb.py
new file mode 100644
index 0000000..b3adf17
--- /dev/null
+++ b/services/tmdb.py
@@ -0,0 +1,226 @@
+import os
+
+import httpx
+
+TMDB_SEARCH_URL = "https://api.themoviedb.org/3/search/movie"
+TMDB_MOVIE_URL = "https://api.themoviedb.org/3/movie/{movie_id}"
+TMDB_POSTER_BASE = "https://image.tmdb.org/t/p/w500"
+
+
+class TMDBNotConfiguredError(RuntimeError):
+ pass
+
+
+def api_key() -> str:
+ key = os.getenv("TMDB_API_KEY", "").strip()
+ if not key:
+ raise TMDBNotConfiguredError("TMDB_API_KEY is not configured.")
+ return key
+
+
+def poster_url(path: str | None) -> str | None:
+ if not path:
+ return None
+ return f"{TMDB_POSTER_BASE}{path}"
+
+
+def year_from_release_date(release_date: str | None) -> int | None:
+ if not release_date:
+ return None
+ try:
+ return int(release_date[:4])
+ except ValueError:
+ return None
+
+
+def directors(movie: dict) -> str | None:
+ crew = movie.get("credits", {}).get("crew", [])
+ names = [person.get("name") for person in crew if person.get("job") == "Director"]
+ names = [name for name in names if name]
+ return ", ".join(names) if names else None
+
+
+def countries(movie: dict) -> str | None:
+ names = [country.get("name") for country in movie.get("production_countries", [])]
+ 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")
+ for language in movie.get("spoken_languages", [])
+ ]
+ names = [name for name in names if name]
+ return ", ".join(names) if names else None
+
+
+def cast_members(movie: dict, limit: int = 5) -> list[str]:
+ cast = movie.get("credits", {}).get("cast", [])
+ names = [person.get("name") for person in cast if person.get("name")]
+ return names[:limit]
+
+
+def detail_context(movie: dict, cast_limit: int = 5) -> dict:
+ tagline = (movie.get("tagline") or "").strip() or None
+ overview = (movie.get("overview") or "").strip() or None
+ return {
+ "tagline": tagline,
+ "overview": overview,
+ "cast": cast_members(movie, cast_limit),
+ }
+
+
+def normalize_title(value: str | None) -> str:
+ return (value or "").casefold().strip()
+
+
+async def movie_detail(client: httpx.AsyncClient, movie_id: int, key: str | None = None) -> dict:
+ response = await client.get(
+ TMDB_MOVIE_URL.format(movie_id=movie_id),
+ params={"api_key": key or api_key(), "append_to_response": "credits"},
+ )
+ response.raise_for_status()
+ return response.json()
+
+
+def movie_payload(movie: dict, fallback: dict | None = None) -> dict:
+ fallback = fallback or {}
+ release_date = movie.get("release_date") or fallback.get("release_date")
+ return {
+ "tmdb_id": movie.get("id") or fallback.get("id"),
+ "title": movie.get("title") or fallback.get("title"),
+ "original_title": movie.get("original_title") or fallback.get("original_title"),
+ "year": year_from_release_date(release_date),
+ "release_date": release_date,
+ "director": directors(movie),
+ "country": countries(movie),
+ "language": languages(movie),
+ "runtime": movie.get("runtime"),
+ "overview": movie.get("overview") or fallback.get("overview"),
+ "poster_url": poster_url(movie.get("poster_path") or fallback.get("poster_path")),
+ }
+
+
+def best_search_result(results: list[dict], title: str, year: int | None = None) -> dict | None:
+ if not results:
+ return None
+
+ wanted_title = normalize_title(title)
+ exact_title = [
+ result
+ for result in results
+ if wanted_title
+ and wanted_title
+ in {normalize_title(result.get("title")), normalize_title(result.get("original_title"))}
+ ]
+ candidates = exact_title or results
+
+ if year is not None:
+ same_year = [
+ result
+ for result in candidates
+ if year_from_release_date(result.get("release_date")) == year
+ ]
+ if same_year:
+ return same_year[0]
+
+ return candidates[0]
+
+
+async def search_movies(
+ query: str,
+ *,
+ year: int | None = None,
+ limit: int = 8,
+ include_details: bool = True,
+ client: httpx.AsyncClient | None = None,
+) -> list[dict]:
+ key = api_key()
+ owns_client = client is None
+ active_client = client or httpx.AsyncClient(timeout=10.0)
+ try:
+ params = {
+ "api_key": key,
+ "query": query,
+ "include_adult": "false",
+ }
+ if year:
+ params["year"] = str(year)
+
+ response = await active_client.get(TMDB_SEARCH_URL, params=params)
+ response.raise_for_status()
+ search_results = response.json().get("results", [])[:limit]
+
+ results = []
+ for item in search_results:
+ movie_id = item.get("id")
+ if not movie_id:
+ continue
+ detail = item
+ if include_details:
+ try:
+ detail = await movie_detail(active_client, movie_id, key)
+ except httpx.HTTPError:
+ detail = item
+ results.append(movie_payload(detail, item))
+ return results
+ finally:
+ if owns_client:
+ await active_client.aclose()
+
+
+async def find_movie(
+ title: str,
+ *,
+ year: int | None = None,
+ client: httpx.AsyncClient | None = None,
+) -> dict | None:
+ if not title.strip():
+ return None
+
+ key = api_key()
+ owns_client = client is None
+ active_client = client or httpx.AsyncClient(timeout=10.0)
+ try:
+ params = {
+ "api_key": key,
+ "query": title,
+ "include_adult": "false",
+ }
+ if year:
+ params["year"] = str(year)
+
+ response = await active_client.get(TMDB_SEARCH_URL, params=params)
+ response.raise_for_status()
+ search_results = response.json().get("results", [])
+ match = best_search_result(search_results, title, year)
+ if not match:
+ return None
+
+ detail = await movie_detail(active_client, match["id"], key)
+ return movie_payload(detail, match)
+ finally:
+ if owns_client:
+ await active_client.aclose()
+
+
+def apply_metadata_to_film(film, metadata: dict) -> bool:
+ changed = False
+ fill_if_empty = {
+ "tmdb_id": metadata.get("tmdb_id"),
+ "poster_url": metadata.get("poster_url"),
+ "original_title": metadata.get("original_title"),
+ "director": metadata.get("director"),
+ "year": metadata.get("year"),
+ "country": metadata.get("country"),
+ "language": metadata.get("language"),
+ "runtime": metadata.get("runtime"),
+ }
+
+ for field, value in fill_if_empty.items():
+ if value is not None and not getattr(film, field):
+ setattr(film, field, value)
+ changed = True
+
+ return changed
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..01b1d79
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,131 @@
+const tmdbQuery = document.querySelector("#tmdb-query");
+const tmdbButton = document.querySelector("#tmdb-search");
+const tmdbResults = document.querySelector("#tmdb-results");
+
+const setValue = (selector, value) => {
+ const element = document.querySelector(selector);
+ if (element && value !== null && value !== undefined) {
+ element.value = value;
+ }
+};
+
+const setPoster = (url) => {
+ setValue("#poster_url", url || "");
+ const preview = document.querySelector("#poster-preview");
+ if (!preview) return;
+
+ if (url) {
+ preview.src = url;
+ preview.classList.add("is-visible");
+ } else {
+ preview.removeAttribute("src");
+ preview.classList.remove("is-visible");
+ }
+};
+
+const clearResults = () => {
+ if (tmdbResults) {
+ tmdbResults.replaceChildren();
+ }
+};
+
+const renderMessage = (message) => {
+ clearResults();
+ const node = document.createElement("p");
+ node.className = "tmdb-message";
+ node.textContent = message;
+ tmdbResults.appendChild(node);
+};
+
+const applyResult = (film) => {
+ setValue("#title", film.title || "");
+ setValue("#original_title", film.original_title || "");
+ setValue("#director", film.director || "");
+ setValue("#year", film.year || "");
+ setValue("#country", film.country || "");
+ setValue("#language", film.language || "");
+ setValue("#runtime", film.runtime || "");
+ setValue("#tmdb_id", film.tmdb_id || "");
+ setPoster(film.poster_url);
+ clearResults();
+};
+
+const renderResults = (films) => {
+ clearResults();
+
+ if (!films.length) {
+ renderMessage("No matches found.");
+ return;
+ }
+
+ films.forEach((film) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "tmdb-result";
+ button.addEventListener("click", () => applyResult(film));
+
+ if (film.poster_url) {
+ const image = document.createElement("img");
+ image.src = film.poster_url;
+ image.alt = "";
+ button.appendChild(image);
+ }
+
+ const text = document.createElement("span");
+ const title = document.createElement("strong");
+ title.textContent = film.year ? `${film.title} (${film.year})` : film.title;
+ text.appendChild(title);
+
+ if (film.director) {
+ const director = document.createElement("small");
+ director.textContent = film.director;
+ text.appendChild(director);
+ }
+
+ button.appendChild(text);
+ tmdbResults.appendChild(button);
+ });
+};
+
+const searchTmdb = async () => {
+ if (!tmdbQuery || !tmdbResults) return;
+
+ const query = tmdbQuery.value.trim();
+ if (query.length < 2) {
+ renderMessage("Enter at least two characters.");
+ return;
+ }
+
+ renderMessage("Searching...");
+
+ try {
+ const response = await fetch(`/tmdb/search?q=${encodeURIComponent(query)}`);
+ const data = await response.json();
+ if (!response.ok) {
+ renderMessage(data.error || "Search failed.");
+ return;
+ }
+ renderResults(data.results || []);
+ } catch (error) {
+ renderMessage("Search failed.");
+ }
+};
+
+if (tmdbButton && tmdbQuery) {
+ tmdbButton.addEventListener("click", searchTmdb);
+ tmdbQuery.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ searchTmdb();
+ }
+ });
+}
+
+document.querySelectorAll("form[data-confirm]").forEach((form) => {
+ form.addEventListener("submit", (event) => {
+ const message = form.dataset.confirm;
+ if (message && !window.confirm(message)) {
+ event.preventDefault();
+ }
+ });
+});
diff --git a/static/styles.css b/static/styles.css
new file mode 100644
index 0000000..f7c6fbc
--- /dev/null
+++ b/static/styles.css
@@ -0,0 +1,789 @@
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap');
+:root {
+ color-scheme: dark;
+ --bg: #0b0b0a;
+ --panel: #171613;
+ --panel-soft: #201f1b;
+ --line: #312f28;
+ --text: #f4efe6;
+ --muted: #a9a197;
+ --subtle: #756f66;
+ --accent: #f0b84d;
+ --accent-strong: #ffcf73;
+ --green: #79a889;
+ --danger: #df6e62;
+ --shadow: 0 20px 70px rgba(0, 0, 0, 0.35);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+img {
+ display: block;
+ max-width: 100%;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+.shell {
+ width: min(1120px, calc(100% - 32px));
+ margin: 0 auto;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 24px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.brand {
+ font-family: 'Cormorant Garamond', Georgia, serif;
+ font-size: 1.55rem;
+ color: var(--accent);
+}
+
+.nav-actions,
+.detail-actions,
+.form-actions,
+.search-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.nav-actions a,
+.form-actions a {
+ color: var(--muted);
+}
+
+.nav-actions a.is-active {
+ color: var(--text);
+}
+
+.nav-actions a.button-link {
+ color: #0e0a04;
+}
+
+.button-link,
+button {
+ border: 1px solid var(--accent);
+ border-radius: 6px;
+ background: var(--accent);
+ color: #0e0a04;
+ padding: 10px 14px;
+ cursor: pointer;
+ font-weight: 800;
+}
+
+.button-link:hover,
+button:hover {
+ background: var(--accent-strong);
+}
+
+.danger-button {
+ border-color: rgba(223, 110, 98, 0.5);
+ background: transparent;
+ color: var(--danger);
+}
+
+.danger-button:hover {
+ background: rgba(223, 110, 98, 0.12);
+}
+
+.secondary-button,
+.small-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.secondary-button {
+ min-height: 44px;
+ padding: 10px 14px;
+}
+
+.small-button {
+ min-height: 32px;
+ padding: 6px 10px;
+ font-size: 0.84rem;
+}
+
+.secondary-button:hover,
+.small-button:hover {
+ border-color: var(--accent);
+ background: var(--panel-soft);
+ color: var(--text);
+}
+
+.page-heading,
+.form-heading {
+ padding: 48px 0 26px;
+}
+
+.page-heading-row,
+.stats-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.eyebrow {
+ margin: 0 0 8px;
+ color: var(--green);
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+
+h1 {
+ margin-bottom: 0;
+ font-family: 'Cormorant Garamond', Georgia, serif;
+ font-size: clamp(2.2rem, 5vw, 4rem);
+ font-weight: 500;
+ line-height: 1.05;
+}
+
+h2 {
+ margin-bottom: 4px;
+ font-size: 1.1rem;
+ line-height: 1.2;
+}
+
+.muted,
+.subtitle {
+ color: var(--muted);
+}
+
+.inline-link {
+ color: var(--text);
+ text-decoration: underline;
+ text-decoration-color: rgba(240, 184, 77, 0.45);
+ text-underline-offset: 0.14em;
+}
+
+.inline-link:hover {
+ color: var(--accent-strong);
+}
+
+.original-title {
+ margin-bottom: 8px;
+ color: var(--subtle);
+ font-family: Georgia, "Times New Roman", serif;
+ font-style: italic;
+}
+
+.notice {
+ margin-bottom: 20px;
+ border: 1px solid rgba(121, 168, 137, 0.4);
+ border-radius: 6px;
+ background: rgba(121, 168, 137, 0.1);
+ color: #d5f0dd;
+ padding: 12px 14px;
+}
+
+.notice.error {
+ border-color: rgba(223, 110, 98, 0.4);
+ background: rgba(223, 110, 98, 0.1);
+ color: #ffc7c0;
+}
+
+.diary-feed {
+ display: grid;
+ gap: 16px;
+ padding-bottom: 56px;
+}
+
+.month-label {
+ margin: 24px 0 14px;
+ color: var(--subtle);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.film-card {
+ display: grid;
+ grid-template-columns: 92px minmax(0, 1fr);
+ gap: 18px;
+ border-bottom: 1px solid var(--line);
+ padding: 0 0 18px;
+}
+
+.poster-frame {
+ display: grid;
+ place-items: center;
+ aspect-ratio: 2 / 3;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--panel);
+ color: var(--accent);
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 2rem;
+ box-shadow: var(--shadow);
+}
+
+.poster-frame img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.film-card-body {
+ min-width: 0;
+ padding-top: 2px;
+}
+
+.film-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.rating {
+ flex: 0 0 auto;
+ color: var(--accent);
+ font-weight: 800;
+}
+
+.meta-row,
+.detail-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 0.92rem;
+}
+
+.meta-row span,
+.detail-meta span {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 4px 9px;
+}
+
+.tag-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.tag-row span {
+ border-radius: 999px;
+ background: var(--panel-soft);
+ color: var(--muted);
+ padding: 4px 9px;
+ font-size: 0.84rem;
+}
+
+.inline-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 14px;
+}
+
+.inline-actions form {
+ margin: 0;
+}
+
+.notes-preview {
+ margin: 14px 0 0;
+ color: #d0c7ba;
+}
+
+.empty-state {
+ margin: 34px 0 64px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 30px;
+}
+
+.empty-state p {
+ color: var(--muted);
+}
+
+.director-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ margin-bottom: 28px;
+}
+
+.summary-card,
+.stats-panel {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 18px;
+}
+
+.summary-card strong,
+.stats-metric strong {
+ display: block;
+ margin-top: 6px;
+ font-size: clamp(1.8rem, 4vw, 2.6rem);
+ font-family: 'Cormorant Garamond', Georgia, serif;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.summary-label {
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.detail-layout {
+ display: grid;
+ grid-template-columns: minmax(180px, 300px) minmax(0, 1fr);
+ gap: 42px;
+ padding: 52px 0 64px;
+}
+
+.poster-large {
+ width: 100%;
+}
+
+.detail-body h1 {
+ margin-bottom: 10px;
+}
+
+.detail-meta {
+ margin: 24px 0 0;
+}
+
+.detail-tags {
+ margin-top: 18px;
+}
+
+.notes-body {
+ margin-top: 28px;
+ max-width: 68ch;
+ color: #e3dacd;
+ white-space: pre-wrap;
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 1.14rem;
+}
+
+.detail-actions {
+ margin-top: 34px;
+}
+
+.detail-actions form {
+ margin: 0;
+}
+
+.form-shell {
+ width: min(900px, 100%);
+ padding-bottom: 64px;
+}
+
+.form-shell.narrow {
+ width: min(560px, 100%);
+}
+
+.danger-zone {
+ margin-top: 10px;
+ border-top: 1px solid var(--line);
+ padding-top: 28px;
+}
+
+.data-tools {
+ margin-top: 10px;
+ border-top: 1px solid var(--line);
+ padding-top: 28px;
+}
+
+.compact-heading {
+ padding: 0 0 14px;
+}
+
+.tmdb-panel {
+ margin-bottom: 24px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 18px;
+}
+
+label {
+ display: block;
+ margin-bottom: 8px;
+ color: var(--muted);
+ font-size: 0.86rem;
+ font-weight: 700;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #11100e;
+ color: var(--text);
+ padding: 11px 12px;
+ outline: none;
+}
+
+textarea {
+ resize: vertical;
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ border-color: var(--accent);
+}
+
+.search-row input {
+ min-width: 0;
+}
+
+.tmdb-results {
+ display: grid;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.tmdb-result {
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr);
+ gap: 10px;
+ align-items: center;
+ width: 100%;
+ border-color: var(--line);
+ background: #11100e;
+ color: var(--text);
+ padding: 8px;
+ text-align: left;
+}
+
+.tmdb-result:hover {
+ background: var(--panel-soft);
+}
+
+.tmdb-result img {
+ width: 38px;
+ aspect-ratio: 2 / 3;
+ border-radius: 4px;
+ object-fit: cover;
+}
+
+.tmdb-result small {
+ display: block;
+ color: var(--muted);
+}
+
+.tmdb-message {
+ margin: 0;
+ color: var(--muted);
+}
+
+.film-form {
+ display: grid;
+ gap: 20px;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.field {
+ min-width: 0;
+}
+
+.checkbox-field {
+ display: flex;
+ align-items: flex-end;
+}
+
+.check-label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 45px;
+ margin: 0;
+ color: var(--text);
+}
+
+.check-label input {
+ width: auto;
+}
+
+.span-2 {
+ grid-column: 1 / -1;
+}
+
+.poster-preview-field {
+ grid-column: 1;
+}
+
+.poster-preview {
+ width: min(180px, 100%);
+ box-shadow: none;
+}
+
+.poster-preview img:not([src]) {
+ display: none;
+}
+
+.notes-field {
+ grid-column: 2;
+}
+
+.form-actions {
+ justify-content: flex-end;
+}
+
+.stats-layout {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+ padding-bottom: 64px;
+}
+
+.stats-panel-wide {
+ grid-column: 1 / -1;
+}
+
+.stats-panel h2 {
+ margin: 2px 0 0;
+}
+
+.stats-map {
+ position: relative;
+ min-height: 320px;
+}
+
+.stats-map svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.map-readout {
+ display: grid;
+ gap: 3px;
+ margin-top: 14px;
+ border-top: 1px solid var(--line);
+ padding-top: 14px;
+}
+
+.map-readout strong {
+ color: var(--text);
+ font-size: 1rem;
+}
+
+.map-readout span {
+ color: var(--muted);
+ font-size: 0.92rem;
+}
+
+.stats-tooltip {
+ position: fixed;
+ z-index: 40;
+ display: grid;
+ gap: 2px;
+ min-width: 120px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: rgba(11, 11, 10, 0.96);
+ color: var(--text);
+ padding: 10px 12px;
+ pointer-events: none;
+ box-shadow: var(--shadow);
+}
+
+.stats-tooltip[hidden] {
+ display: none;
+}
+
+.heatmap-shell {
+ overflow: visible;
+}
+
+.heatmap-months {
+ display: grid;
+ grid-template-columns: repeat(53, 13px);
+ gap: 4px;
+ margin: 0 0 8px 42px;
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.heatmap-body {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 8px;
+ align-items: start;
+}
+
+.heatmap-weekdays {
+ display: grid;
+ grid-template-rows: repeat(7, 13px);
+ gap: 4px;
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.heatmap-grid {
+ display: grid;
+ grid-template-columns: repeat(53, 13px);
+ grid-template-rows: repeat(7, 13px);
+ gap: 4px;
+}
+
+.heatmap-cell {
+ border: 0;
+ border-radius: 3px;
+ padding: 0;
+}
+
+.stats-list {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.stats-list li,
+.stats-bar-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+}
+
+.stats-list li strong,
+.stats-bar-row strong {
+ color: var(--accent);
+}
+
+.stats-bars {
+ display: grid;
+ gap: 10px;
+}
+
+.stats-bar-row {
+ grid-template-columns: 64px minmax(0, 1fr) auto;
+}
+
+.stats-bar-track {
+ height: 10px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: var(--panel-soft);
+}
+
+.stats-bar-fill {
+ height: 100%;
+ min-width: 8px;
+ border-radius: inherit;
+ background: linear-gradient(90deg, rgba(240, 184, 77, 0.4), var(--accent));
+}
+
+.stats-metric {
+ display: grid;
+ gap: 8px;
+}
+
+.stats-metric span {
+ color: var(--muted);
+}
+
+@media (max-width: 760px) {
+ .shell {
+ width: min(100% - 24px, 1120px);
+ }
+
+ .topbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .page-heading,
+ .form-heading {
+ padding-top: 34px;
+ }
+
+ .film-card,
+ .detail-layout,
+ .form-grid,
+ .director-summary,
+ .stats-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .film-card {
+ grid-template-columns: 72px minmax(0, 1fr);
+ }
+
+ .detail-layout {
+ gap: 28px;
+ }
+
+ .detail-poster {
+ width: min(220px, 70%);
+ }
+
+ .poster-preview-field,
+ .notes-field {
+ grid-column: 1;
+ }
+
+ .search-row,
+ .form-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .page-heading-row,
+ .stats-panel-header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .heatmap-months {
+ margin-left: 38px;
+ }
+}
diff --git a/templates/_film_card.html b/templates/_film_card.html
new file mode 100644
index 0000000..9fcd89b
--- /dev/null
+++ b/templates/_film_card.html
@@ -0,0 +1,70 @@
+<article class="film-card">
+ <a class="poster-frame" href="/films/{{ film.id }}" aria-label="{{ film.title }}">
+ {% if film.poster_url %}
+ <img src="{{ film.poster_url }}" alt="{{ film.title }} poster">
+ {% else %}
+ <span>{{ film.title[:1] }}</span>
+ {% endif %}
+ </a>
+ <div class="film-card-body">
+ <div class="film-card-header">
+ <div>
+ <h2><a href="/films/{{ film.id }}">{{ film.title }}</a></h2>
+ <p class="muted">
+ {% if film.year %}{{ film.year }}{% endif %}
+ {% set directors = split_credit_names(film.director) %}
+ {% if directors %}
+ {% if film.year %} · {% endif %}
+ {% for director in directors %}
+ <a class="inline-link" href="{{ director_href(director) }}">{{ director }}</a>{% if not loop.last %}, {% endif %}
+ {% endfor %}
+ {% endif %}
+ </p>
+ </div>
+ {% if film.stars %}
+ <span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span>
+ {% endif %}
+ </div>
+
+ <div class="meta-row">
+ <span>{{ film.shelf|title }}</span>
+ {% 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 %}
+ </div>
+
+ {% if film.context or film.how_found or film.watched_with %}
+ <div class="tag-row">
+ {% 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 %}
+ </div>
+ {% endif %}
+
+ {% if film.notes %}
+ <p class="notes-preview">{{ film.notes[:220] }}{% if film.notes|length > 220 %}...{% endif %}</p>
+ {% endif %}
+
+ <div class="inline-actions">
+ {% if film.shelf == 'queue' %}
+ <a class="small-button" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Abandon</button>
+ </form>
+ {% elif film.shelf == 'diary' %}
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Mark abandoned</button>
+ </form>
+ {% else %}
+ <a class="small-button" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ {% endif %}
+ </div>
+ </div>
+</article>
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..168efde
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{% block title %}Lumière{% endblock %}</title>
+ <link rel="stylesheet" href="/static/styles.css" />
+ <script src="{{ url_for('static', path='/app.js') }}" defer></script>
+ </head>
+ <body>
+ <div class="shell">
+ <header class="topbar">
+ <a class="brand" href="/">Lumière</a>
+ <nav class="nav-actions" aria-label="Primary">
+ <a class="{% if active_shelf == 'diary' %}is-active{% endif %}" href="/diary">Diary</a>
+ <a class="{% if active_shelf == 'queue' %}is-active{% endif %}" href="/queue">Queue</a>
+ <a class="{% if active_shelf == 'abandoned' %}is-active{% endif %}" href="/abandoned">Abandoned</a>
+ <a class="{% if active_page == 'stats' %}is-active{% endif %}" href="/stats">Stats</a>
+ <a class="{% if active_page == 'import' %}is-active{% endif %}" href="/import">Import</a>
+ <a class="button-link" href="/films/new">Add Film</a>
+ </nav>
+ </header>
+
+ <main>
+ {% block content %}{% endblock %}
+ </main>
+ </div>
+ {% block scripts %}{% endblock %}
+ </body>
+</html>
diff --git a/templates/detail.html b/templates/detail.html
new file mode 100644
index 0000000..b937c6b
--- /dev/null
+++ b/templates/detail.html
@@ -0,0 +1,83 @@
+{% extends "base.html" %}
+
+{% block title %}{{ film.title }} · Lumière{% endblock %}
+
+{% block content %}
+ <article class="detail-layout">
+ <aside class="detail-poster">
+ <div class="poster-frame poster-large">
+ {% if film.poster_url %}
+ <img src="{{ film.poster_url }}" alt="{{ film.title }} poster">
+ {% else %}
+ <span>{{ film.title[:1] }}</span>
+ {% endif %}
+ </div>
+ </aside>
+
+ <section class="detail-body">
+ <p class="eyebrow">{{ film.shelf|title }} Entry</p>
+ <h1>{{ film.title }}</h1>
+ {% if film.original_title %}
+ <p class="original-title">{{ film.original_title }}</p>
+ {% endif %}
+ <p class="subtitle">
+ {% if film.year %}{{ film.year }}{% endif %}
+ {% set directors = split_credit_names(film.director) %}
+ {% if directors %}
+ {% if film.year %} · {% endif %}
+ {% for director in directors %}
+ <a class="inline-link" href="{{ director_href(director) }}">{{ director }}</a>{% if not loop.last %}, {% endif %}
+ {% endfor %}
+ {% endif %}
+ </p>
+
+ <div class="detail-meta">
+ {% if film.stars %}<span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span>{% endif %}
+ {% if film.date_watched %}<span>{{ film.date_watched }}</span>{% endif %}
+ {% if film.runtime %}<span>{{ film.runtime }} min</span>{% endif %}
+ {% if film.rewatch %}<span>Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}</span>{% endif %}
+ {% if film.country %}<span>{{ film.country }}</span>{% endif %}
+ {% if film.language %}<span>{{ film.language }}</span>{% endif %}
+ </div>
+
+ {% if film.context or film.how_found or film.watched_with %}
+ <div class="tag-row detail-tags">
+ {% 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 %}
+ </div>
+ {% endif %}
+
+ {% if film.notes %}
+ <div class="notes-body">
+ {{ film.notes }}
+ </div>
+ {% endif %}
+
+ <div class="detail-actions">
+ <a class="secondary-button" href="/films/{{ film.id }}/edit">Edit</a>
+ {% if film.shelf == 'queue' %}
+ <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Abandon</button>
+ </form>
+ {% elif film.shelf == 'diary' %}
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Mark abandoned</button>
+ </form>
+ {% else %}
+ <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ {% endif %}
+ <form method="post" action="/films/{{ film.id }}/delete">
+ <button class="danger-button" type="submit">Delete</button>
+ </form>
+ </div>
+ </section>
+ </article>
+{% endblock %}
diff --git a/templates/director.html b/templates/director.html
new file mode 100644
index 0000000..3257ee9
--- /dev/null
+++ b/templates/director.html
@@ -0,0 +1,31 @@
+{% extends "base.html" %}
+
+{% block title %}{{ director_name }} · Lumière{% endblock %}
+
+{% block content %}
+ <section class="page-heading">
+ <p class="eyebrow">Director</p>
+ <h1>{{ director_name }}</h1>
+ </section>
+
+ <section class="director-summary" aria-label="Director summary">
+ <article class="summary-card">
+ <span class="summary-label">Films logged</span>
+ <strong>{{ director_summary.total_films_logged }}</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Average stars</span>
+ <strong>{{ "%.1f"|format(director_summary.average_stars) }}</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Common shelf</span>
+ <strong>{{ director_summary.most_common_shelf|title }}</strong>
+ </article>
+ </section>
+
+ <section class="diary-feed" aria-label="{{ director_name }} filmography">
+ {% for film in films %}
+ {% include "_film_card.html" %}
+ {% endfor %}
+ </section>
+{% endblock %}
diff --git a/templates/form.html b/templates/form.html
new file mode 100644
index 0000000..4009e87
--- /dev/null
+++ b/templates/form.html
@@ -0,0 +1,144 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page_title }} · Lumière{% endblock %}
+
+{% block content %}
+ <section class="form-shell">
+ <div class="form-heading">
+ <p class="eyebrow">Diary Entry</p>
+ <h1>{{ page_title }}</h1>
+ </div>
+
+ {% if error %}
+ <div class="notice error">{{ error }}</div>
+ {% endif %}
+
+ <div class="tmdb-panel">
+ <label for="tmdb-query">TMDB title search</label>
+ <div class="search-row">
+ <input id="tmdb-query" type="search" autocomplete="off" placeholder="Search by title">
+ <button id="tmdb-search" type="button">Search</button>
+ </div>
+ <div id="tmdb-results" class="tmdb-results" aria-live="polite"></div>
+ </div>
+
+ <form class="film-form" method="post" action="{{ action }}">
+ <input id="tmdb_id" name="tmdb_id" type="hidden" value="{{ film.tmdb_id if film and film.tmdb_id else '' }}">
+
+ <div class="form-grid">
+ <div class="field span-2">
+ <label for="title">Display title</label>
+ <input id="title" name="title" required value="{{ film.title if film else '' }}">
+ </div>
+
+ <div class="field span-2">
+ <label for="original_title">Original title</label>
+ <input id="original_title" name="original_title" value="{{ film.original_title if film and film.original_title else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="director">Director</label>
+ <input id="director" name="director" value="{{ film.director if film and film.director else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="year">Year</label>
+ <input id="year" name="year" inputmode="numeric" value="{{ film.year if film and film.year else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="country">Country</label>
+ <input id="country" name="country" value="{{ film.country if film and film.country 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>
+
+ <div class="field">
+ <label for="runtime">Runtime</label>
+ <input id="runtime" name="runtime" type="number" min="0" inputmode="numeric" value="{{ film.runtime if film and film.runtime is not none else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="date_watched">Watched</label>
+ <input id="date_watched" name="date_watched" type="date" value="{{ film.date_watched if film and film.date_watched else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="shelf">Shelf</label>
+ {% set current_shelf = shelf_override if shelf_override else (film.shelf if film and film.shelf else 'diary') %}
+ <select id="shelf" name="shelf">
+ <option value="diary" {% if current_shelf == 'diary' %}selected{% endif %}>Diary</option>
+ <option value="queue" {% if current_shelf == 'queue' %}selected{% endif %}>Queue</option>
+ <option value="abandoned" {% if current_shelf == 'abandoned' %}selected{% endif %}>Abandoned</option>
+ </select>
+ </div>
+
+ <div class="field">
+ <label for="stars">Stars</label>
+ {% set current_stars = film.stars if film else 0 %}
+ <select id="stars" name="stars">
+ <option value="0" {% if current_stars|string == '0' %}selected{% endif %}>Unstarred</option>
+ <option value="1" {% if current_stars|string == '1' %}selected{% endif %}>1 star</option>
+ <option value="2" {% if current_stars|string == '2' %}selected{% endif %}>2 stars</option>
+ <option value="3" {% if current_stars|string == '3' %}selected{% endif %}>3 stars</option>
+ </select>
+ </div>
+
+ <div class="field checkbox-field">
+ <label class="check-label" for="rewatch">
+ <input id="rewatch" name="rewatch" type="checkbox" value="on" {% if film and film.rewatch %}checked{% endif %}>
+ Rewatch
+ </label>
+ </div>
+
+ <div class="field">
+ <label for="rewatch_count">Rewatch count</label>
+ <input id="rewatch_count" name="rewatch_count" type="number" min="0" inputmode="numeric" value="{{ film.rewatch_count if film and film.rewatch_count is not none else 0 }}">
+ </div>
+
+ <div class="field span-2">
+ <label for="watched_with">Watched with</label>
+ <input id="watched_with" name="watched_with" placeholder="Solo, Maya, Film club" value="{{ film.watched_with if film and film.watched_with else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="how_found">How found</label>
+ <input id="how_found" name="how_found" placeholder="Recommendation, festival, streaming queue" value="{{ film.how_found if film and film.how_found else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="context">Context</label>
+ <input id="context" name="context" placeholder="Criterion, Festival, Kiarostami deep-dive" value="{{ film.context if film and film.context else '' }}">
+ </div>
+
+ <div class="field span-2">
+ <label for="poster_url">Poster URL</label>
+ <input id="poster_url" name="poster_url" value="{{ film.poster_url if film and film.poster_url else '' }}">
+ </div>
+
+ <div class="poster-preview-field">
+ <div class="poster-frame poster-preview">
+ {% if film and film.poster_url %}
+ <img id="poster-preview" src="{{ film.poster_url }}" alt="Poster preview">
+ {% else %}
+ <img id="poster-preview" alt="Poster preview">
+ {% endif %}
+ </div>
+ </div>
+
+ <div class="field notes-field">
+ <label for="notes">Notes</label>
+ <textarea id="notes" name="notes" rows="10">{{ film.notes if film and film.notes else '' }}</textarea>
+ </div>
+ </div>
+
+ <div class="form-actions">
+ <a href="{{ '/films/' ~ film.id if film and film.id else '/' }}">Cancel</a>
+ <button type="submit">{{ submit_label }}</button>
+ </div>
+ </form>
+ </section>
+{% endblock %}
diff --git a/templates/import.html b/templates/import.html
new file mode 100644
index 0000000..50c27d6
--- /dev/null
+++ b/templates/import.html
@@ -0,0 +1,108 @@
+{% extends "base.html" %}
+
+{% block title %}Import · Lumière{% endblock %}
+
+{% block content %}
+ <section class="form-shell narrow">
+ <div class="form-heading">
+ <p class="eyebrow">Letterboxd</p>
+ <h1>Import diary CSV</h1>
+ </div>
+
+ {% if error %}
+ <div class="notice error">{{ error }}</div>
+ {% endif %}
+
+ <form class="film-form" method="post" action="/import/letterboxd" enctype="multipart/form-data">
+ <div class="field">
+ <label for="file">CSV export</label>
+ <input id="file" name="file" type="file" accept=".csv,text/csv" required>
+ </div>
+
+ <div class="form-actions">
+ <a href="/">Cancel</a>
+ <button type="submit">Import entries</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow data-tools">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">Queue</p>
+ <h1>Import watchlist CSV</h1>
+ </div>
+
+ <form class="film-form" method="post" action="/import/watchlist" enctype="multipart/form-data">
+ <div class="field">
+ <label for="watchlist-file">Watchlist CSV</label>
+ <input id="watchlist-file" name="file" type="file" accept=".csv,text/csv" required>
+ </div>
+
+ <div class="form-actions">
+ <a href="/queue">Cancel</a>
+ <button type="submit">Import to queue</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow data-tools">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">TMDB</p>
+ <h1>Fetch missing posters</h1>
+ </div>
+
+ <p class="muted">
+ Match imported films against TMDB and fill blank posters plus missing metadata.
+ </p>
+
+ <form class="film-form" method="post" action="/data/enrich-posters">
+ <div class="form-actions">
+ <button type="submit">Fetch missing posters</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow data-tools">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">Data</p>
+ <h1>Clear duplicates</h1>
+ </div>
+
+ <p class="muted">
+ Remove repeated imports that match the same film and watched date, keeping the oldest entry.
+ </p>
+
+ <form
+ class="film-form"
+ method="post"
+ action="/data/clear-duplicates"
+ data-confirm="Remove duplicate film entries? Lumière will keep the oldest entry for each film/date."
+ >
+ <div class="form-actions">
+ <button type="submit">Clear duplicates</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow danger-zone">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">Data</p>
+ <h1>Clear diary</h1>
+ </div>
+
+ <p class="muted">
+ Remove every film entry from Lumière and reset the local database counter.
+ </p>
+
+ <form
+ class="film-form"
+ method="post"
+ action="/data/clear"
+ data-confirm="Clear all Lumière film data? This cannot be undone."
+ >
+ <div class="form-actions">
+ <button class="danger-button" type="submit">Clear all data</button>
+ </div>
+ </form>
+ </section>
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..52c633f
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+
+{% block title %}{{ shelf_meta.title }} · Lumière{% endblock %}
+
+{% block content %}
+ <section class="page-heading">
+ <p class="eyebrow">{{ shelf_meta.eyebrow }}</p>
+ <div class="page-heading-row">
+ <h1>{{ shelf_meta.title }}</h1>
+ {% if active_shelf == 'queue' %}
+ <a class="button-link" href="/queue/random">Surprise me</a>
+ {% endif %}
+ </div>
+ </section>
+
+ {% if imported is not none %}
+ <div class="notice">{{ imported }} entries imported.</div>
+ {% endif %}
+
+ {% if skipped is not none and skipped %}
+ <div class="notice">{{ skipped }} duplicate entries skipped.</div>
+ {% endif %}
+
+ {% if cleared is not none %}
+ <div class="notice">{{ cleared }} entries cleared.</div>
+ {% endif %}
+
+ {% if deduped is not none %}
+ <div class="notice">{{ deduped }} duplicate entries removed.</div>
+ {% endif %}
+
+ {% if empty_queue is not none %}
+ <div class="notice">The queue is empty. Add a film to get a random pick.</div>
+ {% endif %}
+
+ {% if enriched is not none %}
+ <div class="notice">{{ enriched }} entries enriched from TMDB.</div>
+ {% endif %}
+
+ {% if films %}
+ <section class="diary-feed" aria-label="Diary entries">
+ {% if active_shelf == 'diary' and grouped_films %}
+ {% for group in grouped_films %}
+ <div class="month-group">
+ <p class="month-label">{{ group.month }}</p>
+ {% for film in group.films %}
+ {% include "_film_card.html" %}
+ {% endfor %}
+ </div>
+ {% endfor %}
+ {% else %}
+ {% for film in films %}
+ {% include "_film_card.html" %}
+ {% endfor %}
+ {% endif %}
+ </section>
+ {% else %}
+ <section class="empty-state">
+ <h2>{{ shelf_meta.empty_title }}</h2>
+ <p>{{ shelf_meta.empty_text }}</p>
+ <a class="button-link" href="/films/new">Add Film</a>
+ </section>
+ {% endif %}
+{% endblock %}
diff --git a/templates/stats.html b/templates/stats.html
new file mode 100644
index 0000000..7b54634
--- /dev/null
+++ b/templates/stats.html
@@ -0,0 +1,268 @@
+{% extends "base.html" %}
+
+{% block title %}Stats · Lumière{% endblock %}
+
+{% block content %}
+ <section class="page-heading">
+ <p class="eyebrow">Stats</p>
+ <h1>Watching patterns</h1>
+ </section>
+
+ <section class="stats-layout">
+ <section class="stats-panel stats-panel-wide">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">World Map</p>
+ <h2>Films per country</h2>
+ </div>
+ </div>
+ <div id="world-map" class="stats-map" aria-label="World map of watched films"></div>
+ <div id="map-readout" class="map-readout" aria-live="polite">
+ <strong>Hover a country</strong>
+ <span>Film count will appear here.</span>
+ </div>
+ <div id="map-tooltip" class="stats-tooltip" hidden></div>
+ </section>
+
+ <section class="stats-panel stats-panel-wide">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Heatmap</p>
+ <h2>Past 365 days</h2>
+ </div>
+ </div>
+ <div id="watch-heatmap" class="heatmap-shell" aria-label="Daily watch heatmap"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Directors</p>
+ <h2>Most watched</h2>
+ </div>
+ </div>
+ <ol id="top-directors" class="stats-list"></ol>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Stars</p>
+ <h2>Distribution</h2>
+ </div>
+ </div>
+ <div id="star-distribution" class="stats-bars"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Rewatch</p>
+ <h2>Rate</h2>
+ </div>
+ </div>
+ <div id="rewatch-rate" class="stats-metric"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Companions</p>
+ <h2>Watched with</h2>
+ </div>
+ </div>
+ <ol id="watched-with" class="stats-list"></ol>
+ </section>
+ </section>
+{% endblock %}
+
+{% block scripts %}
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js"></script>
+ <script>
+ const PANEL_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel").trim();
+ const PANEL_SOFT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel-soft").trim();
+ const ACCENT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
+
+ function heatmapColor(count) {
+ if (count <= 0) return PANEL_SOFT_COLOR;
+ if (count === 1) return "rgba(240, 184, 77, 0.35)";
+ if (count === 2) return "rgba(240, 184, 77, 0.65)";
+ return ACCENT_COLOR;
+ }
+
+ function formatMonthLabel(dateString) {
+ return new Date(`${dateString}T00:00:00`).toLocaleDateString(undefined, { month: "short" });
+ }
+
+ function formatCountryCode(value) {
+ return String(value).padStart(3, "0");
+ }
+
+ function renderLists(data) {
+ 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>
+ `).join("");
+
+ const watchedWith = document.getElementById("watched-with");
+ watchedWith.innerHTML = data.watched_with_breakdown.slice(0, 8).map((item) => `
+ <li><span>${item.watched_with}</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) => `
+ <div class="stats-bar-row">
+ <span>${item.stars} star</span>
+ <div class="stats-bar-track"><div class="stats-bar-fill" style="width:${(item.count / maxStars) * 100}%"></div></div>
+ <strong>${item.count}</strong>
+ </div>
+ `).join("");
+
+ const rewatchRate = document.getElementById("rewatch-rate");
+ const rate = data.rewatch_rate.total_watched ? Math.round(data.rewatch_rate.rate * 100) : 0;
+ rewatchRate.innerHTML = `
+ <strong>${rate}%</strong>
+ <span>${data.rewatch_rate.rewatched} of ${data.rewatch_rate.total_watched} watched films were rewatches.</span>
+ `;
+ }
+
+ function renderHeatmap(days) {
+ const container = document.getElementById("watch-heatmap");
+ const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+ const parsedDays = days.map((item) => {
+ const date = new Date(`${item.date}T00:00:00`);
+ return { ...item, dateObj: date };
+ });
+
+ const firstWeekStart = new Date(parsedDays[0].dateObj);
+ const mondayOffset = (firstWeekStart.getDay() + 6) % 7;
+ firstWeekStart.setDate(firstWeekStart.getDate() - mondayOffset);
+
+ const monthLabels = [];
+ const seenMonths = new Set();
+ const cells = parsedDays.map((item) => {
+ const diffDays = Math.floor((item.dateObj - firstWeekStart) / 86400000);
+ const week = Math.floor(diffDays / 7);
+ const weekday = (item.dateObj.getDay() + 6) % 7;
+ const monthKey = item.date.slice(0, 7);
+ if (!seenMonths.has(monthKey)) {
+ seenMonths.add(monthKey);
+ monthLabels.push({ week, label: formatMonthLabel(item.date) });
+ }
+ return `
+ <button
+ class="heatmap-cell"
+ style="grid-column:${week + 1};grid-row:${weekday + 1};background:${heatmapColor(item.count)}"
+ title="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}"
+ aria-label="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}"
+ type="button"
+ ></button>
+ `;
+ }).join("");
+
+ container.innerHTML = `
+ <div class="heatmap-months">
+ ${monthLabels.map((item) => `<span style="grid-column:${item.week + 1}">${item.label}</span>`).join("")}
+ </div>
+ <div class="heatmap-body">
+ <div class="heatmap-weekdays">
+ ${weekdays.map((label) => `<span>${label}</span>`).join("")}
+ </div>
+ <div class="heatmap-grid">${cells}</div>
+ </div>
+ `;
+ }
+
+ async function renderMap(data) {
+ const container = document.getElementById("world-map");
+ const readout = document.getElementById("map-readout");
+ const tooltip = document.getElementById("map-tooltip");
+ const [worldResponse, namesResponse] = await Promise.all([
+ fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"),
+ fetch("https://cdn.jsdelivr.net/gh/lukes/ISO-3166-Countries-with-Regional-Codes@master/all/all.json"),
+ ]);
+ const [world, countryRows] = await Promise.all([worldResponse.json(), namesResponse.json()]);
+ const counts = new Map(data.films_per_country_codes.map((item) => [String(item.code), item.count]));
+ const labels = data.country_labels_by_code || {};
+ const names = countryRows.reduce((map, row) => {
+ map.set(formatCountryCode(row["country-code"]), row.name);
+ return map;
+ }, new Map());
+ const maxCount = Math.max(1, ...data.films_per_country_codes.map((item) => item.count));
+
+ const width = container.clientWidth || 960;
+ const height = Math.min(520, Math.max(320, Math.round(width * 0.55)));
+ const projection = d3.geoNaturalEarth1().fitSize([width, height], topojson.feature(world, world.objects.countries));
+ const path = d3.geoPath(projection);
+ const color = d3.scaleLinear().domain([0, maxCount]).range([PANEL_COLOR, ACCENT_COLOR]);
+
+ const svg = d3.create("svg")
+ .attr("viewBox", `0 0 ${width} ${height}`)
+ .attr("role", "img")
+ .attr("aria-label", "World map shaded by films watched");
+ let activeCountryPath = null;
+
+ function updateReadout(name, count) {
+ readout.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`;
+ }
+
+ function resetMapHover() {
+ if (activeCountryPath) {
+ activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7);
+ activeCountryPath = null;
+ }
+ readout.innerHTML = `<strong>Hover a country</strong><span>Film count will appear here.</span>`;
+ tooltip.hidden = true;
+ }
+
+ svg.append("g")
+ .selectAll("path")
+ .data(topojson.feature(world, world.objects.countries).features)
+ .join("path")
+ .attr("d", path)
+ .attr("fill", (feature) => color(counts.get(String(feature.id)) || 0))
+ .attr("stroke", "rgba(244, 239, 230, 0.18)")
+ .attr("stroke-width", 0.7)
+ .style("cursor", "pointer")
+ .on("pointerenter", function(event, feature) {
+ const count = counts.get(String(feature.id)) || 0;
+ const featureCode = formatCountryCode(feature.id);
+ const name = names.get(featureCode) || labels[String(feature.id)] || `Country ${feature.id}`;
+ if (activeCountryPath) {
+ activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7);
+ }
+ activeCountryPath = d3.select(this);
+ activeCountryPath.attr("stroke", ACCENT_COLOR).attr("stroke-width", 1.4);
+ updateReadout(name, count);
+ tooltip.hidden = false;
+ tooltip.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`;
+ tooltip.style.left = `${event.pageX + 12}px`;
+ tooltip.style.top = `${event.pageY + 12}px`;
+ })
+ .on("pointermove", function(event) {
+ tooltip.style.left = `${event.pageX + 12}px`;
+ tooltip.style.top = `${event.pageY + 12}px`;
+ })
+ .on("pointerleave", function() {
+ resetMapHover();
+ });
+
+ container.innerHTML = "";
+ container.appendChild(svg.node());
+ svg.on("pointerleave", resetMapHover);
+ container.addEventListener("mouseleave", resetMapHover);
+ }
+
+ async function bootStats() {
+ const response = await fetch("/stats/data");
+ const data = await response.json();
+ renderLists(data);
+ renderHeatmap(data.films_per_day_365);
+ await renderMap(data);
+ }
+
+ bootStats();
+ </script>
+{% endblock %}