diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 12:21:26 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 12:21:26 -0700 |
| commit | e708bec6cd76c2686de4158dde4d04f72a3c300d (patch) | |
| tree | 04b0bc4738e090dd7834d47478c7e652da010f92 | |
init: lumiere film diary
| -rw-r--r-- | .gitignore | 6 | ||||
| -rw-r--r-- | AGENTS.md | 22 | ||||
| -rw-r--r-- | README.md | 75 | ||||
| -rw-r--r-- | database.py | 90 | ||||
| -rw-r--r-- | main.py | 26 | ||||
| -rw-r--r-- | models.py | 53 | ||||
| -rw-r--r-- | requirements.txt | 7 | ||||
| -rw-r--r-- | routers/__init__.py | 1 | ||||
| -rw-r--r-- | routers/films.py | 544 | ||||
| -rw-r--r-- | routers/imports.py | 343 | ||||
| -rw-r--r-- | routers/stats.py | 126 | ||||
| -rw-r--r-- | routers/tmdb.py | 32 | ||||
| -rw-r--r-- | services/__init__.py | 1 | ||||
| -rw-r--r-- | services/countries.py | 67 | ||||
| -rw-r--r-- | services/film_people.py | 17 | ||||
| -rw-r--r-- | services/tmdb.py | 226 | ||||
| -rw-r--r-- | static/app.js | 131 | ||||
| -rw-r--r-- | static/styles.css | 789 | ||||
| -rw-r--r-- | templates/_film_card.html | 70 | ||||
| -rw-r--r-- | templates/base.html | 30 | ||||
| -rw-r--r-- | templates/detail.html | 83 | ||||
| -rw-r--r-- | templates/director.html | 31 | ||||
| -rw-r--r-- | templates/form.html | 144 | ||||
| -rw-r--r-- | templates/import.html | 108 | ||||
| -rw-r--r-- | templates/index.html | 64 | ||||
| -rw-r--r-- | templates/stats.html | 268 |
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}") @@ -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 %} |
