- Python 67.8%
- HTML 17.7%
- Jinja 11.7%
- JavaScript 1.9%
- Dockerfile 0.9%
- Restructure cards: full-width title/meta on top, poster left + synopsis right - Directory list is always two columns with ellipsis-truncated titles (year always shown); Screenings listed above New Releases - Directory links scroll to a card without writing the URL hash, so a refresh starts at the top - Resolve best YouTube trailer from TMDB (videos) with one-time cache self-heal for legacy entries - Click-to-load trailer chip at the end of the synopsis injects an autoplaying embed full-width below the poster Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|---|---|---|
| mockups | ||
| src/showtimefinder | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| compose.yaml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
ShowTimeFinder
Aggregated movie showtimes for New York City's independent and repertory theaters, on one fast page. Every night a pipeline scrapes each theater, collapses the same film playing at different venues into a single entry, enriches it with poster/synopsis/credits from TMDB, and renders a self-contained static site.
No database, no runtime backend — just a build step that emits HTML + JSON, served by any static web server (Caddy, in production).
Theaters covered
| Theater | Neighborhood | Adapter |
|---|---|---|
| IFC Center | Greenwich Village | ifc |
| Film Forum | West Village | filmforum |
| Film at Lincoln Center | Lincoln Square | filmlinc |
| Angelika Film Center (Houston St) | NoHo | angelika |
| Village East by Angelika | East Village | angelika |
| Cinema 123 by Angelika | Upper East Side | angelika |
| Nitehawk (Williamsburg) | Williamsburg | nitehawk |
| Nitehawk (Prospect Park) | Park Slope | nitehawk |
| Cobble Hill Cinemas | Cobble Hill | cobblehill |
| BAM Rose Cinemas | Fort Greene | bam |
Theaters that share a brand (the three Angelikas, the two Nitehawks) use one
adapter and one logo; a per-venue location label disambiguates them in the UI.
How it works
adapters ──▶ screenings ──▶ resolver ──▶ films ──▶ render ──▶ public/
(scrape) (raw rows) (TMDB + (grouped (Jinja2) index.html
identity) by film) data/films.json
assets/
- Scrape — one adapter per theater (
src/showtimefinder/adapters/) fetches the source site and emits flatScreeningrows. Each adapter is isolated: a broken site logs and is skipped, it can't sink the whole build. - Resolve —
resolver.pymaps each screening to a canonical film identity. With aTMDB_API_KEYset it pulls poster, director, runtime, genres and synopsis; without one it falls back to a title-normalized identity (no network). Results are cached in.tmdb-cache.json, which doubles as a hand-editable override store. - Group — screenings for the same film across theaters collapse into one
Filmwith all itsShowings. Title normalization unifies things like "Part Two" / "Part 2" and strips theater framing ("… Presents:", "(35mm)", "70th Anniversary"). - Render —
build.pyrendersindex.htmlfromtemplates/index.html.j2, writes the normalizeddata/films.json(a first-class artifact / read-API), and copiesassets/. Output is staged then atomically moved into place, so a reader never sees a half-built site.
Quick start
Requires Python 3.9+.
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
playwright install chromium # the Angelika adapter renders a JS SPA
# Optional: richer film metadata (poster, synopsis, credits)
export TMDB_API_KEY=your_tmdb_v3_key_or_v4_token
python -m showtimefinder.build --out public
Then open public/index.html, or serve the folder:
python -m http.server -d public 8000
Build command
python -m showtimefinder.build [--out DIR]
--out— output directory (defaultpublic). In production this is the folder Caddy serves.
TMDB_API_KEY (or TMDB_TOKEN) is read from the environment. A v4 read-access bearer
token and a v3 API key are auto-detected. Omit it and the build still works, just with
sparser metadata.
Deploy (Docker + Caddy)
The image wraps the exact same build command; the container and the bare script behave
identically. It writes the site to /out, meant to be bind-mounted to the folder Caddy
serves.
cp .env.example .env # then put your TMDB_API_KEY in it
docker compose run --rm build
compose.yaml builds the image, loads .env, and writes the site into ./public. Point
Caddy at that folder. Because the publish step moves files atomically, it's safe to rebuild
while the site is being served.
Run it nightly via cron (Compose resolves paths relative to the repo, so cd in first):
0 4 * * * cd /home/docker/showtime-finder && /usr/bin/docker compose run --rm build >> build.log 2>&1
Prefer plain docker run (no Compose)? The equivalent is:
docker build -t showtimefinder .
docker run --rm --env-file .env -v "$PWD/public:/out" showtimefinder
Adding a theater
- Add an entry to
src/showtimefinder/theaters.jsonwithname,url,address,neighborhood, and ascraperkey. Add alocationfield for venues that share a brand with another theater. - If it's a new brand, write an adapter in
src/showtimefinder/adapters/(subclassAdapter, implementfetch()returningScreenings) and register it inadapters/__init__.py'sSCRAPERSmap. - Add a test with a saved fixture under
tests/fixtures/so the adapter is pinned against a real page snapshot.
New theaters with no adapter yet can use "scraper": "fake" as a placeholder.
Theater logos
Each theater block is branded with a wide wordmark. Logos live in
src/showtimefinder/assets/theaters/wordmarks/ and are wired up in the WORDMARKS dict
in build.py, keyed by brand (the scraper value). Each entry is
(filename, css_treatment), where the treatment normalizes any source art to read on the
dark theme:
invert— dark/colored mark on transparent → forced white.screen— light mark baked onto a dark box → the box drops out viamix-blend-mode: screen.none— already light/colored on transparent → used as-is (e.g. Nitehawk's native-color reel).
A brand with no wide transparent wordmark simply omits its entry; the template then renders
a clean text label instead of a broken square. To light one up, drop a wide transparent logo
in wordmarks/ and add a line to WORDMARKS.
Data artifact
public/data/films.json is a first-class output, not just a render byproduct: a normalized,
stable dump of every film and its showings (generated_at, then films[] with title, year,
director, runtime, poster, genres, overview, and showings[]). It's a ready-made read-API for
anything else that wants the data.
Tests
pytest
Adapter tests run against saved HTML/JSON fixtures (tests/fixtures/), so they're
deterministic and don't hit the network. test_pipeline.py covers grouping and rendering
end to end.
Project layout
src/showtimefinder/
build.py scrape → resolve → group → render entrypoint (python -m showtimefinder.build)
resolver.py film identity + TMDB enrichment with JSON cache
models.py Screening / Showing / Film dataclasses
theaters.json theater registry (name, url, address, scraper, location)
adapters/ one scraper per theater brand
templates/ index.html.j2
assets/theaters/ brand wordmark logos
tests/ adapter + pipeline tests with fixtures
Dockerfile wraps the build command for nightly container runs