diff --git a/.claude/DONE b/.claude/DONE
new file mode 100644
index 0000000..c1f56f9
--- /dev/null
+++ b/.claude/DONE
@@ -0,0 +1 @@
+MVP ferdig og verifisert 2026-09-30. Se docs/status.md.
diff --git a/.claude/agents/backend-utvikler.md b/.claude/agents/backend-utvikler.md
new file mode 100644
index 0000000..d2a7d98
--- /dev/null
+++ b/.claude/agents/backend-utvikler.md
@@ -0,0 +1,13 @@
+---
+name: backend-utvikler
+description: Implementerer database, importskript og GraphQL-API (Node + TypeScript + PostgreSQL). Bruk for alt under backend/.
+model: sonnet
+---
+
+Du er backend-utvikler i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Jobb kun i `backend/` og delte konfigfiler du blir bedt om.
+- All søk/filter/sortering i SQL, parameteriserte spørringer, cursor-paginering.
+- Skriv eller oppdater tester for det du endrer (Vitest mot testdatabase).
+- Kjør `npm run lint -w backend && npm run typecheck -w backend && npm test -w backend` før du svarer.
+- Svar lederen kort: hva endret, hvilke filer, testresultat, evt. åpne problemer. Ikke commit – lederen gjør det.
diff --git a/.claude/agents/frontend-utvikler.md b/.claude/agents/frontend-utvikler.md
new file mode 100644
index 0000000..97fcc75
--- /dev/null
+++ b/.claude/agents/frontend-utvikler.md
@@ -0,0 +1,15 @@
+---
+name: frontend-utvikler
+description: Implementerer React/TypeScript/Vite-klienten med Apollo Client. Bruk for alt under frontend/.
+model: sonnet
+---
+
+Du er frontend-utvikler i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Jobb kun i `frontend/`.
+- Semantisk HTML, tastaturstøtte, synlig fokus, labels på alle input, `aria-live` der innhold endres.
+- Ingen sortering/filtrering på klienten – alt via GraphQL-variabler.
+- Hold bundlen liten; spør deg selv om hvert nytt bibliotek er nødvendig.
+- Skriv komponenttester (Vitest + Testing Library) for det du lager.
+- Kjør `npm run lint -w frontend && npm run typecheck -w frontend && npm test -w frontend` før du svarer.
+- Svar lederen kort: hva endret, hvilke filer, testresultat, evt. åpne problemer. Ikke commit.
diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md
new file mode 100644
index 0000000..d60b4b7
--- /dev/null
+++ b/.claude/agents/reviewer.md
@@ -0,0 +1,11 @@
+---
+name: reviewer
+description: Går gjennom en endring (diff) for korrekthet, sikkerhet, tilgjengelighet og etterlevelse av CLAUDE.md.
+model: sonnet
+---
+
+Du er reviewer i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Les diffen lederen peker på. Se etter feil, SQL-injeksjon, manglende validering, klient-side sortering/filtrering, a11y-brudd og utestede kanttilfeller.
+- Rangér funn: blokkerende / bør fikses / valgfritt. Oppgi fil og linje.
+- Ikke endre kode selv. Svar kort. Ikke commit.
diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md
new file mode 100644
index 0000000..f753f44
--- /dev/null
+++ b/.claude/agents/tester.md
@@ -0,0 +1,12 @@
+---
+name: tester
+description: Kjører og utvider tester (Vitest, Testing Library, Playwright + axe). Bruk etter hver endring for å verifisere.
+model: sonnet
+---
+
+Du er tester i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Kjør relevante tester for endringen og rapporter nøyaktig hva som feilet (kommando + utdrag av output).
+- Legg til tester for utypisk bruk: tomt søk, ingen treff, spesialtegn, veldig lange strenger, nettverksfeil.
+- Ikke endre produksjonskode; foreslå heller fiks til lederen.
+- Svar lederen kort: hvilke tester, resultat, evt. funn. Ikke commit.
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..86a63dc
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..88bb12d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,28 @@
+# Kopier til .env og tilpass. .env skal aldri committes.
+
+# Backend
+PORT=3001
+DATABASE_URL=postgres://postgres@localhost:5432/project2
+# Egen database for tester – tømmes av testene.
+TEST_DATABASE_URL=postgres://postgres@localhost:5432/project2_test
+# Tillatt opprinnelse for CORS i utvikling (Vite dev-server).
+CORS_ORIGIN=http://localhost:5173
+
+# Import: mappe med title.basics.tsv.gz og title.ratings.tsv.gz
+IMDB_DATA_DIR=./data
+# Minste antall stemmer for at en tittel tas med
+IMDB_MIN_VOTES=100
+
+# Frontend (valgfri – standard er /graphql)
+VITE_GRAPHQL_URL=
+
+# Bilder og handling fra TMDB (https://www.themoviedb.org/settings/api). Uten nøkkel vises
+# plassholdere i stedet for plakater.
+TMDB_API_KEY=
+# Overstyres i utvikling/E2E for å bruke den lokale falske TMDB-serveren (npm run tmdb:mock -w backend):
+# TMDB_API_URL=http://localhost:3999/3
+# TMDB_IMAGE_URL=http://localhost:3999/t/p
+
+# Lovlige gratisfilmer fra Internet Archive (npm run db:archive). Standard er den ekte tjenesten;
+# pek mot den falske serveren (npm run archive:mock -w backend) i utvikling/E2E:
+# ARCHIVE_URL=http://localhost:3998
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c407a05
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+# Skallskript må ha LF også når repoet klones på Windows (core.autocrlf), ellers feiler bash
+# med «set: pipefail\r: invalid option name».
+*.sh text eol=lf
+*.service text eol=lf
+*.conf text eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f4a5630
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+node_modules/
+dist/
+coverage/
+*.log
+.env
+.env.*
+!.env.example
+data/*.tsv
+data/*.tsv.gz
+!data/.gitkeep
+playwright-report/
+test-results/
+blob-report/
+.vite/
+*.tsbuildinfo
+.DS_Store
+.claude/DONE.local
+data/e2e/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..3d15688
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,41 @@
+# CI for IT2810 prosjekt 2: lint, typecheck og enhetstester på hver push/MR.
+default:
+ image: node:22
+ cache:
+ key:
+ files:
+ - package-lock.json
+ paths:
+ - .npm/
+ before_script:
+ - npm ci --cache .npm --prefer-offline
+
+stages:
+ - check
+ - test
+
+variables:
+ HUSKY: '0'
+
+lint:
+ stage: check
+ script:
+ - npm run lint
+
+typecheck:
+ stage: check
+ script:
+ - npm run typecheck
+
+unit-tests:
+ stage: test
+ services:
+ - name: postgres:16
+ alias: postgres
+ variables:
+ POSTGRES_DB: project2_test
+ POSTGRES_USER: postgres
+ POSTGRES_HOST_AUTH_METHOD: trust
+ TEST_DATABASE_URL: postgres://postgres@postgres:5432/project2_test
+ script:
+ - npm test
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..2312dc5
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx lint-staged
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..f4e4841
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+coverage
+playwright-report
+test-results
+package-lock.json
+data
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..9014aaa
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "singleQuote": true,
+ "semi": true,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/BLOCKERS.md b/BLOCKERS.md
new file mode 100644
index 0000000..2b4b22c
--- /dev/null
+++ b/BLOCKERS.md
@@ -0,0 +1,8 @@
+# Blokkeringer som krever Nicolay
+
+Punkter her kan ikke løses av agentene alene. Hvert punkt sier hva som trengs.
+
+- ~~**IMDb-datasettet kunne ikke lastes ned i utviklingsmiljøet.**~~ Løst: importert på VM-en av Nicolay 2026-09-30. Sky-containeren agentene kjørte i har
+ en nettverkspolicy som blokkerer `datasets.imdbws.com` (HTTP 403 fra proxyen). Importskriptet er
+ skrevet for de ekte filene og testet mot et syntetisk datasett i samme format
+ (`npm run db:fixture -w backend`). Last ned de ekte filene selv og kjør importen, se `docs/oppsett.md`.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5919673
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,47 @@
+# IT2810 Prosjekt 2 – regler for Claude Code
+
+Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikke still spørsmål, ta fornuftige valg og dokumenter dem i `docs/beslutninger.md`.
+
+## Roller
+
+- **Hovedsesjonen (Opus) er daglig leder.** Den planlegger, deler opp arbeid, delegerer til subagenter, verifiserer og krysser av i `PLAN.md`. Lederen skriver helst ikke produksjonskode selv.
+- **Subagenter (Sonnet):** `backend-utvikler`, `frontend-utvikler`, `tester`, `reviewer`. Gi dem små, avgrensede oppgaver med tydelig ferdigkriterium. Kjør uavhengige oppgaver parallelt.
+
+## Arbeidsløkke (hver runde)
+
+1. Les `PLAN.md` og `BLOCKERS.md`.
+2. Velg neste uavkryssede punkt (eller flere uavhengige).
+3. Deleger → la `reviewer` gå gjennom endringen → la `tester` kjøre relevante tester.
+4. Kjør selv `npm run lint && npm run typecheck && npm test` i berørte pakker. Rødt = ikke ferdig.
+5. Commit, kryss av i `PLAN.md`, skriv én linje i `docs/ki-logg.md`.
+6. Er alle MVP-kriterier oppfylt og verifisert: opprett filen `.claude/DONE`.
+
+## Git
+
+- Jobb alltid på grener fra `mvp`. Aldri commit eller push til `main`, aldri force-push.
+- Én gren per oppgave (`feat/…`, `fix/…`, `test/…`), merge til `mvp` når review og tester er grønne.
+- Små commits med Conventional Commits-meldinger på engelsk.
+- Hvis `glab` er innlogget: opprett issue per PLAN-punkt og MR mot `mvp`, reviewer kommenterer i MR-en. Ellers: gjør det samme lokalt og loggfør i `docs/ki-logg.md`.
+
+## Tekniske rammer (ikke avvik uten å skrive begrunnelse i beslutninger.md)
+
+- Monorepo: `frontend/` (React + TS + Vite), `backend/` (Node + TS + GraphQL), npm workspaces.
+- State: Apollo Client (cache + reactive vars). URL speiler søk/filter/sortering.
+- Database: PostgreSQL, ingen Docker. `pg_trgm` + GIN-indekser. Migrasjoner og importskript i repo.
+- Søk, filter og sortering skjer ALLTID i SQL på hele datasettet. Cursor-basert paginering.
+- Søk debounces (300 ms). Ingen requests for tomme eller uendrede søk.
+- Backend på port 3001. Frontend bygges med `base: '/project2/'`.
+- Tilgjengelighet: WCAG 2.1 AA, semantisk HTML, tastatur overalt, synlig fokus, `aria-live` for antall treff.
+- Bærekraft: lazy-loading, små bilder, ingen tunge biblioteker uten grunn, mørk modus, cache.
+- Kommentarer skrives for eksterne lesere: forklar _hvorfor_, ikke _hva_.
+
+## Tester
+
+- Vitest + Testing Library for komponenter, Vitest for GraphQL-resolvere mot testdatabase, Playwright for E2E.
+- Test både vanlig og utypisk bruk: tomt søk, ingen treff, spesialtegn, veldig lange strenger, nettverksfeil.
+
+## Grenser
+
+- Ingen `sudo`, ingen endringer utenfor repoet, ingen hemmeligheter i git (`.env` i `.gitignore`, `.env.example` i repo).
+- README.md skrives av gruppa. Agentene legger bare fakta og installasjonssteg i `docs/`, som gruppa kan bygge README på.
+- Står du fast på noe som krever Nicolay (passord, VM, VPN, tilganger): skriv det i `BLOCKERS.md` og gå videre.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..3fef19a
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,72 @@
+# PLAN – MVP
+
+Målet er nådd når alle bokser under er krysset av og verifisert. Lederen krysser av, ikke utviklerne.
+
+## Datasett (standardvalg)
+
+IMDb non-commercial datasets (`title.basics.tsv.gz`, `title.ratings.tsv.gz`) fra https://datasets.imdbws.com – ingen API-nøkkel. Filtrer til filmer og serier med minst 100 stemmer (~100k+ rader). Bilder hoppes over i MVP (bærekraft), evt. placeholders.
+Brukergenererte data: anmeldelser (1–5 stjerner + tekst) og "min liste".
+
+## M0 – Oppsett
+
+- [x] Monorepo med npm workspaces, TypeScript strict, ESLint, Prettier, Husky + lint-staged
+- [x] Scripts i rot: `dev`, `build`, `lint`, `typecheck`, `test`, `test:e2e`, `db:migrate`, `db:seed`
+- [x] `.env.example`, `.gitignore`, `docs/beslutninger.md`, `docs/ki-logg.md`, `BLOCKERS.md`
+- [x] GitLab CI (`.gitlab-ci.yml`): lint, typecheck, unit-tester
+
+## M1 – Database og import
+
+- [x] Skjema + migrasjoner (titles, genres, reviews, lists), indekser inkl. `pg_trgm` GIN på tittel
+- [x] Importskript som strømmer TSV (ikke les alt i minnet) og kan kjøres på nytt (idempotent)
+- [x] `EXPLAIN ANALYZE` for søk før/etter indeks lagret i `docs/ytelse.md`
+
+## M2 – GraphQL-API
+
+- [x] `search(query, filters, sort, first, after)` med connection-type (edges, pageInfo, totalCount)
+- [x] `title(id)` med detaljer og anmeldelser
+- [x] `facets(query, filters)` → antall treff per sjanger / tiår / type
+- [x] Mutations: `addReview`, `toggleList` (anonym bruker-id via cookie/localStorage-uuid)
+- [x] Input-validering, feilhåndtering, maks `first`, query-dybde begrenset
+- [x] API-tester for alle queries/mutations inkl. kanttilfeller
+
+## M3 – Frontend
+
+- [x] Søkefelt med debounce, case-insensitivt, tøm-knapp
+- [x] Resultatliste med uendelig scroll + "Last flere"-knapp, skeleton-lasting
+- [x] Filterpanel (sjanger, tiår, type, min. rating) med antall treff, aktive filtre som chips, nullstill
+- [x] Sortering (relevans, rating, år, tittel) – alltid på server
+- [x] Detaljside med anmeldelser og skjema for ny anmeldelse
+- [x] "Min liste"-side
+- [x] State i URL, tilbake-knapp bevarer scroll
+- [x] Tomt/ingen treff/feil-tilstander med "prøv igjen"
+- [x] Mørk modus (følger system), responsivt ned til 320 px
+
+## M4 – Kvalitet
+
+- [x] Komponenttester for søk, liste, filter, detaljside, anmeldelsesskjema
+- [x] Playwright E2E: søk → filtrer → sorter → scroll → detalj → skriv anmeldelse → se den i lista
+- [x] axe-sjekk i Playwright uten feil på alle sider
+- [x] Lighthouse ≥ 90 på alle kategorier (resultat i `docs/ytelse.md`)
+- [x] Alle tester, lint og typecheck grønne fra ren klon (`npm ci`)
+
+## M5 – Deploy-klargjøring (kjøres ikke mot VM uten Nicolay)
+
+- [x] `docs/deploy.md`: Postgres-oppsett, systemd-fil for backend, Apache-konfig for `/project2`, gzip/cache-headere
+- [x] `deploy/`-mappe med systemd-unit og Apache-konfig ferdig utfylt for `it2810-17.idi.ntnu.no`
+
+## M6 – Se lovlige filmer fra Internet Archive
+
+Kun filmer som Internet Archive markerer som fri bruk (public domain eller Creative Commons). Vi
+lenker til og strømmer fra archive.org og lagrer ingen video selv.
+
+- [x] Backend: tabell `title_streams` (title_id, archive_id, fil, lisens, varighet) + migrering
+- [x] Importskript `db:archive`: hent filmer fra Internet Archive-samlinger med fri lisens, koble til IMDb-titler via IMDb-ID i metadata (ellers tittel + år), velg en MP4-fil som nettlesere kan spille. Idempotent, testet mot falsk Archive-server
+- [x] API: `Title.stream { url, license, archiveUrl }` og filter `availableOnly` (+ fasettantall)
+- [x] Frontend: filter «Kun filmer du kan se», rad «Se gratis nå» på forsiden, merke på plakater
+- [x] Videospiller på `/watch/:id`: spill/pause, spoling (tidslinje og ±10 s), volum og demping, fullskjerm, tastatur (mellomrom, piler, M, F), undertekster når de finnes, lisens og kilde under spilleren
+- [x] Tester: import og kobling, API, spillerkontroller (komponent), E2E med liten testvideo, axe
+- [x] Deploy: `setup-vm.sh` kjører `db:archive` etter importen
+
+## Ferdig
+
+- [x] Oppsummering i `docs/status.md`: hva er gjort, hva gjenstår, hva Nicolay må gjøre
diff --git a/README.md b/README.md
index 077b789..e7e6cc2 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,78 @@
-# T17-Project-2
+# Filmsøk – IT2810 prosjekt 2, gruppe 17
-Repository for team IT2810-H26-T17 in IT2810-H26.
+Søk, filtrer og anmeld over 190 000 filmer og serier fra IMDb, med plakater fra TMDB.
-## Working in this repository
+**🔗 Kjørende versjon:** (NTNU-nett eller VPN)
-`main` is protected — you cannot push to it directly. Work on a branch and
-open a pull request:
+## Arbeidsfordeling
+
+| Navn | Rolle |
+| ------- | -------------------------------------------------------------------- |
+| Nicolay | Prosjektleder. Arkitektur, GraphQL-API og React, frontend og backend |
+| Strula | Backendansvarlig: database, import og resolvere |
+| Brage | Frontendansvarlig: komponenter, design og tilgjengelighet |
+| Daniel | Testansvarlig: enhets-, komponent- og E2E-tester |
+
+## Funksjonalitet
+
+- Søk på tittel med 300 ms debounce, uten skille på store og små bokstaver
+- Filter på sjanger, tiår, type og rating, med antall treff per valg
+- Sortering på relevans, rating, år og tittel, alltid på serveren
+- Uendelig scroll med «Last flere»-knapp
+- Detaljside, anmeldelser (1–5 stjerner) og «Min liste»
+- Søk og filtre i URL-en, mørk og lys modus, responsivt ned til 320 px
+
+## Teknologi
+
+**Frontend:** React, TypeScript, Vite, Apollo Client · **Backend:** Node.js, GraphQL (Yoga) ·
+**Database:** PostgreSQL med `pg_trgm` · **Test:** Vitest, Testing Library, Playwright, axe
+
+All søk, filtrering og sortering skjer i SQL med cursor-paginering. API-et er beskrevet i
+[`docs/api.md`](docs/api.md).
+
+## Pipeline
+
+```
+feature-gren ─▶ pre-commit ─▶ pull request ─▶ CI ─▶ main ─▶ deploy til VM
+```
+
+1. **Pre-commit (Husky + lint-staged):** ESLint og Prettier på endrede filer.
+2. **CI (`.gitlab-ci.yml`):** lint, typecheck og enhetstester mot PostgreSQL.
+3. **Review og merge** til `main`.
+4. **Deploy med én kommando:**
+ ```bash
+ ssh -t @it2810-17.idi.ntnu.no bash T17-Project2/deploy/oppdater.sh
+ ```
+ Kommandoen henter siste kode, bygger, migrerer databasen, importerer data, henter plakater og
+ starter backend (systemd) og Apache på nytt. Den stopper med feilmelding hvis API-et ikke svarer.
+
+## Kjøre lokalt
```bash
-git checkout -b my-feature
-git push -u origin my-feature
+npm ci
+cp .env.example .env
+npm run db:migrate && npm run db:seed # krever IMDb-filene i data/ (se docs/oppsett.md)
+npm run dev # http://localhost:5173/project2/
```
-Before a pull request can be merged:
+## Testing
+
+| Type | Antall | Kommando |
+| ------------------- | -----: | ------------------ |
+| Backend (API og DB) | 209 | `npm test` |
+| Komponenter | 111 | `npm test` |
+| E2E + axe | 48 | `npm run test:e2e` |
+
+E2E-testene dekker hele flyten fra søk til anmeldelse, på desktop og mobil, i lys og mørk
+modus.
+
+## Dokumentasjon
+
+[Oppsett](docs/oppsett.md) · [Deploy](docs/deploy.md) · [API](docs/api.md) ·
+[Beslutninger](docs/beslutninger.md) · [Ytelse](docs/ytelse.md) · [KI-logg](docs/ki-logg.md)
+
+Prosjektet er utviklet med Claude Code som KI-assistent. Bruken er logget i
+[`docs/ki-logg.md`](docs/ki-logg.md).
-1. **A member of IT2810-H26-T17 must approve it.** You cannot approve your own pull
- request, and approvals from staff do not count — you review each other's
- code.
-2. **Every review comment must be marked resolved.** Reply to each one, fix it
- or explain why not, then click *Resolve conversation*.
-3. **Pushing new commits dismisses the approval**, so if you change something
- after being approved, ask for another review. This is deliberate: what was
- approved is what gets merged.
\ No newline at end of file
+_Bilder og beskrivelser fra TMDB. Produktet bruker TMDB-API-et, men er ikke godkjent eller
+sertifisert av TMDB._
diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql
new file mode 100644
index 0000000..d5110fe
--- /dev/null
+++ b/backend/migrations/001_init.sql
@@ -0,0 +1,77 @@
+-- Skjema for titler, sjangre, anmeldelser og «min liste».
+
+-- pg_trgm gir trigram-indekser som kan brukes av LIKE '%tekst%' (vanlig btree kan ikke det)
+-- og similarity() for relevanssortering.
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+CREATE TABLE titles (
+ id text PRIMARY KEY, -- IMDb tconst, f.eks. tt0111161
+ title_type text NOT NULL CHECK (title_type IN ('movie', 'series')),
+ primary_title text NOT NULL,
+ original_title text NOT NULL,
+ start_year integer,
+ end_year integer,
+ runtime_minutes integer,
+ average_rating numeric(3,1) CHECK (average_rating BETWEEN 0 AND 10),
+ num_votes integer NOT NULL DEFAULT 0,
+ -- Denormalisert kopi av sjangrene (kilden er title_genres). Gir sjangerfilter med
+ -- `genres @> ARRAY[...]` + GIN uten join, og sjangre til en hel resultatside uten ekstra spørring.
+ genres text[] NOT NULL DEFAULT '{}',
+ -- Tiår som lagret kolonne: filtrering på tiår blir likhet på en indeksert kolonne i stedet for
+ -- range-uttrykk, og fasettene kan gruppere direkte på den.
+ start_decade integer GENERATED ALWAYS AS (start_year / 10 * 10) STORED
+);
+
+CREATE TABLE genres (
+ id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ name text NOT NULL UNIQUE
+);
+
+CREATE TABLE title_genres (
+ title_id text NOT NULL REFERENCES titles (id) ON DELETE CASCADE,
+ genre_id integer NOT NULL REFERENCES genres (id) ON DELETE CASCADE,
+ PRIMARY KEY (title_id, genre_id)
+);
+
+CREATE TABLE reviews (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ title_id text NOT NULL REFERENCES titles (id) ON DELETE CASCADE,
+ user_id uuid NOT NULL,
+ author text NOT NULL CHECK (char_length(author) BETWEEN 1 AND 50),
+ rating smallint NOT NULL CHECK (rating BETWEEN 1 AND 5),
+ body text NOT NULL DEFAULT '' CHECK (char_length(body) <= 2000),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE list_items (
+ user_id uuid NOT NULL,
+ title_id text NOT NULL REFERENCES titles (id) ON DELETE CASCADE,
+ added_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (user_id, title_id)
+);
+
+-- Tekstsøk: uttrykkene må være identiske med de i søkespørringen (lower(...)) for at
+-- planleggeren skal kunne bruke indeksene.
+CREATE INDEX titles_primary_title_trgm_idx ON titles USING gin (lower(primary_title) gin_trgm_ops);
+CREATE INDEX titles_original_title_trgm_idx ON titles USING gin (lower(original_title) gin_trgm_ops);
+
+-- Filtre.
+CREATE INDEX titles_genres_gin_idx ON titles USING gin (genres);
+CREATE INDEX titles_decade_idx ON titles (start_decade);
+CREATE INDEX titles_type_idx ON titles (title_type);
+
+-- Sorteringer. Uttrykkene speiler ORDER BY / keyset-sammenligningen i src/search.ts, og id som
+-- siste kolonne er tiebreakeren. btree kan skannes begge veier, så én indeks dekker ASC og DESC.
+CREATE INDEX titles_votes_idx ON titles (num_votes, id);
+CREATE INDEX titles_rating_idx ON titles ((COALESCE(average_rating, -1)), num_votes, id);
+CREATE INDEX titles_year_idx ON titles ((COALESCE(start_year, 0)), num_votes, id);
+CREATE INDEX titles_title_idx ON titles ((lower(primary_title)), id);
+
+-- title_genres slås opp fra sjangersiden (PK dekker title_id -> genre_id).
+CREATE INDEX title_genres_genre_idx ON title_genres (genre_id, title_id);
+
+-- Anmeldelser: nyeste først per tittel, og aggregater (antall/snitt) per tittel.
+CREATE INDEX reviews_title_created_idx ON reviews (title_id, created_at DESC, id DESC);
+
+-- «Min liste»: sist lagt til først per bruker.
+CREATE INDEX list_items_user_added_idx ON list_items (user_id, added_at DESC, title_id DESC);
diff --git a/backend/migrations/002_title_artwork.sql b/backend/migrations/002_title_artwork.sql
new file mode 100644
index 0000000..7fc07f1
--- /dev/null
+++ b/backend/migrations/002_title_artwork.sql
@@ -0,0 +1,12 @@
+-- Bilder og handling fra TMDB, hentet ved første behov og lagret her (se docs/beslutninger.md).
+-- Egen tabell i stedet for kolonner på titles: titles er en ren IMDb-kopi som kan importeres
+-- på nytt, mens dette er hentet fra en ekstern tjeneste med egen levetid.
+CREATE TABLE title_artwork (
+ title_id text PRIMARY KEY REFERENCES titles (id) ON DELETE CASCADE,
+ -- 'missing' huskes slik at titler TMDB ikke kjenner ikke slås opp igjen ved hvert søk.
+ status text NOT NULL CHECK (status IN ('found', 'missing')),
+ poster_path text,
+ backdrop_path text,
+ overview text,
+ fetched_at timestamptz NOT NULL DEFAULT now()
+);
diff --git a/backend/migrations/003_title_streams.sql b/backend/migrations/003_title_streams.sql
new file mode 100644
index 0000000..fd7e305
--- /dev/null
+++ b/backend/migrations/003_title_streams.sql
@@ -0,0 +1,15 @@
+-- Lovlige gratisversjoner fra Internet Archive (se docs/beslutninger.md, «Strømming»).
+-- Egen tabell, som title_artwork: titles er en reimporterbar IMDb-kopi, mens dette er koblinger
+-- vi har bygget selv og som må overleve en ny IMDb-import.
+-- Vi lagrer bare pekere (element-id og filnavn). Selve videoen strømmes fra archive.org.
+CREATE TABLE title_streams (
+ -- Én stream per tittel: PK gir også oppslaget EXISTS(...) i søk og fasetter som ren indeksprobe.
+ title_id text PRIMARY KEY REFERENCES titles (id) ON DELETE CASCADE,
+ archive_id text NOT NULL,
+ file_name text NOT NULL,
+ license text NOT NULL,
+ license_url text,
+ duration_seconds integer,
+ subtitles_file text,
+ imported_at timestamptz NOT NULL DEFAULT now()
+);
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..d913daa
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "backend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc -p tsconfig.build.json",
+ "start": "node dist/src/index.js",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "db:migrate": "tsx scripts/migrate.ts",
+ "db:seed": "tsx scripts/import-imdb.ts",
+ "db:fixture": "tsx scripts/generate-fixture.ts",
+ "db:artwork": "tsx scripts/prefetch-artwork.ts",
+ "db:archive": "tsx scripts/import-archive.ts",
+ "tmdb:mock": "tsx scripts/tmdb-mock.ts",
+ "archive:mock": "tsx scripts/archive-mock.ts"
+ },
+ "devDependencies": {
+ "@types/node": "^26.6.3",
+ "@types/pg": "^8.23.1",
+ "tsx": "^4.23.15",
+ "typescript": "~6.0",
+ "vitest": "^5.0.2"
+ },
+ "dependencies": {
+ "dotenv": "^18.0.4",
+ "graphql": "^17.0.2",
+ "graphql-yoga": "^5.24.1",
+ "pg": "^8.23.0"
+ }
+}
diff --git a/backend/scripts/archive-mock.ts b/backend/scripts/archive-mock.ts
new file mode 100644
index 0000000..4dcd253
--- /dev/null
+++ b/backend/scripts/archive-mock.ts
@@ -0,0 +1,390 @@
+import { createReadStream } from 'node:fs';
+import { stat } from 'node:fs/promises';
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { repoRoot } from '../src/config.js';
+
+/**
+ * Falsk Internet Archive for utvikling, E2E og tester: internett er ikke alltid tilgjengelig, og
+ * importen skal kunne testes uten å belaste archive.org. Svarer med samme form som Scrape API,
+ * metadata-API og nedlasting (med Range-støtte, som nettleserens videospiller krever for spoling).
+ * Elementene er oppdiktede, men koblet til titler i det syntetiske datasettet.
+ */
+
+export interface MockFile {
+ name: string;
+ format: string;
+ size?: string;
+ length?: string;
+ source?: string;
+}
+
+export interface MockItem {
+ /** Feltene Scrape API gir tilbake. */
+ scrape: Record & { identifier: string };
+ files: MockFile[];
+}
+
+const CC_BY = 'https://creativecommons.org/licenses/by/4.0/';
+const PD_MARK = 'https://creativecommons.org/publicdomain/mark/1.0/';
+const CC0 = 'https://creativecommons.org/publicdomain/zero/1.0/';
+
+const junk = (id: string): MockFile[] => [
+ { name: `${id}.mkv`, format: 'Matroska', size: '2147483648', source: 'original', length: '5400' },
+ { name: `${id}_meta.xml`, format: 'Metadata', size: '900' },
+ { name: `${id}.torrent`, format: 'Archive BitTorrent', size: '12000' },
+ { name: '__ia_thumb.jpg', format: 'Item Tile', size: '8000' },
+];
+// Videobytene er WebM, så de spillbare filene heter .webm; ellers ville Content-Type løyet.
+const video = (id: string, length: string): MockFile => ({
+ name: `${id}.webm`,
+ format: 'WebM',
+ size: '412000',
+ length,
+ source: 'derivative',
+});
+
+/** Koblet til FAMOUS-titlene i scripts/generate-fixture.ts (finnes i project2 og E2E-basen). */
+export const DEFAULT_ITEMS: MockItem[] = [
+ {
+ // IMDb-ID i metadata + CC BY.
+ scrape: {
+ identifier: 'mock-shawshank',
+ title: 'The Shawshank Redemption (mock)',
+ year: '1994',
+ licenseurl: CC_BY,
+ 'external-identifier': ['urn:imdb:tt0111161', 'urn:oclc:record:1234'],
+ collection: ['opensource_movies'],
+ },
+ files: [...junk('mock-shawshank'), video('mock-shawshank', '8.5')],
+ },
+ {
+ // Kuratert samling uten lisens-URL, IMDb-ID.
+ scrape: {
+ identifier: 'mock-godfather',
+ title: 'The Godfather',
+ year: '1972',
+ 'external-identifier': 'urn:imdb:tt0068646',
+ collection: ['feature_films', 'movies'],
+ },
+ files: [...junk('mock-godfather'), video('mock-godfather', '00:00:08')],
+ },
+ {
+ // Ingen IMDb-ID: kobles på tittel + år.
+ scrape: {
+ identifier: 'mock-matrix',
+ title: 'The Matrix',
+ year: 1999,
+ licenseurl: CC0,
+ collection: 'film_noir',
+ },
+ files: [...junk('mock-matrix'), video('mock-matrix', '8')],
+ },
+ {
+ // Tittel med tegnsetting, og år bare i `date` og ett år feil (±1).
+ scrape: {
+ identifier: 'mock-pulp-fiction',
+ title: 'Pulp Fiction!',
+ date: '1995-01-01T00:00:00Z',
+ licenseurl: PD_MARK,
+ collection: ['silent_films'],
+ },
+ files: [...junk('mock-pulp-fiction'), video('mock-pulp-fiction', '0:08')],
+ },
+ {
+ // Aksenter i tittelen og undertekster.
+ scrape: {
+ identifier: 'mock-amelie',
+ title: 'Amelie',
+ year: '2001',
+ licenseurl: CC_BY,
+ collection: ['opensource_movies'],
+ },
+ files: [
+ ...junk('mock-amelie'),
+ video('mock-amelie', '8.0'),
+ { name: 'mock-amelie.en.vtt', format: 'WebVTT', size: '400' },
+ ],
+ },
+ {
+ // Ulovlig: «alle rettigheter forbeholdt», ikke i kuratert samling. Skal hoppes over.
+ scrape: {
+ identifier: 'mock-restricted',
+ title: 'Oldboy',
+ year: '2003',
+ licenseurl: 'http://example.com/all-rights-reserved',
+ 'external-identifier': 'urn:imdb:tt0364569',
+ collection: ['classic_tv'],
+ },
+ files: [...junk('mock-restricted'), video('mock-restricted', '8')],
+ },
+ {
+ // Lovlig og koblet, men ingen fil nettleseren kan spille.
+ scrape: {
+ identifier: 'mock-no-video',
+ title: 'Grave of the Fireflies',
+ year: '1988',
+ licenseurl: CC_BY,
+ 'external-identifier': 'urn:imdb:tt0095327',
+ },
+ files: [
+ { name: 'mock-no-video.mkv', format: 'Matroska', size: '900000000' },
+ { name: 'mock-no-video_meta.xml', format: 'Metadata' },
+ ],
+ },
+ {
+ // Lovlig, men finnes ikke hos IMDb-titlene våre.
+ scrape: {
+ identifier: 'mock-unknown-film',
+ title: 'A Film Nobody Indexed',
+ year: '1931',
+ collection: ['feature_films'],
+ },
+ files: [...junk('mock-unknown-film'), video('mock-unknown-film', '8')],
+ },
+];
+
+const WEBVTT = `WEBVTT
+
+00:00:00.000 --> 00:00:04.000
+Testtekst fra den falske Internet Archive.
+
+00:00:04.000 --> 00:00:08.000
+Andre replikk.
+`;
+
+export interface MockState {
+ /** Alle forespørsler (sti + query), i rekkefølge. */
+ calls: string[];
+ /** Stier som svarer 503 én gang (for å teste retry). */
+ failOnce: Set;
+ /** Stier som svarer for sent én gang (for å teste tidsgrense). */
+ slowOnce: Set;
+ /** Stier som alltid svarer 500. */
+ alwaysFail: Set;
+}
+
+export interface ArchiveMock {
+ port: number;
+ server: Server;
+ state: MockState;
+ close: () => Promise;
+}
+
+export interface ArchiveMockOptions {
+ items?: MockItem[];
+ /** Videofilen som serveres for alle videoer. Standard: e2e/fixtures/test-video.webm. */
+ videoPath?: string;
+ /** Maks elementer per Scrape-side (sidene deles opp med cursor). */
+ pageSize?: number;
+ /** Hvor lenge «for sent» varer for slowOnce. */
+ slowMs?: number;
+}
+
+const TYPES: Record = {
+ '.webm': 'video/webm',
+ '.mp4': 'video/mp4',
+ '.ogv': 'video/ogg',
+ '.mkv': 'video/x-matroska',
+ '.vtt': 'text/vtt; charset=utf-8',
+};
+
+/** `bytes=a-b`, `bytes=a-` og `bytes=-n`. null = ugyldig eller utenfor filen (416). */
+export function parseRange(header: string, size: number): { start: number; end: number } | null {
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
+ if (!m || (m[1] === '' && m[2] === '')) return null;
+ let start: number;
+ let end: number;
+ if (m[1] === '') {
+ const n = Number(m[2]);
+ if (n === 0) return null;
+ start = Math.max(0, size - n);
+ end = size - 1;
+ } else {
+ start = Number(m[1]);
+ end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
+ }
+ return start <= end && start < size ? { start, end } : null;
+}
+
+export async function startArchiveMock(
+ port = 0,
+ opts: ArchiveMockOptions = {},
+): Promise {
+ const items = opts.items ?? DEFAULT_ITEMS;
+ const videoPath = opts.videoPath ?? path.join(repoRoot, 'e2e', 'fixtures', 'test-video.webm');
+ const pageSize = opts.pageSize ?? 1000;
+ const state: MockState = {
+ calls: [],
+ failOnce: new Set(),
+ slowOnce: new Set(),
+ alwaysFail: new Set(),
+ };
+
+ const server = createServer((req, res) => {
+ void handle(req, res).catch(() => {
+ if (!res.headersSent) res.writeHead(500);
+ res.end();
+ });
+ });
+
+ async function handle(req: IncomingMessage, res: ServerResponse) {
+ const url = new URL(req.url ?? '/', 'http://localhost');
+ // Åpen CORS: spilleren i nettleseren henter video og undertekster fra en annen opprinnelse.
+ res.setHeader('access-control-allow-origin', '*');
+ res.setHeader('access-control-expose-headers', 'accept-ranges, content-range, content-length');
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, {
+ 'access-control-allow-methods': 'GET, HEAD, OPTIONS',
+ 'access-control-allow-headers': 'range, content-type',
+ });
+ return res.end();
+ }
+ const json = (status: number, body: unknown) => {
+ res.writeHead(status, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+ state.calls.push(url.pathname + url.search);
+
+ if (url.pathname.startsWith('/services/') || url.pathname.startsWith('/metadata/')) {
+ if (state.alwaysFail.has(url.pathname)) return json(500, { error: 'Mock failure' });
+ if (state.failOnce.delete(url.pathname)) return json(503, { error: 'Mock unavailable' });
+ if (state.slowOnce.delete(url.pathname)) {
+ await new Promise((r) => setTimeout(r, opts.slowMs ?? 500));
+ }
+ }
+
+ if (url.pathname === '/services/search/v1/scrape') {
+ const count = Math.max(1, Math.min(Number(url.searchParams.get('count')) || 1000, pageSize));
+ const offset = Number(Buffer.from(url.searchParams.get('cursor') ?? '', 'base64url')) || 0;
+ const wanted = (url.searchParams.get('fields') ?? 'identifier').split(',');
+ const slice = items.slice(offset, offset + count);
+ const body: Record = {
+ items: slice.map((i) =>
+ Object.fromEntries(
+ Object.entries(i.scrape).filter(([k]) => k === 'identifier' || wanted.includes(k)),
+ ),
+ ),
+ count: slice.length,
+ total: items.length,
+ };
+ // Som ekte Scrape API: cursor finnes bare når det er mer å hente.
+ if (offset + count < items.length) {
+ body.cursor = Buffer.from(String(offset + count)).toString('base64url');
+ }
+ return json(200, body);
+ }
+
+ const meta = /^\/metadata\/([^/]+)$/.exec(url.pathname);
+ if (meta) {
+ const item = items.find((i) => i.scrape.identifier === decodeURIComponent(meta[1]!));
+ // Ekte Archive svarer 200 med {} for ukjente elementer.
+ if (!item) return json(200, {});
+ return json(200, {
+ created: 1700000000,
+ d1: 'ia800000.us.archive.org',
+ dir: `/0/items/${item.scrape.identifier}`,
+ files: item.files,
+ metadata: { identifier: item.scrape.identifier, mediatype: 'movies' },
+ });
+ }
+
+ const details = /^\/details\/([^/]+)$/.exec(url.pathname);
+ if (details) {
+ const id = decodeURIComponent(details[1]!).replace(/[^A-Za-z0-9._-]/g, '');
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ return res.end(
+ `${id}
Falsk Internet Archive: ${id}
`,
+ );
+ }
+
+ const download = /^\/download\/([^/]+)\/([^/]+)$/.exec(url.pathname);
+ if (download && (req.method === 'GET' || req.method === 'HEAD')) {
+ const item = items.find((i) => i.scrape.identifier === decodeURIComponent(download[1]!));
+ const name = decodeURIComponent(download[2]!);
+ const file = item?.files.find((f) => f.name === name);
+ const ext = path.extname(name).toLowerCase();
+ if (!file || !TYPES[ext]) {
+ res.writeHead(404, { 'content-type': 'text/plain' });
+ return res.end('Not found');
+ }
+ if (ext === '.vtt') {
+ res.writeHead(200, {
+ 'content-type': TYPES[ext]!,
+ 'content-length': Buffer.byteLength(WEBVTT),
+ });
+ return res.end(req.method === 'HEAD' ? undefined : WEBVTT);
+ }
+ return serveVideo(req, res, videoPath, TYPES[ext]!);
+ }
+
+ json(404, { error: 'Not found' });
+ }
+
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(port, resolve);
+ });
+ return {
+ port: (server.address() as AddressInfo).port,
+ server,
+ state,
+ close: () =>
+ new Promise((resolve) => {
+ server.closeAllConnections();
+ server.close(() => resolve());
+ }),
+ };
+}
+
+async function serveVideo(
+ req: IncomingMessage,
+ res: ServerResponse,
+ file: string,
+ contentType: string,
+) {
+ let size: number;
+ try {
+ size = (await stat(file)).size;
+ } catch {
+ res.writeHead(503, { 'content-type': 'text/plain' });
+ return res.end(`Testvideoen mangler: ${file}`);
+ }
+ const base = { 'content-type': contentType, 'accept-ranges': 'bytes' };
+ const rangeHeader = req.headers.range;
+ if (rangeHeader === undefined) {
+ res.writeHead(200, { ...base, 'content-length': size });
+ if (req.method === 'HEAD') return res.end();
+ return createReadStream(file).pipe(res);
+ }
+ const range = parseRange(rangeHeader, size);
+ if (!range) {
+ res.writeHead(416, { ...base, 'content-range': `bytes */${size}` });
+ return res.end();
+ }
+ res.writeHead(206, {
+ ...base,
+ 'content-range': `bytes ${range.start}-${range.end}/${size}`,
+ 'content-length': range.end - range.start + 1,
+ });
+ if (req.method === 'HEAD') return res.end();
+ createReadStream(file, { start: range.start, end: range.end }).pipe(res);
+}
+
+async function main() {
+ const port = Number.parseInt(process.env.ARCHIVE_MOCK_PORT ?? '', 10) || 3998;
+ const mock = await startArchiveMock(port);
+ console.log(`Falsk Internet Archive på http://localhost:${mock.port}`);
+ const stop = () => void mock.close().then(() => process.exit(0));
+ process.on('SIGINT', stop);
+ process.on('SIGTERM', stop);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/generate-fixture.ts b/backend/scripts/generate-fixture.ts
new file mode 100644
index 0000000..e5b27cd
--- /dev/null
+++ b/backend/scripts/generate-fixture.ts
@@ -0,0 +1,496 @@
+import { createWriteStream, mkdirSync } from 'node:fs';
+import path from 'node:path';
+import { once } from 'node:events';
+import { pathToFileURL } from 'node:url';
+import { createGzip } from 'node:zlib';
+import { config } from '../src/config.js';
+import { BASICS_HEADER, RATINGS_HEADER } from './import-core.js';
+
+/**
+ * Genererer syntetiske filer i NØYAKTIG IMDb-format, fordi datasets.imdbws.com ikke er
+ * tilgjengelig i alle miljøer. Samme importkode brukes for ekte og syntetiske data.
+ * Deterministisk: samme seed gir byte-identiske filer.
+ */
+
+// mulberry32: liten, rask PRNG med god nok fordeling for testdata.
+export function createRng(seed: number) {
+ let a = seed >>> 0;
+ const next = () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ return {
+ next,
+ int: (min: number, max: number) => min + Math.floor(next() * (max - min + 1)),
+ pick: (arr: readonly T[]): T => arr[Math.floor(next() * arr.length)] as T,
+ /** Standard normalfordeling (Box-Muller). */
+ gauss: () => Math.sqrt(-2 * Math.log(1 - next())) * Math.cos(2 * Math.PI * next()),
+ };
+}
+type Rng = ReturnType;
+
+const GENRES = [
+ 'Action',
+ 'Adventure',
+ 'Animation',
+ 'Biography',
+ 'Comedy',
+ 'Crime',
+ 'Documentary',
+ 'Drama',
+ 'Family',
+ 'Fantasy',
+ 'History',
+ 'Horror',
+ 'Music',
+ 'Musical',
+ 'Mystery',
+ 'Romance',
+ 'Sci-Fi',
+ 'Sport',
+ 'Thriller',
+ 'War',
+ 'Western',
+] as const;
+
+// Vektet: Drama og Comedy er langt vanligere enn Western og Sport, som i ekte data.
+const GENRE_WEIGHTS: Record = {
+ Drama: 30,
+ Comedy: 22,
+ Action: 12,
+ Documentary: 10,
+ Thriller: 9,
+ Romance: 9,
+ Crime: 8,
+ Horror: 8,
+ Adventure: 7,
+ Mystery: 5,
+ 'Sci-Fi': 4,
+ Fantasy: 4,
+ Family: 4,
+ Animation: 4,
+ Biography: 3,
+ History: 3,
+ Music: 3,
+ War: 2,
+ Musical: 2,
+ Western: 2,
+ Sport: 2,
+};
+const WEIGHTED_GENRES = GENRES.flatMap((g) => Array(GENRE_WEIGHTS[g] ?? 1).fill(g));
+
+const ADJECTIVES = [
+ 'Dark',
+ 'Silent',
+ 'Last',
+ 'Lost',
+ 'Golden',
+ 'Broken',
+ 'Hidden',
+ 'Eternal',
+ 'Crimson',
+ 'Wild',
+ 'Frozen',
+ 'Burning',
+ 'Secret',
+ 'Final',
+ 'Endless',
+ 'Forgotten',
+ 'Savage',
+ 'Gentle',
+ 'Electric',
+ 'Midnight',
+ 'Distant',
+ 'Hollow',
+ 'Bitter',
+ 'Scarlet',
+ 'Iron',
+ 'Velvet',
+ 'Restless',
+ 'Sacred',
+ 'Fallen',
+ 'Brave',
+ 'Cold',
+ 'Blind',
+ 'Rising',
+ 'Infinite',
+ 'Quiet',
+ 'Neon',
+];
+const NOUNS = [
+ 'Knight',
+ 'City',
+ 'River',
+ 'Garden',
+ 'Empire',
+ 'Storm',
+ 'Mirror',
+ 'Highway',
+ 'Harvest',
+ 'Ghost',
+ 'Kingdom',
+ 'Island',
+ 'Letter',
+ 'Promise',
+ 'Shadow',
+ 'Horizon',
+ 'Witness',
+ 'Voyage',
+ 'Symphony',
+ 'Circus',
+ 'Detective',
+ 'Bridge',
+ 'Season',
+ 'Desert',
+ 'Lighthouse',
+ 'Warrior',
+ 'Dream',
+ 'Harbor',
+ 'Frontier',
+ 'Assassin',
+ 'Orchard',
+ 'Machine',
+ 'Summer',
+ 'Winter',
+ 'Tower',
+ 'Legacy',
+ 'Rebellion',
+ 'Cathedral',
+ 'Diary',
+ 'Fortune',
+];
+const PLACES = [
+ 'Paris',
+ 'Oslo',
+ 'Tokyo',
+ 'Cairo',
+ 'Lisbon',
+ 'Havana',
+ 'Bergen',
+ 'Vienna',
+ 'Casablanca',
+ 'Montréal',
+ 'São Paulo',
+ 'Zürich',
+ 'København',
+ 'Kraków',
+ 'Málaga',
+ 'Reykjavík',
+ 'Bogotá',
+];
+const PEOPLE = [
+ 'Anna',
+ 'Marco',
+ 'Élodie',
+ 'Søren',
+ 'Ingrid',
+ 'Zoë',
+ 'Nikolai',
+ 'Amélie',
+ 'Renée',
+ 'Lars',
+ 'Chloé',
+ 'José',
+ 'Mateo',
+ 'Freya',
+ 'Björn',
+];
+const SUBTITLES = [
+ 'The Beginning',
+ 'Redemption',
+ 'Origins',
+ 'Reckoning',
+ 'A New Dawn',
+ 'Part II',
+ 'Homecoming',
+ 'The Final Chapter',
+ 'Vengeance',
+ 'Awakening',
+];
+// Utenlandske originaltitler, med aksenter og spesialtegn for å teste tegnsett og søk.
+const FOREIGN = [
+ 'Le Café des Rêves',
+ 'Ça commence ici',
+ 'Über den Wolken',
+ 'El Niño perdido',
+ 'Der letzte Zug',
+ 'La Vie en Rose',
+ 'Ångström',
+ 'Il ragazzo dell’Est',
+ 'Hôtel du Nord',
+ 'Où es-tu, Élise ?',
+];
+
+function makeTitle(rng: Rng): string {
+ const r = rng.next();
+ const adj = rng.pick(ADJECTIVES);
+ const noun = rng.pick(NOUNS);
+ if (r < 0.2) return `The ${adj} ${noun}`;
+ if (r < 0.32) return `${adj} ${noun}`;
+ if (r < 0.42) return `${noun} of ${rng.pick(NOUNS)}s`;
+ if (r < 0.5) return `${rng.pick(PEOPLE)}'s ${noun}`;
+ if (r < 0.58) return `The ${adj} ${noun}: ${rng.pick(SUBTITLES)}`;
+ if (r < 0.64) return `Return to ${rng.pick(PLACES)}`;
+ if (r < 0.7) return `A ${noun} in ${rng.pick(PLACES)}`;
+ if (r < 0.75) return `${rng.pick(PEOPLE)} & ${rng.pick(PEOPLE)}`;
+ if (r < 0.79) return `Café ${rng.pick(PLACES)}`;
+ if (r < 0.82) return `${rng.pick(PEOPLE)}: ${adj} ${noun}`;
+ if (r < 0.85) return `${adj} ${noun} ${rng.int(2, 5)}`;
+ if (r < 0.88) return `Don't Look ${rng.pick(['Back', 'Up', 'Down'])}, ${rng.pick(PEOPLE)}`;
+ if (r < 0.91) return `Night ${rng.pick(['in', 'at', 'over'])} ${rng.pick(PLACES)}`;
+ return `${adj} ${noun}s`;
+}
+
+function makeGenres(rng: Rng): string {
+ const n = rng.pick([1, 1, 2, 2, 2, 3, 3]);
+ const set = new Set();
+ while (set.size < n) set.add(rng.pick(WEIGHTED_GENRES));
+ return [...set].join(',');
+}
+
+interface BasicsRow {
+ tconst: string;
+ titleType: string;
+ primaryTitle: string;
+ originalTitle: string;
+ isAdult: string;
+ startYear: string;
+ endYear: string;
+ runtimeMinutes: string;
+ genres: string;
+}
+interface RatingRow {
+ tconst: string;
+ averageRating: string;
+ numVotes: string;
+}
+interface Entry {
+ basics: BasicsRow;
+ rating: RatingRow | null;
+}
+
+const N = '\\N';
+
+// Kjente titler så søk og E2E-tester har noe gjenkjennelig å lete etter.
+const FAMOUS: [string, string, string, string, string, string, string, number, number][] = [
+ // tconst, type, tittel, år, sluttår, minutter, sjangre, rating, stemmer
+ ['tt0111161', 'movie', 'The Shawshank Redemption', '1994', N, '142', 'Drama', 9.3, 2900000],
+ ['tt0068646', 'movie', 'The Godfather', '1972', N, '175', 'Crime,Drama', 9.2, 2000000],
+ ['tt0468569', 'movie', 'The Dark Knight', '2008', N, '152', 'Action,Crime,Drama', 9.0, 2900000],
+ [
+ 'tt0903747',
+ 'tvSeries',
+ 'Breaking Bad',
+ '2008',
+ '2013',
+ '49',
+ 'Crime,Drama,Thriller',
+ 9.5,
+ 2200000,
+ ],
+ ['tt0133093', 'movie', 'The Matrix', '1999', N, '136', 'Action,Sci-Fi', 8.7, 2100000],
+ ['tt0110912', 'movie', 'Pulp Fiction', '1994', N, '154', 'Crime,Drama', 8.9, 2200000],
+ ['tt0211915', 'movie', 'Amélie', '2001', N, '122', 'Comedy,Romance', 8.3, 800000],
+ [
+ 'tt0944947',
+ 'tvSeries',
+ 'Game of Thrones',
+ '2011',
+ '2019',
+ '57',
+ 'Action,Adventure,Drama',
+ 9.2,
+ 2400000,
+ ],
+ [
+ 'tt0167260',
+ 'movie',
+ 'The Lord of the Rings: The Return of the King',
+ '2003',
+ N,
+ '201',
+ 'Action,Adventure,Drama',
+ 9.0,
+ 1900000,
+ ],
+ ['tt5491994', 'tvMiniSeries', 'Planet Earth II', '2016', N, '50', 'Documentary', 9.5, 150000],
+ [
+ 'tt0095327',
+ 'movie',
+ 'Grave of the Fireflies',
+ '1988',
+ N,
+ '89',
+ 'Animation,Drama,War',
+ 8.5,
+ 300000,
+ ],
+ ['tt0109830', 'movie', 'Forrest Gump', '1994', N, '142', 'Drama,Romance', 8.8, 2300000],
+ ['tt0407887', 'movie', 'The Departed', '2006', N, '151', 'Crime,Drama,Thriller', 8.5, 1400000],
+ ['tt0364569', 'movie', 'Oldboy', '2003', N, '120', 'Action,Drama,Mystery', 8.3, 600000],
+];
+
+function pad(n: number): string {
+ return String(n).padStart(7, '0');
+}
+
+export function generateEntries(size: number, seed = 2810): Entry[] {
+ const rng = createRng(seed);
+ const entries: Entry[] = [];
+ const usedIds = new Set(FAMOUS.map((f) => f[0]));
+
+ for (const [tconst, type, title, start, end, runtime, genres, rating, votes] of FAMOUS) {
+ entries.push({
+ basics: {
+ tconst,
+ titleType: type,
+ primaryTitle: title,
+ originalTitle: title,
+ isAdult: '0',
+ startYear: start,
+ endYear: end,
+ runtimeMinutes: runtime,
+ genres,
+ },
+ rating: { tconst, averageRating: rating.toFixed(1), numVotes: String(votes) },
+ });
+ }
+
+ let idNum = 1;
+ const nextId = () => {
+ idNum += rng.int(1, 40);
+ let id = `tt${pad(idNum)}`;
+ while (usedIds.has(id)) id = `tt${pad(++idNum)}`;
+ return id;
+ };
+
+ const keep = size - FAMOUS.length;
+ const addRow = (kind: 'keep' | 'noise') => {
+ let type: string;
+ let votes: number;
+ if (kind === 'keep') {
+ const r = rng.next();
+ type = r < 0.78 ? 'movie' : r < 0.96 ? 'tvSeries' : 'tvMiniSeries';
+ // Log-normal fordeling: mange obskure titler nær 100 stemmer, noen få med millioner.
+ votes = Math.min(2_500_000, 100 + Math.floor(Math.exp(rng.gauss() * 1.7 + 5.2)));
+ } else {
+ const r = rng.next();
+ // Typer og tilfeller importen skal filtrere bort.
+ type =
+ r < 0.4
+ ? 'tvEpisode'
+ : r < 0.55
+ ? 'short'
+ : r < 0.65
+ ? 'video'
+ : r < 0.72
+ ? 'videoGame'
+ : r < 0.8
+ ? 'tvSpecial'
+ : r < 0.9
+ ? 'tvMovie'
+ : rng.pick(['movie', 'tvSeries']);
+ // Bare «movie/tvSeries» av støy-radene er filtrert bort på stemmer.
+ votes = type === 'movie' || type === 'tvSeries' ? rng.int(5, 99) : rng.int(5, 5000);
+ }
+ const tconst = nextId();
+ const primary = makeTitle(rng);
+ const foreign = rng.next() < 0.1;
+ const year = Math.min(2025, Math.max(1920, Math.round(2000 + rng.gauss() * 22)));
+ const isSeries = type === 'tvSeries' || type === 'tvMiniSeries';
+ const ended = isSeries && rng.next() < 0.6;
+ const missingYear = rng.next() < 0.01;
+ const rating = Math.min(9.9, Math.max(1.0, 6.3 + rng.gauss() * 1.1));
+ entries.push({
+ basics: {
+ tconst,
+ titleType: type,
+ primaryTitle: primary,
+ originalTitle: foreign ? rng.pick(FOREIGN) : primary,
+ isAdult: rng.next() < 0.01 ? '1' : '0',
+ startYear: missingYear ? N : String(year),
+ endYear: ended && !missingYear ? String(Math.min(2025, year + rng.int(1, 12))) : N,
+ runtimeMinutes:
+ rng.next() < 0.03 ? N : String(isSeries ? rng.int(20, 60) : rng.int(70, 180)),
+ genres: rng.next() < 0.02 ? N : makeGenres(rng),
+ },
+ rating: { tconst, averageRating: rating.toFixed(1), numVotes: String(votes) },
+ });
+ };
+
+ for (let i = 0; i < keep; i++) addRow('keep');
+ // ~ 35 % støy oppå: episoder, kortfilmer, spill og titler med for få stemmer.
+ for (let i = 0; i < Math.round(size * 0.35); i++) addRow('noise');
+
+ // Kanttilfeller: tittel uten rating i ratings-fila, og rating uten tittel i basics.
+ entries.push({
+ basics: {
+ tconst: nextId(),
+ titleType: 'movie',
+ primaryTitle: 'Uten Rating',
+ originalTitle: 'Uten Rating',
+ isAdult: '0',
+ startYear: '2020',
+ endYear: N,
+ runtimeMinutes: N,
+ genres: N,
+ },
+ rating: null,
+ });
+
+ entries.sort((a, b) => (a.basics.tconst < b.basics.tconst ? -1 : 1));
+ return entries;
+}
+
+async function writeGz(file: string, header: string[], lines: Iterable) {
+ const gzip = createGzip();
+ const out = createWriteStream(file);
+ gzip.pipe(out);
+ const write = async (parts: string[]) => {
+ // Respekterer backpressure så store filer ikke bygger opp i minnet.
+ if (!gzip.write(parts.join('\t') + '\n')) await once(gzip, 'drain');
+ };
+ await write(header);
+ for (const l of lines) await write(l);
+ gzip.end();
+ await once(out, 'finish');
+}
+
+export async function generateFixture(dir: string, size: number, seed = 2810): Promise {
+ mkdirSync(dir, { recursive: true });
+ const entries = generateEntries(size, seed);
+ await writeGz(
+ path.join(dir, 'title.basics.tsv.gz'),
+ BASICS_HEADER,
+ entries.map((e) => BASICS_HEADER.map((h) => e.basics[h as keyof BasicsRow])),
+ );
+ const rated = entries.filter((e) => e.rating !== null);
+ // Rating uten matchende tittel (finnes i ekte data, f.eks. for fjernede titler).
+ const orphan: RatingRow = { tconst: 'tt9999999', averageRating: '7.0', numVotes: '1000' };
+ const ratingRows = [...rated.map((e) => e.rating as RatingRow), orphan].sort((a, b) =>
+ a.tconst < b.tconst ? -1 : 1,
+ );
+ await writeGz(
+ path.join(dir, 'title.ratings.tsv.gz'),
+ RATINGS_HEADER,
+ ratingRows.map((r) => RATINGS_HEADER.map((h) => r[h as keyof RatingRow])),
+ );
+}
+
+async function main() {
+ const size = Number(process.env.FIXTURE_SIZE ?? process.argv[2] ?? 120_000);
+ if (!Number.isInteger(size) || size < 20) throw new Error('FIXTURE_SIZE må være heltall ≥ 20.');
+ console.log(`Genererer ca. ${size} titler i ${config.imdbDataDir} ...`);
+ await generateFixture(config.imdbDataDir, size);
+ console.log('Ferdig: title.basics.tsv.gz og title.ratings.tsv.gz');
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/import-archive.test.ts b/backend/scripts/import-archive.test.ts
new file mode 100644
index 0000000..f73c85b
--- /dev/null
+++ b/backend/scripts/import-archive.test.ts
@@ -0,0 +1,256 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildTitleIndex,
+ chooseFiles,
+ extractImdbId,
+ extractYear,
+ isLawful,
+ licenseLabel,
+ licenseLink,
+ linkItem,
+ matchByTitle,
+ normalizeTitle,
+ parseDuration,
+} from './import-archive.js';
+
+describe('isLawful', () => {
+ it('godtar Creative Commons og public domain-erklæringer', () => {
+ expect(isLawful({ licenseurl: 'https://creativecommons.org/licenses/by/4.0/' })).toBe(true);
+ expect(isLawful({ licenseurl: 'http://creativecommons.org/licenses/by-nc-nd/3.0/' })).toBe(
+ true,
+ );
+ expect(isLawful({ licenseurl: 'creativecommons.org/licenses/by/2.0' })).toBe(true);
+ expect(isLawful({ licenseurl: 'https://creativecommons.org/publicdomain/zero/1.0/' })).toBe(
+ true,
+ );
+ expect(isLawful({ licenseurl: 'http://www.usa.gov/publicdomain/label/1.0/' })).toBe(true);
+ expect(
+ isLawful({ licenseurl: ['http://example.com/x', 'https://creativecommons.org/a'] }),
+ ).toBe(true);
+ });
+
+ it('godtar kuraterte samlinger uten lisens-URL, som streng eller liste', () => {
+ for (const c of ['feature_films', 'film_noir', 'silent_films']) {
+ expect(isLawful({ collection: c })).toBe(true);
+ expect(isLawful({ collection: ['movies', c] })).toBe(true);
+ }
+ });
+
+ it('avviser alt annet', () => {
+ expect(isLawful({})).toBe(false);
+ expect(isLawful({ collection: 'classic_tv' })).toBe(false);
+ expect(isLawful({ collection: ['movies', 'opensource_movies'] })).toBe(false);
+ expect(isLawful({ licenseurl: 'http://example.com/all-rights-reserved' })).toBe(false);
+ expect(isLawful({ licenseurl: '' })).toBe(false);
+ expect(isLawful({ licenseurl: null, collection: null })).toBe(false);
+ });
+
+ it('lar seg ikke lure av en annen vert som nevner creativecommons.org', () => {
+ expect(
+ isLawful({ licenseurl: 'http://evil.example/creativecommons.org/licenses/by/4.0' }),
+ ).toBe(false);
+ expect(isLawful({ licenseurl: 'http://creativecommons.org.evil.example/x' })).toBe(false);
+ });
+});
+
+describe('licenseLabel / licenseLink', () => {
+ it('mapper lisens-URL til kort tekst', () => {
+ const l = (licenseurl: string) => licenseLabel({ licenseurl });
+ expect(l('https://creativecommons.org/licenses/by/4.0/')).toBe('CC BY 4.0');
+ expect(l('http://creativecommons.org/licenses/by-sa/3.0/de/')).toBe('CC BY-SA 3.0');
+ expect(l('https://creativecommons.org/licenses/by-nc-nd/2.5/')).toBe('CC BY-NC-ND 2.5');
+ expect(l('https://creativecommons.org/publicdomain/zero/1.0/')).toBe('CC0');
+ expect(l('https://creativecommons.org/publicdomain/mark/1.0/')).toBe('Public Domain');
+ expect(l('http://www.usa.gov/publicdomain/label/1.0/')).toBe('Public Domain');
+ expect(l('https://creativecommons.org/weird')).toBe('Creative Commons');
+ });
+
+ it('kuratert samling uten lisens-URL er Public Domain', () => {
+ expect(licenseLabel({ collection: 'feature_films' })).toBe('Public Domain');
+ expect(licenseLink({ collection: 'feature_films' })).toBeNull();
+ });
+
+ it('gir lisenslenken videre', () => {
+ expect(licenseLink({ licenseurl: 'https://creativecommons.org/licenses/by/4.0/' })).toBe(
+ 'https://creativecommons.org/licenses/by/4.0/',
+ );
+ });
+});
+
+describe('normalizeTitle', () => {
+ it('fjerner aksenter, tegnsetting, store bokstaver og ledende the/a', () => {
+ expect(normalizeTitle('Amélie')).toBe('amelie');
+ expect(normalizeTitle('The Godfather')).toBe('godfather');
+ expect(normalizeTitle('A Trip to the Moon')).toBe('trip to the moon');
+ expect(normalizeTitle("Don't Look Up!")).toBe('don t look up');
+ expect(normalizeTitle(' Pulp Fiction: ')).toBe('pulp fiction');
+ expect(normalizeTitle('Æon Flux')).toBe('æon flux');
+ });
+ it('rører ikke the/a midt i tittelen, og «The» alene beholdes', () => {
+ expect(normalizeTitle('Into the Wild')).toBe('into the wild');
+ expect(normalizeTitle('The')).toBe('the');
+ expect(normalizeTitle('!!!')).toBe('');
+ });
+});
+
+describe('extractImdbId / extractYear', () => {
+ it('finner urn:imdb i streng eller liste', () => {
+ expect(extractImdbId('urn:imdb:tt0111161')).toBe('tt0111161');
+ expect(extractImdbId(['urn:oclc:record:1', 'URN:IMDB:TT0068646'])).toBe('tt0068646');
+ expect(extractImdbId('urn:imdb:nm0000001')).toBeNull();
+ expect(extractImdbId('urn:imdb:tt12')).toBeNull();
+ expect(extractImdbId(undefined)).toBeNull();
+ });
+ it('leser år fra year, ellers date', () => {
+ expect(extractYear({ year: '1944' })).toBe(1944);
+ expect(extractYear({ year: 1944 })).toBe(1944);
+ expect(extractYear({ date: '1931-01-01T00:00:00Z' })).toBe(1931);
+ expect(extractYear({ year: 'unknown', date: '1950-05-05' })).toBe(1950);
+ expect(extractYear({ year: '0000' })).toBeNull();
+ expect(extractYear({})).toBeNull();
+ });
+});
+
+describe('kobling', () => {
+ const index = buildTitleIndex([
+ { id: 'tt1', primary_title: 'Metropolis', original_title: 'Metropolis', start_year: 1927 },
+ {
+ id: 'tt2',
+ primary_title: 'Nosferatu',
+ original_title: 'Nosferatu, eine Symphonie',
+ start_year: 1922,
+ },
+ { id: 'tt3', primary_title: 'Dark City', original_title: 'Dark City', start_year: 1950 },
+ { id: 'tt4', primary_title: 'Dark City', original_title: 'Dark City', start_year: 1998 },
+ { id: 'tt5', primary_title: 'Dark City', original_title: 'Dark City', start_year: 1999 },
+ { id: 'tt6', primary_title: 'Yearless', original_title: 'Yearless', start_year: null },
+ ]);
+
+ it('matcher på normalisert tittel og år ±1', () => {
+ expect(matchByTitle(index, 'METROPOLIS!', 1927)).toBe('tt1');
+ expect(matchByTitle(index, 'The Metropolis', 1926)).toBe('tt1');
+ expect(matchByTitle(index, 'Metropolis', 1928)).toBe('tt1');
+ expect(matchByTitle(index, 'Metropolis', 1929)).toBeNull();
+ });
+ it('matcher også originaltittel', () => {
+ expect(matchByTitle(index, 'Nosferatu, eine Symphonie', 1922)).toBe('tt2');
+ });
+ it('hopper over tvetydige treff, manglende år og titler uten treff', () => {
+ expect(matchByTitle(index, 'Dark City', 1998)).toBeNull(); // tt4 og tt5 innen ±1
+ expect(matchByTitle(index, 'Dark City', 1950)).toBe('tt3');
+ expect(matchByTitle(index, 'Metropolis', null)).toBeNull();
+ expect(matchByTitle(index, 'Yearless', 1990)).toBeNull();
+ expect(matchByTitle(index, '???', 1990)).toBeNull();
+ });
+
+ it('IMDb-ID slår tittel, og ukjent ID faller tilbake til tittel + år', () => {
+ const known = { hasId: (id: string) => id === 'tt0000009', titles: index };
+ expect(
+ linkItem(
+ {
+ identifier: 'a',
+ title: 'Metropolis',
+ year: '1927',
+ 'external-identifier': 'urn:imdb:tt0000009',
+ },
+ known,
+ ),
+ ).toEqual({ titleId: 'tt0000009', via: 'imdb' });
+ expect(
+ linkItem(
+ {
+ identifier: 'a',
+ title: 'Metropolis',
+ year: '1927',
+ 'external-identifier': 'urn:imdb:tt0000008',
+ },
+ known,
+ ),
+ ).toEqual({ titleId: 'tt1', via: 'title' });
+ expect(linkItem({ identifier: 'a', title: 'Ukjent', year: '1927' }, known)).toBeNull();
+ expect(linkItem({ identifier: 'a', year: '1927' }, known)).toBeNull();
+ });
+});
+
+describe('parseDuration', () => {
+ it('leser sekunder og tid', () => {
+ expect(parseDuration('5401.23')).toBe(5401);
+ expect(parseDuration('90')).toBe(90);
+ expect(parseDuration(61.6)).toBe(62);
+ expect(parseDuration('01:30:01')).toBe(5401);
+ expect(parseDuration('90:01')).toBe(5401);
+ expect(parseDuration('0:08')).toBe(8);
+ expect(parseDuration(' 00:00:08.4 ')).toBe(8);
+ });
+ it('gir null for ugyldig, null og urimelig', () => {
+ for (const v of ['', 'abc', '0', '-5', '1:2:3:4', undefined, null, NaN, {}, '999999']) {
+ expect(parseDuration(v)).toBeNull();
+ }
+ });
+});
+
+describe('chooseFiles', () => {
+ const f = (name: string, format: string, size?: number, extra = {}) => ({
+ name,
+ format,
+ ...(size ? { size: String(size) } : {}),
+ ...extra,
+ });
+
+ it('foretrekker h.264 foran 512Kb foran stor MPEG4-original', () => {
+ const files = [
+ f('film.mpg', 'MPEG2', 9e9),
+ f('film.mp4', 'MPEG4', 4e9, { source: 'original', length: '5400.5' }),
+ f('film_512kb.mp4', '512Kb MPEG4', 4e8),
+ f('film.ia.mp4', 'h.264 IA', 1e9),
+ ];
+ expect(chooseFiles(files)?.fileName).toBe('film.ia.mp4');
+ expect(chooseFiles(files.slice(0, 3))?.fileName).toBe('film_512kb.mp4');
+ expect(chooseFiles(files.slice(0, 2))?.fileName).toBe('film.mp4');
+ });
+ it('velger minste fil innen samme klasse', () => {
+ const files = [f('a.mp4', 'h.264', 2e9), f('b.mp4', 'h.264', 1e9)];
+ expect(chooseFiles(files)?.fileName).toBe('b.mp4');
+ });
+ it('mp4 foran webm foran ogv', () => {
+ expect(chooseFiles([f('a.ogv', 'Ogg Video'), f('a.webm', 'WebM')])?.fileName).toBe('a.webm');
+ expect(chooseFiles([f('a.webm', 'WebM'), f('a.mp4', 'h.264')])?.fileName).toBe('a.mp4');
+ });
+ it('tar også ukjente mp4-formater som siste utvei', () => {
+ expect(chooseFiles([f('a.mp4', 'Something')])?.fileName).toBe('a.mp4');
+ });
+ it('hopper over filer i undermapper og med farlige navn', () => {
+ expect(chooseFiles([f('dir/a.mp4', 'h.264'), f('../b.mp4', 'h.264')])).toBeNull();
+ expect(chooseFiles([f('dir/a.mp4', 'h.264'), f('c.webm', 'WebM')])?.fileName).toBe('c.webm');
+ });
+ it('gir null uten spillbar fil eller ugyldig liste', () => {
+ expect(chooseFiles([f('a.mkv', 'Matroska'), f('a.avi', 'AVI'), f('a.jpg', 'JPEG')])).toBeNull();
+ expect(chooseFiles([])).toBeNull();
+ expect(chooseFiles(undefined)).toBeNull();
+ expect(chooseFiles({})).toBeNull();
+ expect(chooseFiles([null, 3, 'x'])).toBeNull();
+ });
+ it('henter varighet fra valgt fil, ellers fra en annen fil i elementet', () => {
+ expect(chooseFiles([f('a.mp4', 'h.264', 1, { length: '01:00:00' })])?.durationSeconds).toBe(
+ 3600,
+ );
+ expect(
+ chooseFiles([f('a.mp4', 'h.264'), f('a.mkv', 'Matroska', 1, { length: '120.4' })])
+ ?.durationSeconds,
+ ).toBe(120);
+ expect(chooseFiles([f('a.mp4', 'h.264')])?.durationSeconds).toBeNull();
+ });
+ it('velger .vtt, helst med samme grunnnavn', () => {
+ const files = [
+ f('film.mp4', 'h.264'),
+ f('other.vtt', 'WebVTT'),
+ f('film.en.vtt', 'WebVTT'),
+ f('dir/x.vtt', 'WebVTT'),
+ ];
+ expect(chooseFiles(files)?.subtitlesFile).toBe('film.en.vtt');
+ expect(chooseFiles([f('film.mp4', 'h.264'), f('z.vtt', 'WebVTT')])?.subtitlesFile).toBe(
+ 'z.vtt',
+ );
+ expect(chooseFiles([f('film.mp4', 'h.264'), f('a.srt', 'SubRip')])?.subtitlesFile).toBeNull();
+ });
+});
diff --git a/backend/scripts/import-archive.ts b/backend/scripts/import-archive.ts
new file mode 100644
index 0000000..1ef1b58
--- /dev/null
+++ b/backend/scripts/import-archive.ts
@@ -0,0 +1,545 @@
+import { pathToFileURL } from 'node:url';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+import { isSafeArchiveId, isSafeFileName } from '../src/stream.js';
+
+/**
+ * Importerer lovlige gratisfilmer fra Internet Archive til title_streams. Vi lagrer bare pekere
+ * (element-id og filnavn); videoen strømmes fra archive.org. Se docs/beslutninger.md for
+ * lisensregelen og koblingen til IMDb-titler. Trygt å kjøre flere ganger (upsert).
+ */
+
+// --- Lisensregel --------------------------------------------------------------------------
+
+/** Kuraterte samlinger der Internet Archive selv kun tar imot public domain-filmer. */
+export const CURATED_COLLECTIONS = ['feature_films', 'film_noir', 'silent_films'] as const;
+
+export type Field = string | number | (string | number)[] | null | undefined;
+
+/** Scrape-API-et gir enten én verdi eller en liste, avhengig av hvor mange elementet har. */
+function values(field: Field): string[] {
+ if (field === null || field === undefined) return [];
+ return (Array.isArray(field) ? field : [field]).map(String).filter((v) => v.length > 0);
+}
+
+function licenseUrls(field: Field): URL[] {
+ const urls: URL[] = [];
+ for (const v of values(field)) {
+ try {
+ // Noen elementer har «creativecommons.org/…» uten skjema.
+ urls.push(new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v.trim() : `http://${v.trim()}`));
+ } catch {
+ // Ugyldig URL = ingen lisens å stole på.
+ }
+ }
+ return urls;
+}
+
+const isCreativeCommons = (u: URL) =>
+ u.hostname === 'creativecommons.org' || u.hostname.endsWith('.creativecommons.org');
+const isPublicDomainUrl = (u: URL) => u.href.toLowerCase().includes('publicdomain');
+
+export interface LicenseInput {
+ licenseurl?: Field;
+ collection?: Field;
+}
+
+/**
+ * Er elementet fritt å vise? Ja hvis lisens-URL-en er Creative Commons (verten er
+ * creativecommons.org, ikke bare en URL som nevner det) eller en public domain-erklæring, eller
+ * hvis elementet ligger i en kuratert public domain-samling. Alt annet, også elementer uten
+ * lisens, er ikke lovlig for oss.
+ */
+export function isLawful(item: LicenseInput): boolean {
+ if (licenseUrls(item.licenseurl).some((u) => isCreativeCommons(u) || isPublicDomainUrl(u))) {
+ return true;
+ }
+ const collections = new Set(values(item.collection).map((c) => c.toLowerCase()));
+ return CURATED_COLLECTIONS.some((c) => collections.has(c));
+}
+
+/** Menneskelesbar lisens til visning under spilleren. Forutsetter isLawful(item). */
+export function licenseLabel(item: LicenseInput): string {
+ for (const u of licenseUrls(item.licenseurl)) {
+ if (!isCreativeCommons(u) && !isPublicDomainUrl(u)) continue;
+ const path = u.pathname.toLowerCase();
+ if (path.includes('/publicdomain/zero')) return 'CC0';
+ if (path.includes('/publicdomain')) return 'Public Domain';
+ const m = /\/licenses\/([a-z-]+)\/(\d+\.\d+)/.exec(path);
+ if (m) return `CC ${m[1]!.toUpperCase()} ${m[2]}`;
+ if (isPublicDomainUrl(u)) return 'Public Domain';
+ return 'Creative Commons';
+ }
+ return 'Public Domain';
+}
+
+/** Lisens-URL vi viser videre, bare hvis den er en vanlig http(s)-lenke. */
+export function licenseLink(item: LicenseInput): string | null {
+ const u = licenseUrls(item.licenseurl).find((x) => isCreativeCommons(x) || isPublicDomainUrl(x));
+ return u && (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : null;
+}
+
+// --- Kobling til IMDb-titler ------------------------------------------------------------
+
+/**
+ * Sammenligningsnøkkel for tittel: uten aksenter, tegnsetting og ledende «the»/«a», slik at
+ * «The Amélie!» og «amelie» blir like. Tom streng betyr at tittelen ikke kan brukes til kobling.
+ */
+export function normalizeTitle(title: string): string {
+ const base = title
+ .normalize('NFKD')
+ .replace(/\p{M}+/gu, '')
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
+ .trim();
+ return base.replace(/^(the|a)\s+/, '');
+}
+
+export function extractImdbId(field: Field): string | null {
+ for (const v of values(field)) {
+ const m = /urn:imdb:(tt\d{7,9})\b/i.exec(v);
+ if (m) return m[1]!.toLowerCase();
+ }
+ return null;
+}
+
+/** År fra `year`, ellers fra `date` (ISO-tekst). */
+export function extractYear(item: { year?: Field; date?: Field }): number | null {
+ for (const v of [...values(item.year), ...values(item.date)]) {
+ const m = /^\s*(\d{4})/.exec(v);
+ if (m) {
+ const y = Number(m[1]);
+ if (y >= 1850 && y <= 2100) return y;
+ }
+ }
+ return null;
+}
+
+export interface TitleEntry {
+ id: string;
+ year: number | null;
+}
+
+/** normalisert tittel -> IMDb-filmer med den tittelen (primær- og originaltittel). */
+export type TitleIndex = Map;
+
+export function buildTitleIndex(
+ rows: { id: string; primary_title: string; original_title: string; start_year: number | null }[],
+): TitleIndex {
+ const index: TitleIndex = new Map();
+ for (const r of rows) {
+ for (const t of new Set([r.primary_title, r.original_title])) {
+ const key = normalizeTitle(t);
+ if (!key) continue;
+ const list = index.get(key);
+ const entry = { id: r.id, year: r.start_year };
+ if (list) list.push(entry);
+ else index.set(key, [entry]);
+ }
+ }
+ return index;
+}
+
+/**
+ * Tittel + år ±1 (Archive og IMDb er sjelden enige om årstallet). Bare ved entydig treff: en
+ * feil kobling ville vist feil film under en annen films side, så tvetydige hoppes over.
+ */
+export function matchByTitle(index: TitleIndex, title: string, year: number | null): string | null {
+ if (year === null) return null;
+ const key = normalizeTitle(title);
+ if (!key) return null;
+ const ids = new Set(
+ (index.get(key) ?? [])
+ .filter((e) => e.year !== null && Math.abs(e.year - year) <= 1)
+ .map((e) => e.id),
+ );
+ return ids.size === 1 ? [...ids][0]! : null;
+}
+
+export interface ArchiveItem extends LicenseInput {
+ identifier: string;
+ title?: Field;
+ year?: Field;
+ date?: Field;
+ 'external-identifier'?: Field;
+}
+
+export interface Link {
+ titleId: string;
+ via: 'imdb' | 'title';
+}
+
+/**
+ * 1) IMDb-ID i external-identifier hvis tittelen finnes hos oss, 2) ellers tittel + år.
+ * En IMDb-ID vi ikke kjenner faller videre til tittel + år (IDen kan gjelde en tittel vi
+ * ikke har importert, men tittelen kan likevel finnes under en annen id).
+ */
+export function linkItem(
+ item: ArchiveItem,
+ known: { hasId: (id: string) => boolean; titles: TitleIndex },
+): Link | null {
+ const imdb = extractImdbId(item['external-identifier']);
+ if (imdb && known.hasId(imdb)) return { titleId: imdb, via: 'imdb' };
+ const title = values(item.title)[0];
+ if (!title) return null;
+ const id = matchByTitle(known.titles, title, extractYear(item));
+ return id ? { titleId: id, via: 'title' } : null;
+}
+
+// --- Filvalg ------------------------------------------------------------------------------
+
+export interface ArchiveFile {
+ name?: string;
+ format?: string;
+ size?: string | number;
+ length?: string | number;
+ source?: string;
+}
+
+export interface ChosenFile {
+ fileName: string;
+ durationSeconds: number | null;
+ subtitlesFile: string | null;
+}
+
+/** `length` er sekunder («5401.23») eller tid («01:30:01», «90:01»). Ugyldig eller 0 = null. */
+export function parseDuration(value: unknown): number | null {
+ if (typeof value === 'number') {
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : null;
+ }
+ if (typeof value !== 'string') return null;
+ const text = value.trim();
+ let seconds: number;
+ if (/^\d+(\.\d+)?$/.test(text)) seconds = Number(text);
+ else if (/^\d+(:\d{1,2}){1,2}(\.\d+)?$/.test(text)) {
+ seconds = text.split(':').reduce((acc, part) => acc * 60 + Number(part), 0);
+ } else return null;
+ return seconds > 0 && seconds < 24 * 3600 ? Math.round(seconds) : null;
+}
+
+const MP4_FORMATS: Record = {
+ // Lavere tall = bedre. h.264 er Archives eget strømmeformat (moderat størrelse), 512Kb er
+ // minst (bærekraft), og «MPEG4» er ofte den store originalen.
+ 'h.264': 0,
+ 'h.264 ia': 0,
+ '512kb mpeg4': 1,
+ mpeg4: 2,
+};
+
+function rank(file: ArchiveFile): number | null {
+ const name = file.name ?? '';
+ if (!isSafeFileName(name)) return null;
+ const lower = name.toLowerCase();
+ const format = (file.format ?? '').toLowerCase();
+ // Nettleserne spiller MP4 (H.264) overalt; WebM og Ogg er reserve. Rekkefølgen er
+ // container først, deretter formatklasse.
+ if (lower.endsWith('.mp4')) return 10 + (MP4_FORMATS[format] ?? 3);
+ if (lower.endsWith('.webm')) return 20;
+ if (lower.endsWith('.ogv')) return 30;
+ return null;
+}
+
+const sizeOf = (f: ArchiveFile) => {
+ const n = Number(f.size);
+ return Number.isFinite(n) && n > 0 ? n : Number.MAX_SAFE_INTEGER;
+};
+
+/** Beste spillbare fil i nettleseren, eller null. Mindre filer foretrekkes innen samme klasse. */
+export function chooseFiles(files: unknown): ChosenFile | null {
+ if (!Array.isArray(files)) return null;
+ const list = files.filter((f): f is ArchiveFile => typeof f === 'object' && f !== null);
+ const candidates = list
+ .map((file) => ({ file, r: rank(file) }))
+ .filter((c): c is { file: ArchiveFile; r: number } => c.r !== null)
+ .sort(
+ (a, b) =>
+ a.r - b.r || sizeOf(a.file) - sizeOf(b.file) || a.file.name!.localeCompare(b.file.name!),
+ );
+ const best = candidates[0]?.file;
+ if (!best) return null;
+
+ const base = best.name!.replace(/\.[^.]+$/, '').toLowerCase();
+ const vtts = list
+ .filter((f) => typeof f.name === 'string' && f.name.toLowerCase().endsWith('.vtt'))
+ .filter((f) => isSafeFileName(f.name!))
+ .sort((a, b) => a.name!.localeCompare(b.name!));
+ // Undertekst med samme grunnnavn som videoen hører til akkurat den videoen.
+ const vtt = vtts.find((f) => f.name!.toLowerCase().startsWith(base)) ?? vtts[0];
+
+ // Varigheten står ofte bare på originalfilen, ikke på avledede versjoner.
+ const duration =
+ parseDuration(best.length) ?? list.map((f) => parseDuration(f.length)).find((d) => d !== null);
+ return {
+ fileName: best.name!,
+ durationSeconds: duration ?? null,
+ subtitlesFile: vtt?.name ?? null,
+ };
+}
+
+// --- HTTP ---------------------------------------------------------------------------------
+
+export const CANDIDATE_QUERY =
+ 'mediatype:movies AND (collection:feature_films OR collection:film_noir OR collection:silent_films OR collection:classic_tv OR licenseurl:*creativecommons* OR licenseurl:*publicdomain*)';
+
+const SCRAPE_FIELDS = 'identifier,title,year,date,licenseurl,external-identifier,collection';
+
+export interface HttpOptions {
+ fetch?: typeof fetch;
+ timeoutMs: number;
+ /** Minste tid mellom starten på to kall (alle arbeidere sammen). */
+ paceMs: number;
+ retryDelayMs: number;
+}
+
+/** Deler taktgiveren mellom samtidige arbeidere, slik at 4 tråder ikke gir 4x belastning. */
+class Pacer {
+ private next = 0;
+ constructor(private readonly ms: number) {}
+ async wait(): Promise {
+ const now = Date.now();
+ const at = Math.max(now, this.next);
+ this.next = at + this.ms;
+ if (at > now) await new Promise((r) => setTimeout(r, at - now));
+ }
+}
+
+class HttpError extends Error {
+ constructor(readonly status: number) {
+ super(`HTTP ${status}`);
+ }
+}
+
+function makeGetJson(opts: HttpOptions) {
+ const pacer = new Pacer(opts.paceMs);
+ const fetchImpl = opts.fetch ?? fetch;
+ const once = async (url: string): Promise => {
+ await pacer.wait();
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs);
+ try {
+ const res = await fetchImpl(url, {
+ headers: {
+ accept: 'application/json',
+ 'user-agent': 'it2810-project2-import/1.0 (student project; metadata only)',
+ },
+ signal: ctrl.signal,
+ });
+ if (!res.ok) {
+ await res.body?.cancel().catch(() => {});
+ throw new HttpError(res.status);
+ }
+ return await res.json();
+ } finally {
+ clearTimeout(timer);
+ }
+ };
+ return async (url: string): Promise => {
+ try {
+ return await once(url);
+ } catch (err) {
+ // Retry én gang for det som kan gå over av seg selv: 5xx, 429, timeout og nettverksfeil.
+ // 404 og andre 4xx er endelige svar.
+ const retryable = !(err instanceof HttpError) || err.status >= 500 || err.status === 429;
+ if (!retryable) throw err;
+ await new Promise((r) => setTimeout(r, opts.retryDelayMs));
+ return once(url);
+ }
+ };
+}
+
+// --- Import -------------------------------------------------------------------------------
+
+export interface ImportSummary {
+ scanned: number;
+ unlawful: number;
+ unlinked: number;
+ duplicates: number;
+ noFile: number;
+ failed: number;
+ imported: number;
+}
+
+export interface ImportOptions {
+ pool: Pool;
+ archiveUrl?: string;
+ /** Stopp innsamlingen når så mange koblede kandidater er funnet (til testing). */
+ limit?: number;
+ concurrency?: number;
+ paceMs?: number;
+ timeoutMs?: number;
+ retryDelayMs?: number;
+ pageSize?: number;
+ fetch?: typeof fetch;
+ signal?: { aborted: boolean };
+ log?: (msg: string) => void;
+}
+
+interface Candidate {
+ item: ArchiveItem;
+ link: Link;
+}
+
+export async function importArchive(opts: ImportOptions): Promise {
+ const { pool } = opts;
+ const base = (opts.archiveUrl ?? config.archiveUrl).replace(/\/+$/, '');
+ const log = opts.log ?? (() => {});
+ const getJson = makeGetJson({
+ fetch: opts.fetch,
+ timeoutMs: opts.timeoutMs ?? 20_000,
+ paceMs: opts.paceMs ?? 150,
+ retryDelayMs: opts.retryDelayMs ?? 1000,
+ });
+ const summary: ImportSummary = {
+ scanned: 0,
+ unlawful: 0,
+ unlinked: 0,
+ duplicates: 0,
+ noFile: 0,
+ failed: 0,
+ imported: 0,
+ };
+
+ // Alle filmer i minnet er billigere enn ett DB-kall per kandidat (normaliseringen skjer i JS).
+ const { rows: movieRows } = await pool.query<{
+ id: string;
+ primary_title: string;
+ original_title: string;
+ start_year: number | null;
+ }>("SELECT id, primary_title, original_title, start_year FROM titles WHERE title_type = 'movie'");
+ const titles = buildTitleIndex(movieRows);
+
+ // 1) Samle kandidater fra Scrape API (billig: 1000 per kall) og koble dem.
+ const claimed = new Map();
+ let cursor: string | undefined;
+ collect: do {
+ const url = new URL(`${base}/services/search/v1/scrape`);
+ url.searchParams.set('q', CANDIDATE_QUERY);
+ url.searchParams.set('fields', SCRAPE_FIELDS);
+ url.searchParams.set('count', String(opts.pageSize ?? 1000));
+ if (cursor) url.searchParams.set('cursor', cursor);
+ const page = (await getJson(url.toString())) as { items?: unknown; cursor?: unknown };
+ const items = Array.isArray(page.items) ? (page.items as ArchiveItem[]) : [];
+
+ // Én spørring per side for IMDb-ID-ene, i stedet for ett per element.
+ const wanted = [
+ ...new Set(items.map((i) => extractImdbId(i['external-identifier'])).filter((x) => x)),
+ ] as string[];
+ const existing = new Set();
+ if (wanted.length > 0) {
+ const { rows } = await pool.query<{ id: string }>(
+ 'SELECT id FROM titles WHERE id = ANY($1::text[])',
+ [wanted],
+ );
+ for (const r of rows) existing.add(r.id);
+ }
+
+ for (const item of items) {
+ if (opts.signal?.aborted) break collect;
+ summary.scanned++;
+ if (typeof item.identifier !== 'string' || !isSafeArchiveId(item.identifier)) continue;
+ if (!isLawful(item)) {
+ summary.unlawful++;
+ continue;
+ }
+ const link = linkItem(item, { hasId: (id) => existing.has(id), titles });
+ if (!link) {
+ summary.unlinked++;
+ continue;
+ }
+ // Flere Archive-elementer for samme film: IMDb-ID slår tittelkobling, ellers vinner det
+ // første, så en ny kjøring gir samme utfall.
+ const prev = claimed.get(link.titleId);
+ if (!prev || (prev.link.via === 'title' && link.via === 'imdb')) {
+ claimed.set(link.titleId, { item, link });
+ }
+ if (prev) summary.duplicates++;
+ if (opts.limit !== undefined && claimed.size >= opts.limit) break collect;
+ }
+ cursor =
+ typeof page.cursor === 'string' && page.cursor && items.length > 0 ? page.cursor : undefined;
+ log(`Skannet ${summary.scanned} elementer, ${claimed.size} koblet til IMDb-titler.`);
+ } while (cursor);
+
+ // 2) Hent filliste per koblet kandidat og lagre.
+ const todo = [...claimed.values()];
+ let next = 0;
+ let done = 0;
+ const worker = async () => {
+ while (!opts.signal?.aborted) {
+ const cand = todo[next++];
+ if (!cand) return;
+ const id = cand.item.identifier;
+ try {
+ const meta = (await getJson(`${base}/metadata/${encodeURIComponent(id)}`)) as {
+ files?: unknown;
+ } | null;
+ const chosen = chooseFiles(meta?.files);
+ if (!chosen) {
+ summary.noFile++;
+ log(`Hopper over ${id}: ingen spillbar fil.`);
+ } else {
+ await upsert(pool, cand, chosen);
+ summary.imported++;
+ }
+ } catch (err) {
+ summary.failed++;
+ log(`Feilet for ${id}: ${(err as Error).message}`);
+ }
+ if (++done % 25 === 0) log(`${done}/${todo.length} behandlet.`);
+ }
+ };
+ await Promise.all(Array.from({ length: opts.concurrency ?? 4 }, worker));
+ return summary;
+}
+
+async function upsert(pool: Pool, cand: Candidate, chosen: ChosenFile): Promise {
+ await pool.query(
+ `INSERT INTO title_streams
+ (title_id, archive_id, file_name, license, license_url, duration_seconds, subtitles_file)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (title_id) DO UPDATE SET
+ archive_id = EXCLUDED.archive_id, file_name = EXCLUDED.file_name,
+ license = EXCLUDED.license, license_url = EXCLUDED.license_url,
+ duration_seconds = EXCLUDED.duration_seconds, subtitles_file = EXCLUDED.subtitles_file,
+ imported_at = now()`,
+ [
+ cand.link.titleId,
+ cand.item.identifier,
+ chosen.fileName,
+ licenseLabel(cand.item),
+ licenseLink(cand.item),
+ chosen.durationSeconds,
+ chosen.subtitlesFile,
+ ],
+ );
+}
+
+async function main() {
+ const arg = process.argv.indexOf('--limit');
+ const limitText = arg >= 0 ? process.argv[arg + 1] : process.env.ARCHIVE_LIMIT;
+ const limit = Number.parseInt(limitText ?? '', 10) || undefined;
+ const pool = createPool(config.databaseUrl);
+ const signal = { aborted: false };
+ process.on('SIGINT', () => {
+ console.log('\nAvbryter etter pågående kall …');
+ signal.aborted = true;
+ });
+ console.log(`Henter fra ${config.archiveUrl}${limit ? ` (maks ${limit} titler)` : ''}`);
+ try {
+ const s = await importArchive({ pool, ...(limit ? { limit } : {}), signal, log: console.log });
+ console.log(
+ `Ferdig: ${s.imported} lagret, ${s.unlawful} uten fri lisens, ${s.unlinked} uten IMDb-kobling, ` +
+ `${s.duplicates} duplikater, ${s.noFile} uten spillbar fil, ${s.failed} feilet (av ${s.scanned} skannet).`,
+ );
+ if (s.failed > 0) process.exitCode = 1;
+ } finally {
+ await pool.end();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/import-core.test.ts b/backend/scripts/import-core.test.ts
new file mode 100644
index 0000000..c5cfa69
--- /dev/null
+++ b/backend/scripts/import-core.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isHeaderLine,
+ mapTitleType,
+ parseBasicsLine,
+ parseRatingLine,
+ type Rating,
+} from './import-core.js';
+
+const ratings = new Map([
+ ['tt1', { averageRating: 8.1, numVotes: 5000 }],
+ ['tt2', { averageRating: 5, numVotes: 99 }],
+]);
+
+const basics = (o: Partial> = {}) =>
+ [
+ o.tconst ?? 'tt1',
+ o.type ?? 'movie',
+ o.primary ?? 'Amélie',
+ o.original ?? 'Le Fabuleux Destin d’Amélie Poulain',
+ '0',
+ o.start ?? '2001',
+ o.end ?? '\\N',
+ o.runtime ?? '122',
+ o.genres ?? 'Comedy,Romance',
+ ].join('\t');
+
+describe('parseRatingLine', () => {
+ it('parser gyldig linje', () => {
+ expect(parseRatingLine('tt0111161\t9.3\t2900000')).toEqual({
+ tconst: 'tt0111161',
+ rating: { averageRating: 9.3, numVotes: 2900000 },
+ });
+ });
+ it.each(['', 'tt1\t9.3', 'tt1\tabc\t5', 'tt1\t9.3\t-5', 'tt1\t11.0\t5', 'tt1\t\\N\t\\N'])(
+ 'forkaster %j',
+ (line) => expect(parseRatingLine(line)).toBeNull(),
+ );
+});
+
+describe('parseBasicsLine', () => {
+ it('parser en film', () => {
+ expect(parseBasicsLine(basics(), ratings, 100)).toEqual({
+ id: 'tt1',
+ titleType: 'movie',
+ primaryTitle: 'Amélie',
+ originalTitle: 'Le Fabuleux Destin d’Amélie Poulain',
+ startYear: 2001,
+ endYear: null,
+ runtimeMinutes: 122,
+ averageRating: 8.1,
+ numVotes: 5000,
+ genres: ['Comedy', 'Romance'],
+ });
+ });
+ it('mapper tvSeries og tvMiniSeries til series', () => {
+ expect(parseBasicsLine(basics({ type: 'tvSeries', end: '2013' }), ratings, 100)).toMatchObject({
+ titleType: 'series',
+ endYear: 2013,
+ });
+ expect(parseBasicsLine(basics({ type: 'tvMiniSeries' }), ratings, 100)?.titleType).toBe(
+ 'series',
+ );
+ });
+ it.each(['tvEpisode', 'short', 'video', 'videoGame', 'tvMovie', 'tvSpecial'])(
+ 'hopper over typen %s',
+ (type) => expect(parseBasicsLine(basics({ type }), ratings, 100)).toBeNull(),
+ );
+ it('hopper over titler med for få stemmer eller uten rating', () => {
+ expect(parseBasicsLine(basics({ tconst: 'tt2' }), ratings, 100)).toBeNull();
+ expect(parseBasicsLine(basics({ tconst: 'tt2' }), ratings, 50)).not.toBeNull();
+ expect(parseBasicsLine(basics({ tconst: 'tt404' }), ratings, 1)).toBeNull();
+ });
+ it('håndterer \\N som null', () => {
+ const row = parseBasicsLine(
+ basics({ start: '\\N', runtime: '\\N', genres: '\\N', original: '\\N' }),
+ ratings,
+ 100,
+ );
+ expect(row).toMatchObject({
+ startYear: null,
+ runtimeMinutes: null,
+ genres: [],
+ originalTitle: 'Amélie',
+ });
+ });
+ it('forkaster ødelagte linjer og ugyldige tall blir null', () => {
+ expect(parseBasicsLine('tt1\tmovie\tBare tre', ratings, 100)).toBeNull();
+ expect(parseBasicsLine(basics({ primary: '\\N' }), ratings, 100)).toBeNull();
+ expect(parseBasicsLine(basics({ start: 'abcd' }), ratings, 100)?.startYear).toBeNull();
+ });
+ it('fjerner duplikate sjangre og bevarer spesialtegn i tittel', () => {
+ const row = parseBasicsLine(
+ basics({ genres: 'Drama,Drama, Crime', primary: 'Don\'t "Q"; DROP' }),
+ ratings,
+ 100,
+ );
+ expect(row?.genres).toEqual(['Drama', 'Crime']);
+ expect(row?.primaryTitle).toBe('Don\'t "Q"; DROP');
+ });
+});
+
+describe('hjelpefunksjoner', () => {
+ it('kjenner igjen overskrift', () => {
+ expect(isHeaderLine('tconst\ttitleType\tprimaryTitle')).toBe(true);
+ expect(isHeaderLine('tt1\tmovie')).toBe(false);
+ });
+ it('mapTitleType', () => {
+ expect(mapTitleType('movie')).toBe('movie');
+ expect(mapTitleType('tvEpisode')).toBeNull();
+ });
+});
diff --git a/backend/scripts/import-core.ts b/backend/scripts/import-core.ts
new file mode 100644
index 0000000..8d9bff5
--- /dev/null
+++ b/backend/scripts/import-core.ts
@@ -0,0 +1,124 @@
+/**
+ * Rene funksjoner for IMDb-importen (ingen I/O), slik at parsing og filtrering kan enhetstestes.
+ */
+
+export const BASICS_HEADER = [
+ 'tconst',
+ 'titleType',
+ 'primaryTitle',
+ 'originalTitle',
+ 'isAdult',
+ 'startYear',
+ 'endYear',
+ 'runtimeMinutes',
+ 'genres',
+];
+export const RATINGS_HEADER = ['tconst', 'averageRating', 'numVotes'];
+
+export interface Rating {
+ averageRating: number;
+ numVotes: number;
+}
+
+export interface TitleRow {
+ id: string;
+ titleType: 'movie' | 'series';
+ primaryTitle: string;
+ originalTitle: string;
+ startYear: number | null;
+ endYear: number | null;
+ runtimeMinutes: number | null;
+ averageRating: number;
+ numVotes: number;
+ genres: string[];
+}
+
+/** IMDb skriver `\N` for manglende verdi. */
+const NULL = '\\N';
+
+function optionalInt(value: string | undefined): number | null {
+ if (value === undefined || value === NULL || !/^\d{1,9}$/.test(value)) return null;
+ return Number(value);
+}
+
+/** Tittel-typene vi beholder, og hva de heter hos oss. */
+const TYPE_MAP: Record = {
+ movie: 'movie',
+ tvSeries: 'series',
+ tvMiniSeries: 'series',
+};
+
+export function mapTitleType(imdbType: string): 'movie' | 'series' | null {
+ return TYPE_MAP[imdbType] ?? null;
+}
+
+/** Overskriftslinjer begynner med feltnavnet, ekte data med tconst (tt…). */
+export function isHeaderLine(line: string): boolean {
+ return line.startsWith('tconst\t');
+}
+
+/** Parser én linje fra title.ratings.tsv. Returnerer null for ugyldige linjer. */
+export function parseRatingLine(line: string): { tconst: string; rating: Rating } | null {
+ const parts = line.split('\t');
+ if (parts.length < 3) return null;
+ const [tconst, rating, votes] = parts as [string, string, string];
+ if (!tconst || !/^\d+(\.\d+)?$/.test(rating) || !/^\d{1,10}$/.test(votes)) return null;
+ const averageRating = Number(rating);
+ const numVotes = Number(votes);
+ if (averageRating < 0 || averageRating > 10 || numVotes > 2_147_483_647) return null;
+ return { tconst, rating: { averageRating, numVotes } };
+}
+
+/**
+ * Parser én linje fra title.basics.tsv og slår sammen med rating. Returnerer null hvis linjen
+ * skal hoppes over (feil type, for få stemmer, ingen rating, ødelagt linje).
+ */
+export function parseBasicsLine(
+ line: string,
+ ratings: ReadonlyMap,
+ minVotes: number,
+): TitleRow | null {
+ const parts = line.split('\t');
+ if (parts.length < 9) return null;
+ const [tconst, type, primary, original, , start, end, runtime, genres] = parts as [
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ ];
+ const titleType = mapTitleType(type);
+ if (!titleType) return null;
+ const rating = ratings.get(tconst);
+ if (!rating || rating.numVotes < minVotes) return null;
+ // Postgres-tekst kan ikke inneholde NUL-byte; en slik tittel er uansett ødelagt.
+ if (!primary || primary === NULL || primary.includes('\0') || original.includes('\0'))
+ return null;
+
+ return {
+ id: tconst,
+ titleType,
+ primaryTitle: primary,
+ originalTitle: original && original !== NULL ? original : primary,
+ startYear: optionalInt(start),
+ endYear: optionalInt(end),
+ runtimeMinutes: optionalInt(runtime),
+ averageRating: rating.averageRating,
+ numVotes: rating.numVotes,
+ genres:
+ genres === NULL || genres === ''
+ ? []
+ : [
+ ...new Set(
+ genres
+ .split(',')
+ .map((g) => g.trim())
+ .filter(Boolean),
+ ),
+ ],
+ };
+}
diff --git a/backend/scripts/import-imdb.ts b/backend/scripts/import-imdb.ts
new file mode 100644
index 0000000..53f12ce
--- /dev/null
+++ b/backend/scripts/import-imdb.ts
@@ -0,0 +1,199 @@
+import { createReadStream, existsSync } from 'node:fs';
+import path from 'node:path';
+import { createInterface } from 'node:readline';
+import { pathToFileURL } from 'node:url';
+import { createGunzip } from 'node:zlib';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+import {
+ isHeaderLine,
+ parseBasicsLine,
+ parseRatingLine,
+ type Rating,
+ type TitleRow,
+} from './import-core.js';
+import { migrate } from './migrate.js';
+
+const BATCH_SIZE = 2000;
+
+/** Leser en .tsv.gz linje for linje. Aldri hele fila i minnet – basics er flere hundre MB pakket ut. */
+async function* readLines(file: string): AsyncGenerator {
+ const rl = createInterface({
+ input: createReadStream(file).pipe(createGunzip()),
+ crlfDelay: Infinity,
+ });
+ for await (const line of rl) yield line;
+}
+
+export interface ImportOptions {
+ dataDir: string;
+ minVotes: number;
+ log?: (msg: string) => void;
+}
+
+export function importFiles(dataDir: string) {
+ return {
+ basics: path.join(dataDir, 'title.basics.tsv.gz'),
+ ratings: path.join(dataDir, 'title.ratings.tsv.gz'),
+ };
+}
+
+/**
+ * Ratings leses først og bare rader over stemmegrensen huskes i et Map (i ekte data er det
+ * en liten del av filene). Basics strømmes deretter og slås opp mot dette, så vi slipper en
+ * staging-tabell og kan gi en enkel, parameterisert upsert.
+ */
+async function loadRatings(file: string, minVotes: number, log: (m: string) => void) {
+ const ratings = new Map();
+ let lines = 0;
+ for await (const line of readLines(file)) {
+ lines++;
+ if (isHeaderLine(line)) continue;
+ const parsed = parseRatingLine(line);
+ if (parsed && parsed.rating.numVotes >= minVotes) ratings.set(parsed.tconst, parsed.rating);
+ if (lines % 500_000 === 0) log(` ratings: ${lines} linjer lest`);
+ }
+ log(`Ratings: ${lines - 1} linjer, ${ratings.size} med minst ${minVotes} stemmer.`);
+ return ratings;
+}
+
+/**
+ * Upsert av én batch. Arrays sendes som én parameter per kolonne og pakkes ut med unnest():
+ * få parametere uansett batch-størrelse, og data blir aldri limt inn i SQL-teksten.
+ */
+async function upsertBatch(pool: Pool, rows: TitleRow[]): Promise {
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ await client.query(
+ `INSERT INTO titles (id, title_type, primary_title, original_title, start_year, end_year,
+ runtime_minutes, average_rating, num_votes, genres)
+ SELECT id, title_type, primary_title, original_title, start_year, end_year,
+ runtime_minutes, average_rating, num_votes,
+ string_to_array(genres_csv, ',')
+ FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::int[], $6::int[],
+ $7::int[], $8::numeric[], $9::int[], $10::text[])
+ AS t(id, title_type, primary_title, original_title, start_year, end_year,
+ runtime_minutes, average_rating, num_votes, genres_csv)
+ ON CONFLICT (id) DO UPDATE SET
+ title_type = EXCLUDED.title_type,
+ primary_title = EXCLUDED.primary_title,
+ original_title = EXCLUDED.original_title,
+ start_year = EXCLUDED.start_year,
+ end_year = EXCLUDED.end_year,
+ runtime_minutes = EXCLUDED.runtime_minutes,
+ average_rating = EXCLUDED.average_rating,
+ num_votes = EXCLUDED.num_votes,
+ genres = EXCLUDED.genres`,
+ [
+ rows.map((r) => r.id),
+ rows.map((r) => r.titleType),
+ rows.map((r) => r.primaryTitle),
+ rows.map((r) => r.originalTitle),
+ rows.map((r) => r.startYear),
+ rows.map((r) => r.endYear),
+ rows.map((r) => r.runtimeMinutes),
+ rows.map((r) => r.averageRating),
+ rows.map((r) => r.numVotes),
+ // Sjangernavn inneholder aldri komma (IMDb skiller dem med komma).
+ rows.map((r) => r.genres.join(',')),
+ ],
+ );
+
+ // Normalisert kopi: nye sjangre, og title_genres bygges på nytt for titlene i batchen
+ // slik at en endret sjangerliste ved omkjøring ikke etterlater gamle koblinger.
+ const pairsTitle: string[] = [];
+ const pairsGenre: string[] = [];
+ for (const r of rows) {
+ for (const g of r.genres) {
+ pairsTitle.push(r.id);
+ pairsGenre.push(g);
+ }
+ }
+ await client.query(
+ `INSERT INTO genres (name) SELECT DISTINCT g FROM unnest($1::text[]) AS g ON CONFLICT (name) DO NOTHING`,
+ [pairsGenre],
+ );
+ await client.query('DELETE FROM title_genres WHERE title_id = ANY($1::text[])', [
+ rows.map((r) => r.id),
+ ]);
+ await client.query(
+ `INSERT INTO title_genres (title_id, genre_id)
+ SELECT p.title_id, g.id
+ FROM unnest($1::text[], $2::text[]) AS p(title_id, genre)
+ JOIN genres g ON g.name = p.genre
+ ON CONFLICT DO NOTHING`,
+ [pairsTitle, pairsGenre],
+ );
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+export async function runImport(pool: Pool, opts: ImportOptions): Promise<{ imported: number }> {
+ const log = opts.log ?? (() => {});
+ const files = importFiles(opts.dataDir);
+ const missing = Object.values(files).filter((f) => !existsSync(f));
+ if (missing.length > 0) {
+ throw new Error(
+ `Fant ikke datafilene:\n ${missing.join('\n ')}\n` +
+ 'Last dem ned fra https://datasets.imdbws.com eller generer syntetiske filer med ' +
+ '`npm run db:fixture -w backend`. Se docs/oppsett.md.',
+ );
+ }
+
+ const started = Date.now();
+ const ratings = await loadRatings(files.ratings, opts.minVotes, log);
+
+ let batch: TitleRow[] = [];
+ let lines = 0;
+ let imported = 0;
+ for await (const line of readLines(files.basics)) {
+ lines++;
+ if (isHeaderLine(line)) continue;
+ const row = parseBasicsLine(line, ratings, opts.minVotes);
+ if (row) batch.push(row);
+ if (batch.length >= BATCH_SIZE) {
+ await upsertBatch(pool, batch);
+ imported += batch.length;
+ batch = [];
+ if (imported % (BATCH_SIZE * 10) === 0)
+ log(` basics: ${lines} linjer lest, ${imported} titler lagret`);
+ }
+ }
+ if (batch.length > 0) {
+ await upsertBatch(pool, batch);
+ imported += batch.length;
+ }
+ // Oppdaterer statistikken så planleggeren velger riktige indekser rett etter en stor innlasting.
+ await pool.query('ANALYZE titles');
+ log(
+ `Ferdig: ${imported} titler av ${lines - 1} linjer på ${((Date.now() - started) / 1000).toFixed(1)} s.`,
+ );
+ return { imported };
+}
+
+async function main() {
+ const pool = createPool(config.databaseUrl);
+ try {
+ await migrate(pool, console.log);
+ await runImport(pool, {
+ dataDir: config.imdbDataDir,
+ minVotes: config.imdbMinVotes,
+ log: console.log,
+ });
+ } finally {
+ await pool.end();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/migrate.ts b/backend/scripts/migrate.ts
new file mode 100644
index 0000000..1299fc5
--- /dev/null
+++ b/backend/scripts/migrate.ts
@@ -0,0 +1,90 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+
+// Ligger i backend/migrations både når vi kjører via tsx (scripts/) og fra kompilert kode (dist/scripts/).
+function findMigrationsDir(): string {
+ let dir = path.dirname(fileURLToPath(import.meta.url));
+ for (let i = 0; i < 4; i++) {
+ const candidate = path.join(dir, 'migrations');
+ try {
+ if (readdirSync(candidate).some((f) => f.endsWith('.sql'))) return candidate;
+ } catch {
+ // prøv ett nivå opp
+ }
+ dir = path.dirname(dir);
+ }
+ throw new Error('Fant ikke backend/migrations.');
+}
+
+// Vilkårlig, fast nøkkel: hindrer at to prosesser (f.eks. parallelle CI-jobber mot samme
+// database) migrerer samtidig.
+const LOCK_KEY = 281_001;
+
+/**
+ * Kjører alle migrasjoner som ikke er kjørt ennå, i filnavnrekkefølge, hver i sin transaksjon.
+ * Trygt å kjøre flere ganger. Returnerer navnene på migrasjonene som ble kjørt nå.
+ */
+export async function migrate(
+ pool: Pool,
+ log: (msg: string) => void = () => {},
+): Promise {
+ const dir = findMigrationsDir();
+ const files = readdirSync(dir)
+ .filter((f) => /^\d+_.+\.sql$/.test(f))
+ .sort();
+
+ // Egen tilkobling: advisory lock er knyttet til sesjonen, ikke til poolen.
+ const client = await pool.connect();
+ const applied: string[] = [];
+ try {
+ await client.query('SELECT pg_advisory_lock($1)', [LOCK_KEY]);
+ await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
+ name text PRIMARY KEY,
+ applied_at timestamptz NOT NULL DEFAULT now()
+ )`);
+ const done = new Set(
+ (await client.query<{ name: string }>('SELECT name FROM schema_migrations')).rows.map(
+ (r) => r.name,
+ ),
+ );
+ for (const file of files) {
+ if (done.has(file)) continue;
+ const sql = readFileSync(path.join(dir, file), 'utf8');
+ try {
+ await client.query('BEGIN');
+ await client.query(sql);
+ await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw new Error(`Migrering ${file} feilet: ${(err as Error).message}`);
+ }
+ log(`Kjørte ${file}`);
+ applied.push(file);
+ }
+ } finally {
+ await client.query('SELECT pg_advisory_unlock($1)', [LOCK_KEY]).catch(() => {});
+ client.release();
+ }
+ return applied;
+}
+
+async function main() {
+ const pool = createPool(config.databaseUrl);
+ try {
+ const applied = await migrate(pool, console.log);
+ console.log(applied.length ? `${applied.length} migrering(er) kjørt.` : 'Databasen er à jour.');
+ } finally {
+ await pool.end();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/prefetch-artwork.ts b/backend/scripts/prefetch-artwork.ts
new file mode 100644
index 0000000..964a465
--- /dev/null
+++ b/backend/scripts/prefetch-artwork.ts
@@ -0,0 +1,108 @@
+import { pathToFileURL } from 'node:url';
+import { ArtworkService, type TitleKind } from '../src/artwork.js';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+
+/**
+ * Forhåndshenter bilder for de mest populære titlene, slik at de første søkene i en demo ikke
+ * må vente på TMDB. Trygt å avbryte (Ctrl+C) og kjøre på nytt: titler som allerede har rad
+ * i title_artwork hoppes over.
+ */
+
+export interface PrefetchSummary {
+ found: number;
+ missing: number;
+ errors: number;
+ stoppedBy: 'done' | 'signal' | 'rejected-key';
+}
+
+export async function prefetchArtwork(
+ pool: Pool,
+ service: ArtworkService,
+ opts: {
+ limit: number;
+ ratePerSecond?: number;
+ signal?: { aborted: boolean };
+ log?: (msg: string) => void;
+ },
+): Promise {
+ const log = opts.log ?? (() => {});
+ const interval = 1000 / (opts.ratePerSecond ?? 30);
+ const { rows } = await pool.query<{ id: string; title_type: 'movie' | 'series' }>(
+ `SELECT t.id, t.title_type FROM titles t
+ WHERE NOT EXISTS (SELECT 1 FROM title_artwork a WHERE a.title_id = t.id)
+ ORDER BY t.num_votes DESC, t.id
+ LIMIT $1`,
+ [opts.limit],
+ );
+ log(`${rows.length} titler å hente.`);
+
+ const summary: PrefetchSummary = { found: 0, missing: 0, errors: 0, stoppedBy: 'done' };
+ const running = new Set>();
+ let nextStart = Date.now();
+ let done = 0;
+
+ for (const row of rows) {
+ if (opts.signal?.aborted) {
+ summary.stoppedBy = 'signal';
+ break;
+ }
+ if (!service.enabled) {
+ summary.stoppedBy = 'rejected-key';
+ break;
+ }
+ // Jevn takt i stedet for salver på 30: TMDB teller per sekund.
+ const wait = nextStart - Date.now();
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
+ nextStart = Math.max(nextStart, Date.now()) + interval;
+
+ const kind: TitleKind = row.title_type === 'movie' ? 'MOVIE' : 'SERIES';
+ const p = service.lookup(row.id, kind).then((o) => {
+ if (o.kind === 'found') summary.found++;
+ else if (o.kind === 'missing') summary.missing++;
+ else if (o.kind === 'error') summary.errors++;
+ if (++done % 100 === 0) {
+ log(
+ `${done}/${rows.length} (funnet ${summary.found}, ukjent ${summary.missing}, feil ${summary.errors})`,
+ );
+ }
+ });
+ running.add(p);
+ void p.finally(() => running.delete(p));
+ // Ikke la køen av ventende oppslag vokse ubegrenset hvis TMDB er treg.
+ if (running.size >= 64) await Promise.race(running);
+ }
+ await Promise.all(running);
+ return summary;
+}
+
+async function main() {
+ if (!config.tmdbApiKey) {
+ throw new Error('TMDB_API_KEY er ikke satt (se docs/oppsett.md).');
+ }
+ const arg = process.argv[2] ?? process.env.ARTWORK_LIMIT;
+ const limit = Number.parseInt(arg ?? '', 10) || 2000;
+ const pool = createPool(config.databaseUrl);
+ const service = new ArtworkService({ pool, apiKey: config.tmdbApiKey, disableMs: 3_600_000 });
+ const signal = { aborted: false };
+ process.on('SIGINT', () => {
+ console.log('\nAvbryter etter pågående oppslag …');
+ signal.aborted = true;
+ });
+ try {
+ const s = await prefetchArtwork(pool, service, { limit, signal, log: console.log });
+ console.log(
+ `Ferdig (${s.stoppedBy}): ${s.found} funnet, ${s.missing} ukjent hos TMDB, ${s.errors} feilet. Kjør på nytt for å prøve de feilede.`,
+ );
+ if (s.stoppedBy === 'rejected-key') process.exitCode = 1;
+ } finally {
+ await pool.end();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/tmdb-mock.ts b/backend/scripts/tmdb-mock.ts
new file mode 100644
index 0000000..768db54
--- /dev/null
+++ b/backend/scripts/tmdb-mock.ts
@@ -0,0 +1,225 @@
+import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { pathToFileURL } from 'node:url';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+
+/**
+ * Falsk TMDB for utvikling, E2E og tester: internett er ikke alltid tilgjengelig, og vi vil
+ * ikke bruke en ekte nøkkel (eller bryte TMDBs vilkår) i CI. Svarer med samme form som TMDB,
+ * og tegner plakater som SVG på sparket.
+ */
+
+export interface MockState {
+ /** Forsinkelse før /find svarer (for å teste tidsgrenser). */
+ delayMs: number;
+ /** Tving denne HTTP-statusen fra /find (f.eks. 500 eller 429). */
+ failStatus: number | null;
+ /** Titler som alltid svarer med tomme lister, i tillegg til hver 10. id. */
+ emptyIds: Set;
+ /** Alle /find-kall (imdb-id), i rekkefølge. */
+ calls: string[];
+}
+
+export interface TmdbMock {
+ port: number;
+ server: Server;
+ state: MockState;
+ close: () => Promise;
+}
+
+const idNumber = (id: string) => Number.parseInt(id.replace(/\D/g, ''), 10) || 0;
+
+// Kontrolltegn er ugyldige i XML 1.0 og ville gjort hele SVG-en ulesbar.
+const esc = (s: string) =>
+ [...s]
+ .filter((c) => c.charCodeAt(0) >= 32)
+ .join('')
+ .replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`);
+
+function hue(id: string): number {
+ // Litt spredning av tett nummererte id-er, ellers får nabotitler nesten lik farge.
+ return (idNumber(id) * 137.508) % 360;
+}
+
+/** Bryter tittelen i linjer på ca. `max` tegn uten å splitte ord (lange ord kuttes ikke). */
+function wrap(text: string, max: number): string[] {
+ const lines: string[] = [];
+ let line = '';
+ for (const word of text.split(/\s+/).filter(Boolean)) {
+ if (line && (line + ' ' + word).length > max) {
+ lines.push(line);
+ line = word;
+ } else line = line ? `${line} ${word}` : word;
+ }
+ if (line) lines.push(line);
+ return lines.slice(0, 5);
+}
+
+export function renderSvg(id: string, title: string, backdrop: boolean): string {
+ const h = hue(id);
+ const [w, hgt] = backdrop ? [320, 180] : [200, 300];
+ const lines = wrap(title, backdrop ? 26 : 12);
+ const size = backdrop ? 22 : 24;
+ const startY = hgt / 2 - ((lines.length - 1) * size * 1.2) / 2;
+ const text = lines
+ .map((l, i) => `${esc(l)}`)
+ .join('');
+ return ``;
+}
+
+const ADJECTIVES = ['quiet', 'reckless', 'unlikely', 'haunted', 'restless', 'ambitious', 'faded'];
+const NOUNS = ['detective', 'family', 'astronaut', 'musician', 'stranger', 'chef', 'soldier'];
+const GOALS = [
+ 'uncover a long-buried secret',
+ 'save a failing town',
+ 'outrun a past mistake',
+ 'win one last impossible game',
+ 'find their way home',
+];
+
+function overviewFor(id: string): string {
+ const n = idNumber(id);
+ return `A ${ADJECTIVES[n % ADJECTIVES.length]} ${NOUNS[(n >> 3) % NOUNS.length]} sets out to ${GOALS[(n >> 5) % GOALS.length]}.`;
+}
+
+export interface MockOptions {
+ /** Brukes til å slå opp tittelen som tegnes på plakaten. Uten den brukes id-en. */
+ pool?: Pool;
+ delayMs?: number;
+}
+
+export async function startTmdbMock(port = 0, opts: MockOptions = {}): Promise {
+ const state: MockState = {
+ delayMs: opts.delayMs ?? 0,
+ failStatus: null,
+ emptyIds: new Set(),
+ calls: [],
+ };
+ const titleCache = new Map();
+ const titleOf = async (id: string): Promise => {
+ const hit = titleCache.get(id);
+ if (hit) return hit;
+ let title = id;
+ if (opts.pool) {
+ try {
+ const { rows } = await opts.pool.query<{ primary_title: string }>(
+ 'SELECT primary_title FROM titles WHERE id = $1',
+ [id],
+ );
+ title = rows[0]?.primary_title ?? id;
+ } catch {
+ // Uten tittel duger id-en; plakaten er bare pynt.
+ }
+ }
+ if (titleCache.size > 5000) titleCache.clear();
+ titleCache.set(id, title);
+ return title;
+ };
+
+ const server = createServer((req, res) => {
+ void handle(req.url ?? '/', req.headers, res).catch(() => {
+ if (!res.headersSent) res.writeHead(500);
+ res.end();
+ });
+ });
+
+ async function handle(
+ rawUrl: string,
+ headers: import('node:http').IncomingHttpHeaders,
+ res: import('node:http').ServerResponse,
+ ) {
+ const url = new URL(rawUrl, 'http://localhost');
+ const json = (status: number, body: unknown) => {
+ res.writeHead(status, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+
+ const find = /^\/3\/find\/([^/]+)$/.exec(url.pathname);
+ if (find) {
+ const id = decodeURIComponent(find[1]!);
+ state.calls.push(id);
+ const authed =
+ url.searchParams.get('api_key') || /^Bearer .+/.test(headers.authorization ?? '');
+ if (!authed) return json(401, { status_code: 7, status_message: 'Invalid API key.' });
+ if (state.delayMs > 0) await new Promise((r) => setTimeout(r, state.delayMs));
+ if (state.failStatus) return json(state.failStatus, { status_message: 'Mock failure' });
+ const n = idNumber(id);
+ // Hver 10. id er ukjent for TMDB, slik at «missing»-stien testes i praksis.
+ if (n % 10 === 0 || state.emptyIds.has(id)) {
+ return json(200, { movie_results: [], tv_results: [] });
+ }
+ const result = {
+ id: n,
+ poster_path: `/${id}.svg`,
+ backdrop_path: `/${id}-bg.svg`,
+ overview: overviewFor(id),
+ };
+ // Annenhver id havner i tv_results uavhengig av IMDb-typen, så fallback-logikken testes.
+ return json(
+ 200,
+ n % 2 === 0
+ ? { movie_results: [result], tv_results: [] }
+ : { movie_results: [], tv_results: [result] },
+ );
+ }
+
+ const img = /^\/t\/p\/w\d+\/([A-Za-z0-9._-]+)\.svg$/.exec(url.pathname);
+ if (img) {
+ const name = img[1]!;
+ const backdrop = name.endsWith('-bg');
+ const id = backdrop ? name.slice(0, -3) : name;
+ const svg = renderSvg(id, await titleOf(id), backdrop);
+ res.writeHead(200, {
+ 'content-type': 'image/svg+xml',
+ 'cache-control': 'public, max-age=86400',
+ 'access-control-allow-origin': '*',
+ });
+ return res.end(svg);
+ }
+
+ json(404, { status_message: 'Not found' });
+ }
+
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(port, resolve);
+ });
+ const actualPort = (server.address() as AddressInfo).port;
+ return {
+ port: actualPort,
+ server,
+ state,
+ close: () =>
+ new Promise((resolve) => {
+ server.closeAllConnections();
+ server.close(() => resolve());
+ }),
+ };
+}
+
+async function main() {
+ const port = Number.parseInt(process.env.TMDB_MOCK_PORT ?? '', 10) || 3999;
+ const delayMs = Number.parseInt(process.env.TMDB_MOCK_DELAY_MS ?? '', 10) || 0;
+ const pool = createPool(config.databaseUrl);
+ const mock = await startTmdbMock(port, { pool, delayMs });
+ console.log(`Falsk TMDB på http://localhost:${mock.port} (API: /3, bilder: /t/p)`);
+ const stop = () =>
+ void mock
+ .close()
+ .then(() => pool.end())
+ .then(() => process.exit(0));
+ process.on('SIGINT', stop);
+ process.on('SIGTERM', stop);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/src/app.ts b/backend/src/app.ts
new file mode 100644
index 0000000..c4facf2
--- /dev/null
+++ b/backend/src/app.ts
@@ -0,0 +1,96 @@
+import { GraphQLError, type ValidationRule } from 'graphql';
+import { createYoga, type Plugin } from 'graphql-yoga';
+import { ArtworkService } from './artwork.js';
+import { config } from './config.js';
+import { createContext, type Context } from './context.js';
+import type { Pool } from './db.js';
+import { depthLimit } from './depthLimit.js';
+import { schema } from './schema.js';
+
+export const MAX_QUERY_DEPTH = 6;
+
+/**
+ * Egen regel i stedet for graphqls NoSchemaIntrospectionCustomRule: den bruker instanceof-sjekker
+ * som feiler når graphql lastes to ganger (ESM + CJS), noe som skjer under enkelte verktøy.
+ */
+const noIntrospection: ValidationRule = (context) => ({
+ Field(node) {
+ if (node.name.value === '__schema' || node.name.value === '__type') {
+ context.reportError(
+ new GraphQLError('GraphQL introspection er slått av.', {
+ nodes: [node],
+ extensions: { code: 'BAD_USER_INPUT' },
+ }),
+ );
+ }
+ },
+});
+
+export interface AppOptions {
+ pool: Pool;
+ /** Slår av introspeksjon og GraphiQL. Standard: NODE_ENV === 'production'. */
+ production?: boolean;
+ /** Tillatt CORS-opprinnelse (kun utvikling). false = ingen CORS-headere. */
+ corsOrigin?: string | false;
+ /** TMDB-tjenesten. Standard: bygges fra miljøvariablene (TMDB_API_KEY osv.). */
+ artwork?: ArtworkService;
+ /** Base-URL for Internet Archive i stream-URL-ene. Standard: ARCHIVE_URL. */
+ archiveUrl?: string;
+}
+
+/**
+ * Bygger Yoga-appen uten å åpne noen port, slik at testene kan kalle yoga.fetch() direkte.
+ * Yoga selv er en (req, res)-handler for node:http og en fetch-funksjon i én.
+ */
+export function createApp({
+ pool,
+ production,
+ corsOrigin = false,
+ artwork,
+ archiveUrl,
+}: AppOptions) {
+ const artworkService = artwork ?? new ArtworkService({ pool, apiKey: config.tmdbApiKey });
+ const isProd = production ?? process.env.NODE_ENV === 'production';
+
+ // Egendefinert plugin i stedet for ekstra pakker: envelop lar oss legge til valideringsregler.
+ const limits: Plugin = {
+ onValidate({ addValidationRule }) {
+ addValidationRule(depthLimit(MAX_QUERY_DEPTH));
+ if (isProd) addValidationRule(noIntrospection);
+ },
+ };
+
+ return createYoga
}>
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/LazyRow.tsx b/frontend/src/components/LazyRow.tsx
new file mode 100644
index 0000000..bbe15aa
--- /dev/null
+++ b/frontend/src/components/LazyRow.tsx
@@ -0,0 +1,32 @@
+import { useRef } from 'react';
+import { useQuery } from '@apollo/client/react';
+import { SEARCH_QUERY } from '../graphql/operations';
+import { useNearViewport } from '../hooks/useNearViewport';
+import { rowVariables, seeAllSearch, type BrowseRow } from '../lib/browseRows';
+import { TitleRow } from './TitleRow';
+
+/**
+ * Rader under folden henter først data når de nærmer seg viewport. Det sparer requests
+ * (og TMDB-oppslag på serveren) for rader brukeren aldri scroller ned til.
+ */
+export function LazyRow({ row }: { row: BrowseRow }) {
+ const anchor = useRef(null);
+ const near = useNearViewport(anchor);
+ const { data, loading, error, refetch } = useQuery(SEARCH_QUERY, {
+ variables: rowVariables(row.state),
+ skip: !near,
+ });
+
+ return (
+