From e708bec6cd76c2686de4158dde4d04f72a3c300d Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Wed, 6 May 2026 12:21:26 -0700 Subject: init: lumiere film diary --- .gitignore | 6 + AGENTS.md | 22 ++ README.md | 75 +++++ database.py | 90 ++++++ main.py | 26 ++ models.py | 53 ++++ requirements.txt | 7 + routers/__init__.py | 1 + routers/films.py | 544 ++++++++++++++++++++++++++++++++ routers/imports.py | 343 ++++++++++++++++++++ routers/stats.py | 126 ++++++++ routers/tmdb.py | 32 ++ services/__init__.py | 1 + services/countries.py | 67 ++++ services/film_people.py | 17 + services/tmdb.py | 226 +++++++++++++ static/app.js | 131 ++++++++ static/styles.css | 789 ++++++++++++++++++++++++++++++++++++++++++++++ templates/_film_card.html | 70 ++++ templates/base.html | 30 ++ templates/detail.html | 83 +++++ templates/director.html | 31 ++ templates/form.html | 144 +++++++++ templates/import.html | 108 +++++++ templates/index.html | 64 ++++ templates/stats.html | 268 ++++++++++++++++ 26 files changed, 3354 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 database.py create mode 100644 main.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 routers/__init__.py create mode 100644 routers/films.py create mode 100644 routers/imports.py create mode 100644 routers/stats.py create mode 100644 routers/tmdb.py create mode 100644 services/__init__.py create mode 100644 services/countries.py create mode 100644 services/film_people.py create mode 100644 services/tmdb.py create mode 100644 static/app.js create mode 100644 static/styles.css create mode 100644 templates/_film_card.html create mode 100644 templates/base.html create mode 100644 templates/detail.html create mode 100644 templates/director.html create mode 100644 templates/form.html create mode 100644 templates/import.html create mode 100644 templates/index.html create mode 100644 templates/stats.html 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 + 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 @@ +
+ + {% if film.poster_url %} + {{ film.title }} poster + {% else %} + {{ film.title[:1] }} + {% endif %} + +
+
+
+

{{ film.title }}

+

+ {% if film.year %}{{ film.year }}{% endif %} + {% set directors = split_credit_names(film.director) %} + {% if directors %} + {% if film.year %} · {% endif %} + {% for director in directors %} + {{ director }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% endif %} +

+
+ {% if film.stars %} + {% for _ in range(film.stars) %}✦{% endfor %} + {% endif %} +
+ +
+ {{ film.shelf|title }} + {% if film.date_watched %}{{ film.date_watched }}{% endif %} + {% if film.runtime %}{{ film.runtime }} min{% endif %} + {% if film.language %}{{ film.language }}{% endif %} + {% if film.rewatch %}Rewatch{% endif %} +
+ + {% if film.context or film.how_found or film.watched_with %} +
+ {% if film.context %}{{ film.context }}{% endif %} + {% if film.how_found %}{{ film.how_found }}{% endif %} + {% if film.watched_with %}With {{ film.watched_with }}{% endif %} +
+ {% endif %} + + {% if film.notes %} +

{{ film.notes[:220] }}{% if film.notes|length > 220 %}...{% endif %}

+ {% endif %} + +
+ {% if film.shelf == 'queue' %} + Mark watched +
+ +
+ {% elif film.shelf == 'diary' %} +
+ +
+
+ +
+ {% else %} + Mark watched +
+ +
+ {% endif %} +
+
+
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 @@ + + + + + + {% block title %}Lumière{% endblock %} + + + + +
+
+ Lumière + +
+ +
+ {% block content %}{% endblock %} +
+
+ {% block scripts %}{% endblock %} + + 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 %} +
+ + +
+

{{ film.shelf|title }} Entry

+

{{ film.title }}

+ {% if film.original_title %} +

{{ film.original_title }}

+ {% endif %} +

+ {% if film.year %}{{ film.year }}{% endif %} + {% set directors = split_credit_names(film.director) %} + {% if directors %} + {% if film.year %} · {% endif %} + {% for director in directors %} + {{ director }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% endif %} +

+ +
+ {% if film.stars %}{% for _ in range(film.stars) %}✦{% endfor %}{% endif %} + {% if film.date_watched %}{{ film.date_watched }}{% endif %} + {% if film.runtime %}{{ film.runtime }} min{% endif %} + {% if film.rewatch %}Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}{% endif %} + {% if film.country %}{{ film.country }}{% endif %} + {% if film.language %}{{ film.language }}{% endif %} +
+ + {% if film.context or film.how_found or film.watched_with %} +
+ {% if film.context %}{{ film.context }}{% endif %} + {% if film.how_found %}{{ film.how_found }}{% endif %} + {% if film.watched_with %}With {{ film.watched_with }}{% endif %} +
+ {% endif %} + + {% if film.notes %} +
+ {{ film.notes }} +
+ {% endif %} + +
+ Edit + {% if film.shelf == 'queue' %} + Mark watched +
+ +
+ {% elif film.shelf == 'diary' %} +
+ +
+
+ +
+ {% else %} + Mark watched +
+ +
+ {% endif %} +
+ +
+
+
+
+{% 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 %} +
+

Director

+

{{ director_name }}

+
+ +
+
+ Films logged + {{ director_summary.total_films_logged }} +
+
+ Average stars + {{ "%.1f"|format(director_summary.average_stars) }} +
+
+ Common shelf + {{ director_summary.most_common_shelf|title }} +
+
+ +
+ {% for film in films %} + {% include "_film_card.html" %} + {% endfor %} +
+{% 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 %} +
+
+

Diary Entry

+

{{ page_title }}

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ +
+ + +
+
+
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {% set current_shelf = shelf_override if shelf_override else (film.shelf if film and film.shelf else 'diary') %} + +
+ +
+ + {% set current_stars = film.stars if film else 0 %} + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ {% if film and film.poster_url %} + Poster preview + {% else %} + Poster preview + {% endif %} +
+
+ +
+ + +
+
+ +
+ Cancel + +
+
+
+{% 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 %} +
+
+

Letterboxd

+

Import diary CSV

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ Cancel + +
+
+
+ +
+
+

Queue

+

Import watchlist CSV

+
+ +
+
+ + +
+ +
+ Cancel + +
+
+
+ +
+
+

TMDB

+

Fetch missing posters

+
+ +

+ Match imported films against TMDB and fill blank posters plus missing metadata. +

+ +
+
+ +
+
+
+ +
+
+

Data

+

Clear duplicates

+
+ +

+ Remove repeated imports that match the same film and watched date, keeping the oldest entry. +

+ +
+
+ +
+
+
+ +
+
+

Data

+

Clear diary

+
+ +

+ Remove every film entry from Lumière and reset the local database counter. +

+ +
+
+ +
+
+
+{% 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 %} +
+

{{ shelf_meta.eyebrow }}

+
+

{{ shelf_meta.title }}

+ {% if active_shelf == 'queue' %} + Surprise me + {% endif %} +
+
+ + {% if imported is not none %} +
{{ imported }} entries imported.
+ {% endif %} + + {% if skipped is not none and skipped %} +
{{ skipped }} duplicate entries skipped.
+ {% endif %} + + {% if cleared is not none %} +
{{ cleared }} entries cleared.
+ {% endif %} + + {% if deduped is not none %} +
{{ deduped }} duplicate entries removed.
+ {% endif %} + + {% if empty_queue is not none %} +
The queue is empty. Add a film to get a random pick.
+ {% endif %} + + {% if enriched is not none %} +
{{ enriched }} entries enriched from TMDB.
+ {% endif %} + + {% if films %} +
+ {% if active_shelf == 'diary' and grouped_films %} + {% for group in grouped_films %} +
+

{{ group.month }}

+ {% for film in group.films %} + {% include "_film_card.html" %} + {% endfor %} +
+ {% endfor %} + {% else %} + {% for film in films %} + {% include "_film_card.html" %} + {% endfor %} + {% endif %} +
+ {% else %} +
+

{{ shelf_meta.empty_title }}

+

{{ shelf_meta.empty_text }}

+ Add Film +
+ {% 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 %} +
+

Stats

+

Watching patterns

+
+ +
+
+
+
+

World Map

+

Films per country

+
+
+
+
+ Hover a country + Film count will appear here. +
+ +
+ +
+
+
+

Heatmap

+

Past 365 days

+
+
+
+
+ +
+
+
+

Directors

+

Most watched

+
+
+
    +
    + +
    +
    +
    +

    Stars

    +

    Distribution

    +
    +
    +
    +
    + +
    +
    +
    +

    Rewatch

    +

    Rate

    +
    +
    +
    +
    + +
    +
    +
    +

    Companions

    +

    Watched with

    +
    +
    +
      +
      +
      +{% endblock %} + +{% block scripts %} + + + +{% endblock %} -- cgit v1.3-2-g0d8e