From 583591edf34216ea177b34f328b0dd2469d76261 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:03:19 -0400 Subject: [PATCH 01/20] Add Match Night design spec Tinder-style swipe feature for discovering new watchlist movies: independent async swiping, Criterion+TMDB-seeded deck, mutual thumbs-up auto-adds to watchlist with a match decorator. Co-Authored-By: Claude Sonnet 5 --- .../specs/2026-07-22-match-night-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-match-night-design.md diff --git a/docs/superpowers/specs/2026-07-22-match-night-design.md b/docs/superpowers/specs/2026-07-22-match-night-design.md new file mode 100644 index 0000000..5e94650 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-match-night-design.md @@ -0,0 +1,146 @@ +# Match Night — Design Spec + +Date: 2026-07-22 + +## Summary + +A Tinder-style swipe feature ("Match Night") for discovering new movies to add to the +watchlist. Ian and Krista each swipe 👍/👎 through a shared deck of candidate movies, +independently and asynchronously. The instant both have thumbs-upped the same candidate, +it's automatically added to the watchlist, flagged with a "match" decorator. + +## Goals + +- Give both people a low-friction way to discover and agree on new movies, instead of + one person unilaterally pasting an IMDB/Criterion URL into Add Movie. +- Reuse the existing named-user pattern (no login) and the existing add-to-watchlist + pipeline (TMDB enrichment, streaming sync). +- Deck stays fresh automatically — no manual "load more" step required in normal use. + +## Non-goals + +- No drag/gesture-based swiping — tap buttons only, matching the rest of the app's + interaction style. +- No synchronous/shared-session swiping (passing one device back and forth). +- No notification/toast on match — surfaced passively via a watchlist card decorator. +- Not replacing the existing Add Movie flow (paste URL) — Match Night is an additional + discovery path into the same watchlist. + +## Data Model + +Two new Prisma models, plus one new field on `Movie`. + +```prisma +model SwipeCandidate { + id Int @id @default(autoincrement()) + tmdbId Int @unique + imdbId String? + title String + year Int + description String + posterUrl String + source String // "criterion" | "tmdb" + status String @default("pending") // pending | dead | matched + createdAt DateTime @default(now()) + swipes Swipe[] +} + +model Swipe { + id Int @id @default(autoincrement()) + candidateId Int + user String + vote String // "up" | "down" + swipedAt DateTime @default(now()) + candidate SwipeCandidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + + @@unique([candidateId, user]) +} +``` + +`Movie` model gets: + +```prisma +matchedViaSwipe Boolean @default(false) +``` + +Set `true` only when a `Movie` row is created as the result of a Match Night mutual +thumbs-up. Never set on movies added via the existing Add Movie / bulk import flows. +This flag is permanent history on that watchlist entry — there's nothing to dismiss or +clear. + +## Candidate Sourcing + +Two feeder sources, both writing into `SwipeCandidate`: + +1. **Criterion Collection** — paginate `criterion.com/shop/browse/films` listing pages, + scrape each film's slug + title from the listing HTML (reusing the `og:title` scrape + approach already present in `lookupCriterionSlug` in `src/lib/tmdb.ts`, adapted for a + listing page instead of a single film page). For each title found, resolve via the + existing `searchByTitle()` TMDB helper to get tmdbId/imdbId/year/description/posterUrl. +2. **TMDB popular** — page through TMDB's `/movie/popular` endpoint directly (new small + helper alongside the existing functions in `src/lib/tmdb.ts`). + +**Dedup rules**, applied when inserting fetched candidates: + +- Skip any title whose `tmdbId` or `imdbId` already matches an existing `Movie` row + (regardless of status — watchlist or watched). +- Skip any title whose `tmdbId` already exists as a `SwipeCandidate` (any status — + pending, dead, or matched), to avoid re-adding a candidate someone already killed or + matched on previously. + +**Refill trigger**: when a user requests their next card and their count of pending, +not-yet-swiped-by-them candidates drops below a threshold (5), the API kicks off a +fetch of one additional batch (e.g. 20) from both sources combined before responding. +This is a synchronous part of the request — no cron job, no manual refresh button. + +## Swipe Flow + +New route `/match-night`, new sidebar entry "Match Night 💕" (added to `navItems` in +`src/components/sidebar.tsx`, and to the mobile bottom nav for parity with other primary +routes). + +1. User selects their name via the same named-user selector pattern used elsewhere in + the app (e.g. rating flow) — no separate login. +2. Page requests "next card for this user": the oldest `pending` `SwipeCandidate` that + this user has no `Swipe` row for yet. Card shows poster, title, year, description, and + 👍/👎 buttons. +3. On 👎: create/upsert this user's `Swipe` row (`vote: "down"`), and immediately set the + candidate's `status` to `"dead"`. It disappears from both users' decks (already-fetched + or future). +4. On 👍: create/upsert this user's `Swipe` row (`vote: "up"`). + - Query the other user's `Swipe` for this candidate. + - If they also voted `up`: create a `Movie` row via the same creation logic as + `POST /api/movies` (TMDB enrichment fields already known from the candidate, no + re-fetch needed), set `matchedViaSwipe: true`, set candidate `status: "matched"`, + and kick off `syncMovieProviders` exactly as the existing endpoint does. + - Otherwise: leave candidate `status: "pending"`, just move on. +5. Advance to the next card. If none remain after a refill attempt, show an empty-state + ("You're all caught up — check back later"). + +## Watchlist Decorator + +`MovieRow` / `MovieCard` (`src/components/movie-row.tsx`, `movie-card.tsx`) check +`movie.matchedViaSwipe` and render a small "It's a match! 🎉" badge alongside the existing +card content, styled consistently with the warm amber/cream theme. + +## API Surface (new) + +- `GET /api/match-night/next?user=` — returns the next card for that user (triggers + refill if needed), or `null` if the deck is genuinely exhausted even after a refill + attempt. +- `POST /api/match-night/swipe` — body `{ candidateId, user, vote }`. Performs the vote + recording, dead/match transition, and (on match) watchlist insertion + streaming sync. + +## Testing + +Vitest, following existing patterns (mocked `fetch` for TMDB/Criterion HTTP calls, mocked +Prisma client): + +- Candidate fetch/dedup: verifies existing `Movie` and `SwipeCandidate` rows are excluded + from newly inserted candidates. +- Swipe transitions: down → dead; up (first voter) → still pending; up (second voter, + matching an existing up) → `Movie` created with `matchedViaSwipe: true`, candidate → + matched. +- Refill trigger: deck below threshold triggers a fetch; deck above threshold does not. +- API route tests for `GET /api/match-night/next` and `POST /api/match-night/swipe` + mirroring the style of existing `src/app/api/movies/route.ts` tests. From 66f231d49bdc13cbe802bb975c1462a53d4f1090 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:06:19 -0400 Subject: [PATCH 02/20] Address SPEC_REVIEW.md feedback on Match Night spec Add explicit concurrency handling (transaction + status re-check for the up/up race), refill latency/failure/timeout story, drop the unused upsert semantics, note refill threshold/batch size are hardcoded for v1, and expand the testing section to cover the race and stale-swipe cases. Co-Authored-By: Claude Sonnet 5 --- .../specs/2026-07-22-match-night-design.md | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-07-22-match-night-design.md b/docs/superpowers/specs/2026-07-22-match-night-design.md index 5e94650..41338cc 100644 --- a/docs/superpowers/specs/2026-07-22-match-night-design.md +++ b/docs/superpowers/specs/2026-07-22-match-night-design.md @@ -91,13 +91,32 @@ Two feeder sources, both writing into `SwipeCandidate`: **Refill trigger**: when a user requests their next card and their count of pending, not-yet-swiped-by-them candidates drops below a threshold (5), the API kicks off a fetch of one additional batch (e.g. 20) from both sources combined before responding. -This is a synchronous part of the request — no cron job, no manual refresh button. +This is a synchronous part of the request — no cron job, no manual refresh button. The +threshold (5) and batch size (20) are hardcoded constants for v1, not exposed in +Settings — unlike the streaming region/services settings, there's no user-facing reason +to tune these yet; revisit if usage shows otherwise. + +**Latency and failure handling**: a refill is a chain of sequential external HTTP calls +(paginated Criterion listing fetch + per-title TMDB search, plus TMDB popular paging), +so `GET /api/match-night/next` can stall for a few seconds exactly when the deck is +running low — i.e. mid-swiping-session. Each external call gets a short timeout (e.g. +5s) consistent with the rest of the app's "fail gracefully, return safe defaults" +pattern (per CLAUDE.md). The two sources are independent: if the Criterion scrape fails +or times out (most likely breakage point, since paginating the listing pages is a +larger, more fragile scrape surface than the existing single-film `og:title` lookup), +log and continue with whatever TMDB candidates were fetched rather than aborting the +whole refill. If both sources fail or both are already exhausted, the refill simply adds +zero candidates and the request falls through to the empty-state response — no retry +loop within the request. ## Swipe Flow -New route `/match-night`, new sidebar entry "Match Night 💕" (added to `navItems` in -`src/components/sidebar.tsx`, and to the mobile bottom nav for parity with other primary -routes). +New route `/match-night`, new sidebar entry "Match Night" with icon 💕 (added to +`navItems` in `src/components/sidebar.tsx`, and to the mobile bottom nav for parity with +other primary routes). Existing `navItems` labels are one or two words with a single +leading emoji glyph (e.g. "Watch List" 📋, "Recommend" 🎯) — "Match Night" fits that +pattern in length; do a quick visual check once built since it's slightly longer than +most existing entries. 1. User selects their name via the same named-user selector pattern used elsewhere in the app (e.g. rating flow) — no separate login. @@ -107,7 +126,7 @@ routes). 3. On 👎: create/upsert this user's `Swipe` row (`vote: "down"`), and immediately set the candidate's `status` to `"dead"`. It disappears from both users' decks (already-fetched or future). -4. On 👍: create/upsert this user's `Swipe` row (`vote: "up"`). +4. On 👍: create this user's `Swipe` row (`vote: "up"`). - Query the other user's `Swipe` for this candidate. - If they also voted `up`: create a `Movie` row via the same creation logic as `POST /api/movies` (TMDB enrichment fields already known from the candidate, no @@ -115,7 +134,32 @@ routes). and kick off `syncMovieProviders` exactly as the existing endpoint does. - Otherwise: leave candidate `status: "pending"`, just move on. 5. Advance to the next card. If none remain after a refill attempt, show an empty-state - ("You're all caught up — check back later"). + ("You're all caught up — check back later"). Note this is not terminal for the + session: since refill is attempted on every `next` request while the pending count is + below threshold, the very next card request (e.g. the user reopening the tab, or a + later visit) retries the fetch. There's no separate "wake up and retry" mechanism — + retry only happens on demand, driven by the user asking for a card. + +### Concurrency & consistency + +Two independent users can act on the same candidate at nearly the same moment, which is +the one place in this feature where two actors write to shared state. This needs +explicit handling, not just the happy path above: + +- **Race on the up/up transition.** Steps in 4 above (upsert this user's swipe → read + the other user's swipe → conditionally create `Movie`) must run inside a single + `prisma.$transaction`, so a near-simultaneous double 👍 can't both pass the "other user + already voted up" check and both attempt to create the `Movie`. As a second line of + defense, `Movie.tmdbId` is already `@unique` (existing schema) — a create that races + past the transaction boundary hits that constraint; catch the conflict and treat it as + "already matched" rather than a 500. +- **Server-side re-validation of candidate status at swipe time.** A client can have a + card loaded that has since gone `dead` (other user downvoted) or `matched` + (concurrent match) server-side. `POST /api/match-night/swipe` must re-check + `candidate.status === "pending"` inside the same transaction before recording the + vote. If it's no longer pending, silently no-op the vote (don't record it, don't + error) and respond as if advancing to the next card — consistent with this feature + having no toast/error UX per the non-goals. ## Watchlist Decorator @@ -129,7 +173,9 @@ card content, styled consistently with the warm amber/cream theme. refill if needed), or `null` if the deck is genuinely exhausted even after a refill attempt. - `POST /api/match-night/swipe` — body `{ candidateId, user, vote }`. Performs the vote - recording, dead/match transition, and (on match) watchlist insertion + streaming sync. + recording, dead/match transition, and (on match) watchlist insertion + streaming sync, + all inside a single `prisma.$transaction` per the Concurrency & Consistency section + above. No-ops (no vote recorded, no error) if the candidate is no longer `pending`. ## Testing @@ -142,5 +188,17 @@ Prisma client): matching an existing up) → `Movie` created with `matchedViaSwipe: true`, candidate → matched. - Refill trigger: deck below threshold triggers a fetch; deck above threshold does not. +- Refill partial failure: Criterion source throwing/timing out still yields the TMDB + batch rather than aborting the refill. +- Concurrency: two near-simultaneous 👍 swipes on the same candidate (from different + users) result in exactly one `Movie` row and the candidate ending as `matched`, not a + crash or duplicate. +- Stale swipe: a swipe submitted against a candidate that's already `dead` or `matched` + server-side is a no-op — no `Swipe` row created, no error thrown. - API route tests for `GET /api/match-night/next` and `POST /api/match-night/swipe` mirroring the style of existing `src/app/api/movies/route.ts` tests. + +## Migration Note + +`matchedViaSwipe Boolean @default(false)` on `Movie` backfills existing rows to `false` +automatically via the column default — no manual data migration needed. From 54c77b44272b03305e1055d7f84aa677a45a4f90 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:10:00 -0400 Subject: [PATCH 03/20] Replace Criterion live-scrape with bundled static catalog file Live testing showed criterion.com sits behind a Cloudflare JS challenge, so a server-side fetch to either the browse-listing pages or individual film pages never sees real HTML. Swap the scrape for a checked-in data/criterion-catalog.json (title/year pairs), resolved per-entry via the existing TMDB searchByTitle helper. Co-Authored-By: Claude Sonnet 5 --- .../specs/2026-07-22-match-night-design.md | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/specs/2026-07-22-match-night-design.md b/docs/superpowers/specs/2026-07-22-match-night-design.md index 41338cc..545bcf9 100644 --- a/docs/superpowers/specs/2026-07-22-match-night-design.md +++ b/docs/superpowers/specs/2026-07-22-match-night-design.md @@ -70,13 +70,23 @@ clear. ## Candidate Sourcing -Two feeder sources, both writing into `SwipeCandidate`: - -1. **Criterion Collection** — paginate `criterion.com/shop/browse/films` listing pages, - scrape each film's slug + title from the listing HTML (reusing the `og:title` scrape - approach already present in `lookupCriterionSlug` in `src/lib/tmdb.ts`, adapted for a - listing page instead of a single film page). For each title found, resolve via the - existing `searchByTitle()` TMDB helper to get tmdbId/imdbId/year/description/posterUrl. +Two feeder sources, both writing into `SwipeCandidate`. + +> **Revision note (post-review):** the original design called for live-scraping +> `criterion.com/shop/browse/films`. Testing during planning showed criterion.com sits +> behind a Cloudflare JS challenge — a plain server-side `fetch` (to both listing pages +> and individual film pages) returns a "Just a moment..." challenge page, not real HTML. +> Live scraping the catalog is not viable. Source 1 below replaces the scrape with a +> bundled static file. + +1. **Criterion Collection** — a static JSON file checked into the repo, + `data/criterion-catalog.json`, containing `{ title: string, year?: number }` entries + for the Criterion Collection catalog. This is compiled once (semi-)manually (e.g. from + a public spine-number list) and updated by hand periodically as Criterion adds titles + — there is no live fetch to criterion.com in this feature. For each entry not yet + resolved, resolve via the existing `searchByTitle()` TMDB helper to get + tmdbId/imdbId/year/description/posterUrl, then insert as a `SwipeCandidate` with + `source: "criterion"`. 2. **TMDB popular** — page through TMDB's `/movie/popular` endpoint directly (new small helper alongside the existing functions in `src/lib/tmdb.ts`). @@ -97,17 +107,16 @@ Settings — unlike the streaming region/services settings, there's no user-faci to tune these yet; revisit if usage shows otherwise. **Latency and failure handling**: a refill is a chain of sequential external HTTP calls -(paginated Criterion listing fetch + per-title TMDB search, plus TMDB popular paging), -so `GET /api/match-night/next` can stall for a few seconds exactly when the deck is -running low — i.e. mid-swiping-session. Each external call gets a short timeout (e.g. -5s) consistent with the rest of the app's "fail gracefully, return safe defaults" -pattern (per CLAUDE.md). The two sources are independent: if the Criterion scrape fails -or times out (most likely breakage point, since paginating the listing pages is a -larger, more fragile scrape surface than the existing single-film `og:title` lookup), -log and continue with whatever TMDB candidates were fetched rather than aborting the -whole refill. If both sources fail or both are already exhausted, the refill simply adds -zero candidates and the request falls through to the empty-state response — no retry -loop within the request. +(per-title TMDB search for unresolved Criterion catalog entries, plus TMDB popular +paging), so `GET /api/match-night/next` can stall for a few seconds exactly when the +deck is running low — i.e. mid-swiping-session. Each external call gets a short timeout +(e.g. 5s) consistent with the rest of the app's "fail gracefully, return safe defaults" +pattern (per CLAUDE.md). The two sources are independent: if TMDB resolution fails or +times out for some Criterion catalog entries, log and skip those entries, continuing +with whatever did resolve plus the TMDB-popular batch, rather than aborting the whole +refill. If both sources fail or both are already exhausted, the refill simply adds zero +candidates and the request falls through to the empty-state response — no retry loop +within the request. ## Swipe Flow From 467039d4b489a9e1b84b5bb1f86c746d7728e554 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:18:02 -0400 Subject: [PATCH 04/20] Add Match Night implementation plan 11 bite-sized TDD tasks covering: schema/types, bundled Criterion catalog data, TMDB popular helper, deck refill with cursor-tracked sourcing, swipe/match transaction with concurrency safeguards, API routes, swipe card + page UI, nav entries, and the watchlist match badge. Self-review caught and fixed an unused REFILL_THRESHOLD constant before finalizing. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-22-match-night.md | 1754 +++++++++++++++++ 1 file changed, 1754 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-match-night.md diff --git a/docs/superpowers/plans/2026-07-22-match-night.md b/docs/superpowers/plans/2026-07-22-match-night.md new file mode 100644 index 0000000..e6bea5f --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-match-night.md @@ -0,0 +1,1754 @@ +# Match Night Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Tinder-style swipe feature ("Match Night") where Ian and Krista each independently thumbs-up/down a deck of candidate movies (seeded from a bundled Criterion catalog file + TMDB popular), and a mutual thumbs-up auto-adds the film to the watchlist with a match badge. + +**Architecture:** Two new Prisma models (`SwipeCandidate`, `Swipe`) plus a `matchedViaSwipe` flag on `Movie`. A `src/lib/match-night.ts` module owns all business logic (deck refill, next-candidate lookup, swipe/match transaction); two thin API routes expose it; a new `/match-night` page + card component drive the UI; the existing `MovieRow` gets a small badge. + +**Tech Stack:** Next.js API routes, Prisma (SQLite via `better-sqlite3` adapter), Vitest + Testing Library — all matching existing project conventions. + +## Global Constraints + +- Follow the spec at `docs/superpowers/specs/2026-07-22-match-night-design.md` (as revised after review — Criterion sourcing uses a bundled static file, not a live scrape). +- `User` type is `'user1' | 'user2'` (existing `src/types/index.ts`); user display names come from `getUserNames()` / `/api/user-names`, never hardcode "Krista"/"Ian" in code. +- Reuse `USER_KEYS` / `otherUser` from `src/lib/user-utils.ts` for validation — do not redefine. +- No drag/swipe gestures — tap-only 👍/👎 buttons (spec non-goal). +- No toast/notification on match — the only surfacing is the `matchedViaSwipe` badge on the watchlist card (spec decision). +- Refill threshold = 5, batch size = 20 — hardcoded constants for v1, not in Settings (spec decision). +- The read-check-write around a mutual up-vote (and the stale-candidate check) MUST run inside a single `prisma.$transaction(async (tx) => {...})` interactive transaction — this is the concurrency guarantee from the spec, not optional. +- Match every existing file's code style when editing it (e.g. `movie-row.tsx` uses semicolons; most `src/lib`/`src/app/api` files do not — don't reformat surrounding code). + +--- + +### Task 1: Prisma schema — new models, `matchedViaSwipe`, and shared types + +**Files:** +- Modify: `prisma/schema.prisma` +- Modify: `src/types/index.ts` +- Test: manual verification (schema/type tasks aren't unit-testable in isolation; verified via `tsc` and `prisma validate`) + +**Interfaces:** +- Produces: Prisma models `SwipeCandidate` (fields: `id, tmdbId, imdbId, title, year, runtime, description, posterUrl, source, status, createdAt, swipes`) and `Swipe` (fields: `id, candidateId, user, vote, swipedAt, candidate`), each accessible as `prisma.swipeCandidate` / `prisma.swipe`. `Movie.matchedViaSwipe: boolean`. +- Produces TS types in `src/types/index.ts`: `SwipeSource = 'criterion' | 'tmdb'`, `SwipeStatus = 'pending' | 'dead' | 'matched'`, `SwipeVote = 'up' | 'down'`, `SwipeCandidateRecord` interface, `Swipe` interface. `Movie` interface gains `matchedViaSwipe: boolean`. + +- [ ] **Step 1: Add the new models and field to `prisma/schema.prisma`** + +Add `matchedViaSwipe` to the existing `Movie` model (insert after `streamingLink`): + +```prisma +model Movie { + id Int @id @default(autoincrement()) + title String + year Int + runtime Int + description String + posterUrl String + imdbId String @unique + tmdbId Int @unique + criterionUrl String? + imdbUrl String? + sortOrder Int + status String @default("watchlist") + seerrRequestId String? + seerrMediaId String? + seerrStatus String @default("not_requested") + watchedAt DateTime? + createdAt DateTime @default(now()) + streamingLastChecked DateTime? + streamingLink String? + matchedViaSwipe Boolean @default(false) + ratings Rating[] + streamingProviders StreamingProvider[] +} +``` + +Add two new models after `StreamingProvider`: + +```prisma +model SwipeCandidate { + id Int @id @default(autoincrement()) + tmdbId Int @unique + imdbId String + title String + year Int + runtime Int + description String + posterUrl String + source String // "criterion" | "tmdb" + status String @default("pending") // pending | dead | matched + createdAt DateTime @default(now()) + swipes Swipe[] +} + +model Swipe { + id Int @id @default(autoincrement()) + candidateId Int + user String + vote String // "up" | "down" + swipedAt DateTime @default(now()) + candidate SwipeCandidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + + @@unique([candidateId, user]) +} +``` + +- [ ] **Step 2: Generate and run the migration** + +Run: `npx prisma migrate dev --name add_match_night` +Expected: a new folder under `prisma/migrations/` is created, migration applies cleanly, and `npx prisma generate` runs as part of the command with no errors. + +- [ ] **Step 3: Add TypeScript types to `src/types/index.ts`** + +Add near the existing `Rating`/`RatingValue` types: + +```typescript +export type SwipeSource = 'criterion' | 'tmdb' +export type SwipeStatus = 'pending' | 'dead' | 'matched' +export type SwipeVote = 'up' | 'down' + +export interface SwipeCandidateRecord { + id: number + tmdbId: number + imdbId: string + title: string + year: number + runtime: number + description: string + posterUrl: string + source: SwipeSource + status: SwipeStatus + createdAt: Date | string +} + +export interface Swipe { + id: number + candidateId: number + user: User + vote: SwipeVote + swipedAt: Date | string +} +``` + +Add `matchedViaSwipe: boolean` to the existing `Movie` interface, right after `streamingLink`: + +```typescript + streamingLink?: string | null + matchedViaSwipe: boolean + ratings?: Rating[] +``` + +- [ ] **Step 4: Verify the project still typechecks** + +Run: `npx tsc --noEmit` +Expected: no new errors (existing test mock objects that build a full `Movie` literal, e.g. in `tests/api.movies.test.ts` and `tests/movie-row.test.tsx`, will need `matchedViaSwipe: false` added — do that now if `tsc` flags them). + +- [ ] **Step 5: Commit** + +```bash +git add prisma/schema.prisma prisma/migrations src/types/index.ts tests/api.movies.test.ts tests/movie-row.test.tsx +git commit -m "feat: add SwipeCandidate/Swipe models and matchedViaSwipe field for Match Night" +``` + +--- + +### Task 2: Bundled Criterion catalog data file + +**Files:** +- Create: `data/criterion-catalog.json` +- Test: `tests/criterion-catalog.test.ts` + +**Interfaces:** +- Produces: a JSON file at `data/criterion-catalog.json` containing an array of `{ title: string, year?: number }`, and a loader `getCriterionCatalog(): Array<{ title: string; year?: number }>` in `src/lib/criterion-catalog.ts` that later tasks import. + +- [ ] **Step 1: Write the failing test** + +Create `tests/criterion-catalog.test.ts`: + +```typescript +// tests/criterion-catalog.test.ts +import { describe, it, expect } from 'vitest' +import { getCriterionCatalog } from '@/lib/criterion-catalog' + +describe('getCriterionCatalog', () => { + it('returns a non-empty array of title entries', () => { + const catalog = getCriterionCatalog() + expect(Array.isArray(catalog)).toBe(true) + expect(catalog.length).toBeGreaterThan(10) + }) + + it('every entry has a non-empty string title', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + expect(typeof entry.title).toBe('string') + expect(entry.title.length).toBeGreaterThan(0) + } + }) + + it('every entry with a year has a plausible film year', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + if (entry.year !== undefined) { + expect(entry.year).toBeGreaterThan(1880) + expect(entry.year).toBeLessThanOrEqual(new Date().getFullYear()) + } + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/criterion-catalog.test.ts` +Expected: FAIL — `Cannot find module '@/lib/criterion-catalog'` + +- [ ] **Step 3: Create the data file** + +Create `data/criterion-catalog.json`: + +```json +[ + { "title": "Seven Samurai", "year": 1954 }, + { "title": "Rashomon", "year": 1950 }, + { "title": "Ikiru", "year": 1952 }, + { "title": "Yojimbo", "year": 1961 }, + { "title": "Sanjuro", "year": 1962 }, + { "title": "High and Low", "year": 1963 }, + { "title": "The Hidden Fortress", "year": 1958 }, + { "title": "Ran", "year": 1985 }, + { "title": "Kagemusha", "year": 1980 }, + { "title": "The Seventh Seal", "year": 1957 }, + { "title": "Persona", "year": 1966 }, + { "title": "Wild Strawberries", "year": 1957 }, + { "title": "Fanny and Alexander", "year": 1982 }, + { "title": "8 1/2", "year": 1963 }, + { "title": "La Strada", "year": 1954 }, + { "title": "Nights of Cabiria", "year": 1957 }, + { "title": "Amarcord", "year": 1973 }, + { "title": "L'Avventura", "year": 1960 }, + { "title": "The 400 Blows", "year": 1959 }, + { "title": "Breathless", "year": 1960 }, + { "title": "Contempt", "year": 1963 }, + { "title": "Playtime", "year": 1967 }, + { "title": "Mon Oncle", "year": 1958 }, + { "title": "Le Samourai", "year": 1967 }, + { "title": "Diabolique", "year": 1955 }, + { "title": "The Third Man", "year": 1949 }, + { "title": "Rebecca", "year": 1940 }, + { "title": "M", "year": 1931 }, + { "title": "Metropolis", "year": 1927 }, + { "title": "Beauty and the Beast", "year": 1946 }, + { "title": "Orpheus", "year": 1950 }, + { "title": "Tokyo Story", "year": 1953 }, + { "title": "Late Spring", "year": 1949 }, + { "title": "Ugetsu", "year": 1953 }, + { "title": "Sansho the Bailiff", "year": 1954 }, + { "title": "Harakiri", "year": 1962 }, + { "title": "Onibaba", "year": 1964 }, + { "title": "Woman in the Dunes", "year": 1964 }, + { "title": "Kwaidan", "year": 1964 }, + { "title": "In the Mood for Love", "year": 2000 }, + { "title": "Chungking Express", "year": 1994 }, + { "title": "Branded to Kill", "year": 1967 }, + { "title": "Tokyo Drifter", "year": 1966 }, + { "title": "House", "year": 1977 }, + { "title": "The Killing", "year": 1956 }, + { "title": "Paths of Glory", "year": 1957 }, + { "title": "Dr. Strangelove", "year": 1964 }, + { "title": "Rosemary's Baby", "year": 1968 }, + { "title": "Repo Man", "year": 1984 }, + { "title": "The Man Who Fell to Earth", "year": 1976 }, + { "title": "Blood Simple", "year": 1984 }, + { "title": "Do the Right Thing", "year": 1989 }, + { "title": "Malcolm X", "year": 1992 }, + { "title": "Hoop Dreams", "year": 1994 }, + { "title": "Chimes at Midnight", "year": 1965 }, + { "title": "Sullivan's Travels", "year": 1941 }, + { "title": "The Lady Eve", "year": 1941 }, + { "title": "Sweet Smell of Success", "year": 1957 }, + { "title": "Rififi", "year": 1955 }, + { "title": "Charade", "year": 1963 } +] +``` + +- [ ] **Step 4: Create the loader** + +Create `src/lib/criterion-catalog.ts`: + +```typescript +// src/lib/criterion-catalog.ts +import catalog from '../../data/criterion-catalog.json' + +export interface CriterionCatalogEntry { + title: string + year?: number +} + +export function getCriterionCatalog(): CriterionCatalogEntry[] { + return catalog as CriterionCatalogEntry[] +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/criterion-catalog.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 6: Commit** + +```bash +git add data/criterion-catalog.json src/lib/criterion-catalog.ts tests/criterion-catalog.test.ts +git commit -m "feat: add bundled Criterion catalog data file and loader" +``` + +--- + +### Task 3: TMDB popular-movies helper + fetch timeouts + +**Files:** +- Modify: `src/lib/tmdb.ts` +- Test: `tests/tmdb.test.ts` + +**Interfaces:** +- Consumes: existing `fetchDetails(tmdbId, key)` (module-private), `getConfig()` from `@/lib/config`, `TmdbMovieDetails` from `@/types`. +- Produces: `fetchPopularMovies(page: number): Promise` — later tasks (Task 4) call this during deck refill. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/tmdb.test.ts` (append a new `describe` block, and add the new import to the existing destructured `await import('@/lib/tmdb')` line at the top of the file): + +```typescript +// change the top-of-file import line to also pull in fetchPopularMovies: +// const { findByImdbId, searchByTitle, lookupCriterionSlug, fetchWatchProviders, fetchProviderList, fetchPopularMovies } = await import('@/lib/tmdb') +``` + +```typescript +describe('fetchPopularMovies', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.mocked(getConfig).mockResolvedValue(mockConfig) + }) + + it('resolves full details for each popular result', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 345911 }] }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Seven Samurai') + }) + + it('returns empty array on fetch error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }) + expect(await fetchPopularMovies(1)).toEqual([]) + }) + + it('filters out results TMDB details couldn\'t resolve', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 1 }, { id: 2 }] }), + }) + .mockResolvedValueOnce({ ok: false }) // details for id 1 fail + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) // id 2 succeeds + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/tmdb.test.ts` +Expected: FAIL — `fetchPopularMovies is not a function` / import error + +- [ ] **Step 3: Implement `fetchPopularMovies` and add fetch timeouts** + +In `src/lib/tmdb.ts`, add a timeout wrapper and use it inside `fetchDetails` and `searchByTitle`'s search call, then add the new function. Replace the top of the file: + +```typescript +// src/lib/tmdb.ts +import type { TmdbMovieDetails } from '@/types' +import { getConfig } from './config' + +const BASE = 'https://api.themoviedb.org/3' +const IMG_BASE = 'https://image.tmdb.org/t/p/w500' +const FETCH_TIMEOUT_MS = 5000 + +function fetchWithTimeout(url: string): Promise { + return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) +} + +async function fetchDetails(tmdbId: number, key: string): Promise { + const res = await fetchWithTimeout(`${BASE}/movie/${tmdbId}?api_key=${key}`) + if (!res.ok) return null + const m = await res.json() + return { + tmdbId: m.id, + title: m.title, + year: parseInt((m.release_date || '0').split('-')[0], 10) || 0, + runtime: m.runtime ?? 0, + description: m.overview ?? '', + posterUrl: m.poster_path ? `${IMG_BASE}${m.poster_path}` : '', + imdbId: m.imdb_id ?? '', + } +} +``` + +Update `searchByTitle`'s own fetch call to use `fetchWithTimeout` instead of `fetch`: + +```typescript +export async function searchByTitle( + title: string, + year?: number +): Promise { + const { tmdbApiKey } = await getConfig() + const yearParam = year ? `&year=${year}` : '' + const res = await fetchWithTimeout( + `${BASE}/search/movie?api_key=${tmdbApiKey}&query=${encodeURIComponent(title)}${yearParam}` + ) + if (!res.ok) return null + const data = await res.json() + const hit = data.results?.[0] + if (!hit) return null + return fetchDetails(hit.id, tmdbApiKey) +} +``` + +Add the new function at the end of the file (after `fetchProviderList`): + +```typescript +export async function fetchPopularMovies(page: number): Promise { + const { tmdbApiKey } = await getConfig() + const res = await fetchWithTimeout(`${BASE}/movie/popular?api_key=${tmdbApiKey}&page=${page}`) + if (!res.ok) return [] + const data = await res.json() + const hits = (data.results ?? []) as Array<{ id: number }> + const details = await Promise.all(hits.map((h) => fetchDetails(h.id, tmdbApiKey))) + return details.filter((d): d is TmdbMovieDetails => d !== null) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/tmdb.test.ts` +Expected: PASS, all tests in the file (existing + 3 new) green + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/tmdb.ts tests/tmdb.test.ts +git commit -m "feat: add fetchPopularMovies helper and request timeouts to tmdb.ts" +``` + +--- + +### Task 4: Deck refill logic + +**Files:** +- Create: `src/lib/match-night.ts` +- Test: `tests/match-night.test.ts` + +**Interfaces:** +- Consumes: `getCriterionCatalog()` from `@/lib/criterion-catalog`, `searchByTitle` / `fetchPopularMovies` from `@/lib/tmdb`, `prisma` from `@/lib/db`. +- Produces: `refillCandidates(): Promise` (returns count of newly inserted candidates) — consumed by Task 5's `getNextCandidateForUser`. +- Produces internally (not exported): `REFILL_THRESHOLD = 5`, `REFILL_BATCH_SIZE = 20` constants — Task 5 imports `REFILL_THRESHOLD` too. + +- [ ] **Step 1: Write the failing test** + +Create `tests/match-night.test.ts`: + +```typescript +// tests/match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => { + const prisma: any = { + movie: { findMany: vi.fn(), aggregate: vi.fn(), create: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn() }, + swipeCandidate: { + findMany: vi.fn(), createMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn(), count: vi.fn(), + }, + swipe: { create: vi.fn(), findUnique: vi.fn() }, + setting: { findUnique: vi.fn(), upsert: vi.fn() }, + } + prisma.$transaction = vi.fn((cb: any) => cb(prisma)) + return { prisma } +}) +vi.mock('@/lib/criterion-catalog', () => ({ + getCriterionCatalog: vi.fn(), +})) +vi.mock('@/lib/tmdb', () => ({ + searchByTitle: vi.fn(), + fetchPopularMovies: vi.fn(), +})) + +import { prisma } from '@/lib/db' +import { getCriterionCatalog } from '@/lib/criterion-catalog' +import { searchByTitle, fetchPopularMovies } from '@/lib/tmdb' +import { refillCandidates } from '@/lib/match-night' + +const tmdbDetails = (overrides: Partial = {}) => ({ + tmdbId: 1, title: 'Some Film', year: 1960, runtime: 90, + description: 'desc', posterUrl: 'poster.jpg', imdbId: 'tt0000001', + ...overrides, +}) + +describe('refillCandidates', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + vi.mocked(prisma.setting.upsert).mockResolvedValue({} as any) + vi.mocked(prisma.swipeCandidate.createMany).mockResolvedValue({ count: 0 } as any) + }) + + it('resolves catalog entries via TMDB and inserts them as criterion candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911, title: 'Seven Samurai' })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 345911, source: 'criterion' })], + }) + }) + + it('skips a resolved title that already exists as a Movie', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('skips a resolved title that already exists as a SwipeCandidate', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('adds TMDB popular results as tmdb-source candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('continues with TMDB-only results when a Criterion title fails to resolve', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Unresolvable Title' }]) + vi.mocked(searchByTitle).mockRejectedValue(new Error('timeout')) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('persists the TMDB popular page cursor after a refill', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue({ key: 'match_night_tmdb_popular_page', value: '3' } as any) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + await refillCandidates() + expect(fetchPopularMovies).toHaveBeenCalledWith(3) + expect(prisma.setting.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { key: 'match_night_tmdb_popular_page' } }) + ) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: FAIL — `Cannot find module '@/lib/match-night'` + +- [ ] **Step 3: Implement `src/lib/match-night.ts` (refill portion only)** + +```typescript +// src/lib/match-night.ts +import { prisma } from './db' +import { getCriterionCatalog } from './criterion-catalog' +import { searchByTitle, fetchPopularMovies } from './tmdb' +import type { TmdbMovieDetails } from '@/types' + +export const REFILL_THRESHOLD = 5 +export const REFILL_BATCH_SIZE = 20 + +const CRITERION_CURSOR_KEY = 'match_night_criterion_cursor' +const TMDB_PAGE_CURSOR_KEY = 'match_night_tmdb_popular_page' +const MAX_TMDB_PAGES_PER_REFILL = 5 + +interface NewCandidate extends TmdbMovieDetails { + source: 'criterion' | 'tmdb' +} + +async function getCursor(key: string): Promise { + const row = await prisma.setting.findUnique({ where: { key } }) + return row ? parseInt(row.value, 10) || 0 : 0 +} + +async function setCursor(key: string, value: number): Promise { + await prisma.setting.upsert({ + where: { key }, + create: { key, value: String(value) }, + update: { value: String(value) }, + }) +} + +async function getExistingTmdbIds(): Promise> { + const [movies, candidates] = await Promise.all([ + prisma.movie.findMany({ select: { tmdbId: true } }), + prisma.swipeCandidate.findMany({ select: { tmdbId: true } }), + ]) + return new Set([ + ...movies.map((m) => m.tmdbId), + ...candidates.map((c) => c.tmdbId), + ]) +} + +export async function refillCandidates(): Promise { + const existingTmdbIds = await getExistingTmdbIds() + const toInsert: NewCandidate[] = [] + const halfBatch = REFILL_BATCH_SIZE / 2 + + // Criterion: resolve forward from the saved cursor through the static catalog + const catalog = getCriterionCatalog() + let criterionCursor = await getCursor(CRITERION_CURSOR_KEY) + const criterionCollected = () => toInsert.filter((c) => c.source === 'criterion').length + + while (criterionCursor < catalog.length && criterionCollected() < halfBatch) { + const entry = catalog[criterionCursor] + criterionCursor++ + const details = await searchByTitle(entry.title, entry.year).catch(() => null) + if (details && !existingTmdbIds.has(details.tmdbId)) { + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'criterion' }) + } + } + await setCursor(CRITERION_CURSOR_KEY, criterionCursor) + + // TMDB popular: page forward from the saved cursor + let page = (await getCursor(TMDB_PAGE_CURSOR_KEY)) || 1 + let pagesFetched = 0 + + while (toInsert.length < REFILL_BATCH_SIZE && pagesFetched < MAX_TMDB_PAGES_PER_REFILL) { + const results = await fetchPopularMovies(page).catch(() => []) + pagesFetched++ + page++ + for (const details of results) { + if (existingTmdbIds.has(details.tmdbId)) continue + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'tmdb' }) + if (toInsert.length >= REFILL_BATCH_SIZE) break + } + } + await setCursor(TMDB_PAGE_CURSOR_KEY, page) + + if (toInsert.length > 0) { + await prisma.swipeCandidate.createMany({ data: toInsert }) + } + return toInsert.length +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/match-night.ts tests/match-night.test.ts +git commit -m "feat: add Match Night deck refill logic with cursor-tracked sourcing" +``` + +--- + +### Task 5: Next-candidate lookup and swipe/match transaction + +**Files:** +- Modify: `src/lib/match-night.ts` +- Modify: `tests/match-night.test.ts` + +**Interfaces:** +- Consumes: `otherUser` from `@/lib/user-utils`, `Prisma` from `@prisma/client`, `syncMovieProviders` from `@/lib/streaming`, `REFILL_THRESHOLD`/`refillCandidates` (same file, Task 4). +- Produces: `getNextCandidateForUser(user: User): Promise` and `recordSwipe(candidateId: number, user: User, vote: SwipeVote): Promise` where `SwipeResult = { status: 'recorded' } | { status: 'matched'; movie: Movie } | { status: 'ignored' }` — Task 6's API routes call both. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/match-night.test.ts` (update the mocks at the top first — add `deleteMany` is not needed, but add `vi.mock('@/lib/streaming', ...)`): + +Add this mock near the top of the file, alongside the other `vi.mock` calls: + +```typescript +vi.mock('@/lib/streaming', () => ({ syncMovieProviders: vi.fn().mockResolvedValue(undefined) })) +``` + +Update the import line to add the two new functions: + +```typescript +import { refillCandidates, getNextCandidateForUser, recordSwipe } from '@/lib/match-night' +``` + +Add new `describe` blocks: + +```typescript +describe('getNextCandidateForUser', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate without refilling when the pending count is at or above threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(5) + const candidate = { id: 5, status: 'pending' } + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(candidate as any) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual(candidate) + expect(prisma.swipeCandidate.count).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + }) + expect(prisma.swipeCandidate.findFirst).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + orderBy: { createdAt: 'asc' }, + }) + expect(getCriterionCatalog).not.toHaveBeenCalled() + }) + + it('refills before returning when the pending count is below threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(2) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue({ id: 7 } as any) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 42 })]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual({ id: 7 }) + expect(getCriterionCatalog).toHaveBeenCalled() + expect(prisma.swipeCandidate.createMany).toHaveBeenCalled() + }) + + it('returns null when the pending count is below threshold and a refill adds nothing', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(0) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(null) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toBeNull() + }) +}) + +describe('recordSwipe', () => { + beforeEach(() => vi.clearAllMocks()) + + const pendingCandidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', + year: 1954, runtime: 207, description: 'desc', posterUrl: 'p.jpg', status: 'pending', + } + + it('marks the candidate dead on a down vote and does not touch Movie', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + + const result = await recordSwipe(1, 'user1', 'down') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.swipe.create).toHaveBeenCalledWith({ data: { candidateId: 1, user: 'user1', vote: 'down' } }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'dead' } }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('just records the vote when the other user has not voted up yet', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('creates the Movie and marks matched when both users are up', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ candidateId: 1, user: 'user2', vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 4 } } as any) + const createdMovie = { id: 10, title: 'Seven Samurai', tmdbId: 345911 } + vi.mocked(prisma.movie.create).mockResolvedValue(createdMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'matched', movie: createdMovie }) + expect(prisma.movie.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tmdbId: 345911, title: 'Seven Samurai', sortOrder: 5, matchedViaSwipe: true, + }), + }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'matched' } }) + }) + + it('triggers syncMovieProviders after a match', async () => { + const { syncMovieProviders } = await import('@/lib/streaming') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + vi.mocked(prisma.movie.create).mockResolvedValue({ id: 10, tmdbId: 345911 } as any) + + await recordSwipe(1, 'user1', 'up') + await new Promise((r) => setTimeout(r, 0)) + expect(syncMovieProviders).toHaveBeenCalledWith(10, 345911) + }) + + it('is a no-op when the candidate is no longer pending', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue({ ...pendingCandidate, status: 'dead' } as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'ignored' }) + expect(prisma.swipe.create).not.toHaveBeenCalled() + }) + + it('is a no-op when the candidate does not exist', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(999, 'user1', 'up') + expect(result).toEqual({ status: 'ignored' }) + }) + + it('falls back to the existing Movie on a unique constraint race instead of erroring', async () => { + const { Prisma } = await import('@prisma/client') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', clientVersion: '7.0.0', + }) + vi.mocked(prisma.movie.create).mockRejectedValue(p2002) + const existingMovie = { id: 10, tmdbId: 345911 } + vi.mocked(prisma.movie.findUniqueOrThrow).mockResolvedValue(existingMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + expect(result).toEqual({ status: 'matched', movie: existingMovie }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: FAIL — `getNextCandidateForUser`/`recordSwipe` not exported + +- [ ] **Step 3: Implement the rest of `src/lib/match-night.ts`** + +Add these imports to the top of `src/lib/match-night.ts` (alongside the existing ones): + +```typescript +import { Prisma } from '@prisma/client' +import { otherUser } from './user-utils' +import { syncMovieProviders } from './streaming' +import type { Movie, SwipeCandidateRecord, SwipeVote, User } from '@/types' +``` + +Append to the end of `src/lib/match-night.ts`: + +```typescript +export async function getNextCandidateForUser(user: User): Promise { + const pendingCount = await prisma.swipeCandidate.count({ + where: { status: 'pending', swipes: { none: { user } } }, + }) + if (pendingCount < REFILL_THRESHOLD) { + await refillCandidates() + } + + return (await findNextPending(user)) as unknown as SwipeCandidateRecord | null +} + +function findNextPending(user: User) { + return prisma.swipeCandidate.findFirst({ + where: { status: 'pending', swipes: { none: { user } } }, + orderBy: { createdAt: 'asc' }, + }) +} + +export type SwipeResult = + | { status: 'recorded' } + | { status: 'matched'; movie: Movie } + | { status: 'ignored' } + +export async function recordSwipe( + candidateId: number, + user: User, + vote: SwipeVote +): Promise { + return prisma.$transaction(async (tx) => { + const candidate = await tx.swipeCandidate.findUnique({ where: { id: candidateId } }) + if (!candidate || candidate.status !== 'pending') { + return { status: 'ignored' } + } + + await tx.swipe.create({ data: { candidateId, user, vote } }) + + if (vote === 'down') { + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'dead' } }) + return { status: 'recorded' } + } + + const other = otherUser(user) + const otherSwipe = await tx.swipe.findUnique({ + where: { candidateId_user: { candidateId, user: other } }, + }) + if (!otherSwipe || otherSwipe.vote !== 'up') { + return { status: 'recorded' } + } + + const { _max } = await tx.movie.aggregate({ _max: { sortOrder: true } }) + let movie + try { + movie = await tx.movie.create({ + data: { + title: candidate.title, + year: candidate.year, + runtime: candidate.runtime, + description: candidate.description, + posterUrl: candidate.posterUrl, + imdbId: candidate.imdbId, + tmdbId: candidate.tmdbId, + sortOrder: (_max.sortOrder ?? 0) + 1, + matchedViaSwipe: true, + }, + }) + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + movie = await tx.movie.findUniqueOrThrow({ where: { tmdbId: candidate.tmdbId } }) + } else { + throw err + } + } + + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'matched' } }) + return { status: 'matched', movie } + }).then((result: SwipeResult) => { + if (result.status === 'matched') { + syncMovieProviders(result.movie.id, result.movie.tmdbId).catch((err) => + console.error('[match-night] Failed to sync providers for matched movie:', err) + ) + } + return result + }) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night.test.ts` +Expected: PASS (all tests in the file, ~14 total) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/match-night.ts tests/match-night.test.ts +git commit -m "feat: add Match Night swipe/match transaction with concurrency safeguards" +``` + +--- + +### Task 6: API routes + +**Files:** +- Create: `src/app/api/match-night/next/route.ts` +- Create: `src/app/api/match-night/swipe/route.ts` +- Test: `tests/api.match-night.test.ts` + +**Interfaces:** +- Consumes: `getNextCandidateForUser`, `recordSwipe` from `@/lib/match-night` (Task 5); `USER_KEYS` from `@/lib/user-utils`. +- Produces: `GET /api/match-night/next?user=` → `{ candidate: SwipeCandidateRecord | null }`; `POST /api/match-night/swipe` (body `{ candidateId, user, vote }`) → the `SwipeResult` JSON — consumed by Task 8's page. + +- [ ] **Step 1: Write the failing test** + +Create `tests/api.match-night.test.ts`: + +```typescript +// tests/api.match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/match-night', () => ({ + getNextCandidateForUser: vi.fn(), + recordSwipe: vi.fn(), +})) + +import { getNextCandidateForUser, recordSwipe } from '@/lib/match-night' +import { GET } from '@/app/api/match-night/next/route' +import { POST } from '@/app/api/match-night/swipe/route' + +describe('GET /api/match-night/next', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate for a valid user', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue({ id: 1, title: 'Seven Samurai' } as any) + const req = new Request('http://localhost/api/match-night/next?user=user1') + const res = await GET(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ candidate: { id: 1, title: 'Seven Samurai' } }) + expect(getNextCandidateForUser).toHaveBeenCalledWith('user1') + }) + + it('returns candidate: null when the deck is empty', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue(null) + const req = new Request('http://localhost/api/match-night/next?user=user2') + const res = await GET(req) + expect(await res.json()).toEqual({ candidate: null }) + }) + + it('returns 422 for a missing/invalid user', async () => { + const req = new Request('http://localhost/api/match-night/next?user=nobody') + const res = await GET(req) + expect(res.status).toBe(422) + }) +}) + +describe('POST /api/match-night/swipe', () => { + beforeEach(() => vi.clearAllMocks()) + + it('records a valid swipe', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'recorded' }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'up' }), + }) + const res = await POST(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'recorded' }) + expect(recordSwipe).toHaveBeenCalledWith(1, 'user1', 'up') + }) + + it('returns the matched movie payload on a match', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'matched', movie: { id: 10 } as any }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user2', vote: 'up' }), + }) + const res = await POST(req) + expect(await res.json()).toEqual({ status: 'matched', movie: { id: 10 } }) + }) + + it('returns 422 for an invalid user', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'nobody', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for a missing candidateId', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ user: 'user1', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for an invalid vote value', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'sideways' }), + }) + expect((await POST(req)).status).toBe(422) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/api.match-night.test.ts` +Expected: FAIL — route modules don't exist + +- [ ] **Step 3: Implement the routes** + +Create `src/app/api/match-night/next/route.ts`: + +```typescript +// src/app/api/match-night/next/route.ts +import { NextResponse } from 'next/server' +import { getNextCandidateForUser } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { User } from '@/types' + +export const dynamic = 'force-dynamic' + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const user = searchParams.get('user') + if (!USER_KEYS.includes(user as User)) { + return NextResponse.json({ error: 'invalid user' }, { status: 422 }) + } + + const candidate = await getNextCandidateForUser(user as User) + return NextResponse.json({ candidate }) +} +``` + +Create `src/app/api/match-night/swipe/route.ts`: + +```typescript +// src/app/api/match-night/swipe/route.ts +import { NextResponse } from 'next/server' +import { recordSwipe } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeVote, User } from '@/types' + +interface SwipeBody { + candidateId?: number + user?: User + vote?: SwipeVote +} + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as SwipeBody + + if (!body.candidateId || !USER_KEYS.includes(body.user as User)) { + return NextResponse.json({ error: 'invalid request' }, { status: 422 }) + } + if (body.vote !== 'up' && body.vote !== 'down') { + return NextResponse.json({ error: 'vote must be "up" or "down"' }, { status: 422 }) + } + + const result = await recordSwipe(body.candidateId, body.user as User, body.vote) + return NextResponse.json(result) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/api.match-night.test.ts` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/match-night tests/api.match-night.test.ts +git commit -m "feat: add /api/match-night/next and /api/match-night/swipe routes" +``` + +--- + +### Task 7: `MatchNightCard` component + +**Files:** +- Create: `src/components/match-night-card.tsx` +- Test: `tests/match-night-card.test.tsx` + +**Interfaces:** +- Consumes: `MoviePoster` from `./movie-poster`, `Button` from `@/components/ui/button`, `SwipeCandidateRecord` from `@/types`. +- Produces: `` — consumed by Task 8's page. + +- [ ] **Step 1: Write the failing test** + +Create `tests/match-night-card.test.tsx`: + +```typescript +// tests/match-night-card.test.tsx +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MatchNightCard } from '@/components/match-night-card' +import type { SwipeCandidateRecord } from '@/types' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const candidate: SwipeCandidateRecord = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'A poor village recruits seven samurai.', posterUrl: 'poster.jpg', + source: 'criterion', status: 'pending', createdAt: new Date().toISOString(), +} + +describe('MatchNightCard', () => { + it('renders the title, year, and description', () => { + render() + expect(screen.getByText('Seven Samurai')).toBeInTheDocument() + expect(screen.getByText('1954')).toBeInTheDocument() + expect(screen.getByText(/seven samurai\.$/i)).toBeInTheDocument() + }) + + it('calls onVote("up") when the thumbs-up button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + expect(onVote).toHaveBeenCalledWith('up') + }) + + it('calls onVote("down") when the thumbs-down button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs down/i })) + expect(onVote).toHaveBeenCalledWith('down') + }) + + it('disables both buttons while voting', () => { + render() + expect(screen.getByRole('button', { name: /thumbs up/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /thumbs down/i })).toBeDisabled() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night-card.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the component** + +Create `src/components/match-night-card.tsx`: + +```typescript +// src/components/match-night-card.tsx +'use client' +import { MoviePoster } from './movie-poster' +import { Button } from '@/components/ui/button' +import type { SwipeCandidateRecord, SwipeVote } from '@/types' + +interface MatchNightCardProps { + candidate: SwipeCandidateRecord + voting: boolean + onVote: (vote: SwipeVote) => void +} + +export function MatchNightCard({ candidate, voting, onVote }: MatchNightCardProps) { + return ( +
+ +
+

{candidate.title}

+

{candidate.year}

+

{candidate.description}

+
+
+ + +
+
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night-card.test.tsx` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/components/match-night-card.tsx tests/match-night-card.test.tsx +git commit -m "feat: add MatchNightCard component" +``` + +--- + +### Task 8: Match Night page + +**Files:** +- Create: `src/app/match-night/page.tsx` +- Test: `tests/match-night-page.test.tsx` + +**Interfaces:** +- Consumes: `MatchNightCard` (Task 7), `USER_KEYS` from `@/lib/user-utils`, `/api/user-names`, `/api/match-night/next`, `/api/match-night/swipe`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/match-night-page.test.tsx`: + +```typescript +// tests/match-night-page.test.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import MatchNightPage from '@/app/match-night/page' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +const candidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'desc', posterUrl: 'poster.jpg', source: 'criterion', + status: 'pending', createdAt: new Date().toISOString(), +} + +function jsonResponse(data: unknown) { + return Promise.resolve({ ok: true, json: async () => data }) +} + +describe('MatchNightPage', () => { + beforeEach(() => { + mockFetch.mockReset() + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'recorded' }) + return jsonResponse({}) + }) + }) + + it('shows a user picker before swiping starts', async () => { + render() + await waitFor(() => expect(screen.getByText('Ian')).toBeInTheDocument()) + expect(screen.getByText('Krista')).toBeInTheDocument() + }) + + it('loads the next candidate after picking a user', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText('Seven Samurai')).toBeInTheDocument()) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + }) + + it('submits a swipe and requests the next card', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + '/api/match-night/swipe', + expect.objectContaining({ method: 'POST' }) + ) + ) + }) + + it('shows a match banner when the swipe result is a match', async () => { + mockFetch.mockImplementation((url: string, opts?: any) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'matched', movie: { id: 10 } }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + await waitFor(() => expect(screen.getByText(/it's a match/i)).toBeInTheDocument()) + }) + + it('shows an empty state when there is no next candidate', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate: null }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText(/all caught up/i)).toBeInTheDocument()) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/match-night-page.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the page** + +Create `src/app/match-night/page.tsx`: + +```typescript +// src/app/match-night/page.tsx +'use client' +import { useState, useEffect, useCallback } from 'react' +import { MatchNightCard } from '@/components/match-night-card' +import { Button } from '@/components/ui/button' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeCandidateRecord, SwipeVote, User } from '@/types' + +export default function MatchNightPage() { + const [userNames, setUserNames] = useState>({ user1: 'User 1', user2: 'User 2' }) + const [currentUser, setCurrentUser] = useState(null) + const [candidate, setCandidate] = useState(null) + const [loading, setLoading] = useState(false) + const [voting, setVoting] = useState(false) + const [justMatched, setJustMatched] = useState(false) + + useEffect(() => { + fetch('/api/user-names') + .then((r) => r.json()) + .then(setUserNames) + .catch(() => {}) + }, []) + + const loadNext = useCallback(async (user: User) => { + setLoading(true) + try { + const res = await fetch(`/api/match-night/next?user=${user}`) + const data = await res.json() + setCandidate(data.candidate) + } finally { + setLoading(false) + } + }, []) + + const handleSelectUser = (user: User) => { + setCurrentUser(user) + loadNext(user) + } + + const handleVote = async (vote: SwipeVote) => { + if (!currentUser || !candidate) return + setVoting(true) + setJustMatched(false) + try { + const res = await fetch('/api/match-night/swipe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ candidateId: candidate.id, user: currentUser, vote }), + }) + const data = await res.json() + if (data.status === 'matched') setJustMatched(true) + await loadNext(currentUser) + } finally { + setVoting(false) + } + } + + if (!currentUser) { + return ( +
+

Match Night 💕

+

Who's swiping?

+
+ {USER_KEYS.map((user) => ( + + ))} +
+
+ ) + } + + return ( +
+

Match Night 💕

+

Swiping as {userNames[currentUser]}

+ + {justMatched && ( +

+ It's a match! 🎉 Added to your watchlist. +

+ )} + + {loading ? ( +
Loading next film…
+ ) : candidate ? ( + + ) : ( +
+
🎬
+

You're all caught up!

+

Check back later for more films to swipe on.

+
+ )} +
+ ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/match-night-page.test.tsx` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/app/match-night tests/match-night-page.test.tsx +git commit -m "feat: add Match Night swipe page" +``` + +--- + +### Task 9: Sidebar and mobile nav entries + +**Files:** +- Modify: `src/components/sidebar.tsx` +- Modify: `src/components/mobile-bottom-nav.tsx` +- Modify: `tests/sidebar.test.tsx` +- Modify: `tests/mobile-bottom-nav.test.tsx` + +**Interfaces:** +- No new exports — this task only adds a nav entry to each existing component's `navItems`/`tabs` array. + +- [ ] **Step 1: Update the failing assertions in existing tests** + +In `tests/sidebar.test.tsx`, add an assertion to the first test: + +```typescript + it('renders primary nav links', () => { + render() + expect(screen.getByRole('link', { name: /watch list/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /watched/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /add movie/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /recommend/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /match night/i })).toBeInTheDocument() + }) +``` + +In `tests/mobile-bottom-nav.test.tsx`, change the "four" test to five and add assertions: + +```typescript + it('renders all five navigation tabs', () => { + render() + expect(screen.getByText('List')).toBeInTheDocument() + expect(screen.getByText('Watched')).toBeInTheDocument() + expect(screen.getByText('Add')).toBeInTheDocument() + expect(screen.getByText('Recs')).toBeInTheDocument() + expect(screen.getByText('Match')).toBeInTheDocument() + }) +``` + +And add a route assertion inside the existing "links to the correct routes" test: + +```typescript + it('links to the correct routes', () => { + render() + expect(screen.getByRole('link', { name: /list/i })).toHaveAttribute('href', '/watchlist') + expect(screen.getByRole('link', { name: /watched/i })).toHaveAttribute('href', '/watched') + expect(screen.getByRole('link', { name: /add/i })).toHaveAttribute('href', '/add') + expect(screen.getByRole('link', { name: /recs/i })).toHaveAttribute('href', '/recommendations') + expect(screen.getByRole('link', { name: /match/i })).toHaveAttribute('href', '/match-night') + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx` +Expected: FAIL — "Match Night" / "Match" link not found + +- [ ] **Step 3: Add the nav entries** + +In `src/components/sidebar.tsx`, update `navItems`: + +```typescript +const navItems = [ + { href: '/watchlist', label: 'Watch List', icon: '📋' }, + { href: '/watched', label: 'Watched', icon: '✅' }, + { href: '/add', label: 'Add Movie', icon: '➕' }, + { href: '/match-night', label: 'Match Night', icon: '💕' }, + { href: '/recommendations', label: 'Recommend', icon: '🎯' }, +] +``` + +In `src/components/mobile-bottom-nav.tsx`, update `tabs`: + +```typescript +const tabs = [ + { href: '/watchlist', label: 'List', icon: '📋' }, + { href: '/watched', label: 'Watched', icon: '✅' }, + { href: '/add', label: 'Add', icon: '➕' }, + { href: '/match-night', label: 'Match', icon: '💕' }, + { href: '/recommendations', label: 'Recs', icon: '🎯' }, +] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/sidebar.tsx src/components/mobile-bottom-nav.tsx tests/sidebar.test.tsx tests/mobile-bottom-nav.test.tsx +git commit -m "feat: add Match Night entry to sidebar and mobile bottom nav" +``` + +--- + +### Task 10: "It's a match!" badge on the watchlist + +**Files:** +- Modify: `src/components/movie-row.tsx` +- Modify: `tests/movie-row.test.tsx` + +**Interfaces:** +- Consumes: `movie.matchedViaSwipe` (Task 1's `Movie` type addition). + +- [ ] **Step 1: Write the failing test** + +Add to `tests/movie-row.test.tsx`, inside a new `describe` block: + +```typescript +describe('MovieRow match badge', () => { + beforeEach(() => mockFetch.mockReset()) + + it('shows the match badge when matchedViaSwipe is true', () => { + render() + expect(screen.getByText(/it's a match/i)).toBeInTheDocument() + }) + + it('does not show the match badge for regularly-added movies', () => { + render() + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) +}) +``` + +Also add `matchedViaSwipe: false` to the `makeMovie()` default object in the same file (it's a required field per Task 1's type change): + +```typescript +function makeMovie(overrides: Partial = {}): Movie { + return { + id: 1, + title: 'Jeanne Dielman', + year: 1975, + runtime: 201, + description: '', + posterUrl: '', + imdbId: 'tt0073198', + tmdbId: 11650, + criterionUrl: null, + imdbUrl: null, + sortOrder: 1, + status: 'watchlist', + seerrRequestId: null, + seerrMediaId: null, + seerrStatus: 'not_requested', + watchedAt: null, + createdAt: new Date().toISOString(), + streamingLastChecked: new Date().toISOString(), + streamingLink: null, + matchedViaSwipe: false, + ratings: [], + streamingProviders: [], + ...overrides, + } +} +``` + +(If Task 1 already added this line while fixing `tsc` errors, skip this part — just add the two new tests.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/movie-row.test.tsx` +Expected: FAIL — badge text not found + +- [ ] **Step 3: Add the badge** + +In `src/components/movie-row.tsx`, inside the pills row (the `
` block), add the badge as the first pill: + +```tsx +
+ {movie.matchedViaSwipe && ( + + It's a match! 🎉 + + )} + {isStreamable && ( +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/movie-row.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/components/movie-row.tsx tests/movie-row.test.tsx +git commit -m "feat: show It's a match! badge on watchlist rows added via Match Night" +``` + +--- + +### Task 11: Full verification pass + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full test suite** + +Run: `npm run test:run` +Expected: all test files pass (previous count + this feature's new files), 0 failures + +- [ ] **Step 2: Run the linter** + +Run: `npm run lint` +Expected: no errors + +- [ ] **Step 3: Run the full typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 4: Run the build** + +Run: `npm run build` +Expected: build succeeds + +- [ ] **Step 5: Manual smoke test** + +Run: `npm run dev`, then in a browser: +1. Set `TMDB_API_KEY` in `.env.local` to a real key (required — Match Night can't resolve any candidates without it). +2. Visit `/match-night`, pick a user, confirm a card loads with poster/title/year/description. +3. Thumbs-down it, confirm a new card loads. +4. Thumbs-up a card, switch users (reload page, pick the other name), find the same title (deck order is FIFO so it should reappear for the other user), thumbs-up it too, confirm the "It's a match!" banner appears. +5. Visit `/watchlist`, confirm the matched movie appears with the "It's a match! 🎉" badge. + +No commit for this task — it's verification only. If any step fails, fix the underlying issue and re-run from Step 1. From 884b5c7e64907c8a91ee42df61712a95e46f8895 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:36:47 -0400 Subject: [PATCH 05/20] Remove in-page match banner from Task 8 (contradicted spec non-goal) Task 8 as originally written showed an "It's a match!" banner on the swipe page itself, which directly contradicted the spec's "no toast/notification on match" non-goal (surfacing is watchlist-only via Task 10's badge). Caught during pre-flight plan review; user confirmed the spec should govern. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-22-match-night.md | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-07-22-match-night.md b/docs/superpowers/plans/2026-07-22-match-night.md index e6bea5f..e2b7732 100644 --- a/docs/superpowers/plans/2026-07-22-match-night.md +++ b/docs/superpowers/plans/2026-07-22-match-night.md @@ -1368,8 +1368,8 @@ describe('MatchNightPage', () => { ) }) - it('shows a match banner when the swipe result is a match', async () => { - mockFetch.mockImplementation((url: string, opts?: any) => { + it('requests the next card after a match, without showing any in-page match feedback', async () => { + mockFetch.mockImplementation((url: string) => { if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'matched', movie: { id: 10 } }) @@ -1380,7 +1380,10 @@ describe('MatchNightPage', () => { fireEvent.click(screen.getByText('Ian')) await waitFor(() => screen.getByText('Seven Samurai')) fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) - await waitFor(() => expect(screen.getByText(/it's a match/i)).toBeInTheDocument()) + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + ) + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() }) it('shows an empty state when there is no next candidate', async () => { @@ -1421,7 +1424,6 @@ export default function MatchNightPage() { const [candidate, setCandidate] = useState(null) const [loading, setLoading] = useState(false) const [voting, setVoting] = useState(false) - const [justMatched, setJustMatched] = useState(false) useEffect(() => { fetch('/api/user-names') @@ -1449,15 +1451,12 @@ export default function MatchNightPage() { const handleVote = async (vote: SwipeVote) => { if (!currentUser || !candidate) return setVoting(true) - setJustMatched(false) try { - const res = await fetch('/api/match-night/swipe', { + await fetch('/api/match-night/swipe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ candidateId: candidate.id, user: currentUser, vote }), }) - const data = await res.json() - if (data.status === 'matched') setJustMatched(true) await loadNext(currentUser) } finally { setVoting(false) @@ -1489,12 +1488,6 @@ export default function MatchNightPage() {

Match Night 💕

Swiping as {userNames[currentUser]}

- {justMatched && ( -

- It's a match! 🎉 Added to your watchlist. -

- )} - {loading ? (
Loading next film…
) : candidate ? ( @@ -1748,7 +1741,7 @@ Run: `npm run dev`, then in a browser: 1. Set `TMDB_API_KEY` in `.env.local` to a real key (required — Match Night can't resolve any candidates without it). 2. Visit `/match-night`, pick a user, confirm a card loads with poster/title/year/description. 3. Thumbs-down it, confirm a new card loads. -4. Thumbs-up a card, switch users (reload page, pick the other name), find the same title (deck order is FIFO so it should reappear for the other user), thumbs-up it too, confirm the "It's a match!" banner appears. +4. Thumbs-up a card, switch users (reload page, pick the other name), find the same title (deck order is FIFO so it should reappear for the other user), thumbs-up it too. 5. Visit `/watchlist`, confirm the matched movie appears with the "It's a match! 🎉" badge. No commit for this task — it's verification only. If any step fails, fix the underlying issue and re-run from Step 1. From 61bfc3000f90908062af87dc75ec83ed95172750 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:39:11 -0400 Subject: [PATCH 06/20] feat: add SwipeCandidate/Swipe models and matchedViaSwipe field for Match Night - Add matchedViaSwipe boolean field to Movie model (default: false) - Add SwipeCandidate model to track swiped movies with metadata - Add Swipe model to record individual user swipe votes - Add TypeScript types: SwipeSource, SwipeStatus, SwipeVote, SwipeCandidateRecord, Swipe interface - Update Movie interface with matchedViaSwipe field - Generate migration: 20260723023828_add_match_night Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 63 +++++++++++++++++++ prisma/schema.prisma | 27 ++++++++ src/types/index.ts | 27 ++++++++ 3 files changed, 117 insertions(+) create mode 100644 prisma/migrations/20260723023828_add_match_night/migration.sql diff --git a/prisma/migrations/20260723023828_add_match_night/migration.sql b/prisma/migrations/20260723023828_add_match_night/migration.sql new file mode 100644 index 0000000..35abe6c --- /dev/null +++ b/prisma/migrations/20260723023828_add_match_night/migration.sql @@ -0,0 +1,63 @@ +-- CreateTable +CREATE TABLE "SwipeCandidate" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "tmdbId" INTEGER NOT NULL, + "imdbId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "runtime" INTEGER NOT NULL, + "description" TEXT NOT NULL, + "posterUrl" TEXT NOT NULL, + "source" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Swipe" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "candidateId" INTEGER NOT NULL, + "user" TEXT NOT NULL, + "vote" TEXT NOT NULL, + "swipedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Swipe_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "SwipeCandidate" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Movie" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "runtime" INTEGER NOT NULL, + "description" TEXT NOT NULL, + "posterUrl" TEXT NOT NULL, + "imdbId" TEXT NOT NULL, + "tmdbId" INTEGER NOT NULL, + "criterionUrl" TEXT, + "imdbUrl" TEXT, + "sortOrder" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'watchlist', + "seerrRequestId" TEXT, + "seerrMediaId" TEXT, + "seerrStatus" TEXT NOT NULL DEFAULT 'not_requested', + "watchedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "streamingLastChecked" DATETIME, + "streamingLink" TEXT, + "matchedViaSwipe" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_Movie" ("createdAt", "criterionUrl", "description", "id", "imdbId", "imdbUrl", "posterUrl", "runtime", "seerrMediaId", "seerrRequestId", "seerrStatus", "sortOrder", "status", "streamingLastChecked", "streamingLink", "title", "tmdbId", "watchedAt", "year") SELECT "createdAt", "criterionUrl", "description", "id", "imdbId", "imdbUrl", "posterUrl", "runtime", "seerrMediaId", "seerrRequestId", "seerrStatus", "sortOrder", "status", "streamingLastChecked", "streamingLink", "title", "tmdbId", "watchedAt", "year" FROM "Movie"; +DROP TABLE "Movie"; +ALTER TABLE "new_Movie" RENAME TO "Movie"; +CREATE UNIQUE INDEX "Movie_imdbId_key" ON "Movie"("imdbId"); +CREATE UNIQUE INDEX "Movie_tmdbId_key" ON "Movie"("tmdbId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "SwipeCandidate_tmdbId_key" ON "SwipeCandidate"("tmdbId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Swipe_candidateId_user_key" ON "Swipe"("candidateId", "user"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ef87d5..6f573d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model Movie { createdAt DateTime @default(now()) streamingLastChecked DateTime? streamingLink String? + matchedViaSwipe Boolean @default(false) ratings Rating[] streamingProviders StreamingProvider[] } @@ -41,6 +42,32 @@ model StreamingProvider { @@unique([movieId, providerId]) } +model SwipeCandidate { + id Int @id @default(autoincrement()) + tmdbId Int @unique + imdbId String + title String + year Int + runtime Int + description String + posterUrl String + source String // "criterion" | "tmdb" + status String @default("pending") // pending | dead | matched + createdAt DateTime @default(now()) + swipes Swipe[] +} + +model Swipe { + id Int @id @default(autoincrement()) + candidateId Int + user String + vote String // "up" | "down" + swipedAt DateTime @default(now()) + candidate SwipeCandidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + + @@unique([candidateId, user]) +} + model Rating { id Int @id @default(autoincrement()) movieId Int diff --git a/src/types/index.ts b/src/types/index.ts index 2325b15..a64bfd1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,10 @@ export type User = 'user1' | 'user2' export type RatingValue = 'up' | 'down' +export type SwipeSource = 'criterion' | 'tmdb' +export type SwipeStatus = 'pending' | 'dead' | 'matched' +export type SwipeVote = 'up' | 'down' + export interface StreamingProvider { id: number movieId: number @@ -40,6 +44,7 @@ export interface Movie { createdAt: Date | string streamingLastChecked?: Date | string | null streamingLink?: string | null + matchedViaSwipe: boolean ratings?: Rating[] streamingProviders?: StreamingProvider[] } @@ -53,6 +58,28 @@ export interface Rating { submittedAt: Date | string } +export interface SwipeCandidateRecord { + id: number + tmdbId: number + imdbId: string + title: string + year: number + runtime: number + description: string + posterUrl: string + source: SwipeSource + status: SwipeStatus + createdAt: Date | string +} + +export interface Swipe { + id: number + candidateId: number + user: User + vote: SwipeVote + swipedAt: Date | string +} + export interface TmdbMovieDetails { tmdbId: number title: string From cb61f70ed35cb4989c63a4f07cf2472142f135ab Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:41:41 -0400 Subject: [PATCH 07/20] feat: add bundled Criterion catalog data file and loader Adds a static JSON file with 60 classic Criterion Collection films and a loader function to return them. Used by future deck-refill feature to resolve real movie data via TMDB. Co-Authored-By: Claude Sonnet 5 --- data/criterion-catalog.json | 62 +++++++++++++++++++++++++++++++++ src/lib/criterion-catalog.ts | 10 ++++++ tests/criterion-catalog.test.ts | 28 +++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 data/criterion-catalog.json create mode 100644 src/lib/criterion-catalog.ts create mode 100644 tests/criterion-catalog.test.ts diff --git a/data/criterion-catalog.json b/data/criterion-catalog.json new file mode 100644 index 0000000..ba8d51f --- /dev/null +++ b/data/criterion-catalog.json @@ -0,0 +1,62 @@ +[ + { "title": "Seven Samurai", "year": 1954 }, + { "title": "Rashomon", "year": 1950 }, + { "title": "Ikiru", "year": 1952 }, + { "title": "Yojimbo", "year": 1961 }, + { "title": "Sanjuro", "year": 1962 }, + { "title": "High and Low", "year": 1963 }, + { "title": "The Hidden Fortress", "year": 1958 }, + { "title": "Ran", "year": 1985 }, + { "title": "Kagemusha", "year": 1980 }, + { "title": "The Seventh Seal", "year": 1957 }, + { "title": "Persona", "year": 1966 }, + { "title": "Wild Strawberries", "year": 1957 }, + { "title": "Fanny and Alexander", "year": 1982 }, + { "title": "8 1/2", "year": 1963 }, + { "title": "La Strada", "year": 1954 }, + { "title": "Nights of Cabiria", "year": 1957 }, + { "title": "Amarcord", "year": 1973 }, + { "title": "L'Avventura", "year": 1960 }, + { "title": "The 400 Blows", "year": 1959 }, + { "title": "Breathless", "year": 1960 }, + { "title": "Contempt", "year": 1963 }, + { "title": "Playtime", "year": 1967 }, + { "title": "Mon Oncle", "year": 1958 }, + { "title": "Le Samourai", "year": 1967 }, + { "title": "Diabolique", "year": 1955 }, + { "title": "The Third Man", "year": 1949 }, + { "title": "Rebecca", "year": 1940 }, + { "title": "M", "year": 1931 }, + { "title": "Metropolis", "year": 1927 }, + { "title": "Beauty and the Beast", "year": 1946 }, + { "title": "Orpheus", "year": 1950 }, + { "title": "Tokyo Story", "year": 1953 }, + { "title": "Late Spring", "year": 1949 }, + { "title": "Ugetsu", "year": 1953 }, + { "title": "Sansho the Bailiff", "year": 1954 }, + { "title": "Harakiri", "year": 1962 }, + { "title": "Onibaba", "year": 1964 }, + { "title": "Woman in the Dunes", "year": 1964 }, + { "title": "Kwaidan", "year": 1964 }, + { "title": "In the Mood for Love", "year": 2000 }, + { "title": "Chungking Express", "year": 1994 }, + { "title": "Branded to Kill", "year": 1967 }, + { "title": "Tokyo Drifter", "year": 1966 }, + { "title": "House", "year": 1977 }, + { "title": "The Killing", "year": 1956 }, + { "title": "Paths of Glory", "year": 1957 }, + { "title": "Dr. Strangelove", "year": 1964 }, + { "title": "Rosemary's Baby", "year": 1968 }, + { "title": "Repo Man", "year": 1984 }, + { "title": "The Man Who Fell to Earth", "year": 1976 }, + { "title": "Blood Simple", "year": 1984 }, + { "title": "Do the Right Thing", "year": 1989 }, + { "title": "Malcolm X", "year": 1992 }, + { "title": "Hoop Dreams", "year": 1994 }, + { "title": "Chimes at Midnight", "year": 1965 }, + { "title": "Sullivan's Travels", "year": 1941 }, + { "title": "The Lady Eve", "year": 1941 }, + { "title": "Sweet Smell of Success", "year": 1957 }, + { "title": "Rififi", "year": 1955 }, + { "title": "Charade", "year": 1963 } +] diff --git a/src/lib/criterion-catalog.ts b/src/lib/criterion-catalog.ts new file mode 100644 index 0000000..0789ff0 --- /dev/null +++ b/src/lib/criterion-catalog.ts @@ -0,0 +1,10 @@ +import catalog from '../../data/criterion-catalog.json' + +export interface CriterionCatalogEntry { + title: string + year?: number +} + +export function getCriterionCatalog(): CriterionCatalogEntry[] { + return catalog as CriterionCatalogEntry[] +} diff --git a/tests/criterion-catalog.test.ts b/tests/criterion-catalog.test.ts new file mode 100644 index 0000000..b37c33c --- /dev/null +++ b/tests/criterion-catalog.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { getCriterionCatalog } from '@/lib/criterion-catalog' + +describe('getCriterionCatalog', () => { + it('returns a non-empty array of title entries', () => { + const catalog = getCriterionCatalog() + expect(Array.isArray(catalog)).toBe(true) + expect(catalog.length).toBeGreaterThan(10) + }) + + it('every entry has a non-empty string title', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + expect(typeof entry.title).toBe('string') + expect(entry.title.length).toBeGreaterThan(0) + } + }) + + it('every entry with a year has a plausible film year', () => { + const catalog = getCriterionCatalog() + for (const entry of catalog) { + if (entry.year !== undefined) { + expect(entry.year).toBeGreaterThan(1880) + expect(entry.year).toBeLessThanOrEqual(new Date().getFullYear()) + } + } + }) +}) From c59c91ddd6c80e0732be484e6811393535f9e146 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:42:50 -0400 Subject: [PATCH 08/20] Add .gitignore exception for the bundled Criterion catalog data/ is gitignored for local runtime data (the mounted SQLite db). data/criterion-catalog.json is a checked-in static asset, not runtime data, so it needs an explicit exception rather than a force-add on every future edit. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index eb35a65..f118cca 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ next-env.d.ts # local data data/ +!data/criterion-catalog.json /*.csv # runtime-cached streaming service logos (downloaded from TMDB at runtime) From 4fc1be029040350cf0240a4d750cec73c6dc5416 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:45:00 -0400 Subject: [PATCH 09/20] feat: add fetchPopularMovies helper and request timeouts to tmdb.ts - Added FETCH_TIMEOUT_MS (5000ms) constant and fetchWithTimeout wrapper - Updated fetchDetails to use fetchWithTimeout for request timeout - Updated searchByTitle's search call to use fetchWithTimeout - Added new fetchPopularMovies(page) function that: * Fetches popular movies from TMDB API * Resolves full details for each result via fetchDetails * Filters out any results that fail to resolve * Returns empty array on fetch error - Updated test imports to include fetchPopularMovies - Added 3 new tests for fetchPopularMovies behavior All 18 tests pass (15 existing + 3 new). Co-Authored-By: Claude Sonnet 5 --- src/lib/tmdb.ts | 19 +++++++++++++++++-- tests/tmdb.test.ts | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/lib/tmdb.ts b/src/lib/tmdb.ts index 931302f..4b1d584 100644 --- a/src/lib/tmdb.ts +++ b/src/lib/tmdb.ts @@ -4,9 +4,14 @@ import { getConfig } from './config' const BASE = 'https://api.themoviedb.org/3' const IMG_BASE = 'https://image.tmdb.org/t/p/w500' +const FETCH_TIMEOUT_MS = 5000 + +function fetchWithTimeout(url: string): Promise { + return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) +} async function fetchDetails(tmdbId: number, key: string): Promise { - const res = await fetch(`${BASE}/movie/${tmdbId}?api_key=${key}`) + const res = await fetchWithTimeout(`${BASE}/movie/${tmdbId}?api_key=${key}`) if (!res.ok) return null const m = await res.json() return { @@ -40,7 +45,7 @@ export async function searchByTitle( ): Promise { const { tmdbApiKey } = await getConfig() const yearParam = year ? `&year=${year}` : '' - const res = await fetch( + const res = await fetchWithTimeout( `${BASE}/search/movie?api_key=${tmdbApiKey}&query=${encodeURIComponent(title)}${yearParam}` ) if (!res.ok) return null @@ -124,3 +129,13 @@ export async function fetchProviderList(region: string): Promise { + const { tmdbApiKey } = await getConfig() + const res = await fetchWithTimeout(`${BASE}/movie/popular?api_key=${tmdbApiKey}&page=${page}`) + if (!res.ok) return [] + const data = await res.json() + const hits = (data.results ?? []) as Array<{ id: number }> + const details = await Promise.all(hits.map((h) => fetchDetails(h.id, tmdbApiKey))) + return details.filter((d): d is TmdbMovieDetails => d !== null) +} diff --git a/tests/tmdb.test.ts b/tests/tmdb.test.ts index 5647457..e0ad1d6 100644 --- a/tests/tmdb.test.ts +++ b/tests/tmdb.test.ts @@ -9,7 +9,7 @@ vi.mock('@/lib/config', () => ({ })) import { getConfig } from '@/lib/config' -const { findByImdbId, searchByTitle, lookupCriterionSlug, fetchWatchProviders, fetchProviderList } = await import('@/lib/tmdb') +const { findByImdbId, searchByTitle, lookupCriterionSlug, fetchWatchProviders, fetchProviderList, fetchPopularMovies } = await import('@/lib/tmdb') const mockConfig = { tmdbApiKey: 'test-key', @@ -223,3 +223,41 @@ describe('fetchProviderList', () => { expect(await fetchProviderList('US')).toEqual([]) }) }) + +describe('fetchPopularMovies', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.mocked(getConfig).mockResolvedValue(mockConfig) + }) + + it('resolves full details for each popular result', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 345911 }] }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Seven Samurai') + }) + + it('returns empty array on fetch error', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }) + expect(await fetchPopularMovies(1)).toEqual([]) + }) + + it('filters out results TMDB details couldn\'t resolve', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 1 }, { id: 2 }] }), + }) + .mockResolvedValueOnce({ ok: false }) // details for id 1 fail + .mockResolvedValueOnce({ ok: true, json: async () => detailsResponse }) // id 2 succeeds + + const result = await fetchPopularMovies(1) + expect(result).toHaveLength(1) + }) +}) From 729abed03b444a880ff149dc778e54194509f835 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:47:46 -0400 Subject: [PATCH 10/20] feat: add Match Night deck refill logic with cursor-tracked sourcing --- src/lib/match-night.ts | 84 ++++++++++++++++++++++++++++ tests/match-night.test.ts | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/lib/match-night.ts create mode 100644 tests/match-night.test.ts diff --git a/src/lib/match-night.ts b/src/lib/match-night.ts new file mode 100644 index 0000000..f25656e --- /dev/null +++ b/src/lib/match-night.ts @@ -0,0 +1,84 @@ +// src/lib/match-night.ts +import { prisma } from './db' +import { getCriterionCatalog } from './criterion-catalog' +import { searchByTitle, fetchPopularMovies } from './tmdb' +import type { TmdbMovieDetails } from '@/types' + +export const REFILL_THRESHOLD = 5 +export const REFILL_BATCH_SIZE = 20 + +const CRITERION_CURSOR_KEY = 'match_night_criterion_cursor' +const TMDB_PAGE_CURSOR_KEY = 'match_night_tmdb_popular_page' +const MAX_TMDB_PAGES_PER_REFILL = 5 + +interface NewCandidate extends TmdbMovieDetails { + source: 'criterion' | 'tmdb' +} + +async function getCursor(key: string): Promise { + const row = await prisma.setting.findUnique({ where: { key } }) + return row ? parseInt(row.value, 10) || 0 : 0 +} + +async function setCursor(key: string, value: number): Promise { + await prisma.setting.upsert({ + where: { key }, + create: { key, value: String(value) }, + update: { value: String(value) }, + }) +} + +async function getExistingTmdbIds(): Promise> { + const [movies, candidates] = await Promise.all([ + prisma.movie.findMany({ select: { tmdbId: true } }), + prisma.swipeCandidate.findMany({ select: { tmdbId: true } }), + ]) + return new Set([ + ...movies.map((m) => m.tmdbId), + ...candidates.map((c) => c.tmdbId), + ]) +} + +export async function refillCandidates(): Promise { + const existingTmdbIds = await getExistingTmdbIds() + const toInsert: NewCandidate[] = [] + const halfBatch = REFILL_BATCH_SIZE / 2 + + // Criterion: resolve forward from the saved cursor through the static catalog + const catalog = getCriterionCatalog() + let criterionCursor = await getCursor(CRITERION_CURSOR_KEY) + const criterionCollected = () => toInsert.filter((c) => c.source === 'criterion').length + + while (criterionCursor < catalog.length && criterionCollected() < halfBatch) { + const entry = catalog[criterionCursor] + criterionCursor++ + const details = await searchByTitle(entry.title, entry.year).catch(() => null) + if (details && !existingTmdbIds.has(details.tmdbId)) { + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'criterion' }) + } + } + await setCursor(CRITERION_CURSOR_KEY, criterionCursor) + + // TMDB popular: page forward from the saved cursor + let page = (await getCursor(TMDB_PAGE_CURSOR_KEY)) || 1 + let pagesFetched = 0 + + while (toInsert.length < REFILL_BATCH_SIZE && pagesFetched < MAX_TMDB_PAGES_PER_REFILL) { + const results = await fetchPopularMovies(page).catch(() => []) + pagesFetched++ + page++ + for (const details of results) { + if (existingTmdbIds.has(details.tmdbId)) continue + existingTmdbIds.add(details.tmdbId) + toInsert.push({ ...details, source: 'tmdb' }) + if (toInsert.length >= REFILL_BATCH_SIZE) break + } + } + await setCursor(TMDB_PAGE_CURSOR_KEY, page) + + if (toInsert.length > 0) { + await prisma.swipeCandidate.createMany({ data: toInsert }) + } + return toInsert.length +} diff --git a/tests/match-night.test.ts b/tests/match-night.test.ts new file mode 100644 index 0000000..7e1bbde --- /dev/null +++ b/tests/match-night.test.ts @@ -0,0 +1,112 @@ +// tests/match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => { + const prisma: any = { + movie: { findMany: vi.fn(), aggregate: vi.fn(), create: vi.fn(), findUnique: vi.fn(), findUniqueOrThrow: vi.fn() }, + swipeCandidate: { + findMany: vi.fn(), createMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn(), count: vi.fn(), + }, + swipe: { create: vi.fn(), findUnique: vi.fn() }, + setting: { findUnique: vi.fn(), upsert: vi.fn() }, + } + prisma.$transaction = vi.fn((cb: any) => cb(prisma)) + return { prisma } +}) +vi.mock('@/lib/criterion-catalog', () => ({ + getCriterionCatalog: vi.fn(), +})) +vi.mock('@/lib/tmdb', () => ({ + searchByTitle: vi.fn(), + fetchPopularMovies: vi.fn(), +})) + +import { prisma } from '@/lib/db' +import { getCriterionCatalog } from '@/lib/criterion-catalog' +import { searchByTitle, fetchPopularMovies } from '@/lib/tmdb' +import { refillCandidates } from '@/lib/match-night' + +const tmdbDetails = (overrides: Partial = {}) => ({ + tmdbId: 1, title: 'Some Film', year: 1960, runtime: 90, + description: 'desc', posterUrl: 'poster.jpg', imdbId: 'tt0000001', + ...overrides, +}) + +describe('refillCandidates', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + vi.mocked(prisma.setting.upsert).mockResolvedValue({} as any) + vi.mocked(prisma.swipeCandidate.createMany).mockResolvedValue({ count: 0 } as any) + }) + + it('resolves catalog entries via TMDB and inserts them as criterion candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911, title: 'Seven Samurai' })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 345911, source: 'criterion' })], + }) + }) + + it('skips a resolved title that already exists as a Movie', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('skips a resolved title that already exists as a SwipeCandidate', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Seven Samurai', year: 1954 }]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([{ tmdbId: 345911 }] as any) + vi.mocked(searchByTitle).mockResolvedValue(tmdbDetails({ tmdbId: 345911 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + const count = await refillCandidates() + expect(count).toBe(0) + }) + + it('adds TMDB popular results as tmdb-source candidates', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('continues with TMDB-only results when a Criterion title fails to resolve', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([{ title: 'Unresolvable Title' }]) + vi.mocked(searchByTitle).mockRejectedValue(new Error('timeout')) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 99 })]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 99, source: 'tmdb' })], + }) + }) + + it('persists the TMDB popular page cursor after a refill', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue({ key: 'match_night_tmdb_popular_page', value: '3' } as any) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + + await refillCandidates() + expect(fetchPopularMovies).toHaveBeenCalledWith(3) + expect(prisma.setting.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { key: 'match_night_tmdb_popular_page' } }) + ) + }) +}) From 075d5777faf4c0acf7db8e0e1a6463d3114da3da Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:51:46 -0400 Subject: [PATCH 11/20] feat: add Match Night swipe/match transaction with concurrency safeguards Adds getNextCandidateForUser() and recordSwipe() to match-night.ts. The swipe transaction re-checks candidate status inside prisma.$transaction before recording, so a stale/expired candidate is a safe no-op, and falls back to reading the existing Movie row on a P2002 race so a mutual thumbs-up never creates a duplicate. --- src/lib/match-night.ts | 90 ++++++++++++++++++++++- tests/match-night.test.ts | 147 +++++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 2 deletions(-) diff --git a/src/lib/match-night.ts b/src/lib/match-night.ts index f25656e..dfbdecf 100644 --- a/src/lib/match-night.ts +++ b/src/lib/match-night.ts @@ -2,7 +2,10 @@ import { prisma } from './db' import { getCriterionCatalog } from './criterion-catalog' import { searchByTitle, fetchPopularMovies } from './tmdb' -import type { TmdbMovieDetails } from '@/types' +import { Prisma } from '@prisma/client' +import { otherUser } from './user-utils' +import { syncMovieProviders } from './streaming' +import type { TmdbMovieDetails, Movie, SwipeCandidateRecord, SwipeVote, User } from '@/types' export const REFILL_THRESHOLD = 5 export const REFILL_BATCH_SIZE = 20 @@ -82,3 +85,88 @@ export async function refillCandidates(): Promise { } return toInsert.length } + +export async function getNextCandidateForUser(user: User): Promise { + const pendingCount = await prisma.swipeCandidate.count({ + where: { status: 'pending', swipes: { none: { user } } }, + }) + if (pendingCount < REFILL_THRESHOLD) { + await refillCandidates() + } + + return (await findNextPending(user)) as unknown as SwipeCandidateRecord | null +} + +function findNextPending(user: User) { + return prisma.swipeCandidate.findFirst({ + where: { status: 'pending', swipes: { none: { user } } }, + orderBy: { createdAt: 'asc' }, + }) +} + +export type SwipeResult = + | { status: 'recorded' } + | { status: 'matched'; movie: Movie } + | { status: 'ignored' } + +export async function recordSwipe( + candidateId: number, + user: User, + vote: SwipeVote +): Promise { + return prisma.$transaction(async (tx): Promise => { + const candidate = await tx.swipeCandidate.findUnique({ where: { id: candidateId } }) + if (!candidate || candidate.status !== 'pending') { + return { status: 'ignored' } + } + + await tx.swipe.create({ data: { candidateId, user, vote } }) + + if (vote === 'down') { + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'dead' } }) + return { status: 'recorded' } + } + + const other = otherUser(user) + const otherSwipe = await tx.swipe.findUnique({ + where: { candidateId_user: { candidateId, user: other } }, + }) + if (!otherSwipe || otherSwipe.vote !== 'up') { + return { status: 'recorded' } + } + + const { _max } = await tx.movie.aggregate({ _max: { sortOrder: true } }) + let movie + try { + movie = await tx.movie.create({ + data: { + title: candidate.title, + year: candidate.year, + runtime: candidate.runtime, + description: candidate.description, + posterUrl: candidate.posterUrl, + imdbId: candidate.imdbId, + tmdbId: candidate.tmdbId, + sortOrder: (_max.sortOrder ?? 0) + 1, + matchedViaSwipe: true, + }, + }) + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + movie = await tx.movie.findUniqueOrThrow({ where: { tmdbId: candidate.tmdbId } }) + } else { + throw err + } + } + + await tx.swipeCandidate.update({ where: { id: candidateId }, data: { status: 'matched' } }) + return { status: 'matched', movie: movie as unknown as Movie } + }).then((result: SwipeResult) => { + if (result.status === 'matched') { + syncMovieProviders(result.movie.id, result.movie.tmdbId).catch((err) => + console.error('[match-night] Failed to sync providers for matched movie:', err) + ) + } + return result + }) +} diff --git a/tests/match-night.test.ts b/tests/match-night.test.ts index 7e1bbde..195107b 100644 --- a/tests/match-night.test.ts +++ b/tests/match-night.test.ts @@ -20,11 +20,12 @@ vi.mock('@/lib/tmdb', () => ({ searchByTitle: vi.fn(), fetchPopularMovies: vi.fn(), })) +vi.mock('@/lib/streaming', () => ({ syncMovieProviders: vi.fn().mockResolvedValue(undefined) })) import { prisma } from '@/lib/db' import { getCriterionCatalog } from '@/lib/criterion-catalog' import { searchByTitle, fetchPopularMovies } from '@/lib/tmdb' -import { refillCandidates } from '@/lib/match-night' +import { refillCandidates, getNextCandidateForUser, recordSwipe } from '@/lib/match-night' const tmdbDetails = (overrides: Partial = {}) => ({ tmdbId: 1, title: 'Some Film', year: 1960, runtime: 90, @@ -110,3 +111,147 @@ describe('refillCandidates', () => { ) }) }) + +describe('getNextCandidateForUser', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate without refilling when the pending count is at or above threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(5) + const candidate = { id: 5, status: 'pending' } + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(candidate as any) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual(candidate) + expect(prisma.swipeCandidate.count).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + }) + expect(prisma.swipeCandidate.findFirst).toHaveBeenCalledWith({ + where: { status: 'pending', swipes: { none: { user: 'user1' } } }, + orderBy: { createdAt: 'asc' }, + }) + expect(getCriterionCatalog).not.toHaveBeenCalled() + }) + + it('refills before returning when the pending count is below threshold', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(2) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue({ id: 7 } as any) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([tmdbDetails({ tmdbId: 42 })]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toEqual({ id: 7 }) + expect(getCriterionCatalog).toHaveBeenCalled() + expect(prisma.swipeCandidate.createMany).toHaveBeenCalled() + }) + + it('returns null when the pending count is below threshold and a refill adds nothing', async () => { + vi.mocked(prisma.swipeCandidate.count).mockResolvedValue(0) + vi.mocked(prisma.swipeCandidate.findFirst).mockResolvedValue(null) + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([]) + vi.mocked(prisma.movie.findMany).mockResolvedValue([]) + vi.mocked(prisma.swipeCandidate.findMany).mockResolvedValue([]) + vi.mocked(prisma.setting.findUnique).mockResolvedValue(null) + + const result = await getNextCandidateForUser('user1') + + expect(result).toBeNull() + }) +}) + +describe('recordSwipe', () => { + beforeEach(() => vi.clearAllMocks()) + + const pendingCandidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', + year: 1954, runtime: 207, description: 'desc', posterUrl: 'p.jpg', status: 'pending', + } + + it('marks the candidate dead on a down vote and does not touch Movie', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + + const result = await recordSwipe(1, 'user1', 'down') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.swipe.create).toHaveBeenCalledWith({ data: { candidateId: 1, user: 'user1', vote: 'down' } }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'dead' } }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('just records the vote when the other user has not voted up yet', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'recorded' }) + expect(prisma.movie.create).not.toHaveBeenCalled() + }) + + it('creates the Movie and marks matched when both users are up', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ candidateId: 1, user: 'user2', vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 4 } } as any) + const createdMovie = { id: 10, title: 'Seven Samurai', tmdbId: 345911 } + vi.mocked(prisma.movie.create).mockResolvedValue(createdMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'matched', movie: createdMovie }) + expect(prisma.movie.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tmdbId: 345911, title: 'Seven Samurai', sortOrder: 5, matchedViaSwipe: true, + }), + }) + expect(prisma.swipeCandidate.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { status: 'matched' } }) + }) + + it('triggers syncMovieProviders after a match', async () => { + const { syncMovieProviders } = await import('@/lib/streaming') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + vi.mocked(prisma.movie.create).mockResolvedValue({ id: 10, tmdbId: 345911 } as any) + + await recordSwipe(1, 'user1', 'up') + await new Promise((r) => setTimeout(r, 0)) + expect(syncMovieProviders).toHaveBeenCalledWith(10, 345911) + }) + + it('is a no-op when the candidate is no longer pending', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue({ ...pendingCandidate, status: 'dead' } as any) + + const result = await recordSwipe(1, 'user1', 'up') + + expect(result).toEqual({ status: 'ignored' }) + expect(prisma.swipe.create).not.toHaveBeenCalled() + }) + + it('is a no-op when the candidate does not exist', async () => { + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(null) + + const result = await recordSwipe(999, 'user1', 'up') + expect(result).toEqual({ status: 'ignored' }) + }) + + it('falls back to the existing Movie on a unique constraint race instead of erroring', async () => { + const { Prisma } = await import('@prisma/client') + vi.mocked(prisma.swipeCandidate.findUnique).mockResolvedValue(pendingCandidate as any) + vi.mocked(prisma.swipe.findUnique).mockResolvedValue({ vote: 'up' } as any) + vi.mocked(prisma.movie.aggregate).mockResolvedValue({ _max: { sortOrder: 0 } } as any) + const p2002 = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', clientVersion: '7.0.0', + }) + vi.mocked(prisma.movie.create).mockRejectedValue(p2002) + const existingMovie = { id: 10, tmdbId: 345911 } + vi.mocked(prisma.movie.findUniqueOrThrow).mockResolvedValue(existingMovie as any) + + const result = await recordSwipe(1, 'user1', 'up') + expect(result).toEqual({ status: 'matched', movie: existingMovie }) + }) +}) From 6a8bbe75d39f7e26d756b2eb9126f01842ff8ca6 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:55:23 -0400 Subject: [PATCH 12/20] feat: add /api/match-night/next and /api/match-night/swipe routes - GET /api/match-night/next: returns next candidate for a user, validates user and returns 422 for invalid input - POST /api/match-night/swipe: records a swipe vote, validates user, candidateId, and vote value with 422 responses for invalid input - All 8 tests passing Co-Authored-By: Claude Sonnet 5 --- src/app/api/match-night/next/route.ts | 18 ++++++ src/app/api/match-night/swipe/route.ts | 25 ++++++++ tests/api.match-night.test.ts | 87 ++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/app/api/match-night/next/route.ts create mode 100644 src/app/api/match-night/swipe/route.ts create mode 100644 tests/api.match-night.test.ts diff --git a/src/app/api/match-night/next/route.ts b/src/app/api/match-night/next/route.ts new file mode 100644 index 0000000..07d081e --- /dev/null +++ b/src/app/api/match-night/next/route.ts @@ -0,0 +1,18 @@ +// src/app/api/match-night/next/route.ts +import { NextResponse } from 'next/server' +import { getNextCandidateForUser } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { User } from '@/types' + +export const dynamic = 'force-dynamic' + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const user = searchParams.get('user') + if (!USER_KEYS.includes(user as User)) { + return NextResponse.json({ error: 'invalid user' }, { status: 422 }) + } + + const candidate = await getNextCandidateForUser(user as User) + return NextResponse.json({ candidate }) +} diff --git a/src/app/api/match-night/swipe/route.ts b/src/app/api/match-night/swipe/route.ts new file mode 100644 index 0000000..5325a02 --- /dev/null +++ b/src/app/api/match-night/swipe/route.ts @@ -0,0 +1,25 @@ +// src/app/api/match-night/swipe/route.ts +import { NextResponse } from 'next/server' +import { recordSwipe } from '@/lib/match-night' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeVote, User } from '@/types' + +interface SwipeBody { + candidateId?: number + user?: User + vote?: SwipeVote +} + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as SwipeBody + + if (!body.candidateId || !USER_KEYS.includes(body.user as User)) { + return NextResponse.json({ error: 'invalid request' }, { status: 422 }) + } + if (body.vote !== 'up' && body.vote !== 'down') { + return NextResponse.json({ error: 'vote must be "up" or "down"' }, { status: 422 }) + } + + const result = await recordSwipe(body.candidateId, body.user as User, body.vote) + return NextResponse.json(result) +} diff --git a/tests/api.match-night.test.ts b/tests/api.match-night.test.ts new file mode 100644 index 0000000..74c197a --- /dev/null +++ b/tests/api.match-night.test.ts @@ -0,0 +1,87 @@ +// tests/api.match-night.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/match-night', () => ({ + getNextCandidateForUser: vi.fn(), + recordSwipe: vi.fn(), +})) + +import { getNextCandidateForUser, recordSwipe } from '@/lib/match-night' +import { GET } from '@/app/api/match-night/next/route' +import { POST } from '@/app/api/match-night/swipe/route' + +describe('GET /api/match-night/next', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the next candidate for a valid user', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue({ id: 1, title: 'Seven Samurai' } as any) + const req = new Request('http://localhost/api/match-night/next?user=user1') + const res = await GET(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ candidate: { id: 1, title: 'Seven Samurai' } }) + expect(getNextCandidateForUser).toHaveBeenCalledWith('user1') + }) + + it('returns candidate: null when the deck is empty', async () => { + vi.mocked(getNextCandidateForUser).mockResolvedValue(null) + const req = new Request('http://localhost/api/match-night/next?user=user2') + const res = await GET(req) + expect(await res.json()).toEqual({ candidate: null }) + }) + + it('returns 422 for a missing/invalid user', async () => { + const req = new Request('http://localhost/api/match-night/next?user=nobody') + const res = await GET(req) + expect(res.status).toBe(422) + }) +}) + +describe('POST /api/match-night/swipe', () => { + beforeEach(() => vi.clearAllMocks()) + + it('records a valid swipe', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'recorded' }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'up' }), + }) + const res = await POST(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'recorded' }) + expect(recordSwipe).toHaveBeenCalledWith(1, 'user1', 'up') + }) + + it('returns the matched movie payload on a match', async () => { + vi.mocked(recordSwipe).mockResolvedValue({ status: 'matched', movie: { id: 10 } as any }) + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user2', vote: 'up' }), + }) + const res = await POST(req) + expect(await res.json()).toEqual({ status: 'matched', movie: { id: 10 } }) + }) + + it('returns 422 for an invalid user', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'nobody', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for a missing candidateId', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ user: 'user1', vote: 'up' }), + }) + expect((await POST(req)).status).toBe(422) + }) + + it('returns 422 for an invalid vote value', async () => { + const req = new Request('http://localhost/api/match-night/swipe', { + method: 'POST', + body: JSON.stringify({ candidateId: 1, user: 'user1', vote: 'sideways' }), + }) + expect((await POST(req)).status).toBe(422) + }) +}) From be83d38198fae64736b171bc8a07adbd57b85f07 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:57:39 -0400 Subject: [PATCH 13/20] feat: add MatchNightCard component Implements the MatchNightCard presentational component that displays a movie candidate with poster, title, year, and description. Provides thumbs-up and thumbs-down buttons for voting, with disabled state during voting. Co-Authored-By: Claude Sonnet 5 --- src/components/match-night-card.tsx | 45 +++++++++++++++++++++++++++++ tests/match-night-card.test.tsx | 43 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/components/match-night-card.tsx create mode 100644 tests/match-night-card.test.tsx diff --git a/src/components/match-night-card.tsx b/src/components/match-night-card.tsx new file mode 100644 index 0000000..ed11db2 --- /dev/null +++ b/src/components/match-night-card.tsx @@ -0,0 +1,45 @@ +'use client' +import { MoviePoster } from './movie-poster' +import { Button } from '@/components/ui/button' +import type { SwipeCandidateRecord, SwipeVote } from '@/types' + +interface MatchNightCardProps { + candidate: SwipeCandidateRecord + voting: boolean + onVote: (vote: SwipeVote) => void +} + +export function MatchNightCard({ candidate, voting, onVote }: MatchNightCardProps) { + return ( +
+ +
+

{candidate.title}

+

{candidate.year}

+

{candidate.description}

+
+
+ + +
+
+ ) +} diff --git a/tests/match-night-card.test.tsx b/tests/match-night-card.test.tsx new file mode 100644 index 0000000..ef7eba1 --- /dev/null +++ b/tests/match-night-card.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MatchNightCard } from '@/components/match-night-card' +import type { SwipeCandidateRecord } from '@/types' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const candidate: SwipeCandidateRecord = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'A poor village recruits seven samurai.', posterUrl: 'poster.jpg', + source: 'criterion', status: 'pending', createdAt: new Date().toISOString(), +} + +describe('MatchNightCard', () => { + it('renders the title, year, and description', () => { + render() + expect(screen.getByText('Seven Samurai')).toBeInTheDocument() + expect(screen.getByText('1954')).toBeInTheDocument() + expect(screen.getByText(/seven samurai\.$/i)).toBeInTheDocument() + }) + + it('calls onVote("up") when the thumbs-up button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + expect(onVote).toHaveBeenCalledWith('up') + }) + + it('calls onVote("down") when the thumbs-down button is clicked', () => { + const onVote = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /thumbs down/i })) + expect(onVote).toHaveBeenCalledWith('down') + }) + + it('disables both buttons while voting', () => { + render() + expect(screen.getByRole('button', { name: /thumbs up/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /thumbs down/i })).toBeDisabled() + }) +}) From 7c2b2a9fbafaab72440d872ac6243e94b0d39c39 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 22:59:49 -0400 Subject: [PATCH 14/20] feat: add Match Night swipe page --- src/app/match-night/page.tsx | 92 ++++++++++++++++++++++++++++++++ tests/match-night-page.test.tsx | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/app/match-night/page.tsx create mode 100644 tests/match-night-page.test.tsx diff --git a/src/app/match-night/page.tsx b/src/app/match-night/page.tsx new file mode 100644 index 0000000..8a110fc --- /dev/null +++ b/src/app/match-night/page.tsx @@ -0,0 +1,92 @@ +// src/app/match-night/page.tsx +'use client' +import { useState, useEffect, useCallback } from 'react' +import { MatchNightCard } from '@/components/match-night-card' +import { Button } from '@/components/ui/button' +import { USER_KEYS } from '@/lib/user-utils' +import type { SwipeCandidateRecord, SwipeVote, User } from '@/types' + +export default function MatchNightPage() { + const [userNames, setUserNames] = useState>({ user1: 'User 1', user2: 'User 2' }) + const [currentUser, setCurrentUser] = useState(null) + const [candidate, setCandidate] = useState(null) + const [loading, setLoading] = useState(false) + const [voting, setVoting] = useState(false) + + useEffect(() => { + fetch('/api/user-names') + .then((r) => r.json()) + .then(setUserNames) + .catch(() => {}) + }, []) + + const loadNext = useCallback(async (user: User) => { + setLoading(true) + try { + const res = await fetch(`/api/match-night/next?user=${user}`) + const data = await res.json() + setCandidate(data.candidate) + } finally { + setLoading(false) + } + }, []) + + const handleSelectUser = (user: User) => { + setCurrentUser(user) + loadNext(user) + } + + const handleVote = async (vote: SwipeVote) => { + if (!currentUser || !candidate) return + setVoting(true) + try { + await fetch('/api/match-night/swipe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ candidateId: candidate.id, user: currentUser, vote }), + }) + await loadNext(currentUser) + } finally { + setVoting(false) + } + } + + if (!currentUser) { + return ( +
+

Match Night 💕

+

Who's swiping?

+
+ {USER_KEYS.map((user) => ( + + ))} +
+
+ ) + } + + return ( +
+

Match Night 💕

+

Swiping as {userNames[currentUser]}

+ + {loading ? ( +
Loading next film…
+ ) : candidate ? ( + + ) : ( +
+
🎬
+

You're all caught up!

+

Check back later for more films to swipe on.

+
+ )} +
+ ) +} diff --git a/tests/match-night-page.test.tsx b/tests/match-night-page.test.tsx new file mode 100644 index 0000000..863cf4b --- /dev/null +++ b/tests/match-night-page.test.tsx @@ -0,0 +1,93 @@ +// tests/match-night-page.test.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import MatchNightPage from '@/app/match-night/page' + +vi.mock('next/image', () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})) + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +const candidate = { + id: 1, tmdbId: 345911, imdbId: 'tt0047478', title: 'Seven Samurai', year: 1954, + runtime: 207, description: 'desc', posterUrl: 'poster.jpg', source: 'criterion', + status: 'pending', createdAt: new Date().toISOString(), +} + +function jsonResponse(data: unknown) { + return Promise.resolve({ ok: true, json: async () => data }) +} + +describe('MatchNightPage', () => { + beforeEach(() => { + mockFetch.mockReset() + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'recorded' }) + return jsonResponse({}) + }) + }) + + it('shows a user picker before swiping starts', async () => { + render() + await waitFor(() => expect(screen.getByText('Ian')).toBeInTheDocument()) + expect(screen.getByText('Krista')).toBeInTheDocument() + }) + + it('loads the next candidate after picking a user', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText('Seven Samurai')).toBeInTheDocument()) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + }) + + it('submits a swipe and requests the next card', async () => { + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + '/api/match-night/swipe', + expect.objectContaining({ method: 'POST' }) + ) + ) + }) + + it('requests the next card after a match, without showing any in-page match feedback', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate }) + if (url.includes('/api/match-night/swipe')) return jsonResponse({ status: 'matched', movie: { id: 10 } }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => screen.getByText('Seven Samurai')) + fireEvent.click(screen.getByRole('button', { name: /thumbs up/i })) + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/match-night/next?user=user1')) + ) + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) + + it('shows an empty state when there is no next candidate', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/user-names')) return jsonResponse({ user1: 'Ian', user2: 'Krista' }) + if (url.includes('/api/match-night/next')) return jsonResponse({ candidate: null }) + return jsonResponse({}) + }) + render() + await waitFor(() => screen.getByText('Ian')) + fireEvent.click(screen.getByText('Ian')) + await waitFor(() => expect(screen.getByText(/all caught up/i)).toBeInTheDocument()) + }) +}) From 4fe50d90bad9f7d56692d294203a213656f7640b Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 23:02:01 -0400 Subject: [PATCH 15/20] feat: add Match Night entry to sidebar and mobile bottom nav --- src/components/mobile-bottom-nav.tsx | 1 + src/components/sidebar.tsx | 1 + tests/mobile-bottom-nav.test.tsx | 4 +++- tests/sidebar.test.tsx | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/mobile-bottom-nav.tsx b/src/components/mobile-bottom-nav.tsx index 8d79fd4..a568f0c 100644 --- a/src/components/mobile-bottom-nav.tsx +++ b/src/components/mobile-bottom-nav.tsx @@ -7,6 +7,7 @@ const tabs = [ { href: '/watchlist', label: 'List', icon: '📋' }, { href: '/watched', label: 'Watched', icon: '✅' }, { href: '/add', label: 'Add', icon: '➕' }, + { href: '/match-night', label: 'Match', icon: '💕' }, { href: '/recommendations', label: 'Recs', icon: '🎯' }, ] diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index fbf6763..81f1732 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -9,6 +9,7 @@ const navItems = [ { href: '/watchlist', label: 'Watch List', icon: '📋' }, { href: '/watched', label: 'Watched', icon: '✅' }, { href: '/add', label: 'Add Movie', icon: '➕' }, + { href: '/match-night', label: 'Match Night', icon: '💕' }, { href: '/recommendations', label: 'Recommend', icon: '🎯' }, ] diff --git a/tests/mobile-bottom-nav.test.tsx b/tests/mobile-bottom-nav.test.tsx index ba7747f..40a3bfe 100644 --- a/tests/mobile-bottom-nav.test.tsx +++ b/tests/mobile-bottom-nav.test.tsx @@ -9,12 +9,13 @@ vi.mock('next/navigation', () => ({ import { MobileBottomNav } from '@/components/mobile-bottom-nav' describe('MobileBottomNav', () => { - it('renders all four navigation tabs', () => { + it('renders all five navigation tabs', () => { render() expect(screen.getByText('List')).toBeInTheDocument() expect(screen.getByText('Watched')).toBeInTheDocument() expect(screen.getByText('Add')).toBeInTheDocument() expect(screen.getByText('Recs')).toBeInTheDocument() + expect(screen.getByText('Match')).toBeInTheDocument() }) it('links to the correct routes', () => { @@ -23,6 +24,7 @@ describe('MobileBottomNav', () => { expect(screen.getByRole('link', { name: /watched/i })).toHaveAttribute('href', '/watched') expect(screen.getByRole('link', { name: /add/i })).toHaveAttribute('href', '/add') expect(screen.getByRole('link', { name: /recs/i })).toHaveAttribute('href', '/recommendations') + expect(screen.getByRole('link', { name: /match/i })).toHaveAttribute('href', '/match-night') }) it('does not include Settings in the bottom nav tabs', () => { diff --git a/tests/sidebar.test.tsx b/tests/sidebar.test.tsx index 6165594..305daae 100644 --- a/tests/sidebar.test.tsx +++ b/tests/sidebar.test.tsx @@ -20,6 +20,7 @@ describe('Sidebar', () => { expect(screen.getByRole('link', { name: /watched/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /add movie/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /recommend/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /match night/i })).toBeInTheDocument() }) it('does not render Browse Criterion or Browse IMDB in the utility footer', () => { From 546006bebbee25dcbf37514a38123705c52f36fd Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 23:06:58 -0400 Subject: [PATCH 16/20] feat: show It's a match! badge on watchlist rows added via Match Night Co-Authored-By: Claude Haiku 4.5 --- src/components/movie-row.tsx | 5 +++++ tests/movie-row.test.tsx | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/components/movie-row.tsx b/src/components/movie-row.tsx index ff18e0d..5c1970c 100644 --- a/src/components/movie-row.tsx +++ b/src/components/movie-row.tsx @@ -104,6 +104,11 @@ export function MovieRow({ {/* Status pills + streaming info live here */}
+ {movie.matchedViaSwipe && ( + + It's a match! 🎉 + + )} {isStreamable && ( Streaming diff --git a/tests/movie-row.test.tsx b/tests/movie-row.test.tsx index 91f245e..9ea3e5e 100644 --- a/tests/movie-row.test.tsx +++ b/tests/movie-row.test.tsx @@ -32,6 +32,7 @@ function makeMovie(overrides: Partial = {}): Movie { createdAt: new Date().toISOString(), streamingLastChecked: new Date().toISOString(), streamingLink: null, + matchedViaSwipe: false, ratings: [], streamingProviders: [], ...overrides, @@ -116,3 +117,17 @@ describe('MovieRow layout', () => { expect(watchLink).toHaveClass('border-amber-400') }) }) + +describe('MovieRow match badge', () => { + beforeEach(() => mockFetch.mockReset()) + + it('shows the match badge when matchedViaSwipe is true', () => { + render() + expect(screen.getByText(/it's a match/i)).toBeInTheDocument() + }) + + it('does not show the match badge for regularly-added movies', () => { + render() + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) +}) From f512a7c966dd1f5d1dd52933ff893328c8c42d82 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 23:17:23 -0400 Subject: [PATCH 17/20] fix: prevent empty-imdbId dedup gap and restore match badge on MovieCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refillCandidates() now skips any TMDB result with an empty imdbId in both the Criterion-resolution loop and the TMDB-popular loop, closing a gap where two empty-imdbId candidates could match and crash the second movie.create with an uncaught P2025 (the P2002 fallback looks up by tmdbId, which won't find a row from a failed create). - MovieCard (watched view) now renders the "It's a match! 🎉" badge when movie.matchedViaSwipe is true, matching MovieRow's existing behavior on the watchlist view, per the Match Night design spec. Co-Authored-By: Claude Sonnet 5 --- src/components/movie-card.tsx | 5 +++++ src/lib/match-night.ts | 4 ++-- tests/match-night.test.ts | 14 ++++++++++++++ tests/movie-card.test.tsx | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/components/movie-card.tsx b/src/components/movie-card.tsx index 11294a7..169b2ca 100644 --- a/src/components/movie-card.tsx +++ b/src/components/movie-card.tsx @@ -121,6 +121,11 @@ export function MovieCard({ movie, userNames, seerrUrl }: MovieCardProps) { )}
+ {movie.matchedViaSwipe && ( + + It's a match! 🎉 + + )}
diff --git a/src/lib/match-night.ts b/src/lib/match-night.ts index dfbdecf..1909b49 100644 --- a/src/lib/match-night.ts +++ b/src/lib/match-night.ts @@ -56,7 +56,7 @@ export async function refillCandidates(): Promise { const entry = catalog[criterionCursor] criterionCursor++ const details = await searchByTitle(entry.title, entry.year).catch(() => null) - if (details && !existingTmdbIds.has(details.tmdbId)) { + if (details && details.imdbId !== '' && !existingTmdbIds.has(details.tmdbId)) { existingTmdbIds.add(details.tmdbId) toInsert.push({ ...details, source: 'criterion' }) } @@ -72,7 +72,7 @@ export async function refillCandidates(): Promise { pagesFetched++ page++ for (const details of results) { - if (existingTmdbIds.has(details.tmdbId)) continue + if (details.imdbId === '' || existingTmdbIds.has(details.tmdbId)) continue existingTmdbIds.add(details.tmdbId) toInsert.push({ ...details, source: 'tmdb' }) if (toInsert.length >= REFILL_BATCH_SIZE) break diff --git a/tests/match-night.test.ts b/tests/match-night.test.ts index 195107b..c82ebc8 100644 --- a/tests/match-night.test.ts +++ b/tests/match-night.test.ts @@ -99,6 +99,20 @@ describe('refillCandidates', () => { }) }) + it('skips a TMDB result with an empty imdbId', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([]) + vi.mocked(fetchPopularMovies).mockResolvedValue([ + tmdbDetails({ tmdbId: 99, imdbId: '' }), + tmdbDetails({ tmdbId: 100, imdbId: 'tt0000100' }), + ]) + + const count = await refillCandidates() + expect(count).toBe(1) + expect(prisma.swipeCandidate.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ tmdbId: 100, source: 'tmdb' })], + }) + }) + it('persists the TMDB popular page cursor after a refill', async () => { vi.mocked(getCriterionCatalog).mockReturnValue([]) vi.mocked(prisma.setting.findUnique).mockResolvedValue({ key: 'match_night_tmdb_popular_page', value: '3' } as any) diff --git a/tests/movie-card.test.tsx b/tests/movie-card.test.tsx index b84c4c8..c9ef29e 100644 --- a/tests/movie-card.test.tsx +++ b/tests/movie-card.test.tsx @@ -109,3 +109,17 @@ describe('MovieCard cleanup button', () => { await waitFor(() => expect(screen.getByText("Alice's verdict")).toBeInTheDocument()) }) }) + +describe('MovieCard match badge', () => { + beforeEach(() => mockFetch.mockReset()) + + it('shows the match badge when matchedViaSwipe is true', () => { + render() + expect(screen.getByText(/it's a match/i)).toBeInTheDocument() + }) + + it('does not show the match badge for regularly-added movies', () => { + render() + expect(screen.queryByText(/it's a match/i)).not.toBeInTheDocument() + }) +}) From c469ebc7f7181dadd353d35114fb7cebd0987bf6 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 23:33:40 -0400 Subject: [PATCH 18/20] Fix Docker build: dockerignore exception + lockfile sync The Docker build was broken on main for two independent reasons, both surfaced while building the image for the Match Night release: - package-lock.json was out of sync with package.json (missing @emnapi/core/runtime entries from an earlier dependency bump), so `npm ci` failed with EUSAGE. Regenerated inside a node:24-alpine container to match the build image's platform-specific optional dependency resolution exactly. - .dockerignore excludes the whole data/ directory (mirroring the runtime-data .gitignore convention), which silently dropped data/criterion-catalog.json from the build context, breaking the Next.js build with a module-not-found in src/lib/criterion-catalog.ts. Added the same targeted exception used in .gitignore. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 1 + package-lock.json | 77 +++++++++++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.dockerignore b/.dockerignore index 2c2a02a..0a334d6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ node_modules .env*.local !.env.example data +!data/criterion-catalog.json docs diff --git a/package-lock.json b/package-lock.json index 8049dca..8ce1bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -175,7 +175,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -711,7 +710,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -760,7 +758,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -912,7 +909,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -968,8 +964,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", @@ -1019,7 +1014,17 @@ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "devOptional": true, "license": "MIT", - "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { "tslib": "^2.4.0" } @@ -2485,7 +2490,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3711,6 +3715,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -4153,6 +4180,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -4242,7 +4270,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", @@ -4315,7 +4344,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4326,7 +4354,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4827,7 +4854,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4907,6 +4933,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5401,7 +5428,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -6148,6 +6174,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -6200,7 +6227,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "17.4.2", @@ -6604,7 +6632,6 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6738,7 +6765,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6977,7 +7003,6 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -7437,7 +7462,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8203,7 +8227,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9021,7 +9044,6 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -9586,6 +9608,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10555,7 +10578,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -10640,6 +10662,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10655,6 +10678,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10667,7 +10691,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -10690,7 +10715,6 @@ "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -10916,7 +10940,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10926,7 +10949,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12277,7 +12299,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12415,7 +12436,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -12561,7 +12581,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12797,7 +12816,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -13272,7 +13290,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 5580e9883b7864a3b9078defb5fc27b11077456e Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 23:39:43 -0400 Subject: [PATCH 19/20] Shuffle candidate deck and expand the Criterion catalog Match Night's refill filled the Criterion half of each batch before the TMDB half, and served candidates oldest-first, so a whole run of Criterion picks always showed before any TMDB ones instead of being mixed. Shuffle the batch before insertion so sources interleave, with an id tiebreaker added to the serving query since same-batch rows can share a createdAt timestamp. Also expands the bundled Criterion catalog from 60 to 137 titles. Co-Authored-By: Claude Sonnet 5 --- data/criterion-catalog.json | 79 ++++++++++++++++++++++++++++++++++++- src/lib/match-night.ts | 13 +++++- tests/match-night.test.ts | 37 ++++++++++++++++- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/data/criterion-catalog.json b/data/criterion-catalog.json index ba8d51f..92be5ec 100644 --- a/data/criterion-catalog.json +++ b/data/criterion-catalog.json @@ -58,5 +58,82 @@ { "title": "The Lady Eve", "year": 1941 }, { "title": "Sweet Smell of Success", "year": 1957 }, { "title": "Rififi", "year": 1955 }, - { "title": "Charade", "year": 1963 } + { "title": "Charade", "year": 1963 }, + { "title": "Black Narcissus", "year": 1947 }, + { "title": "The Red Shoes", "year": 1948 }, + { "title": "A Matter of Life and Death", "year": 1946 }, + { "title": "I Know Where I'm Going!", "year": 1945 }, + { "title": "The Battle of Algiers", "year": 1966 }, + { "title": "Salo, or the 120 Days of Sodom", "year": 1975 }, + { "title": "Ordet", "year": 1955 }, + { "title": "Day of Wrath", "year": 1943 }, + { "title": "Andrei Rublev", "year": 1966 }, + { "title": "Solaris", "year": 1972 }, + { "title": "Stalker", "year": 1979 }, + { "title": "Mirror", "year": 1975 }, + { "title": "Come and See", "year": 1985 }, + { "title": "Diary of a Country Priest", "year": 1951 }, + { "title": "Au Hasard Balthazar", "year": 1966 }, + { "title": "Mouchette", "year": 1967 }, + { "title": "Pickpocket", "year": 1959 }, + { "title": "L'Argent", "year": 1983 }, + { "title": "A Man Escaped", "year": 1956 }, + { "title": "Grand Illusion", "year": 1937 }, + { "title": "Elevator to the Gallows", "year": 1958 }, + { "title": "My Dinner with Andre", "year": 1981 }, + { "title": "Withnail and I", "year": 1987 }, + { "title": "Local Hero", "year": 1983 }, + { "title": "Europa", "year": 1991 }, + { "title": "The Vanishing", "year": 1988 }, + { "title": "The Discreet Charm of the Bourgeoisie", "year": 1972 }, + { "title": "Belle de Jour", "year": 1967 }, + { "title": "That Obscure Object of Desire", "year": 1977 }, + { "title": "Los Olvidados", "year": 1950 }, + { "title": "Viridiana", "year": 1961 }, + { "title": "The Exterminating Angel", "year": 1962 }, + { "title": "Simon of the Desert", "year": 1965 }, + { "title": "Pather Panchali", "year": 1955 }, + { "title": "Aparajito", "year": 1956 }, + { "title": "The World of Apu", "year": 1959 }, + { "title": "Charulata", "year": 1964 }, + { "title": "Days of Heaven", "year": 1978 }, + { "title": "Modern Times", "year": 1936 }, + { "title": "City Lights", "year": 1931 }, + { "title": "The Gold Rush", "year": 1925 }, + { "title": "The Great Dictator", "year": 1940 }, + { "title": "The Circus", "year": 1928 }, + { "title": "Sherlock Jr.", "year": 1924 }, + { "title": "The General", "year": 1926 }, + { "title": "Sunrise: A Song of Two Humans", "year": 1927 }, + { "title": "The Passion of Joan of Arc", "year": 1928 }, + { "title": "Vampyr", "year": 1932 }, + { "title": "L'Atalante", "year": 1934 }, + { "title": "Zero for Conduct", "year": 1933 }, + { "title": "The Phantom Carriage", "year": 1921 }, + { "title": "Haxan", "year": 1922 }, + { "title": "Nosferatu", "year": 1922 }, + { "title": "The Cabinet of Dr. Caligari", "year": 1920 }, + { "title": "Pandora's Box", "year": 1929 }, + { "title": "Wages of Fear", "year": 1953 }, + { "title": "Le Cercle Rouge", "year": 1970 }, + { "title": "Le Doulos", "year": 1962 }, + { "title": "Bob le Flambeur", "year": 1956 }, + { "title": "Army of Shadows", "year": 1969 }, + { "title": "The Umbrellas of Cherbourg", "year": 1964 }, + { "title": "Jules and Jim", "year": 1962 }, + { "title": "Shoot the Piano Player", "year": 1960 }, + { "title": "Vivre Sa Vie", "year": 1962 }, + { "title": "Alphaville", "year": 1965 }, + { "title": "Band of Outsiders", "year": 1964 }, + { "title": "Cure", "year": 1997 }, + { "title": "Godzilla", "year": 1954 }, + { "title": "Big Deal on Madonna Street", "year": 1958 }, + { "title": "Divorce Italian Style", "year": 1961 }, + { "title": "Rocco and His Brothers", "year": 1960 }, + { "title": "The Leopard", "year": 1963 }, + { "title": "Umberto D.", "year": 1952 }, + { "title": "Bicycle Thieves", "year": 1948 }, + { "title": "Rome, Open City", "year": 1945 }, + { "title": "Paisan", "year": 1946 }, + { "title": "The Conformist", "year": 1970 } ] diff --git a/src/lib/match-night.ts b/src/lib/match-night.ts index 1909b49..d2e3627 100644 --- a/src/lib/match-night.ts +++ b/src/lib/match-night.ts @@ -18,6 +18,15 @@ interface NewCandidate extends TmdbMovieDetails { source: 'criterion' | 'tmdb' } +function shuffle(items: T[]): T[] { + const result = [...items] + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[result[i], result[j]] = [result[j], result[i]] + } + return result +} + async function getCursor(key: string): Promise { const row = await prisma.setting.findUnique({ where: { key } }) return row ? parseInt(row.value, 10) || 0 : 0 @@ -81,7 +90,7 @@ export async function refillCandidates(): Promise { await setCursor(TMDB_PAGE_CURSOR_KEY, page) if (toInsert.length > 0) { - await prisma.swipeCandidate.createMany({ data: toInsert }) + await prisma.swipeCandidate.createMany({ data: shuffle(toInsert) }) } return toInsert.length } @@ -100,7 +109,7 @@ export async function getNextCandidateForUser(user: User): Promise { }) }) + it('interleaves criterion and tmdb candidates instead of blocking them by source', async () => { + vi.mocked(getCriterionCatalog).mockReturnValue([ + { title: 'Criterion A' }, { title: 'Criterion B' }, { title: 'Criterion C' }, + { title: 'Criterion D' }, { title: 'Criterion E' }, + ]) + vi.mocked(searchByTitle) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 1 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 2 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 3 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 4 })) + .mockResolvedValueOnce(tmdbDetails({ tmdbId: 5 })) + vi.mocked(fetchPopularMovies).mockResolvedValue([ + tmdbDetails({ tmdbId: 101 }), tmdbDetails({ tmdbId: 102 }), tmdbDetails({ tmdbId: 103 }), + tmdbDetails({ tmdbId: 104 }), tmdbDetails({ tmdbId: 105 }), + ]) + + // Deterministic Fisher-Yates: forcing every draw to 0 still produces a + // real permutation (not the identity), so this exercises real shuffle + // logic without relying on chance to avoid a flaky assertion. + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + try { + await refillCandidates() + } finally { + randomSpy.mockRestore() + } + + const inserted = vi.mocked(prisma.swipeCandidate.createMany).mock.calls[0][0].data as Array<{ source: string }> + expect(inserted).toHaveLength(10) + // Insertion order shouldn't be strictly "all criterion, then all tmdb" — + // some tmdb candidate must appear before the last criterion candidate. + const lastCriterionIndex = inserted.map((c) => c.source).lastIndexOf('criterion') + const firstTmdbIndex = inserted.map((c) => c.source).indexOf('tmdb') + expect(firstTmdbIndex).toBeLessThan(lastCriterionIndex) + }) + it('persists the TMDB popular page cursor after a refill', async () => { vi.mocked(getCriterionCatalog).mockReturnValue([]) vi.mocked(prisma.setting.findUnique).mockResolvedValue({ key: 'match_night_tmdb_popular_page', value: '3' } as any) @@ -142,7 +177,7 @@ describe('getNextCandidateForUser', () => { }) expect(prisma.swipeCandidate.findFirst).toHaveBeenCalledWith({ where: { status: 'pending', swipes: { none: { user: 'user1' } } }, - orderBy: { createdAt: 'asc' }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], }) expect(getCriterionCatalog).not.toHaveBeenCalled() }) From 20e9dc4f62755dadef23748bde291416d0ffa507 Mon Sep 17 00:00:00 2001 From: Ian Chesal Date: Wed, 22 Jul 2026 23:43:28 -0400 Subject: [PATCH 20/20] Replace hand-curated Criterion catalog with the full public dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit criterion.com blocks server-side scraping (Cloudflare), so the catalog was hand-compiled from memory (137 titles) — necessarily a small, imperfect subset. Replaced it with a cleaned, deduped export from the arrismo/criterioncollection GitHub dataset (spine/year/ title/director for the whole collection), keeping only single-film entries with a parseable year (1555 films) and dropping box-set/ anthology entries, which lack a year and wouldn't resolve to a single TMDB movie via searchByTitle anyway. Source: https://github.com/arrismo/criterioncollection Co-Authored-By: Claude Sonnet 5 --- data/criterion-catalog.json | 6357 ++++++++++++++++++++++++++++++++++- 1 file changed, 6220 insertions(+), 137 deletions(-) diff --git a/data/criterion-catalog.json b/data/criterion-catalog.json index 92be5ec..902b4f3 100644 --- a/data/criterion-catalog.json +++ b/data/criterion-catalog.json @@ -1,139 +1,6222 @@ [ - { "title": "Seven Samurai", "year": 1954 }, - { "title": "Rashomon", "year": 1950 }, - { "title": "Ikiru", "year": 1952 }, - { "title": "Yojimbo", "year": 1961 }, - { "title": "Sanjuro", "year": 1962 }, - { "title": "High and Low", "year": 1963 }, - { "title": "The Hidden Fortress", "year": 1958 }, - { "title": "Ran", "year": 1985 }, - { "title": "Kagemusha", "year": 1980 }, - { "title": "The Seventh Seal", "year": 1957 }, - { "title": "Persona", "year": 1966 }, - { "title": "Wild Strawberries", "year": 1957 }, - { "title": "Fanny and Alexander", "year": 1982 }, - { "title": "8 1/2", "year": 1963 }, - { "title": "La Strada", "year": 1954 }, - { "title": "Nights of Cabiria", "year": 1957 }, - { "title": "Amarcord", "year": 1973 }, - { "title": "L'Avventura", "year": 1960 }, - { "title": "The 400 Blows", "year": 1959 }, - { "title": "Breathless", "year": 1960 }, - { "title": "Contempt", "year": 1963 }, - { "title": "Playtime", "year": 1967 }, - { "title": "Mon Oncle", "year": 1958 }, - { "title": "Le Samourai", "year": 1967 }, - { "title": "Diabolique", "year": 1955 }, - { "title": "The Third Man", "year": 1949 }, - { "title": "Rebecca", "year": 1940 }, - { "title": "M", "year": 1931 }, - { "title": "Metropolis", "year": 1927 }, - { "title": "Beauty and the Beast", "year": 1946 }, - { "title": "Orpheus", "year": 1950 }, - { "title": "Tokyo Story", "year": 1953 }, - { "title": "Late Spring", "year": 1949 }, - { "title": "Ugetsu", "year": 1953 }, - { "title": "Sansho the Bailiff", "year": 1954 }, - { "title": "Harakiri", "year": 1962 }, - { "title": "Onibaba", "year": 1964 }, - { "title": "Woman in the Dunes", "year": 1964 }, - { "title": "Kwaidan", "year": 1964 }, - { "title": "In the Mood for Love", "year": 2000 }, - { "title": "Chungking Express", "year": 1994 }, - { "title": "Branded to Kill", "year": 1967 }, - { "title": "Tokyo Drifter", "year": 1966 }, - { "title": "House", "year": 1977 }, - { "title": "The Killing", "year": 1956 }, - { "title": "Paths of Glory", "year": 1957 }, - { "title": "Dr. Strangelove", "year": 1964 }, - { "title": "Rosemary's Baby", "year": 1968 }, - { "title": "Repo Man", "year": 1984 }, - { "title": "The Man Who Fell to Earth", "year": 1976 }, - { "title": "Blood Simple", "year": 1984 }, - { "title": "Do the Right Thing", "year": 1989 }, - { "title": "Malcolm X", "year": 1992 }, - { "title": "Hoop Dreams", "year": 1994 }, - { "title": "Chimes at Midnight", "year": 1965 }, - { "title": "Sullivan's Travels", "year": 1941 }, - { "title": "The Lady Eve", "year": 1941 }, - { "title": "Sweet Smell of Success", "year": 1957 }, - { "title": "Rififi", "year": 1955 }, - { "title": "Charade", "year": 1963 }, - { "title": "Black Narcissus", "year": 1947 }, - { "title": "The Red Shoes", "year": 1948 }, - { "title": "A Matter of Life and Death", "year": 1946 }, - { "title": "I Know Where I'm Going!", "year": 1945 }, - { "title": "The Battle of Algiers", "year": 1966 }, - { "title": "Salo, or the 120 Days of Sodom", "year": 1975 }, - { "title": "Ordet", "year": 1955 }, - { "title": "Day of Wrath", "year": 1943 }, - { "title": "Andrei Rublev", "year": 1966 }, - { "title": "Solaris", "year": 1972 }, - { "title": "Stalker", "year": 1979 }, - { "title": "Mirror", "year": 1975 }, - { "title": "Come and See", "year": 1985 }, - { "title": "Diary of a Country Priest", "year": 1951 }, - { "title": "Au Hasard Balthazar", "year": 1966 }, - { "title": "Mouchette", "year": 1967 }, - { "title": "Pickpocket", "year": 1959 }, - { "title": "L'Argent", "year": 1983 }, - { "title": "A Man Escaped", "year": 1956 }, - { "title": "Grand Illusion", "year": 1937 }, - { "title": "Elevator to the Gallows", "year": 1958 }, - { "title": "My Dinner with Andre", "year": 1981 }, - { "title": "Withnail and I", "year": 1987 }, - { "title": "Local Hero", "year": 1983 }, - { "title": "Europa", "year": 1991 }, - { "title": "The Vanishing", "year": 1988 }, - { "title": "The Discreet Charm of the Bourgeoisie", "year": 1972 }, - { "title": "Belle de Jour", "year": 1967 }, - { "title": "That Obscure Object of Desire", "year": 1977 }, - { "title": "Los Olvidados", "year": 1950 }, - { "title": "Viridiana", "year": 1961 }, - { "title": "The Exterminating Angel", "year": 1962 }, - { "title": "Simon of the Desert", "year": 1965 }, - { "title": "Pather Panchali", "year": 1955 }, - { "title": "Aparajito", "year": 1956 }, - { "title": "The World of Apu", "year": 1959 }, - { "title": "Charulata", "year": 1964 }, - { "title": "Days of Heaven", "year": 1978 }, - { "title": "Modern Times", "year": 1936 }, - { "title": "City Lights", "year": 1931 }, - { "title": "The Gold Rush", "year": 1925 }, - { "title": "The Great Dictator", "year": 1940 }, - { "title": "The Circus", "year": 1928 }, - { "title": "Sherlock Jr.", "year": 1924 }, - { "title": "The General", "year": 1926 }, - { "title": "Sunrise: A Song of Two Humans", "year": 1927 }, - { "title": "The Passion of Joan of Arc", "year": 1928 }, - { "title": "Vampyr", "year": 1932 }, - { "title": "L'Atalante", "year": 1934 }, - { "title": "Zero for Conduct", "year": 1933 }, - { "title": "The Phantom Carriage", "year": 1921 }, - { "title": "Haxan", "year": 1922 }, - { "title": "Nosferatu", "year": 1922 }, - { "title": "The Cabinet of Dr. Caligari", "year": 1920 }, - { "title": "Pandora's Box", "year": 1929 }, - { "title": "Wages of Fear", "year": 1953 }, - { "title": "Le Cercle Rouge", "year": 1970 }, - { "title": "Le Doulos", "year": 1962 }, - { "title": "Bob le Flambeur", "year": 1956 }, - { "title": "Army of Shadows", "year": 1969 }, - { "title": "The Umbrellas of Cherbourg", "year": 1964 }, - { "title": "Jules and Jim", "year": 1962 }, - { "title": "Shoot the Piano Player", "year": 1960 }, - { "title": "Vivre Sa Vie", "year": 1962 }, - { "title": "Alphaville", "year": 1965 }, - { "title": "Band of Outsiders", "year": 1964 }, - { "title": "Cure", "year": 1997 }, - { "title": "Godzilla", "year": 1954 }, - { "title": "Big Deal on Madonna Street", "year": 1958 }, - { "title": "Divorce Italian Style", "year": 1961 }, - { "title": "Rocco and His Brothers", "year": 1960 }, - { "title": "The Leopard", "year": 1963 }, - { "title": "Umberto D.", "year": 1952 }, - { "title": "Bicycle Thieves", "year": 1948 }, - { "title": "Rome, Open City", "year": 1945 }, - { "title": "Paisan", "year": 1946 }, - { "title": "The Conformist", "year": 1970 } + { + "title": ". . . And the Pursuit of Happiness", + "year": 1986 + }, + { + "title": "12 Angry Men", + "year": 1957 + }, + { + "title": "1984", + "year": 1984 + }, + { + "title": "2 or 3 Things I Know About Her", + "year": 1967 + }, + { + "title": "2046", + "year": 2004 + }, + { + "title": "24 Frames", + "year": 2017 + }, + { + "title": "3 Women", + "year": 1977 + }, + { + "title": "3:10 to Yuma", + "year": 1957 + }, + { + "title": "4 Months, 3 Weeks and 2 Days", + "year": 2007 + }, + { + "title": "45 Years", + "year": 2015 + }, + { + "title": "49th Parallel", + "year": 1941 + }, + { + "title": "71 Fragments of a Chronology of Chance", + "year": 1994 + }, + { + "title": "8½", + "year": 1963 + }, + { + "title": "A Brief History of Time", + "year": 1991 + }, + { + "title": "A Brighter Summer Day", + "year": 1991 + }, + { + "title": "A Canterbury Tale", + "year": 1944 + }, + { + "title": "A Christmas Tale", + "year": 2008 + }, + { + "title": "A Colt Is My Passport", + "year": 1967 + }, + { + "title": "A Constant Forge", + "year": 2000 + }, + { + "title": "A Day in the Country", + "year": 1936 + }, + { + "title": "A Dry White Season", + "year": 1989 + }, + { + "title": "A Face in the Crowd", + "year": 1957 + }, + { + "title": "A Generation", + "year": 1955 + }, + { + "title": "A Hard Day’s Night", + "year": 1964 + }, + { + "title": "A Lesson in Love", + "year": 1954 + }, + { + "title": "A Man Escaped", + "year": 1956 + }, + { + "title": "A Married Couple", + "year": 1969 + }, + { + "title": "A Master Builder", + "year": 2014 + }, + { + "title": "A Matter of Life and Death", + "year": 1946 + }, + { + "title": "A Night to Remember", + "year": 1958 + }, + { + "title": "A Poem Is a Naked Person", + "year": 1974 + }, + { + "title": "A Raisin in the Sun", + "year": 1961 + }, + { + "title": "A Report on the Party and Guests", + "year": 1966 + }, + { + "title": "A River Called Titas", + "year": 1973 + }, + { + "title": "A Room with a View", + "year": 1986 + }, + { + "title": "A Safe Place", + "year": 1971 + }, + { + "title": "A Ship to India", + "year": 1947 + }, + { + "title": "A Special Day", + "year": 1977 + }, + { + "title": "A Story from Chikamatsu", + "year": 1954 + }, + { + "title": "A Story of Floating Weeds", + "year": 1934 + }, + { + "title": "A Tale of Autumn", + "year": 1998 + }, + { + "title": "A Tale of Springtime", + "year": 1990 + }, + { + "title": "A Tale of Summer", + "year": 1996 + }, + { + "title": "A Tale of Winter", + "year": 1992 + }, + { + "title": "A Taste of Honey", + "year": 1961 + }, + { + "title": "A Touch of Zen", + "year": 1971 + }, + { + "title": "A Woman Is a Woman", + "year": 1961 + }, + { + "title": "A Woman Under the Influence", + "year": 1974 + }, + { + "title": "A Woman’s Face", + "year": 1938 + }, + { + "title": "About Dry Grasses", + "year": 2023 + }, + { + "title": "Accattone", + "year": 1961 + }, + { + "title": "Ace in the Hole", + "year": 1951 + }, + { + "title": "Adoption", + "year": 1975 + }, + { + "title": "Adventures of Zatoichi", + "year": 1964 + }, + { + "title": "Affirmations", + "year": 1990 + }, + { + "title": "Afire", + "year": 2023 + }, + { + "title": "After Hours", + "year": 1985 + }, + { + "title": "After Life", + "year": 1998 + }, + { + "title": "After the Curfew", + "year": 1954 + }, + { + "title": "After the Rehearsal", + "year": 1984 + }, + { + "title": "Agnès de ci de là Varda", + "year": 2011 + }, + { + "title": "Akira Kurosawa’s Dreams", + "year": 1990 + }, + { + "title": "Alex Wheatle", + "year": 2020 + }, + { + "title": "Alexander Nevsky", + "year": 1938 + }, + { + "title": "Ali: Fear Eats the Soul", + "year": 1974 + }, + { + "title": "Alice in the Cities", + "year": 1974 + }, + { + "title": "All About Eve", + "year": 1950 + }, + { + "title": "All About My Mother", + "year": 1999 + }, + { + "title": "All Monsters Attack", + "year": 1969 + }, + { + "title": "All Night Long", + "year": 1962 + }, + { + "title": "All of Us Strangers", + "year": 2023 + }, + { + "title": "All That Breathes", + "year": 2022 + }, + { + "title": "All That Heaven Allows", + "year": 1955 + }, + { + "title": "All That Jazz", + "year": 1979 + }, + { + "title": "All That Money Can Buy (a.k.a. The Devil and Daniel Webster)", + "year": 1941 + }, + { + "title": "All the Beauty and the Bloodshed", + "year": 2022 + }, + { + "title": "All These Women", + "year": 1964 + }, + { + "title": "Alphaville", + "year": 1965 + }, + { + "title": "Amarcord", + "year": 1973 + }, + { + "title": "Amores perros", + "year": 2000 + }, + { + "title": "An Actor’s Revenge", + "year": 1963 + }, + { + "title": "An Angel at My Table", + "year": 1990 + }, + { + "title": "An Autumn Afternoon", + "year": 1962 + }, + { + "title": "An Enemy of the People", + "year": 1989 + }, + { + "title": "An Unmarried Woman", + "year": 1978 + }, + { + "title": "Anatomy of a Fall", + "year": 2023 + }, + { + "title": "Anatomy of a Murder", + "year": 1959 + }, + { + "title": "And Everything Is Going Fine", + "year": 2010 + }, + { + "title": "And God Created Woman", + "year": 1956 + }, + { + "title": "And Life Goes On", + "year": 1992 + }, + { + "title": "And the Ship Sails On", + "year": 1983 + }, + { + "title": "Andrei Rublev", + "year": 1966 + }, + { + "title": "Androcles and the Lion", + "year": 1952 + }, + { + "title": "Anselm", + "year": 2023 + }, + { + "title": "Anthem", + "year": 1991 + }, + { + "title": "Antichrist", + "year": 2009 + }, + { + "title": "Antonio Gaudí", + "year": 1984 + }, + { + "title": "Aparajito", + "year": 1956 + }, + { + "title": "Apart from You", + "year": 1933 + }, + { + "title": "Apur Sansar", + "year": 1959 + }, + { + "title": "Arabian Nights", + "year": 1974 + }, + { + "title": "Ariel", + "year": 1988 + }, + { + "title": "Armageddon", + "year": 1998 + }, + { + "title": "Army", + "year": 1944 + }, + { + "title": "Army of Shadows", + "year": 1969 + }, + { + "title": "Arsenic and Old Lace", + "year": 1944 + }, + { + "title": "As Long as You’ve Got Your Health", + "year": 1966 + }, + { + "title": "As Tears Go By", + "year": 1988 + }, + { + "title": "Ashes and Diamonds", + "year": 1958 + }, + { + "title": "Au hasard Balthazar", + "year": 1966 + }, + { + "title": "Au revoir les enfants", + "year": 1987 + }, + { + "title": "Autumn Sonata", + "year": 1978 + }, + { + "title": "Baal", + "year": 1970 + }, + { + "title": "Babette’s Feast", + "year": 1987 + }, + { + "title": "Babo 73", + "year": 1964 + }, + { + "title": "Bad Timing", + "year": 1980 + }, + { + "title": "Badlands", + "year": 1973 + }, + { + "title": "Ballad of a Soldier", + "year": 1959 + }, + { + "title": "Bamboozled", + "year": 2000 + }, + { + "title": "Band of Outsiders", + "year": 1964 + }, + { + "title": "Barcelona", + "year": 1994 + }, + { + "title": "Barry Lyndon", + "year": 1975 + }, + { + "title": "Baxter, Vera Baxter", + "year": 1977 + }, + { + "title": "Bay of Angels", + "year": 1963 + }, + { + "title": "Beastie Boys Video Anthology", + "year": 2000 + }, + { + "title": "Beasts of No Nation", + "year": 2015 + }, + { + "title": "Beau travail", + "year": 1999 + }, + { + "title": "Beauty and the Beast", + "year": 1946 + }, + { + "title": "Bed and Board", + "year": 1970 + }, + { + "title": "Before Midnight", + "year": 2013 + }, + { + "title": "Before Sunrise", + "year": 1995 + }, + { + "title": "Before Sunset", + "year": 2004 + }, + { + "title": "Before the Rain", + "year": 1994 + }, + { + "title": "Being John Malkovich", + "year": 1999 + }, + { + "title": "Being There", + "year": 1979 + }, + { + "title": "Belle de jour", + "year": 1967 + }, + { + "title": "Benny’s Video", + "year": 1992 + }, + { + "title": "Bergman Island", + "year": 2006 + }, + { + "title": "Berlin Alexanderplatz", + "year": 1980 + }, + { + "title": "Betty Blue", + "year": 1986 + }, + { + "title": "Beware of a Holy Whore", + "year": 1970 + }, + { + "title": "Beyond the Hills", + "year": 2012 + }, + { + "title": "Beyond the Law", + "year": 1968 + }, + { + "title": "Beyond the Valley of the Dolls", + "year": 1970 + }, + { + "title": "Bicycle Thieves", + "year": 1948 + }, + { + "title": "Big Deal on Madonna Street", + "year": 1958 + }, + { + "title": "Bigger Than Life", + "year": 1956 + }, + { + "title": "Billy Liar", + "year": 1963 + }, + { + "title": "Bim, the Little Donkey", + "year": 1951 + }, + { + "title": "Bitter Rice", + "year": 1949 + }, + { + "title": "Black Girl", + "year": 1966 + }, + { + "title": "Black God, White Devil", + "year": 1964 + }, + { + "title": "Black Is . . . Black Ain’t", + "year": 1995 + }, + { + "title": "Black Moon", + "year": 1975 + }, + { + "title": "Black Narcissus", + "year": 1947 + }, + { + "title": "Black Orpheus", + "year": 1959 + }, + { + "title": "Black Panthers", + "year": 1970 + }, + { + "title": "Black River", + "year": 1956 + }, + { + "title": "Black Sun", + "year": 1964 + }, + { + "title": "Blaise Pascal", + "year": 1972 + }, + { + "title": "Blast of Silence", + "year": 1961 + }, + { + "title": "Blind Chance", + "year": 1981 + }, + { + "title": "Blithe Spirit", + "year": 1945 + }, + { + "title": "Blonde Venus", + "year": 1932 + }, + { + "title": "Blood for Dracula", + "year": 1974 + }, + { + "title": "Blood Simple", + "year": 1984 + }, + { + "title": "Blood Wedding", + "year": 1981 + }, + { + "title": "Blow Out", + "year": 1981 + }, + { + "title": "Blow-Up", + "year": 1966 + }, + { + "title": "Blue Is the Warmest Color", + "year": 2013 + }, + { + "title": "Blue Velvet", + "year": 1986 + }, + { + "title": "Boat People", + "year": 1982 + }, + { + "title": "Bob le flambeur", + "year": 1956 + }, + { + "title": "Body and Soul", + "year": 1925 + }, + { + "title": "Border Radio", + "year": 1987 + }, + { + "title": "Borderline", + "year": 1930 + }, + { + "title": "Bottle Rocket", + "year": 1996 + }, + { + "title": "Boudu Saved from Drowning", + "year": 1932 + }, + { + "title": "Bound", + "year": 1996 + }, + { + "title": "Bowling for Columbine", + "year": 2002 + }, + { + "title": "Boyhood", + "year": 2014 + }, + { + "title": "Brand upon the Brain!", + "year": 2006 + }, + { + "title": "Branded to Kill", + "year": 1967 + }, + { + "title": "Brazil", + "year": 1985 + }, + { + "title": "Breaker Morant", + "year": 1980 + }, + { + "title": "Breaking the Waves", + "year": 1996 + }, + { + "title": "Breathless", + "year": 1960 + }, + { + "title": "Brief Encounter", + "year": 1945 + }, + { + "title": "Brief Encounters", + "year": 1967 + }, + { + "title": "Bringing Up Baby", + "year": 1938 + }, + { + "title": "Brink of Life", + "year": 1958 + }, + { + "title": "Broadcast News", + "year": 1987 + }, + { + "title": "Brute Force", + "year": 1947 + }, + { + "title": "Buchanan Rides Alone", + "year": 1958 + }, + { + "title": "Buck and the Preacher", + "year": 1972 + }, + { + "title": "Buena Vista Social Club", + "year": 1999 + }, + { + "title": "Bull Durham", + "year": 1988 + }, + { + "title": "Burden of Dreams", + "year": 1982 + }, + { + "title": "Burroughs: The Movie", + "year": 1983 + }, + { + "title": "Caesar and Cleopatra", + "year": 1945 + }, + { + "title": "Calcutta", + "year": 1969 + }, + { + "title": "Cameraperson", + "year": 2016 + }, + { + "title": "Canoa: A Shameful Memory", + "year": 1976 + }, + { + "title": "Capricious Summer", + "year": 1968 + }, + { + "title": "Carl Th. Dreyer—My Metier", + "year": 1995 + }, + { + "title": "Carlos", + "year": 2010 + }, + { + "title": "Carmen", + "year": 1983 + }, + { + "title": "Carnival of Souls", + "year": 1962 + }, + { + "title": "Cartesius", + "year": 1974 + }, + { + "title": "Casque d’or", + "year": 1952 + }, + { + "title": "Cat People", + "year": 1942 + }, + { + "title": "Ceddo", + "year": 1977 + }, + { + "title": "Certain Women", + "year": 2016 + }, + { + "title": "Certified Copy", + "year": 2010 + }, + { + "title": "Chafed Elbows", + "year": 1966 + }, + { + "title": "Chains", + "year": 1949 + }, + { + "title": "Chan Is Missing", + "year": 1982 + }, + { + "title": "Charade", + "year": 1963 + }, + { + "title": "Charulata", + "year": 1964 + }, + { + "title": "Chasing Amy", + "year": 1997 + }, + { + "title": "Che", + "year": 2008 + }, + { + "title": "Chess of the Wind", + "year": 1976 + }, + { + "title": "Children of Paradise", + "year": 1945 + }, + { + "title": "Chilly Scenes of Winter", + "year": 1979 + }, + { + "title": "Chimes at Midnight", + "year": 1966 + }, + { + "title": "Chop Shop", + "year": 2007 + }, + { + "title": "Christ Stopped at Eboli", + "year": 1979 + }, + { + "title": "Chronicle of a Summer", + "year": 1961 + }, + { + "title": "Chungking Express", + "year": 1994 + }, + { + "title": "Circus Angel", + "year": 1965 + }, + { + "title": "Citizen Kane", + "year": 1941 + }, + { + "title": "City Lights", + "year": 1931 + }, + { + "title": "Claire’s Knee", + "year": 1970 + }, + { + "title": "Classe tous risques", + "year": 1960 + }, + { + "title": "Claudine", + "year": 1974 + }, + { + "title": "Clean, Shaven", + "year": 1994 + }, + { + "title": "Close-up", + "year": 1990 + }, + { + "title": "Closely Watched Trains", + "year": 1966 + }, + { + "title": "Clouds of Sils Maria", + "year": 2014 + }, + { + "title": "Cluny Brown", + "year": 1946 + }, + { + "title": "Cléo from 5 to 7", + "year": 1962 + }, + { + "title": "Code Unknown", + "year": 2000 + }, + { + "title": "Cold War", + "year": 2018 + }, + { + "title": "Cold Water", + "year": 1994 + }, + { + "title": "Color Adjustment", + "year": 1992 + }, + { + "title": "Colossal Youth", + "year": 2006 + }, + { + "title": "Comanche Station", + "year": 1960 + }, + { + "title": "Come and See", + "year": 1985 + }, + { + "title": "Come On Children", + "year": 1972 + }, + { + "title": "Contempt", + "year": 1963 + }, + { + "title": "Cooley High", + "year": 1975 + }, + { + "title": "Corridors of Blood", + "year": 1959 + }, + { + "title": "Coup de grâce", + "year": 1976 + }, + { + "title": "Coup de torchon", + "year": 1981 + }, + { + "title": "Crash", + "year": 1996 + }, + { + "title": "Crazed Fruit", + "year": 1956 + }, + { + "title": "Cries and Whispers", + "year": 1972 + }, + { + "title": "Crisis", + "year": 1946 + }, + { + "title": "Cronos", + "year": 1993 + }, + { + "title": "Crossing Delancey", + "year": 1988 + }, + { + "title": "Cruel Gun Story", + "year": 1964 + }, + { + "title": "Crumb", + "year": 1995 + }, + { + "title": "Cría cuervos . . .", + "year": 1976 + }, + { + "title": "Cul-de-sac", + "year": 1966 + }, + { + "title": "Cure", + "year": 1997 + }, + { + "title": "Céline and Julie Go Boating", + "year": 1974 + }, + { + "title": "César", + "year": 1936 + }, + { + "title": "Daddy Longlegs", + "year": 2009 + }, + { + "title": "Daguerréotypes", + "year": 1975 + }, + { + "title": "Daisies", + "year": 1966 + }, + { + "title": "Dance, Girl, Dance", + "year": 1940 + }, + { + "title": "Danton", + "year": 1983 + }, + { + "title": "David Golder", + "year": 1930 + }, + { + "title": "David Lynch: The Art Life", + "year": 2016 + }, + { + "title": "Day for Night", + "year": 1973 + }, + { + "title": "Day of Wrath", + "year": 1943 + }, + { + "title": "Days of Being Wild", + "year": 1990 + }, + { + "title": "Days of Heaven", + "year": 1978 + }, + { + "title": "Dazed and Confused", + "year": 1993 + }, + { + "title": "Dead Man", + "year": 1995 + }, + { + "title": "Dead Ringers", + "year": 1988 + }, + { + "title": "Death by Hanging", + "year": 1968 + }, + { + "title": "Death in Venice", + "year": 1971 + }, + { + "title": "Death of a Cyclist", + "year": 1955 + }, + { + "title": "Decision at Sundown", + "year": 1957 + }, + { + "title": "Deep Cover", + "year": 1992 + }, + { + "title": "Defending Your Life", + "year": 1991 + }, + { + "title": "Dekalog", + "year": 1988 + }, + { + "title": "Demon Pond", + "year": 1979 + }, + { + "title": "Desert Hearts", + "year": 1985 + }, + { + "title": "Design for Living", + "year": 1933 + }, + { + "title": "Destroy All Monsters", + "year": 1968 + }, + { + "title": "Destry Rides Again", + "year": 1939 + }, + { + "title": "Detour", + "year": 1945 + }, + { + "title": "Devi", + "year": 1960 + }, + { + "title": "Devil in a Blue Dress", + "year": 1995 + }, + { + "title": "Dheepan", + "year": 2015 + }, + { + "title": "Diabolique", + "year": 1955 + }, + { + "title": "Diamonds of the Night", + "year": 1964 + }, + { + "title": "Diary of a Chambermaid", + "year": 1964 + }, + { + "title": "Diary of a Country Priest", + "year": 1951 + }, + { + "title": "Dick Johnson Is Dead", + "year": 2020 + }, + { + "title": "Dillinger Is Dead", + "year": 1969 + }, + { + "title": "Dim Sum: A Little Bit of Heart", + "year": 1985 + }, + { + "title": "Dishonored", + "year": 1931 + }, + { + "title": "Divorce Italian Style", + "year": 1961 + }, + { + "title": "Do the Right Thing", + "year": 1989 + }, + { + "title": "Documenteur", + "year": 1981 + }, + { + "title": "Dodes’ka-den", + "year": 1970 + }, + { + "title": "Dogfight", + "year": 1991 + }, + { + "title": "Dollar", + "year": 1938 + }, + { + "title": "Donkey Skin", + "year": 1970 + }, + { + "title": "Dont Look Back", + "year": 1967 + }, + { + "title": "Don’t Look Now", + "year": 1973 + }, + { + "title": "Don’t Play Us Cheap", + "year": 1972 + }, + { + "title": "Dos monjes", + "year": 1934 + }, + { + "title": "Double Indemnity", + "year": 1944 + }, + { + "title": "Double Suicide", + "year": 1969 + }, + { + "title": "Douce", + "year": 1943 + }, + { + "title": "Down by Law", + "year": 1986 + }, + { + "title": "Downhill Racer", + "year": 1969 + }, + { + "title": "Downpour", + "year": 1972 + }, + { + "title": "Dr. Strangelove, or: How I Learned to Stop Worrying and Love the Bomb", + "year": 1964 + }, + { + "title": "Dragnet Girl", + "year": 1933 + }, + { + "title": "Dragon Inn", + "year": 1967 + }, + { + "title": "Dreams", + "year": 1955 + }, + { + "title": "Dressed to Kill", + "year": 1980 + }, + { + "title": "Drive My Car", + "year": 2021 + }, + { + "title": "Drive, He Said", + "year": 1970 + }, + { + "title": "Drugstore Cowboy", + "year": 1989 + }, + { + "title": "Drunken Angel", + "year": 1948 + }, + { + "title": "Dry Summer", + "year": 1964 + }, + { + "title": "Drylongso", + "year": 1998 + }, + { + "title": "Dying at Grace", + "year": 2003 + }, + { + "title": "Désiré", + "year": 1937 + }, + { + "title": "Early Spring", + "year": 1956 + }, + { + "title": "Early Summer", + "year": 1951 + }, + { + "title": "Eastern Condors", + "year": 1987 + }, + { + "title": "Easy Rider", + "year": 1969 + }, + { + "title": "Eating Raoul", + "year": 1982 + }, + { + "title": "Ebirah, Horror of the Deep", + "year": 1966 + }, + { + "title": "Education", + "year": 2020 + }, + { + "title": "Eight Hours Don’t Make a Day", + "year": 1972 + }, + { + "title": "El amor brujo", + "year": 1986 + }, + { + "title": "El Norte", + "year": 1983 + }, + { + "title": "El Sur", + "year": 1983 + }, + { + "title": "Election", + "year": 1999 + }, + { + "title": "Elena and Her Men", + "year": 1956 + }, + { + "title": "Elephant Boy", + "year": 1937 + }, + { + "title": "Elevator to the Gallows", + "year": 1958 + }, + { + "title": "Elvira Madigan", + "year": 1967 + }, + { + "title": "Emitaï", + "year": 1971 + }, + { + "title": "Empire of Passion", + "year": 1978 + }, + { + "title": "Enter the Dragon", + "year": 1973 + }, + { + "title": "EO", + "year": 2022 + }, + { + "title": "Epidemic", + "year": 1987 + }, + { + "title": "Equinox", + "year": 1970 + }, + { + "title": "Equinox Flower", + "year": 1958 + }, + { + "title": "Eraserhead", + "year": 1977 + }, + { + "title": "Ethnic Notions", + "year": 1986 + }, + { + "title": "Europa", + "year": 1991 + }, + { + "title": "Europa Europa", + "year": 1990 + }, + { + "title": "Europe ’51", + "year": 1952 + }, + { + "title": "Everlasting Moments", + "year": 2008 + }, + { + "title": "Every Man for Himself", + "year": 1980 + }, + { + "title": "Every-Night Dreams", + "year": 1933 + }, + { + "title": "Eve’s Bayou", + "year": 1997 + }, + { + "title": "Evil Does Not Exist", + "year": 2023 + }, + { + "title": "Executioners", + "year": 1993 + }, + { + "title": "Exotica", + "year": 1994 + }, + { + "title": "Eyes Without a Face", + "year": 1960 + }, + { + "title": "Eyimofe (This Is My Desire)", + "year": 2020 + }, + { + "title": "F for Fake", + "year": 1975 + }, + { + "title": "Faces", + "year": 1968 + }, + { + "title": "Faces Places", + "year": 2017 + }, + { + "title": "Fail Safe", + "year": 1964 + }, + { + "title": "Fallen Angels", + "year": 1995 + }, + { + "title": "Fanfan la Tulipe", + "year": 1952 + }, + { + "title": "Fanny", + "year": 1932 + }, + { + "title": "Fanny and Alexander: Television Version", + "year": 1983 + }, + { + "title": "Fanny and Alexander: Theatrical Version", + "year": 1982 + }, + { + "title": "Fantastic Mr. Fox", + "year": 2009 + }, + { + "title": "Fantastic Planet", + "year": 1973 + }, + { + "title": "Farewell Amor", + "year": 2020 + }, + { + "title": "Farewell My Concubine", + "year": 1993 + }, + { + "title": "Fast Times at Ridgemont High", + "year": 1982 + }, + { + "title": "Fat Girl", + "year": 2001 + }, + { + "title": "Faya dayi", + "year": 2021 + }, + { + "title": "Fear and Loathing in Las Vegas", + "year": 1998 + }, + { + "title": "Fearless Hyena II", + "year": 1983 + }, + { + "title": "Fellini Satyricon", + "year": 1969 + }, + { + "title": "Female Trouble", + "year": 1974 + }, + { + "title": "Festival", + "year": 1967 + }, + { + "title": "Fiend Without a Face", + "year": 1958 + }, + { + "title": "Fight, Zatoichi, Fight", + "year": 1964 + }, + { + "title": "Fighting Elegy", + "year": 1966 + }, + { + "title": "Fires on the Plain", + "year": 1959 + }, + { + "title": "First Man into Space", + "year": 1959 + }, + { + "title": "Fish Tank", + "year": 2009 + }, + { + "title": "Fishing with John", + "year": 1992 + }, + { + "title": "Fist of Fury", + "year": 1972 + }, + { + "title": "Fists in the Pocket", + "year": 1965 + }, + { + "title": "Five Easy Pieces", + "year": 1970 + }, + { + "title": "Flesh for Frankenstein", + "year": 1973 + }, + { + "title": "Floating Weeds", + "year": 1959 + }, + { + "title": "Flowers of Shanghai", + "year": 1998 + }, + { + "title": "Flunky, Work Hard", + "year": 1931 + }, + { + "title": "Following", + "year": 1999 + }, + { + "title": "For All Mankind", + "year": 1989 + }, + { + "title": "Forbidden Games", + "year": 1952 + }, + { + "title": "Foreign Correspondent", + "year": 1940 + }, + { + "title": "Forty Guns", + "year": 1957 + }, + { + "title": "Fox and His Friends", + "year": 1975 + }, + { + "title": "Frances Ha", + "year": 2013 + }, + { + "title": "Freaks", + "year": 1932 + }, + { + "title": "French Cancan", + "year": 1955 + }, + { + "title": "From the Life of the Marionettes", + "year": 1980 + }, + { + "title": "Frownland", + "year": 2007 + }, + { + "title": "Funny Games", + "year": 1997 + }, + { + "title": "Funny Girl", + "year": 1968 + }, + { + "title": "Fårö Document", + "year": 1970 + }, + { + "title": "Fårö Document 1979", + "year": 1979 + }, + { + "title": "Game of Death", + "year": 1978 + }, + { + "title": "Gate of Flesh", + "year": 1964 + }, + { + "title": "Gate of Hell", + "year": 1953 + }, + { + "title": "Gates of Heaven", + "year": 1978 + }, + { + "title": "General Idi Amin Dada: A Self-Portrait", + "year": 1974 + }, + { + "title": "Genocide", + "year": 1968 + }, + { + "title": "George Washington", + "year": 2000 + }, + { + "title": "Germany Year Zero", + "year": 1948 + }, + { + "title": "Gertrud", + "year": 1964 + }, + { + "title": "Gervaise", + "year": 1956 + }, + { + "title": "Ghidorah, the Three-Headed Monster", + "year": 1964 + }, + { + "title": "Ghost Dog: The Way of the Samurai", + "year": 1999 + }, + { + "title": "Ghost World", + "year": 2001 + }, + { + "title": "Gilda", + "year": 1946 + }, + { + "title": "Gimme Shelter", + "year": 1970 + }, + { + "title": "Girlfight", + "year": 2000 + }, + { + "title": "Girlfriends", + "year": 1978 + }, + { + "title": "Godland", + "year": 2022 + }, + { + "title": "Gods of the Plague", + "year": 1969 + }, + { + "title": "Godzilla", + "year": 1954 + }, + { + "title": "Godzilla Raids Again", + "year": 1955 + }, + { + "title": "Godzilla vs. Gigan", + "year": 1972 + }, + { + "title": "Godzilla vs. Hedorah", + "year": 1971 + }, + { + "title": "Godzilla vs. Mechagodzilla", + "year": 1974 + }, + { + "title": "Godzilla vs. Megalon", + "year": 1973 + }, + { + "title": "God’s Country", + "year": 1985 + }, + { + "title": "Goke, Body Snatcher from Hell", + "year": 1968 + }, + { + "title": "Gomorrah", + "year": 2008 + }, + { + "title": "Good Morning", + "year": 1959 + }, + { + "title": "Graduation", + "year": 2016 + }, + { + "title": "Grand Illusion", + "year": 1937 + }, + { + "title": "Gray’s Anatomy", + "year": 1997 + }, + { + "title": "Great Expectations", + "year": 1946 + }, + { + "title": "Green for Danger", + "year": 1946 + }, + { + "title": "Grey Gardens", + "year": 1976 + }, + { + "title": "Guillermo del Toro’s Pinocchio", + "year": 2022 + }, + { + "title": "Gummo", + "year": 1997 + }, + { + "title": "Half a Loaf of Kung Fu", + "year": 1978 + }, + { + "title": "Hamlet", + "year": 1948 + }, + { + "title": "Hands over the City", + "year": 1963 + }, + { + "title": "Happiness", + "year": 1998 + }, + { + "title": "Happy Together", + "year": 1997 + }, + { + "title": "Harakiri", + "year": 1962 + }, + { + "title": "Hard Boiled", + "year": 1992 + }, + { + "title": "Harlan County USA", + "year": 1976 + }, + { + "title": "Harold and Maude", + "year": 1971 + }, + { + "title": "Head", + "year": 1968 + }, + { + "title": "Heart of a Dog", + "year": 2015 + }, + { + "title": "Hearts and Minds", + "year": 1974 + }, + { + "title": "Heaven Can Wait", + "year": 1943 + }, + { + "title": "Heaven’s Gate", + "year": 1980 + }, + { + "title": "Hedwig and the Angry Inch", + "year": 2001 + }, + { + "title": "Henry V", + "year": 1944 + }, + { + "title": "Here Comes Mr. Jordan", + "year": 1941 + }, + { + "title": "Here Is Your Life", + "year": 1966 + }, + { + "title": "High and Low", + "year": 1963 + }, + { + "title": "High Sierra", + "year": 1941 + }, + { + "title": "Hiroshima mon amour", + "year": 1959 + }, + { + "title": "His Girl Friday", + "year": 1940 + }, + { + "title": "History Is Made at Night", + "year": 1937 + }, + { + "title": "Hobson’s Choice", + "year": 1954 + }, + { + "title": "Holiday", + "year": 1938 + }, + { + "title": "Hollywood Shuffle", + "year": 1987 + }, + { + "title": "Homicide", + "year": 1991 + }, + { + "title": "Hoop Dreams", + "year": 1994 + }, + { + "title": "Hopscotch", + "year": 1980 + }, + { + "title": "Hotel Monterey", + "year": 1972 + }, + { + "title": "Hour of the Wolf", + "year": 1968 + }, + { + "title": "House", + "year": 1977 + }, + { + "title": "House of Games", + "year": 1987 + }, + { + "title": "How to Get Ahead in Advertising", + "year": 1988 + }, + { + "title": "Howards End", + "year": 1992 + }, + { + "title": "Humain, trop humain", + "year": 1973 + }, + { + "title": "Hunger", + "year": 2008 + }, + { + "title": "Husbands", + "year": 1970 + }, + { + "title": "Häxan", + "year": 1922 + }, + { + "title": "Hôtel du Nord", + "year": 1938 + }, + { + "title": "I Am Cuba", + "year": 1964 + }, + { + "title": "I Am Curious—Blue", + "year": 1967 + }, + { + "title": "I Am Curious—Yellow", + "year": 1967 + }, + { + "title": "I Am Waiting", + "year": 1957 + }, + { + "title": "I fidanzati", + "year": 1962 + }, + { + "title": "I Hate But Love", + "year": 1962 + }, + { + "title": "I Knew Her Well", + "year": 1965 + }, + { + "title": "I Know Where I’m Going!", + "year": 1945 + }, + { + "title": "I Live in Fear", + "year": 1955 + }, + { + "title": "I Married a Witch", + "year": 1942 + }, + { + "title": "I Shot Jesse James", + "year": 1949 + }, + { + "title": "I vitelloni", + "year": 1953 + }, + { + "title": "I Walked with a Zombie", + "year": 1943 + }, + { + "title": "I Wanna Hold Your Hand", + "year": 1978 + }, + { + "title": "I Was Born, But . . .", + "year": 1932 + }, + { + "title": "I Will Buy You", + "year": 1956 + }, + { + "title": "I, Daniel Blake", + "year": 2016 + }, + { + "title": "Identification of a Woman", + "year": 1982 + }, + { + "title": "If....", + "year": 1969 + }, + { + "title": "Ikiru", + "year": 1952 + }, + { + "title": "Il bidone", + "year": 1955 + }, + { + "title": "Il Generale Della Rovere", + "year": 1959 + }, + { + "title": "Il posto", + "year": 1961 + }, + { + "title": "Il sorpasso", + "year": 1962 + }, + { + "title": "Imitation of Life", + "year": 1934 + }, + { + "title": "In a Lonely Place", + "year": 1950 + }, + { + "title": "In Cold Blood", + "year": 1967 + }, + { + "title": "In the Heat of the Night", + "year": 1967 + }, + { + "title": "In the Mood for Love", + "year": 2000 + }, + { + "title": "In the Realm of the Senses", + "year": 1976 + }, + { + "title": "In Vanda’s Room", + "year": 2000 + }, + { + "title": "In Which We Serve", + "year": 1942 + }, + { + "title": "India Song", + "year": 1975 + }, + { + "title": "Indiscretion of an American Wife", + "year": 1953 + }, + { + "title": "Infernal Affairs", + "year": 2002 + }, + { + "title": "Infernal Affairs II", + "year": 2003 + }, + { + "title": "Infernal Affairs III", + "year": 2003 + }, + { + "title": "Ingrid Bergman: In Her Own Words", + "year": 2015 + }, + { + "title": "Inland Empire", + "year": 2006 + }, + { + "title": "Innocence Unprotected", + "year": 1968 + }, + { + "title": "Insiang", + "year": 1976 + }, + { + "title": "Inside Llewyn Davis", + "year": 2013 + }, + { + "title": "Insignificance", + "year": 1985 + }, + { + "title": "Insomnia", + "year": 1997 + }, + { + "title": "Intentions of Murder", + "year": 1964 + }, + { + "title": "Intermezzo", + "year": 1936 + }, + { + "title": "Intervista", + "year": 1987 + }, + { + "title": "Intimidation", + "year": 1960 + }, + { + "title": "Invasion of Astro-Monster", + "year": 1965 + }, + { + "title": "Invention for Destruction", + "year": 1958 + }, + { + "title": "Investigation of a Citizen Above Suspicion", + "year": 1970 + }, + { + "title": "Irma Vep", + "year": 1996 + }, + { + "title": "Island of Lost Souls", + "year": 1932 + }, + { + "title": "It Happened One Night", + "year": 1934 + }, + { + "title": "It’s a Mad, Mad, Mad, Mad World", + "year": 1963 + }, + { + "title": "Ivan the Terrible, Part I", + "year": 1944 + }, + { + "title": "Ivan the Terrible, Part II", + "year": 1958 + }, + { + "title": "Ivan’s Childhood", + "year": 1962 + }, + { + "title": "Jabberwocky", + "year": 1977 + }, + { + "title": "Jacquot de Nantes", + "year": 1991 + }, + { + "title": "Jane B. par Agnès V.", + "year": 1988 + }, + { + "title": "Japanese Girls at the Harbor", + "year": 1933 + }, + { + "title": "Japanese Summer: Double Suicide", + "year": 1967 + }, + { + "title": "Japón", + "year": 2002 + }, + { + "title": "Je tu il elle", + "year": 1975 + }, + { + "title": "Jeanne Dielman, 23, quai du Commerce, 1080 Bruxelles", + "year": 1975 + }, + { + "title": "Jellyfish Eyes", + "year": 2013 + }, + { + "title": "Jericho", + "year": 1937 + }, + { + "title": "Jigoku", + "year": 1960 + }, + { + "title": "Jimi Plays Monterey & Shake! Otis at Monterey", + "year": 1986 + }, + { + "title": "Jo Jo Dancer, Your Life Is Calling", + "year": 1986 + }, + { + "title": "Jour de fête", + "year": 1949 + }, + { + "title": "Journey to Italy", + "year": 1954 + }, + { + "title": "Journey to the Beginning of Time", + "year": 1955 + }, + { + "title": "Jubal", + "year": 1956 + }, + { + "title": "Jubilation Street", + "year": 1944 + }, + { + "title": "Jubilee", + "year": 1978 + }, + { + "title": "Judex", + "year": 1963 + }, + { + "title": "Jules and Jim", + "year": 1962 + }, + { + "title": "Juliet of the Spirits", + "year": 1965 + }, + { + "title": "June Night", + "year": 1940 + }, + { + "title": "Jungle Book", + "year": 1942 + }, + { + "title": "Kagemusha", + "year": 1980 + }, + { + "title": "Kalpana", + "year": 1948 + }, + { + "title": "Kameradschaft", + "year": 1931 + }, + { + "title": "Kanal", + "year": 1957 + }, + { + "title": "Kapò", + "year": 1959 + }, + { + "title": "Katzelmacher", + "year": 1969 + }, + { + "title": "Kes", + "year": 1970 + }, + { + "title": "Kicking and Screaming", + "year": 1995 + }, + { + "title": "Kill!", + "year": 1968 + }, + { + "title": "Kind Hearts and Coronets", + "year": 1949 + }, + { + "title": "King Kong vs. Godzilla", + "year": 1963 + }, + { + "title": "King Lear", + "year": 1987 + }, + { + "title": "King of Jazz", + "year": 1930 + }, + { + "title": "King of the Hill", + "year": 1993 + }, + { + "title": "Kings of the Road", + "year": 1976 + }, + { + "title": "Kiss Me Deadly", + "year": 1955 + }, + { + "title": "Klute", + "year": 1971 + }, + { + "title": "Knife in the Water", + "year": 1962 + }, + { + "title": "Koko: A Talking Gorilla", + "year": 1978 + }, + { + "title": "Koyaanisqatsi", + "year": 1983 + }, + { + "title": "Kung-Fu Master!", + "year": 1988 + }, + { + "title": "Kuroneko", + "year": 1968 + }, + { + "title": "Kwaidan", + "year": 1965 + }, + { + "title": "La Bamba", + "year": 1987 + }, + { + "title": "La bête humaine", + "year": 1938 + }, + { + "title": "La Cage aux Folles", + "year": 1978 + }, + { + "title": "La chambre", + "year": 1972 + }, + { + "title": "La chienne", + "year": 1931 + }, + { + "title": "La Ciénaga", + "year": 2001 + }, + { + "title": "La collectionneuse", + "year": 1967 + }, + { + "title": "La commare secca", + "year": 1962 + }, + { + "title": "La cérémonie", + "year": 1995 + }, + { + "title": "La dolce vita", + "year": 1960 + }, + { + "title": "La haine", + "year": 1995 + }, + { + "title": "La Jetée", + "year": 1963 + }, + { + "title": "La Llorona", + "year": 2019 + }, + { + "title": "La notte", + "year": 1961 + }, + { + "title": "La piscine", + "year": 1969 + }, + { + "title": "La Pointe Courte", + "year": 1955 + }, + { + "title": "La poison", + "year": 1951 + }, + { + "title": "La promesse", + "year": 1996 + }, + { + "title": "La ronde", + "year": 1950 + }, + { + "title": "La strada", + "year": 1954 + }, + { + "title": "La tête d’un homme", + "year": 1933 + }, + { + "title": "La vie de bohème", + "year": 1992 + }, + { + "title": "La vie de Jésus", + "year": 1997 + }, + { + "title": "La vérité", + "year": 1960 + }, + { + "title": "Lacombe, Lucien", + "year": 1974 + }, + { + "title": "Lady Snowblood", + "year": 1973 + }, + { + "title": "Lady Snowblood: Love Song of Vengeance", + "year": 1974 + }, + { + "title": "Land of Milk and Honey", + "year": 1971 + }, + { + "title": "Last Holiday", + "year": 1950 + }, + { + "title": "Last Hurrah for Chivalry", + "year": 1979 + }, + { + "title": "Last Summer", + "year": 2023 + }, + { + "title": "Last Year at Marienbad", + "year": 1961 + }, + { + "title": "Late Autumn", + "year": 1960 + }, + { + "title": "Late Spring", + "year": 1949 + }, + { + "title": "Law of the Border", + "year": 1966 + }, + { + "title": "Le 15/8", + "year": 1973 + }, + { + "title": "Le amiche", + "year": 1955 + }, + { + "title": "Le beau Serge", + "year": 1958 + }, + { + "title": "Le bonheur", + "year": 1965 + }, + { + "title": "Le cercle rouge", + "year": 1970 + }, + { + "title": "Le ciel est à vous", + "year": 1944 + }, + { + "title": "Le Corbeau", + "year": 1943 + }, + { + "title": "Le deuxième souffle", + "year": 1966 + }, + { + "title": "Le doulos", + "year": 1962 + }, + { + "title": "Le grand amour", + "year": 1969 + }, + { + "title": "Le Havre", + "year": 2011 + }, + { + "title": "Le jour se lève", + "year": 1939 + }, + { + "title": "Le mariage de Chiffon", + "year": 1942 + }, + { + "title": "Le million", + "year": 1931 + }, + { + "title": "Le notti bianche", + "year": 1957 + }, + { + "title": "Le petit soldat", + "year": 1963 + }, + { + "title": "Le plaisir", + "year": 1952 + }, + { + "title": "Le samouraï", + "year": 1967 + }, + { + "title": "Le silence de la mer", + "year": 1949 + }, + { + "title": "Le trou", + "year": 1960 + }, + { + "title": "Leave Her to Heaven", + "year": 1945 + }, + { + "title": "Leningrad Cowboys Go America", + "year": 1989 + }, + { + "title": "Leningrad Cowboys Meet Moses", + "year": 1994 + }, + { + "title": "Les Blank: Always for Pleasure", + "year": 1968 + }, + { + "title": "Les cousins", + "year": 1959 + }, + { + "title": "Les créatures", + "year": 1966 + }, + { + "title": "Les dames du Bois de Boulogne", + "year": 1945 + }, + { + "title": "Les enfants terribles", + "year": 1950 + }, + { + "title": "Les misérables", + "year": 1934 + }, + { + "title": "Les rendez-vous d’Anna", + "year": 1978 + }, + { + "title": "Les visiteurs du soir", + "year": 1942 + }, + { + "title": "Let the Sunshine In", + "year": 2017 + }, + { + "title": "Letter Never Sent", + "year": 1959 + }, + { + "title": "Lettres d’amour", + "year": 1942 + }, + { + "title": "Life During Wartime", + "year": 2010 + }, + { + "title": "Life Is Sweet", + "year": 1990 + }, + { + "title": "Like Someone in Love", + "year": 2012 + }, + { + "title": "Limelight", + "year": 1952 + }, + { + "title": "Limite", + "year": 1931 + }, + { + "title": "Lions Love (. . . and Lies)", + "year": 1969 + }, + { + "title": "Local Hero", + "year": 1983 + }, + { + "title": "Lola", + "year": 1981 + }, + { + "title": "Lola Montès", + "year": 1955 + }, + { + "title": "Lone Star", + "year": 1996 + }, + { + "title": "Lone Wolf and Cub: Baby Cart at the River Styx", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: Baby Cart in Peril", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: Baby Cart in the Land of Demons", + "year": 1973 + }, + { + "title": "Lone Wolf and Cub: Baby Cart to Hades", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: Sword of Vengeance", + "year": 1972 + }, + { + "title": "Lone Wolf and Cub: White Heaven in Hell", + "year": 1974 + }, + { + "title": "Lonesome", + "year": 1928 + }, + { + "title": "Lord of the Flies", + "year": 1963 + }, + { + "title": "Lost Highway", + "year": 1997 + }, + { + "title": "Lost in America", + "year": 1985 + }, + { + "title": "Louie Bluie", + "year": 1985 + }, + { + "title": "Love & Basketball", + "year": 2000 + }, + { + "title": "Love Affair", + "year": 1939 + }, + { + "title": "Love Affair, or the Case of the Missing Switchboard Operator", + "year": 1967 + }, + { + "title": "Love in the Afternoon", + "year": 1972 + }, + { + "title": "Love Is Colder Than Death", + "year": 1969 + }, + { + "title": "love jones", + "year": 1997 + }, + { + "title": "Love Meetings", + "year": 1964 + }, + { + "title": "Love on the Run", + "year": 1979 + }, + { + "title": "Love Streams", + "year": 1984 + }, + { + "title": "Lovers Rock", + "year": 2020 + }, + { + "title": "Loves of a Blonde", + "year": 1965 + }, + { + "title": "Loving Couples", + "year": 1964 + }, + { + "title": "Lucía", + "year": 1968 + }, + { + "title": "Lumière d’été", + "year": 1943 + }, + { + "title": "Lynch/Oz", + "year": 2022 + }, + { + "title": "Léon Morin, Priest", + "year": 1961 + }, + { + "title": "L’argent", + "year": 1983 + }, + { + "title": "L’Atalante", + "year": 1934 + }, + { + "title": "L’avventura", + "year": 1960 + }, + { + "title": "L’eclisse", + "year": 1962 + }, + { + "title": "L’enfance nue", + "year": 1968 + }, + { + "title": "L’enfant aimé ou Je joue à être une femme mariée", + "year": 1971 + }, + { + "title": "L’humanité", + "year": 1999 + }, + { + "title": "M", + "year": 1931 + }, + { + "title": "Macbeth", + "year": 1971 + }, + { + "title": "Madadayo", + "year": 1993 + }, + { + "title": "Made in U.S.A", + "year": 1966 + }, + { + "title": "Madonna of the Seven Moons", + "year": 1945 + }, + { + "title": "Mafioso", + "year": 1962 + }, + { + "title": "Magnificent Obsession", + "year": 1954 + }, + { + "title": "Maidstone", + "year": 1970 + }, + { + "title": "Major Barbara", + "year": 1941 + }, + { + "title": "Make Way for Tomorrow", + "year": 1937 + }, + { + "title": "Mala Noche", + "year": 1985 + }, + { + "title": "Malcolm X", + "year": 1992 + }, + { + "title": "Mamma Roma", + "year": 1962 + }, + { + "title": "Man Bites Dog", + "year": 1992 + }, + { + "title": "Man Is Not a Bird", + "year": 1965 + }, + { + "title": "Man Push Cart", + "year": 2005 + }, + { + "title": "Mandabi", + "year": 1968 + }, + { + "title": "Mangrove", + "year": 2020 + }, + { + "title": "Manila in the Claws of Light", + "year": 1975 + }, + { + "title": "Marius", + "year": 1931 + }, + { + "title": "Marketa Lazarová", + "year": 1967 + }, + { + "title": "Marriage Story", + "year": 2019 + }, + { + "title": "Martha Graham: Dance on Film", + "year": 1959 + }, + { + "title": "Masculin féminin", + "year": 1966 + }, + { + "title": "Master of the House", + "year": 1925 + }, + { + "title": "Matewan", + "year": 1987 + }, + { + "title": "Mayerling", + "year": 1936 + }, + { + "title": "Maîtresse", + "year": 1976 + }, + { + "title": "McCabe & Mrs. Miller", + "year": 1971 + }, + { + "title": "Me and You and Everyone We Know", + "year": 2005 + }, + { + "title": "Mean Streets", + "year": 1973 + }, + { + "title": "Meantime", + "year": 1984 + }, + { + "title": "Medea", + "year": 1969 + }, + { + "title": "Medicine for Melancholy", + "year": 2008 + }, + { + "title": "Medium Cool", + "year": 1969 + }, + { + "title": "Memories of Murder", + "year": 2003 + }, + { + "title": "Memories of Underdevelopment", + "year": 1968 + }, + { + "title": "Memory for Max, Claire, Ida and Company", + "year": 2005 + }, + { + "title": "Menace II Society", + "year": 1993 + }, + { + "title": "Merrily We Go to Hell", + "year": 1932 + }, + { + "title": "Merry Christmas Mr. Lawrence", + "year": 1983 + }, + { + "title": "Metropolitan", + "year": 1990 + }, + { + "title": "Midnight Cowboy", + "year": 1969 + }, + { + "title": "Mikey and Nicky", + "year": 1976 + }, + { + "title": "Mildred Pierce", + "year": 1945 + }, + { + "title": "Miller’s Crossing", + "year": 1990 + }, + { + "title": "Minding the Gap", + "year": 2018 + }, + { + "title": "Ministry of Fear", + "year": 1944 + }, + { + "title": "Miracle in Milan", + "year": 1951 + }, + { + "title": "Mirror", + "year": 1975 + }, + { + "title": "Mishima: A Life in Four Chapters", + "year": 1985 + }, + { + "title": "Miss Julie", + "year": 1951 + }, + { + "title": "Missing", + "year": 1982 + }, + { + "title": "Mississippi Masala", + "year": 1991 + }, + { + "title": "Mister Johnson", + "year": 1990 + }, + { + "title": "Modern Times", + "year": 1936 + }, + { + "title": "Mon oncle", + "year": 1958 + }, + { + "title": "Mon oncle Antoine", + "year": 1971 + }, + { + "title": "Mona Lisa", + "year": 1986 + }, + { + "title": "Monsieur Hulot’s Holiday", + "year": 1953 + }, + { + "title": "Monsieur Verdoux", + "year": 1947 + }, + { + "title": "Monsoon Wedding", + "year": 2001 + }, + { + "title": "Monte Carlo", + "year": 1930 + }, + { + "title": "Monterey Pop", + "year": 1968 + }, + { + "title": "Monty Python’s Life of Brian", + "year": 1979 + }, + { + "title": "Moonage Daydream", + "year": 2022 + }, + { + "title": "Moonrise", + "year": 1948 + }, + { + "title": "Moonrise Kingdom", + "year": 2012 + }, + { + "title": "Moonstruck", + "year": 1987 + }, + { + "title": "Morning for the Osone Family", + "year": 1946 + }, + { + "title": "Morocco", + "year": 1930 + }, + { + "title": "Mother", + "year": 1996 + }, + { + "title": "Mothra vs. Godzilla", + "year": 1964 + }, + { + "title": "Mouchette", + "year": 1967 + }, + { + "title": "Mr. Freedom", + "year": 1969 + }, + { + "title": "Mr. Klein", + "year": 1976 + }, + { + "title": "Mr. Thank You", + "year": 1936 + }, + { + "title": "Mudbound", + "year": 2017 + }, + { + "title": "Mulholland Dr.", + "year": 2001 + }, + { + "title": "Multiple Maniacs", + "year": 1970 + }, + { + "title": "Muna moto", + "year": 1975 + }, + { + "title": "Mur Murs", + "year": 1981 + }, + { + "title": "Muriel, or The Time of Return", + "year": 1963 + }, + { + "title": "Murmur of the Heart", + "year": 1971 + }, + { + "title": "My Beautiful Laundrette", + "year": 1985 + }, + { + "title": "My Brilliant Career", + "year": 1979 + }, + { + "title": "My Crasy Life", + "year": 1992 + }, + { + "title": "My Darling Clementine", + "year": 1946 + }, + { + "title": "My Dinner with André", + "year": 1981 + }, + { + "title": "My Life as a Dog", + "year": 1985 + }, + { + "title": "My Lucky Stars", + "year": 1985 + }, + { + "title": "My Man Godfrey", + "year": 1936 + }, + { + "title": "My Night at Maud’s", + "year": 1969 + }, + { + "title": "My Own Private Idaho", + "year": 1991 + }, + { + "title": "My Winnipeg", + "year": 2007 + }, + { + "title": "Mysterious Object at Noon", + "year": 2000 + }, + { + "title": "Mystery Train", + "year": 1989 + }, + { + "title": "Naked", + "year": 1993 + }, + { + "title": "Naked Lunch", + "year": 1991 + }, + { + "title": "Nanny", + "year": 2022 + }, + { + "title": "Nanook of the North", + "year": 1922 + }, + { + "title": "Naqoyqatsi", + "year": 2002 + }, + { + "title": "Nashville", + "year": 1975 + }, + { + "title": "Native Land", + "year": 1942 + }, + { + "title": "New Tale of Zatoichi", + "year": 1963 + }, + { + "title": "News from Home", + "year": 1976 + }, + { + "title": "Night and Fog", + "year": 1955 + }, + { + "title": "Night and the City", + "year": 1950 + }, + { + "title": "Night Games", + "year": 1966 + }, + { + "title": "Night of the Living Dead", + "year": 1968 + }, + { + "title": "Night on Earth", + "year": 1991 + }, + { + "title": "Night Train to Munich", + "year": 1940 + }, + { + "title": "Nightmare Alley", + "year": 1947 + }, + { + "title": "Nights of Cabiria", + "year": 1957 + }, + { + "title": "No Bears", + "year": 2022 + }, + { + "title": "No Blood Relation", + "year": 1932 + }, + { + "title": "No Country for Old Men", + "year": 2007 + }, + { + "title": "No More Excuses", + "year": 1968 + }, + { + "title": "No Regrets for Our Youth", + "year": 1946 + }, + { + "title": "Nobody’s Children", + "year": 1952 + }, + { + "title": "Non, Je Ne Regrette Rien (No Regret)", + "year": 1993 + }, + { + "title": "Not a Pretty Picture", + "year": 1975 + }, + { + "title": "Nothing but a Man", + "year": 1964 + }, + { + "title": "Notorious", + "year": 1946 + }, + { + "title": "Now, Voyager", + "year": 1942 + }, + { + "title": "Nowhere", + "year": 1997 + }, + { + "title": "Odd Man Out", + "year": 1947 + }, + { + "title": "Oedipus Rex", + "year": 1967 + }, + { + "title": "Okja", + "year": 2017 + }, + { + "title": "Old Joy", + "year": 2006 + }, + { + "title": "Oliver Twist", + "year": 1948 + }, + { + "title": "On the Waterfront", + "year": 1954 + }, + { + "title": "Once Upon a Time in China", + "year": 1991 + }, + { + "title": "Once Upon a Time in China II", + "year": 1992 + }, + { + "title": "Once Upon a Time in China III", + "year": 1993 + }, + { + "title": "Once Upon a Time in China IV", + "year": 1993 + }, + { + "title": "Once Upon a Time in China V", + "year": 1994 + }, + { + "title": "One False Move", + "year": 1992 + }, + { + "title": "One Hour with You", + "year": 1932 + }, + { + "title": "One Hundred and One Nights", + "year": 1995 + }, + { + "title": "One Night in Miami . . .", + "year": 2020 + }, + { + "title": "One Sings, the Other Doesn’t", + "year": 1977 + }, + { + "title": "One Wonderful Sunday", + "year": 1947 + }, + { + "title": "One-Eyed Jacks", + "year": 1961 + }, + { + "title": "Onibaba", + "year": 1964 + }, + { + "title": "Only Angels Have Wings", + "year": 1939 + }, + { + "title": "Opening Night", + "year": 1977 + }, + { + "title": "Ordet", + "year": 1955 + }, + { + "title": "Original Cast Album: “Company”", + "year": 1970 + }, + { + "title": "Orlando, My Political Biography", + "year": 2023 + }, + { + "title": "Ornamental Hairpin", + "year": 1941 + }, + { + "title": "Orpheus", + "year": 1950 + }, + { + "title": "Osaka Elegy", + "year": 1936 + }, + { + "title": "Ossos", + "year": 1997 + }, + { + "title": "Othello", + "year": 1952 + }, + { + "title": "Overlord", + "year": 1975 + }, + { + "title": "Paddle to the Sea", + "year": 1966 + }, + { + "title": "Paisan", + "year": 1946 + }, + { + "title": "Pale Flower", + "year": 1964 + }, + { + "title": "Pandora’s Box", + "year": 1929 + }, + { + "title": "Panique", + "year": 1946 + }, + { + "title": "Pan’s Labyrinth", + "year": 2006 + }, + { + "title": "Paper Moon", + "year": 1973 + }, + { + "title": "Parade", + "year": 1974 + }, + { + "title": "Parasite", + "year": 2019 + }, + { + "title": "Pariah", + "year": 2011 + }, + { + "title": "Paris Belongs to Us", + "year": 1961 + }, + { + "title": "Paris Is Burning", + "year": 1990 + }, + { + "title": "Paris, Texas", + "year": 1984 + }, + { + "title": "Passing Fancy", + "year": 1933 + }, + { + "title": "Pat Garrett and Billy the Kid", + "year": 1973 + }, + { + "title": "Pather Panchali", + "year": 1955 + }, + { + "title": "Paths of Glory", + "year": 1957 + }, + { + "title": "Patriotism", + "year": 1966 + }, + { + "title": "Paul Robeson: Tribute to an Artist", + "year": 1979 + }, + { + "title": "Pearls of the Deep", + "year": 1966 + }, + { + "title": "Peeping Tom", + "year": 1960 + }, + { + "title": "People on Sunday", + "year": 1930 + }, + { + "title": "Perfect Days", + "year": 2023 + }, + { + "title": "Performance", + "year": 1970 + }, + { + "title": "Persona", + "year": 1966 + }, + { + "title": "Personal Shopper", + "year": 2016 + }, + { + "title": "Petite maman", + "year": 2021 + }, + { + "title": "Phantom India", + "year": 1969 + }, + { + "title": "Phoenix", + "year": 2014 + }, + { + "title": "Pickpocket", + "year": 1959 + }, + { + "title": "Pickup on South Street", + "year": 1953 + }, + { + "title": "Picnic at Hanging Rock", + "year": 1975 + }, + { + "title": "Pierrot le fou", + "year": 1965 + }, + { + "title": "Pigs and Battleships", + "year": 1962 + }, + { + "title": "Pina", + "year": 2011 + }, + { + "title": "Pink Flamingos", + "year": 1972 + }, + { + "title": "Pitfall", + "year": 1962 + }, + { + "title": "Pixote", + "year": 1980 + }, + { + "title": "Place de la République", + "year": 1974 + }, + { + "title": "PlayTime", + "year": 1967 + }, + { + "title": "Pleasures of the Flesh", + "year": 1965 + }, + { + "title": "Poil de carotte", + "year": 1932 + }, + { + "title": "Police Story", + "year": 1985 + }, + { + "title": "Police Story 2", + "year": 1988 + }, + { + "title": "Polyester", + "year": 1981 + }, + { + "title": "Porcile", + "year": 1969 + }, + { + "title": "Port of Call", + "year": 1948 + }, + { + "title": "Port of Flowers", + "year": 1943 + }, + { + "title": "Port of Shadows", + "year": 1938 + }, + { + "title": "Portrait of a Lady on Fire", + "year": 2019 + }, + { + "title": "Poto and Cabengo", + "year": 1980 + }, + { + "title": "Powaqqatsi", + "year": 1988 + }, + { + "title": "Prisioneros de la tierra", + "year": 1939 + }, + { + "title": "Punch-Drunk Love", + "year": 2002 + }, + { + "title": "Purple Noon", + "year": 1960 + }, + { + "title": "Putney Swope", + "year": 1969 + }, + { + "title": "Pygmalion", + "year": 1938 + }, + { + "title": "Pépé le moko", + "year": 1937 + }, + { + "title": "Quadrille", + "year": 1938 + }, + { + "title": "Quadrophenia", + "year": 1979 + }, + { + "title": "Quai des Orfèvres", + "year": 1947 + }, + { + "title": "Querelle", + "year": 1982 + }, + { + "title": "Raging Bull", + "year": 1980 + }, + { + "title": "Ran", + "year": 1985 + }, + { + "title": "Rashomon", + "year": 1950 + }, + { + "title": "Ratcatcher", + "year": 1999 + }, + { + "title": "Raven’s End", + "year": 1963 + }, + { + "title": "Real Life", + "year": 1979 + }, + { + "title": "Rebecca", + "year": 1940 + }, + { + "title": "Red Beard", + "year": 1965 + }, + { + "title": "Red Desert", + "year": 1964 + }, + { + "title": "Red River", + "year": 1948 + }, + { + "title": "Red, White and Blue", + "year": 2020 + }, + { + "title": "Redes", + "year": 1936 + }, + { + "title": "Rembrandt", + "year": 1936 + }, + { + "title": "Remorques", + "year": 1941 + }, + { + "title": "Repo Man", + "year": 1984 + }, + { + "title": "Repulsion", + "year": 1965 + }, + { + "title": "Return of the Prodigal Son", + "year": 1967 + }, + { + "title": "Revanche", + "year": 2008 + }, + { + "title": "Revenge", + "year": 1989 + }, + { + "title": "Richard III", + "year": 1955 + }, + { + "title": "Ride in the Whirlwind", + "year": 1966 + }, + { + "title": "Ride Lonesome", + "year": 1959 + }, + { + "title": "Ride the Pink Horse", + "year": 1947 + }, + { + "title": "Ride with the Devil", + "year": 1999 + }, + { + "title": "Rififi", + "year": 1955 + }, + { + "title": "Riot in Cell Block 11", + "year": 1954 + }, + { + "title": "Risky Business", + "year": 1983 + }, + { + "title": "Robinson Crusoe on Mars", + "year": 1964 + }, + { + "title": "RoboCop", + "year": 1987 + }, + { + "title": "Rolling Thunder Revue: A Bob Dylan Story by Martin Scorsese", + "year": 2019 + }, + { + "title": "Roma", + "year": 1972 + }, + { + "title": "Rome Open City", + "year": 1945 + }, + { + "title": "Romeo and Juliet", + "year": 1968 + }, + { + "title": "Rosemary’s Baby", + "year": 1968 + }, + { + "title": "Rosetta", + "year": 1999 + }, + { + "title": "Rouge", + "year": 1987 + }, + { + "title": "Routine Pleasures", + "year": 1986 + }, + { + "title": "Rumble Fish", + "year": 1983 + }, + { + "title": "Rushmore", + "year": 1998 + }, + { + "title": "Rusty Knife", + "year": 1958 + }, + { + "title": "Ryuichi Sakamoto | Opus", + "year": 2023 + }, + { + "title": "Safe", + "year": 1995 + }, + { + "title": "Safety Last!", + "year": 1923 + }, + { + "title": "Saint Omer", + "year": 2022 + }, + { + "title": "Salesman", + "year": 1969 + }, + { + "title": "Salvatore Giuliano", + "year": 1962 + }, + { + "title": "Salò, or The 120 Days of Sodom", + "year": 1976 + }, + { + "title": "Samaritan Zatoichi", + "year": 1968 + }, + { + "title": "Sambizanga", + "year": 1972 + }, + { + "title": "Samurai I: Musashi Miyamoto", + "year": 1954 + }, + { + "title": "Samurai II: Duel at Ichijoji Temple", + "year": 1955 + }, + { + "title": "Samurai III: Duel at Ganryu Island", + "year": 1956 + }, + { + "title": "Samurai Rebellion", + "year": 1967 + }, + { + "title": "Samurai Spy", + "year": 1965 + }, + { + "title": "Sanders of the River", + "year": 1935 + }, + { + "title": "Sanjuro", + "year": 1962 + }, + { + "title": "Sans Soleil", + "year": 1983 + }, + { + "title": "Sanshiro Sugata", + "year": 1943 + }, + { + "title": "Sanshiro Sugata, Part Two", + "year": 1945 + }, + { + "title": "Sansho the Bailiff", + "year": 1954 + }, + { + "title": "Sapphire", + "year": 1959 + }, + { + "title": "Saraband", + "year": 2003 + }, + { + "title": "Saute ma ville", + "year": 1968 + }, + { + "title": "Sawdust and Tinsel", + "year": 1953 + }, + { + "title": "Scandal", + "year": 1950 + }, + { + "title": "Scanners", + "year": 1981 + }, + { + "title": "Scarface", + "year": 1932 + }, + { + "title": "Scenes from a Marriage", + "year": 1973 + }, + { + "title": "Schizopolis", + "year": 1996 + }, + { + "title": "Seconds", + "year": 1966 + }, + { + "title": "Secret Honor", + "year": 1984 + }, + { + "title": "Secret Sunshine", + "year": 2007 + }, + { + "title": "Secrets & Lies", + "year": 1996 + }, + { + "title": "Seduced and Abandoned", + "year": 1964 + }, + { + "title": "Senso", + "year": 1954 + }, + { + "title": "Seven Samurai", + "year": 1954 + }, + { + "title": "sex, lies, and videotape", + "year": 1989 + }, + { + "title": "Shadows", + "year": 1959 + }, + { + "title": "Shadows in Paradise", + "year": 1986 + }, + { + "title": "Shaft", + "year": 1971 + }, + { + "title": "Shallow Grave", + "year": 1994 + }, + { + "title": "Shame", + "year": 1968 + }, + { + "title": "Shampoo", + "year": 1975 + }, + { + "title": "Shanghai Express", + "year": 1932 + }, + { + "title": "Shoah", + "year": 1985 + }, + { + "title": "Shock Corridor", + "year": 1963 + }, + { + "title": "Shoot the Piano Player", + "year": 1960 + }, + { + "title": "Short Cuts", + "year": 1993 + }, + { + "title": "Show Boat", + "year": 1936 + }, + { + "title": "Sid & Nancy", + "year": 1986 + }, + { + "title": "Simon of the Desert", + "year": 1965 + }, + { + "title": "Sing a Song of Sex", + "year": 1967 + }, + { + "title": "Sisters", + "year": 1973 + }, + { + "title": "Sisters of the Gion", + "year": 1936 + }, + { + "title": "Slacker", + "year": 1991 + }, + { + "title": "Smiles of a Summer Night", + "year": 1955 + }, + { + "title": "Smithereens", + "year": 1982 + }, + { + "title": "Smooth Talk", + "year": 1985 + }, + { + "title": "Solaris", + "year": 1972 + }, + { + "title": "Soleil Ô", + "year": 1970 + }, + { + "title": "Some Like It Hot", + "year": 1959 + }, + { + "title": "Something Wild", + "year": 1986 + }, + { + "title": "Son of Godzilla", + "year": 1967 + }, + { + "title": "Sound of Metal", + "year": 2019 + }, + { + "title": "Spartacus", + "year": 1960 + }, + { + "title": "Speedy", + "year": 1928 + }, + { + "title": "Spellbound", + "year": 1945 + }, + { + "title": "Spiritual Kung Fu", + "year": 1978 + }, + { + "title": "Stagecoach", + "year": 1939 + }, + { + "title": "Stalker", + "year": 1979 + }, + { + "title": "State of Siege", + "year": 1972 + }, + { + "title": "Still Walking", + "year": 2008 + }, + { + "title": "Stolen Kisses", + "year": 1968 + }, + { + "title": "Story of a Prostitute", + "year": 1965 + }, + { + "title": "Stowaway in the Sky", + "year": 1960 + }, + { + "title": "Stranger Than Paradise", + "year": 1984 + }, + { + "title": "Straw Dogs", + "year": 1971 + }, + { + "title": "Stray Dog", + "year": 1949 + }, + { + "title": "Street of Shame", + "year": 1956 + }, + { + "title": "Street Without End", + "year": 1934 + }, + { + "title": "Streetwise", + "year": 1984 + }, + { + "title": "Stromboli", + "year": 1950 + }, + { + "title": "Sullivan’s Travels", + "year": 1941 + }, + { + "title": "Summer Hours", + "year": 2008 + }, + { + "title": "Summer Interlude", + "year": 1951 + }, + { + "title": "Summer with Monika", + "year": 1953 + }, + { + "title": "Summertime", + "year": 1955 + }, + { + "title": "Sunday Bloody Sunday", + "year": 1971 + }, + { + "title": "Sundays and Cybèle", + "year": 1962 + }, + { + "title": "Suzanne’s Career", + "year": 1963 + }, + { + "title": "Sweet Movie", + "year": 1974 + }, + { + "title": "Sweet Smell of Success", + "year": 1957 + }, + { + "title": "Sweet Sweetback’s Baadasssss Song", + "year": 1971 + }, + { + "title": "Sweetie", + "year": 1989 + }, + { + "title": "Swing Time", + "year": 1936 + }, + { + "title": "Sword of the Beast", + "year": 1965 + }, + { + "title": "Sylvie et le fantôme", + "year": 1946 + }, + { + "title": "Sólo con tu pareja", + "year": 1991 + }, + { + "title": "Taipei Story", + "year": 1985 + }, + { + "title": "Take Aim at the Police Van", + "year": 1960 + }, + { + "title": "Take Out", + "year": 2004 + }, + { + "title": "Tampopo", + "year": 1985 + }, + { + "title": "Tanner ’88", + "year": 1988 + }, + { + "title": "Targets", + "year": 1968 + }, + { + "title": "Taste of Cherry", + "year": 1997 + }, + { + "title": "Teorema", + "year": 1968 + }, + { + "title": "Terror of Mechagodzilla", + "year": 1975 + }, + { + "title": "Tess", + "year": 1979 + }, + { + "title": "Testament of Orpheus", + "year": 1959 + }, + { + "title": "That Hamilton Woman", + "year": 1941 + }, + { + "title": "That Night’s Wife", + "year": 1930 + }, + { + "title": "That Obscure Object of Desire", + "year": 1977 + }, + { + "title": "The 39 Steps", + "year": 1935 + }, + { + "title": "The 400 Blows", + "year": 1959 + }, + { + "title": "The Adventures of Baron Munchausen", + "year": 1988 + }, + { + "title": "The Age of Innocence", + "year": 1993 + }, + { + "title": "The Age of the Medici", + "year": 1973 + }, + { + "title": "The American Friend", + "year": 1977 + }, + { + "title": "The American Soldier", + "year": 1970 + }, + { + "title": "The Ascent", + "year": 1977 + }, + { + "title": "The Asphalt Jungle", + "year": 1950 + }, + { + "title": "The Atomic Submarine", + "year": 1959 + }, + { + "title": "The Awful Truth", + "year": 1937 + }, + { + "title": "The Baby Carriage", + "year": 1963 + }, + { + "title": "The Bad Sleep Well", + "year": 1960 + }, + { + "title": "The Bakery Girl of Monceau", + "year": 1963 + }, + { + "title": "The Baker’s Wife", + "year": 1938 + }, + { + "title": "The Ballad of Gregorio Cortez", + "year": 1982 + }, + { + "title": "The Ballad of Narayama", + "year": 1958 + }, + { + "title": "The Bank Dick", + "year": 1940 + }, + { + "title": "The Baron of Arizona", + "year": 1950 + }, + { + "title": "The Battle of Algiers", + "year": 1966 + }, + { + "title": "The Beaches of Agnès", + "year": 2008 + }, + { + "title": "The Beales of Grey Gardens", + "year": 2006 + }, + { + "title": "The Beast", + "year": 2023 + }, + { + "title": "The Big Boss", + "year": 1971 + }, + { + "title": "The Big Chill", + "year": 1983 + }, + { + "title": "The Big City", + "year": 1963 + }, + { + "title": "The Bitter Tears of Petra von Kant", + "year": 1972 + }, + { + "title": "The Black Stallion", + "year": 1979 + }, + { + "title": "The Blob", + "year": 1958 + }, + { + "title": "The Blood of a Poet", + "year": 1930 + }, + { + "title": "The Breakfast Club", + "year": 1985 + }, + { + "title": "The Breaking Point", + "year": 1950 + }, + { + "title": "The Bridge", + "year": 1959 + }, + { + "title": "The Brood", + "year": 1979 + }, + { + "title": "The Browning Version", + "year": 1951 + }, + { + "title": "The Burmese Harp", + "year": 1956 + }, + { + "title": "The Cameraman", + "year": 1928 + }, + { + "title": "The Canterbury Tales", + "year": 1972 + }, + { + "title": "The Celebration", + "year": 1998 + }, + { + "title": "The Children Are Watching Us", + "year": 1944 + }, + { + "title": "The Circus", + "year": 1928 + }, + { + "title": "The Cloud-Capped Star", + "year": 1960 + }, + { + "title": "The Color of Pomegranates", + "year": 1969 + }, + { + "title": "The Comfort of Strangers", + "year": 1990 + }, + { + "title": "The Complete Mr. Arkadin", + "year": 1955 + }, + { + "title": "The Confession", + "year": 1970 + }, + { + "title": "The Count of the Old Town", + "year": 1935 + }, + { + "title": "The Cranes Are Flying", + "year": 1957 + }, + { + "title": "The Cremator", + "year": 1969 + }, + { + "title": "The Curious Case of Benjamin Button", + "year": 2008 + }, + { + "title": "The Damned", + "year": 1969 + }, + { + "title": "The Darjeeling Limited", + "year": 2007 + }, + { + "title": "The Daytrippers", + "year": 1996 + }, + { + "title": "The Decameron", + "year": 1971 + }, + { + "title": "The Devil Is a Woman", + "year": 1935 + }, + { + "title": "The Devil’s Backbone", + "year": 2001 + }, + { + "title": "The Devil’s Eye", + "year": 1960 + }, + { + "title": "The Discreet Charm of the Bourgeoisie", + "year": 1972 + }, + { + "title": "The Docks of New York", + "year": 1928 + }, + { + "title": "The Doom Generation", + "year": 1995 + }, + { + "title": "The Double Life of Véronique", + "year": 1991 + }, + { + "title": "The Drum", + "year": 1938 + }, + { + "title": "The Earrings of Madame de . . .", + "year": 1953 + }, + { + "title": "The Eight Mountains", + "year": 2022 + }, + { + "title": "The Element of Crime", + "year": 1984 + }, + { + "title": "The Elephant Man", + "year": 1980 + }, + { + "title": "The Emigrants", + "year": 1971 + }, + { + "title": "The Emperor Jones", + "year": 1933 + }, + { + "title": "The End of Summer", + "year": 1961 + }, + { + "title": "The Executioner", + "year": 1963 + }, + { + "title": "The Exterminating Angel", + "year": 1962 + }, + { + "title": "The Fabulous Baron Munchausen", + "year": 1962 + }, + { + "title": "The Face of Another", + "year": 1966 + }, + { + "title": "The Fallen Idol", + "year": 1948 + }, + { + "title": "The Fearless Hyena", + "year": 1979 + }, + { + "title": "The Fire Within", + "year": 1963 + }, + { + "title": "The Firemen’s Ball", + "year": 1967 + }, + { + "title": "The Fisher King", + "year": 1991 + }, + { + "title": "The Flavor of Green Tea over Rice", + "year": 1952 + }, + { + "title": "The Flight of the Phoenix", + "year": 1965 + }, + { + "title": "The Flowers of St. Francis", + "year": 1950 + }, + { + "title": "The Forgiveness of Blood", + "year": 2011 + }, + { + "title": "The Four Feathers", + "year": 1939 + }, + { + "title": "The French Lieutenant’s Woman", + "year": 1981 + }, + { + "title": "The Freshman", + "year": 1925 + }, + { + "title": "The Friends of Eddie Coyle", + "year": 1973 + }, + { + "title": "The Fugitive Kind", + "year": 1960 + }, + { + "title": "The Funeral", + "year": 1984 + }, + { + "title": "The Furies", + "year": 1950 + }, + { + "title": "The Game", + "year": 1997 + }, + { + "title": "The Girl Can’t Help It", + "year": 1956 + }, + { + "title": "The Girls", + "year": 1968 + }, + { + "title": "The Gleaners and I", + "year": 2000 + }, + { + "title": "The Gleaners and I: Two Years Later", + "year": 2002 + }, + { + "title": "The Gold Rush", + "year": 1942 + }, + { + "title": "The Golden Coach", + "year": 1953 + }, + { + "title": "The Gospel According to Matthew", + "year": 1964 + }, + { + "title": "The Graduate", + "year": 1967 + }, + { + "title": "The Grand Budapest Hotel", + "year": 2014 + }, + { + "title": "The Great Beauty", + "year": 2013 + }, + { + "title": "The Great Dictator", + "year": 1940 + }, + { + "title": "The Great Escape", + "year": 1963 + }, + { + "title": "The Grifters", + "year": 1990 + }, + { + "title": "The Gunfighter", + "year": 1950 + }, + { + "title": "The Harder They Come", + "year": 1972 + }, + { + "title": "The Haunted Strangler", + "year": 1958 + }, + { + "title": "The Hawks and the Sparrows", + "year": 1966 + }, + { + "title": "The Heiress", + "year": 1949 + }, + { + "title": "The Hero", + "year": 1966 + }, + { + "title": "The Heroic Trio", + "year": 1993 + }, + { + "title": "The Hidden Fortress", + "year": 1958 + }, + { + "title": "The Hit", + "year": 1984 + }, + { + "title": "The Home and the World", + "year": 1984 + }, + { + "title": "The Honeymoon Killers", + "year": 1969 + }, + { + "title": "The Horse’s Mouth", + "year": 1958 + }, + { + "title": "The Housemaid", + "year": 1960 + }, + { + "title": "The Human Condition", + "year": 1959 + }, + { + "title": "The Ice Storm", + "year": 1997 + }, + { + "title": "The Idiot", + "year": 1951 + }, + { + "title": "The Immortal Story", + "year": 1968 + }, + { + "title": "The Importance of Being Earnest", + "year": 1952 + }, + { + "title": "The In-Laws", + "year": 1979 + }, + { + "title": "The Incredible Shrinking Man", + "year": 1957 + }, + { + "title": "The Inheritance", + "year": 1962 + }, + { + "title": "The Inland Sea", + "year": 1991 + }, + { + "title": "The Innocent", + "year": 2022 + }, + { + "title": "The Innocents", + "year": 1961 + }, + { + "title": "The Insect Woman", + "year": 1963 + }, + { + "title": "The Irishman", + "year": 2019 + }, + { + "title": "The Joke", + "year": 1969 + }, + { + "title": "The Kid", + "year": 1921 + }, + { + "title": "The Kid Brother", + "year": 1927 + }, + { + "title": "The Kid with a Bike", + "year": 2011 + }, + { + "title": "The Killer", + "year": 1989 + }, + { + "title": "The Killing", + "year": 1956 + }, + { + "title": "The Killing of a Chinese Bookie", + "year": 1976 + }, + { + "title": "The King of Kings", + "year": 1927 + }, + { + "title": "The King of Marvin Gardens", + "year": 1972 + }, + { + "title": "The Lady Eve", + "year": 1941 + }, + { + "title": "The Lady Vanishes", + "year": 1938 + }, + { + "title": "The Last Command", + "year": 1928 + }, + { + "title": "The Last Days of Disco", + "year": 1998 + }, + { + "title": "The Last Emperor", + "year": 1987 + }, + { + "title": "The Last Metro", + "year": 1980 + }, + { + "title": "The Last Picture Show", + "year": 1971 + }, + { + "title": "The Last Temptation of Christ", + "year": 1988 + }, + { + "title": "The Last Waltz", + "year": 1978 + }, + { + "title": "The Last Wave", + "year": 1977 + }, + { + "title": "The League of Gentlemen", + "year": 1960 + }, + { + "title": "The Learning Tree", + "year": 1969 + }, + { + "title": "The Leopard", + "year": 1963 + }, + { + "title": "The Life and Death of Colonel Blimp", + "year": 1943 + }, + { + "title": "The Life Aquatic with Steve Zissou", + "year": 2004 + }, + { + "title": "The Life of Oharu", + "year": 1952 + }, + { + "title": "The Living Magoroku", + "year": 1943 + }, + { + "title": "The Living Skeleton", + "year": 1968 + }, + { + "title": "The Lodger: A Story of the London Fog", + "year": 1927 + }, + { + "title": "The Long Day Closes", + "year": 1992 + }, + { + "title": "The Long Farewell", + "year": 1971 + }, + { + "title": "The Long Good Friday", + "year": 1980 + }, + { + "title": "The Lost Honor of Katharina Blum", + "year": 1975 + }, + { + "title": "The Love Parade", + "year": 1929 + }, + { + "title": "The Lovers", + "year": 1958 + }, + { + "title": "The Lure", + "year": 2015 + }, + { + "title": "The Magic Flute", + "year": 1975 + }, + { + "title": "The Magician", + "year": 1958 + }, + { + "title": "The Magnificent Ambersons", + "year": 1942 + }, + { + "title": "The Making of Fanny and Alexander", + "year": 1982 + }, + { + "title": "The Makioka Sisters", + "year": 1983 + }, + { + "title": "The Man in Grey", + "year": 1943 + }, + { + "title": "The Man Who Fell to Earth", + "year": 1976 + }, + { + "title": "The Man Who Knew Too Much", + "year": 1934 + }, + { + "title": "The Manchurian Candidate", + "year": 1962 + }, + { + "title": "The Marriage of Maria Braun", + "year": 1979 + }, + { + "title": "The Masseurs and a Woman", + "year": 1938 + }, + { + "title": "The Match Factory Girl", + "year": 1990 + }, + { + "title": "The Men Who Tread on the Tiger’s Tail", + "year": 1945 + }, + { + "title": "The Merchant of Four Seasons", + "year": 1971 + }, + { + "title": "The Mikado", + "year": 1939 + }, + { + "title": "The Milky Way", + "year": 1969 + }, + { + "title": "The Model Couple", + "year": 1977 + }, + { + "title": "The Moment of Truth", + "year": 1965 + }, + { + "title": "The Most Beautiful", + "year": 1944 + }, + { + "title": "The Most Dangerous Game", + "year": 1932 + }, + { + "title": "The Mother and the Whore", + "year": 1973 + }, + { + "title": "The Music Room", + "year": 1958 + }, + { + "title": "The Mystic", + "year": 1925 + }, + { + "title": "The Naked City", + "year": 1948 + }, + { + "title": "The Naked Island", + "year": 1960 + }, + { + "title": "The Naked Kiss", + "year": 1964 + }, + { + "title": "The Naked Prey", + "year": 1965 + }, + { + "title": "The New Land", + "year": 1972 + }, + { + "title": "The New World", + "year": 2005 + }, + { + "title": "The Night of the Hunter", + "year": 1955 + }, + { + "title": "The Night Porter", + "year": 1974 + }, + { + "title": "The Only Son", + "year": 1936 + }, + { + "title": "The Organizer", + "year": 1963 + }, + { + "title": "The Other Side of Hope", + "year": 2017 + }, + { + "title": "The Others", + "year": 2001 + }, + { + "title": "The Palm Beach Story", + "year": 1942 + }, + { + "title": "The Parallax View", + "year": 1974 + }, + { + "title": "The Passion of Anna", + "year": 1969 + }, + { + "title": "The Passion of Joan of Arc", + "year": 1928 + }, + { + "title": "The Pearls of the Crown", + "year": 1937 + }, + { + "title": "The Phantom Carriage", + "year": 1921 + }, + { + "title": "The Phantom of Liberty", + "year": 1974 + }, + { + "title": "The Philadelphia Story", + "year": 1940 + }, + { + "title": "The Piano", + "year": 1993 + }, + { + "title": "The Piano Teacher", + "year": 2001 + }, + { + "title": "The Player", + "year": 1992 + }, + { + "title": "The Pornographers", + "year": 1966 + }, + { + "title": "The Power of the Dog", + "year": 2021 + }, + { + "title": "The Prince of Tides", + "year": 1991 + }, + { + "title": "The Princess Bride", + "year": 1987 + }, + { + "title": "The Private Life of Don Juan", + "year": 1934 + }, + { + "title": "The Private Life of Henry VIII", + "year": 1933 + }, + { + "title": "The Proud Valley", + "year": 1940 + }, + { + "title": "The Red Balloon", + "year": 1956 + }, + { + "title": "The Red Shoes", + "year": 1948 + }, + { + "title": "The Rise of Catherine the Great", + "year": 1934 + }, + { + "title": "The Rite", + "year": 1969 + }, + { + "title": "The River", + "year": 1951 + }, + { + "title": "The Roaring Twenties", + "year": 1939 + }, + { + "title": "The Rock", + "year": 1996 + }, + { + "title": "The Rose", + "year": 1979 + }, + { + "title": "The Royal Tenenbaums", + "year": 2001 + }, + { + "title": "The Rules of the Game", + "year": 1939 + }, + { + "title": "The Ruling Class", + "year": 1972 + }, + { + "title": "The Runner", + "year": 1984 + }, + { + "title": "The Scarlet Empress", + "year": 1934 + }, + { + "title": "The Secret of the Grain", + "year": 2007 + }, + { + "title": "The Serpent’s Egg", + "year": 1977 + }, + { + "title": "The Servant", + "year": 1963 + }, + { + "title": "The Seventh Continent", + "year": 1989 + }, + { + "title": "The Seventh Seal", + "year": 1957 + }, + { + "title": "The Seventh Victim", + "year": 1943 + }, + { + "title": "The Shape of Water", + "year": 2017 + }, + { + "title": "The Shooting", + "year": 1966 + }, + { + "title": "The Shop on Main Street", + "year": 1965 + }, + { + "title": "The Silence", + "year": 1963 + }, + { + "title": "The Silence of the Lambs", + "year": 1991 + }, + { + "title": "The Small Back Room", + "year": 1949 + }, + { + "title": "The Smiling Lieutenant", + "year": 1931 + }, + { + "title": "The Soft Skin", + "year": 1964 + }, + { + "title": "The Spirit of the Beehive", + "year": 1973 + }, + { + "title": "The Spy Who Came in from the Cold", + "year": 1965 + }, + { + "title": "The Squid and the Whale", + "year": 2005 + }, + { + "title": "The Steel Helmet", + "year": 1951 + }, + { + "title": "The Story of a Cheat", + "year": 1936 + }, + { + "title": "The Story of a Three Day Pass", + "year": 1967 + }, + { + "title": "The Story of Temple Drake", + "year": 1933 + }, + { + "title": "The Story of the Last Chrysanthemum", + "year": 1939 + }, + { + "title": "The Stranger", + "year": 1991 + }, + { + "title": "The Suitor", + "year": 1963 + }, + { + "title": "The Sword of Doom", + "year": 1966 + }, + { + "title": "The Taking of Power by Louis XIV", + "year": 1966 + }, + { + "title": "The Tale of Zatoichi", + "year": 1962 + }, + { + "title": "The Tale of Zatoichi Continues", + "year": 1962 + }, + { + "title": "The Tales of Hoffmann", + "year": 1951 + }, + { + "title": "The Tall T", + "year": 1957 + }, + { + "title": "The Testament of Dr. Mabuse", + "year": 1933 + }, + { + "title": "The Thick-Walled Room", + "year": 1956 + }, + { + "title": "The Thief of Bagdad", + "year": 1940 + }, + { + "title": "The Thin Blue Line", + "year": 1988 + }, + { + "title": "The Thin Red Line", + "year": 1998 + }, + { + "title": "The Third Man", + "year": 1949 + }, + { + "title": "The Threepenny Opera", + "year": 1931 + }, + { + "title": "The Times of Harvey Milk", + "year": 1984 + }, + { + "title": "The Tin Drum", + "year": 1979 + }, + { + "title": "The Touch", + "year": 1971 + }, + { + "title": "The Tree of Life", + "year": 2011 + }, + { + "title": "The Tree of Wooden Clogs", + "year": 1978 + }, + { + "title": "The Trial", + "year": 1962 + }, + { + "title": "The Two of Us", + "year": 1967 + }, + { + "title": "The Umbrellas of Cherbourg", + "year": 1964 + }, + { + "title": "The Unbearable Lightness of Being", + "year": 1988 + }, + { + "title": "The Underground Railroad", + "year": 2021 + }, + { + "title": "The Uninvited", + "year": 1944 + }, + { + "title": "The Unknown", + "year": 1927 + }, + { + "title": "The Vanishing", + "year": 1988 + }, + { + "title": "The Velvet Underground", + "year": 2021 + }, + { + "title": "The Virgin Spring", + "year": 1960 + }, + { + "title": "The Virgin Suicides", + "year": 1999 + }, + { + "title": "The Wages of Fear", + "year": 1953 + }, + { + "title": "The War of the Worlds", + "year": 1953 + }, + { + "title": "The War Room", + "year": 1993 + }, + { + "title": "The Warped Ones", + "year": 1960 + }, + { + "title": "The Watermelon Woman", + "year": 1996 + }, + { + "title": "The Way of the Dragon", + "year": 1972 + }, + { + "title": "The White Angel", + "year": 1955 + }, + { + "title": "The White Sheik", + "year": 1952 + }, + { + "title": "The Wicked Lady", + "year": 1945 + }, + { + "title": "The Worst Person in the World", + "year": 2021 + }, + { + "title": "The X from Outer Space", + "year": 1967 + }, + { + "title": "The Young Girls of Rochefort", + "year": 1967 + }, + { + "title": "The Young Master", + "year": 1980 + }, + { + "title": "Thelma & Louise", + "year": 1991 + }, + { + "title": "There Was a Father", + "year": 1942 + }, + { + "title": "They Live by Night", + "year": 1948 + }, + { + "title": "Thief", + "year": 1981 + }, + { + "title": "Thieves’ Highway", + "year": 1949 + }, + { + "title": "Things to Come", + "year": 1936 + }, + { + "title": "Thirst", + "year": 1949 + }, + { + "title": "Thirst for Love", + "year": 1967 + }, + { + "title": "This Happy Breed", + "year": 1944 + }, + { + "title": "This Is Not a Burial, It’s a Resurrection", + "year": 2019 + }, + { + "title": "This Is Spinal Tap", + "year": 1984 + }, + { + "title": "This Sporting Life", + "year": 1963 + }, + { + "title": "Three Colors: Blue", + "year": 1993 + }, + { + "title": "Three Colors: Red", + "year": 1994 + }, + { + "title": "Three Colors: White", + "year": 1994 + }, + { + "title": "Three Documentaries", + "year": 1962 + }, + { + "title": "Three Outlaw Samurai", + "year": 1964 + }, + { + "title": "Three Resurrected Drunkards", + "year": 1968 + }, + { + "title": "Throne of Blood", + "year": 1957 + }, + { + "title": "Through a Glass Darkly", + "year": 1961 + }, + { + "title": "Through the Olive Trees", + "year": 1994 + }, + { + "title": "Throw Down", + "year": 2004 + }, + { + "title": "Tie Me Up! Tie Me Down!", + "year": 1990 + }, + { + "title": "Time", + "year": 2020 + }, + { + "title": "Time Bandits", + "year": 1981 + }, + { + "title": "Tiny Furniture", + "year": 2010 + }, + { + "title": "Tiny: The Life of Erin Blackwell", + "year": 2016 + }, + { + "title": "To Be or Not to Be", + "year": 1942 + }, + { + "title": "To Die For", + "year": 1995 + }, + { + "title": "To Joy", + "year": 1950 + }, + { + "title": "To Sleep with Anger", + "year": 1990 + }, + { + "title": "Tokyo Chorus", + "year": 1931 + }, + { + "title": "Tokyo Drifter", + "year": 1966 + }, + { + "title": "Tokyo Olympiad", + "year": 1965 + }, + { + "title": "Tokyo Story", + "year": 1953 + }, + { + "title": "Tokyo Twilight", + "year": 1957 + }, + { + "title": "Tom Jones", + "year": 1963 + }, + { + "title": "Tongues Untied", + "year": 1989 + }, + { + "title": "Toni", + "year": 1935 + }, + { + "title": "Tootsie", + "year": 1982 + }, + { + "title": "Topsy-Turvy", + "year": 1999 + }, + { + "title": "Tori and Lokita", + "year": 2022 + }, + { + "title": "Torment", + "year": 1944 + }, + { + "title": "Tormento", + "year": 1950 + }, + { + "title": "Total Balalaika Show", + "year": 1994 + }, + { + "title": "Totally F***ed Up", + "year": 1993 + }, + { + "title": "Touchez pas au grisbi", + "year": 1954 + }, + { + "title": "Touki bouki", + "year": 1973 + }, + { + "title": "Tout va bien", + "year": 1972 + }, + { + "title": "Town Bloody Hall", + "year": 1979 + }, + { + "title": "Traffic", + "year": 2000 + }, + { + "title": "Trafic", + "year": 1971 + }, + { + "title": "Trainspotting", + "year": 1996 + }, + { + "title": "Trances", + "year": 1981 + }, + { + "title": "Triangle of Sadness", + "year": 2022 + }, + { + "title": "Trouble in Paradise", + "year": 1932 + }, + { + "title": "True Stories", + "year": 1986 + }, + { + "title": "Tunes of Glory", + "year": 1960 + }, + { + "title": "Twenty-Four Eyes", + "year": 1954 + }, + { + "title": "Twin Peaks: Fire Walk with Me", + "year": 1992 + }, + { + "title": "Two Days, One Night", + "year": 2014 + }, + { + "title": "Two Girls on the Street", + "year": 1939 + }, + { + "title": "Two Tons of Turquoise to Taos Tonight", + "year": 1975 + }, + { + "title": "Two-Lane Blacktop", + "year": 1971 + }, + { + "title": "Tótem", + "year": 2023 + }, + { + "title": "Ugetsu", + "year": 1953 + }, + { + "title": "Umberto D.", + "year": 1952 + }, + { + "title": "Un carnet de bal", + "year": 1937 + }, + { + "title": "Uncle Yanco", + "year": 1968 + }, + { + "title": "Uncut Gems", + "year": 2019 + }, + { + "title": "Under the Roofs of Paris", + "year": 1930 + }, + { + "title": "Under the Volcano", + "year": 1984 + }, + { + "title": "Underworld", + "year": 1927 + }, + { + "title": "Une chambre en ville", + "year": 1982 + }, + { + "title": "Unfaithfully Yours", + "year": 1948 + }, + { + "title": "Until the End of the World", + "year": 1991 + }, + { + "title": "Vagabond", + "year": 1985 + }, + { + "title": "Valerie and Her Week of Wonders", + "year": 1970 + }, + { + "title": "Valley of the Dolls", + "year": 1967 + }, + { + "title": "Vampyr", + "year": 1932 + }, + { + "title": "Vanya on 42nd Street", + "year": 1994 + }, + { + "title": "Varda by Agnès", + "year": 2019 + }, + { + "title": "Variety Lights", + "year": 1950 + }, + { + "title": "Vengeance Is Mine", + "year": 1979 + }, + { + "title": "Vernon, Florida", + "year": 1981 + }, + { + "title": "Veronika Voss", + "year": 1982 + }, + { + "title": "Victim", + "year": 1961 + }, + { + "title": "Victims of Sin", + "year": 1951 + }, + { + "title": "Videodrome", + "year": 1983 + }, + { + "title": "Violence at Noon", + "year": 1966 + }, + { + "title": "Viridiana", + "year": 1961 + }, + { + "title": "Visions of Eight", + "year": 1973 + }, + { + "title": "Vive le Tour", + "year": 1962 + }, + { + "title": "Vivre sa vie", + "year": 1962 + }, + { + "title": "W. C. Fields—Six Short Films", + "year": 1933 + }, + { + "title": "Waiting Women", + "year": 1952 + }, + { + "title": "Walk Cheerfully", + "year": 1930 + }, + { + "title": "Walkabout", + "year": 1971 + }, + { + "title": "Walker", + "year": 1987 + }, + { + "title": "WALL·E", + "year": 2008 + }, + { + "title": "Walpurgis Night", + "year": 1935 + }, + { + "title": "Wanda", + "year": 1970 + }, + { + "title": "War and Peace", + "year": 1966 + }, + { + "title": "Warrendale", + "year": 1967 + }, + { + "title": "Watermelon Man", + "year": 1970 + }, + { + "title": "Watership Down", + "year": 1978 + }, + { + "title": "Weekend", + "year": 2011 + }, + { + "title": "Werckmeister Harmonies", + "year": 2000 + }, + { + "title": "Westfront 1918", + "year": 1930 + }, + { + "title": "When a Woman Ascends the Stairs", + "year": 1960 + }, + { + "title": "When We Were Kings", + "year": 1996 + }, + { + "title": "Where Is the Friend’s House?", + "year": 1987 + }, + { + "title": "White Dog", + "year": 1982 + }, + { + "title": "White Mane", + "year": 1953 + }, + { + "title": "White Material", + "year": 2009 + }, + { + "title": "Who Are You, Polly Maggoo?", + "year": 1966 + }, + { + "title": "Wild 90", + "year": 1968 + }, + { + "title": "Wild Strawberries", + "year": 1957 + }, + { + "title": "Wildlife", + "year": 2018 + }, + { + "title": "Winchester ’73", + "year": 1950 + }, + { + "title": "Wings", + "year": 1966 + }, + { + "title": "Wings of Desire", + "year": 1987 + }, + { + "title": "Winter Light", + "year": 1963 + }, + { + "title": "Wise Blood", + "year": 1979 + }, + { + "title": "Withnail and I", + "year": 1986 + }, + { + "title": "Woman in the Dunes", + "year": 1964 + }, + { + "title": "Woman of the Year", + "year": 1942 + }, + { + "title": "Women in Love", + "year": 1969 + }, + { + "title": "Women of the Night", + "year": 1948 + }, + { + "title": "Women on the Verge of a Nervous Breakdown", + "year": 1988 + }, + { + "title": "Wooden Crosses", + "year": 1932 + }, + { + "title": "Working Girls", + "year": 1986 + }, + { + "title": "World on a Wire", + "year": 1973 + }, + { + "title": "WR: Mysteries of the Organism", + "year": 1971 + }, + { + "title": "Written on the Wind", + "year": 1956 + }, + { + "title": "Wrong Move", + "year": 1975 + }, + { + "title": "Xala", + "year": 1975 + }, + { + "title": "Y tu mamá también", + "year": 2001 + }, + { + "title": "Yi Yi", + "year": 2000 + }, + { + "title": "Yojimbo", + "year": 1961 + }, + { + "title": "Young Mr. Lincoln", + "year": 1939 + }, + { + "title": "Young Törless", + "year": 1966 + }, + { + "title": "Youth of the Beast", + "year": 1963 + }, + { + "title": "Yoyo", + "year": 1965 + }, + { + "title": "Z", + "year": 1969 + }, + { + "title": "Zatoichi and the Chess Expert", + "year": 1965 + }, + { + "title": "Zatoichi and the Chest of Gold", + "year": 1964 + }, + { + "title": "Zatoichi and the Doomed Man", + "year": 1965 + }, + { + "title": "Zatoichi and the Fugitives", + "year": 1968 + }, + { + "title": "Zatoichi at Large", + "year": 1972 + }, + { + "title": "Zatoichi Challenged", + "year": 1967 + }, + { + "title": "Zatoichi Goes to the Fire Festival", + "year": 1970 + }, + { + "title": "Zatoichi in Desperation", + "year": 1972 + }, + { + "title": "Zatoichi Meets the One-Armed Swordsman", + "year": 1971 + }, + { + "title": "Zatoichi Meets Yojimbo", + "year": 1970 + }, + { + "title": "Zatoichi on the Road", + "year": 1963 + }, + { + "title": "Zatoichi the Fugitive", + "year": 1963 + }, + { + "title": "Zatoichi the Outlaw", + "year": 1967 + }, + { + "title": "Zatoichi’s Cane Sword", + "year": 1967 + }, + { + "title": "Zatoichi’s Conspiracy", + "year": 1973 + }, + { + "title": "Zatoichi’s Flashing Sword", + "year": 1964 + }, + { + "title": "Zatoichi’s Pilgrimage", + "year": 1966 + }, + { + "title": "Zatoichi’s Revenge", + "year": 1965 + }, + { + "title": "Zatoichi’s Vengeance", + "year": 1966 + }, + { + "title": "Zazie dans le métro", + "year": 1960 + }, + { + "title": "Zéro de conduite", + "year": 1933 + }, + { + "title": "¡Alambrista!", + "year": 1977 + }, + { + "title": "À nos amours", + "year": 1983 + }, + { + "title": "À nous la liberté", + "year": 1931 + }, + { + "title": "À propos de Nice", + "year": 1930 + }, + { + "title": "Ådalen 31", + "year": 1969 + }, + { + "title": "’Round Midnight", + "year": 1986 + } ]