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 ` + + +${text} +`; +} + +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({ + schema, + graphqlEndpoint: '/graphql', + plugins: [limits], + context: ({ request }) => + createContext(pool, request, artworkService, archiveUrl ?? config.archiveUrl), + logging: process.env.NODE_ENV !== 'test', + graphiql: !isProd, + landingPage: !isProd, + // I produksjon står Apache foran og frontend er same-origin; CORS trengs bare i utvikling. + cors: corsOrigin + ? { + origin: corsOrigin, + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['content-type', 'x-user-id'], + } + : false, + maskedErrors: { + // Uventede feil (databasen nede, bugs) skal aldri lekke SQL eller stack til klienten. + maskError(error: unknown): Error { + // Egne, forventede feil (GraphQLError uten underliggende systemfeil) slippes gjennom. + const cause = (error as GraphQLError).originalError ?? error; + if (cause instanceof GraphQLError) return cause; + // Protokollfeil fra Yoga selv (ugyldig JSON, feil metode) bærer en http-status og er trygge. + const http = (error as GraphQLError).extensions?.http as { status?: number } | undefined; + if (http?.status && http.status < 500) { + return error as GraphQLError; + } + if (process.env.NODE_ENV !== 'test') console.error('Uventet feil:', error); + return new GraphQLError('Intern feil.', { extensions: { code: 'INTERNAL_SERVER_ERROR' } }); + }, + }, + }); +} diff --git a/backend/src/artwork.ts b/backend/src/artwork.ts new file mode 100644 index 0000000..d9bcb25 --- /dev/null +++ b/backend/src/artwork.ts @@ -0,0 +1,324 @@ +import { config } from './config.js'; +import type { Pool } from './db.js'; +import { badInput } from './errors.js'; +import { batchLoader } from './loaders.js'; + +export interface Artwork { + posterPath: string | null; + backdropPath: string | null; + overview: string | null; +} + +export type TitleKind = 'MOVIE' | 'SERIES'; + +export const POSTER_WIDTHS = [92, 154, 185, 342, 500, 780] as const; +export const BACKDROP_WIDTHS = [300, 780, 1280] as const; + +// Stien havner i en URL som klienten laster; vi slipper derfor bare gjennom det TMDB faktisk +// bruker (filnavn med tillatte tegn), slik at en ødelagt eller ondsinnet respons ikke kan gi +// `../`, skjema eller query-strenger i bilde-URL-en. +const SAFE_PATH = /^\/[A-Za-z0-9._-]+\.(jpg|jpeg|png|svg)$/; + +/** + * Snapper til nærmeste tillatte bredde (likt avstand: den største, bedre kvalitet enn skarphet). + * Ugyldig bredde (≤ 0 eller ikke heltall) avvises heller enn å gjettes på: det er en klientfeil. + */ +export function snapWidth(width: number, allowed: readonly number[]): number { + if (!Number.isInteger(width) || width <= 0) { + throw badInput('width må være et positivt heltall.'); + } + let best = allowed[0]!; + for (const w of allowed) { + if (Math.abs(w - width) <= Math.abs(best - width)) best = w; + } + return best; +} + +export function buildImageUrl( + base: string, + path: string | null, + width: number, + allowed: readonly number[], +): string | null { + // Bredden valideres også når path mangler, så feil input gir samme svar uavhengig av data. + const w = snapWidth(width, allowed); + if (!path || !SAFE_PATH.test(path)) return null; + return `${base}/w${w}${path}`; +} + +type Outcome = + | { kind: 'found'; artwork: Artwork } + | { kind: 'missing' } + | { kind: 'error' } + | { kind: 'disabled' }; + +export interface ArtworkOptions { + pool: Pool; + apiKey?: string | undefined; + apiUrl?: string; + imageUrl?: string; + /** Maks samtidige TMDB-kall totalt i prosessen. */ + concurrency?: number; + /** Tidsgrense per TMDB-kall. */ + callTimeoutMs?: number; + /** Hvor lenge én request maksimalt venter på bilder. */ + batchDeadlineMs?: number; + /** Hvor lenge en feilet tittel hoppes over før vi prøver TMDB igjen. */ + negativeTtlMs?: number; + /** Hvor lenge vi lar TMDB være i fred etter 401/403. */ + disableMs?: number; + fetch?: typeof fetch; + log?: (msg: string) => void; +} + +interface TmdbResult { + poster_path?: unknown; + backdrop_path?: unknown; + overview?: unknown; +} + +const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null); + +/** + * TMDB-klient med DB-cache. Én instans per prosess (og per test), fordi negativ cache, + * «nøkkelen er ugyldig»-tilstand og samtidighetsgrensen skal deles av alle requests. + */ +export class ArtworkService { + readonly imageUrl: string; + private readonly pool: Pool; + private readonly apiKey: string | undefined; + private readonly apiUrl: string; + private readonly concurrency: number; + private readonly callTimeoutMs: number; + private readonly batchDeadlineMs: number; + private readonly negativeTtlMs: number; + private readonly disableMs: number; + private readonly fetchImpl: typeof fetch; + private readonly log: (msg: string) => void; + + private active = 0; + private readonly waiting: (() => void)[] = []; + /** Samme tittel spurt av flere requests samtidig gir ett TMDB-kall. */ + private readonly inflight = new Map>(); + private readonly failedUntil = new Map(); + private disabledUntil = 0; + + constructor(opts: ArtworkOptions) { + this.pool = opts.pool; + this.apiKey = opts.apiKey || undefined; + this.apiUrl = (opts.apiUrl ?? config.tmdbApiUrl).replace(/\/+$/, ''); + this.imageUrl = (opts.imageUrl ?? config.tmdbImageUrl).replace(/\/+$/, ''); + this.concurrency = opts.concurrency ?? 8; + this.callTimeoutMs = opts.callTimeoutMs ?? 4000; + this.batchDeadlineMs = opts.batchDeadlineMs ?? 2500; + this.negativeTtlMs = opts.negativeTtlMs ?? 5 * 60_000; + this.disableMs = opts.disableMs ?? 10 * 60_000; + this.fetchImpl = opts.fetch ?? fetch; + this.log = opts.log ?? ((m) => console.warn(m)); + } + + get enabled(): boolean { + return this.apiKey !== undefined && Date.now() >= this.disabledUntil; + } + + /** + * Cachede rader fra databasen, og TMDB-oppslag for resten innen tidsgrensen. Titler uten svar + * i tide utelates (→ null hos kalleren); oppslaget fullføres og lagres likevel i bakgrunnen. + */ + async resolve(refs: { id: string; kind: TitleKind }[]): Promise> { + const result = new Map(); + const ids = [...new Set(refs.map((r) => r.id))]; + if (ids.length === 0) return result; + + const { rows } = await this.pool.query<{ + title_id: string; + status: 'found' | 'missing'; + poster_path: string | null; + backdrop_path: string | null; + overview: string | null; + }>( + 'SELECT title_id, status, poster_path, backdrop_path, overview FROM title_artwork WHERE title_id = ANY($1::text[])', + [ids], + ); + for (const r of rows) { + result.set( + r.title_id, + r.status === 'found' + ? { posterPath: r.poster_path, backdropPath: r.backdrop_path, overview: r.overview } + : null, + ); + } + + if (!this.enabled) return result; + const now = Date.now(); + const todo = refs.filter((r, i) => { + if (result.has(r.id) || refs.findIndex((x) => x.id === r.id) !== i) return false; + const until = this.failedUntil.get(r.id); + return until === undefined || until <= now; + }); + if (todo.length === 0) return result; + + const settled = Promise.all( + todo.map(async (r) => { + const outcome = await this.lookup(r.id, r.kind); + if (outcome.kind === 'found') result.set(r.id, outcome.artwork); + else if (outcome.kind === 'missing') result.set(r.id, null); + }), + ); + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(resolve, this.batchDeadlineMs); + }); + try { + await Promise.race([settled, deadline]); + } finally { + clearTimeout(timer); + } + // Kopi: oppslag som ferdigstilles etter fristen skal ikke endre et svar som allerede er levert. + return new Map(result); + } + + /** Slår opp én tittel hos TMDB og lagrer resultatet. Kaster aldri. */ + lookup(id: string, kind: TitleKind): Promise { + const existing = this.inflight.get(id); + if (existing) return existing; + const p = this.doLookup(id, kind) + .catch((err: unknown): Outcome => { + this.log(`Uventet feil i artwork-oppslag: ${(err as Error).message}`); + return { kind: 'error' }; + }) + .finally(() => this.inflight.delete(id)); + this.inflight.set(id, p); + return p; + } + + private async doLookup(id: string, kind: TitleKind): Promise { + if (!this.enabled) return { kind: 'disabled' }; + await this.acquire(); + let outcome: Outcome; + try { + // Nøkkelen kan ha blitt avvist mens vi sto i kø. + outcome = this.enabled ? await this.fetchFind(id, kind) : { kind: 'disabled' }; + } finally { + this.release(); + } + if (outcome.kind === 'error') { + if (this.failedUntil.size > 50_000) this.failedUntil.clear(); + this.failedUntil.set(id, Date.now() + this.negativeTtlMs); + } else if (outcome.kind === 'found' || outcome.kind === 'missing') { + await this.store(id, outcome); + } + return outcome; + } + + private async fetchFind(id: string, kind: TitleKind): Promise { + const url = new URL(`${this.apiUrl}/find/${encodeURIComponent(id)}`); + url.searchParams.set('external_source', 'imdb_id'); + const headers: Record = { accept: 'application/json' }; + const key = this.apiKey!; + // v4-lesetoken er en JWT og sendes som Bearer; v3-nøkkelen er en kort hex-streng i query. + if (key.startsWith('eyJ')) headers.authorization = `Bearer ${key}`; + else url.searchParams.set('api_key', key); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), this.callTimeoutMs); + try { + const res = await this.fetchImpl(url, { headers, signal: ctrl.signal }); + if (res.status === 401 || res.status === 403) { + await res.body?.cancel().catch(() => {}); + this.disable(res.status); + return { kind: 'disabled' }; + } + if (!res.ok) { + await res.body?.cancel().catch(() => {}); + return { kind: 'error' }; + } + const body = (await res.json()) as { + movie_results?: unknown; + tv_results?: unknown; + } | null; + const list = (v: unknown): TmdbResult[] => (Array.isArray(v) ? (v as TmdbResult[]) : []); + const movies = list(body?.movie_results); + const tv = list(body?.tv_results); + const hit = (kind === 'MOVIE' ? [...movies, ...tv] : [...tv, ...movies])[0]; + if (!hit || typeof hit !== 'object') return { kind: 'missing' }; + return { + kind: 'found', + artwork: { + posterPath: str(hit.poster_path), + backdropPath: str(hit.backdrop_path), + overview: str(hit.overview), + }, + }; + } catch { + // Nettverksfeil, timeout (abort) eller ugyldig JSON: alt er forbigående. Feilmeldingen + // kan inneholde URL-en (og dermed nøkkelen), så den logges ikke. + return { kind: 'error' }; + } finally { + clearTimeout(timer); + } + } + + private disable(status: number): void { + if (Date.now() < this.disabledUntil) return; + this.disabledUntil = Date.now() + this.disableMs; + this.log( + `TMDB avviste API-nøkkelen (HTTP ${status}). Slår av TMDB-oppslag i ${Math.round(this.disableMs / 60_000)} min; sjekk TMDB_API_KEY.`, + ); + } + + private async store(id: string, outcome: Outcome): Promise { + const a = outcome.kind === 'found' ? outcome.artwork : null; + try { + // Upsert: to requests kan ha slått opp samme tittel samtidig (eller i to prosesser). + await this.pool.query( + `INSERT INTO title_artwork (title_id, status, poster_path, backdrop_path, overview) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (title_id) DO UPDATE SET + status = EXCLUDED.status, poster_path = EXCLUDED.poster_path, + backdrop_path = EXCLUDED.backdrop_path, overview = EXCLUDED.overview, + fetched_at = now()`, + [ + id, + a ? 'found' : 'missing', + a?.posterPath ?? null, + a?.backdropPath ?? null, + a?.overview ?? null, + ], + ); + } catch (err) { + // Bakgrunnsoppslag kan fullføres etter at basen er lukket eller tittelen slettet. + this.log(`Kunne ikke lagre artwork for ${id}: ${(err as Error).message}`); + } + } + + private acquire(): Promise { + if (this.active < this.concurrency) { + this.active++; + return Promise.resolve(); + } + return new Promise((resolve) => this.waiting.push(resolve)); + } + + private release(): void { + const next = this.waiting.shift(); + if (next) + next(); // plassen går direkte videre til neste i køen + else this.active--; + } +} + +/** Per-request-loader: alle titler i samme tick slås opp samlet. */ +export function createArtworkLoader(service: ArtworkService) { + const loader = batchLoader(async (keys) => { + const refs = keys.map((k) => ({ + kind: k.slice(0, k.indexOf(':')) as TitleKind, + id: k.slice(k.indexOf(':') + 1), + })); + const found = await service.resolve(refs); + return new Map(refs.map((r) => [`${r.kind}:${r.id}`, found.get(r.id) ?? null])); + }, null); + return { load: (id: string, kind: TitleKind) => loader.load(`${kind}:${id}`) }; +} + +export type ArtworkLoader = ReturnType; diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..bb91b09 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,47 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; + +/** + * Finner monorepo-roten ved å gå oppover til package.json med "workspaces". Vi kan ikke bruke + * process.cwd(): `npm run -w backend` kjører med cwd=backend, mens tsx/node kan startes hvor som helst, + * og kompilert kode ligger på et annet dybdenivå (dist/src) enn kildekoden. + */ +function findRepoRoot(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 6; i++) { + const pkg = path.join(dir, 'package.json'); + if (existsSync(pkg) && readFileSync(pkg, 'utf8').includes('"workspaces"')) return dir; + dir = path.dirname(dir); + } + return process.cwd(); +} + +export const repoRoot = findRepoRoot(); + +// quiet: dotenv 17+ skriver ellers en reklamelinje til stdout ved hver import. +// Miljøvariabler som allerede er satt (f.eks. i CI) vinner over .env. +dotenv.config({ path: path.join(repoRoot, '.env'), quiet: true }); + +function int(value: string | undefined, fallback: number): number { + const n = Number.parseInt(value ?? '', 10); + return Number.isFinite(n) ? n : fallback; +} + +export const config = { + port: int(process.env.PORT, 3001), + databaseUrl: process.env.DATABASE_URL || 'postgres://postgres@localhost:5432/project2', + testDatabaseUrl: + process.env.TEST_DATABASE_URL || 'postgres://postgres@localhost:5432/project2_test', + corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173', + isProduction: process.env.NODE_ENV === 'production', + imdbDataDir: path.resolve(repoRoot, process.env.IMDB_DATA_DIR || 'data'), + imdbMinVotes: int(process.env.IMDB_MIN_VOTES, 100), + // Uten nøkkel gjøres aldri nettverkskall mot TMDB; bare det som allerede ligger i databasen brukes. + tmdbApiKey: process.env.TMDB_API_KEY?.trim() || undefined, + tmdbApiUrl: (process.env.TMDB_API_URL || 'https://api.themoviedb.org/3').replace(/\/+$/, ''), + tmdbImageUrl: (process.env.TMDB_IMAGE_URL || 'https://image.tmdb.org/t/p').replace(/\/+$/, ''), + // Videoen strømmes rett fra Internet Archive; vi lagrer bare pekere. Kan peke mot den falske serveren. + archiveUrl: (process.env.ARCHIVE_URL || 'https://archive.org').replace(/\/+$/, ''), +}; diff --git a/backend/src/context.ts b/backend/src/context.ts new file mode 100644 index 0000000..a585356 --- /dev/null +++ b/backend/src/context.ts @@ -0,0 +1,68 @@ +import { createArtworkLoader, type ArtworkLoader, type ArtworkService } from './artwork.js'; +import type { Pool } from './db.js'; +import { config } from './config.js'; +import { batchLoader } from './loaders.js'; +import { createStreamLoader, type StreamLoader } from './stream.js'; +import { parseUserId } from './validation.js'; + +export interface ReviewStats { + count: number; + average: number | null; +} + +export interface Context { + pool: Pool; + /** Gyldig, normalisert UUID fra x-user-id, ellers null. */ + userId: string | null; + artwork: ArtworkService; + loaders: { + artwork: ArtworkLoader; + stream: StreamLoader; + reviewStats: { load: (titleId: string) => Promise }; + inMyList: { load: (titleId: string) => Promise }; + }; +} + +/** Bygges per request, slik at loader-cachene aldri lekker data mellom brukere. */ +export function createContext( + pool: Pool, + request: Request, + artwork: ArtworkService, + archiveUrl: string = config.archiveUrl, +): Context { + const userId = parseUserId(request.headers.get('x-user-id')); + return { + pool, + userId, + artwork, + loaders: { + artwork: createArtworkLoader(artwork), + stream: createStreamLoader(pool, archiveUrl), + // Én spørring for hele resultatsiden i stedet for én per tittel (N+1). + reviewStats: batchLoader( + async (ids) => { + const { rows } = await pool.query<{ title_id: string; n: string; avg: string }>( + `SELECT title_id, count(*) AS n, avg(rating) AS avg + FROM reviews WHERE title_id = ANY($1::text[]) GROUP BY title_id`, + [ids], + ); + return new Map( + rows.map((r) => [ + r.title_id, + { count: Number(r.n), average: Math.round(Number(r.avg) * 100) / 100 }, + ]), + ); + }, + { count: 0, average: null }, + ), + inMyList: batchLoader(async (ids) => { + if (!userId) return new Map(); + const { rows } = await pool.query<{ title_id: string }>( + 'SELECT title_id FROM list_items WHERE user_id = $1 AND title_id = ANY($2::text[])', + [userId, ids], + ); + return new Map(rows.map((r) => [r.title_id, true])); + }, false), + }, + }; +} diff --git a/backend/src/cursor.test.ts b/backend/src/cursor.test.ts new file mode 100644 index 0000000..6de894a --- /dev/null +++ b/backend/src/cursor.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { decodeCursor, encodeCursor, type KeyDef } from './keyset.js'; + +const keys: KeyDef[] = [ + { expr: 'a', cast: 'int', kind: 'int' }, + { expr: 'b', cast: 'float8', kind: 'float' }, + { expr: 'id', cast: 'text', kind: 'text' }, +]; + +describe('cursor', () => { + it('rundtur bevarer verdiene', () => { + const c = encodeCursor('S', [5, 0.3333333432674408, 'tt1']); + expect(decodeCursor(c, 'S', keys)).toEqual([5, 0.3333333432674408, 'tt1']); + }); + + it('er base64url uten spesialtegn', () => { + expect(encodeCursor('S', [1, 2, 'æøå🎬'])).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it.each([ + ['tom streng', ''], + ['ikke base64/JSON', '%%%%'], + ['gyldig base64, ikke JSON', Buffer.from('hei').toString('base64url')], + ['JSON null', Buffer.from('null').toString('base64url')], + ['mangler felt', Buffer.from('{}').toString('base64url')], + ['feil signatur', encodeCursor('ANNEN', [1, 2, 'x'])], + ['feil antall verdier', encodeCursor('S', [1, 2])], + ['feil type (streng i stedet for tall)', encodeCursor('S', ['1', 2, 'x'])], + ['desimaltall i heltallsnøkkel', encodeCursor('S', [1.5, 2, 'x'])], + ['heltall utenfor int32', encodeCursor('S', [2 ** 40, 2, 'x'])], + ['tall i tekstnøkkel', encodeCursor('S', [1, 2, 7])], + ['for lang cursor', 'A'.repeat(5000)], + ])('avviser %s', (_navn, cursor) => { + expect(() => decodeCursor(cursor, 'S', keys)).toThrowError(/cursor/i); + }); + + it('avviser tekst som ikke matcher formatkravet', () => { + const k: KeyDef[] = [{ expr: 't', cast: 'timestamptz', kind: 'text', pattern: /^\d+$/ }]; + expect(() => decodeCursor(encodeCursor('S', ['abc']), 'S', k)).toThrow(); + expect(decodeCursor(encodeCursor('S', ['123']), 'S', k)).toEqual(['123']); + }); +}); diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..ac8b063 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,20 @@ +import pg from 'pg'; + +/** + * Felles pool-oppsett. statement_timeout er et sikkerhetsnett: en uventet dyr spørring skal + * ikke kunne holde en tilkobling (og dermed hele API-et) opptatt i minutter. + */ +export function createPool(connectionString: string): pg.Pool { + const pool = new pg.Pool({ + connectionString, + max: 10, + statement_timeout: 15_000, + }); + // Uten en error-lytter krasjer Node hvis en ledig tilkobling brytes (f.eks. Postgres restarter). + pool.on('error', (err) => { + console.error('Uventet feil på ledig databasetilkobling:', err.message); + }); + return pool; +} + +export type Pool = pg.Pool; diff --git a/backend/src/depthLimit.test.ts b/backend/src/depthLimit.test.ts new file mode 100644 index 0000000..7d61258 --- /dev/null +++ b/backend/src/depthLimit.test.ts @@ -0,0 +1,31 @@ +import { buildSchema, parse, validate } from 'graphql'; +import { describe, expect, it } from 'vitest'; +import { depthLimit } from './depthLimit.js'; + +// Sirkulært testskjema: det ekte skjemaet kan ikke nøstes dypt nok til å teste regelen. +const schema = buildSchema(` + type Node { id: ID, child: Node } + type Query { root: Node } +`); +const check = (q: string, max = 3) => validate(schema, parse(q), [depthLimit(max)]); + +describe('depthLimit', () => { + it('godtar spørring på grensen', () => { + expect(check('{ root { child { child { id } } } }')).toHaveLength(0); + }); + it('avviser spørring over grensen med BAD_USER_INPUT', () => { + const errors = check('{ root { child { child { child { id } } } } }'); + expect(errors).toHaveLength(1); + expect(errors[0]?.extensions.code).toBe('BAD_USER_INPUT'); + }); + it('teller dybde gjennom fragmenter', () => { + const q = ` + query { root { ...A } } + fragment A on Node { child { ...B } } + fragment B on Node { child { child { id } } }`; + expect(check(q)).toHaveLength(1); + }); + it('hopper over introspeksjon', () => { + expect(check('{ __schema { types { fields { type { ofType { name } } } } } }')).toHaveLength(0); + }); +}); diff --git a/backend/src/depthLimit.ts b/backend/src/depthLimit.ts new file mode 100644 index 0000000..218a914 --- /dev/null +++ b/backend/src/depthLimit.ts @@ -0,0 +1,56 @@ +import { + GraphQLError, + Kind, + type FragmentDefinitionNode, + type SelectionSetNode, + type ValidationRule, +} from 'graphql'; + +/** + * Validerer at ingen spørring nøster dypere enn `max` nivåer (rotfeltet er nivå 0, som i + * `graphql-depth-limit`). Dagens skjema kan ikke nøstes dypere enn 6, så regelen er et + * vern mot at en fremtidig sirkulær relasjon (f.eks. Review.title) åpner for dyre spørringer. + * Introspeksjon (__schema/__type) er dyp av natur og hoppes over; den slås av separat i produksjon. + */ +export function depthLimit(max: number): ValidationRule { + return (context) => ({ + OperationDefinition(operation) { + const fragments = new Map(); + for (const def of context.getDocument().definitions) { + if (def.kind === Kind.FRAGMENT_DEFINITION) fragments.set(def.name.value, def); + } + + const depthOf = (set: SelectionSetNode, level: number, visiting: Set): number => { + let deepest = level; + for (const sel of set.selections) { + if (sel.kind === Kind.FIELD) { + if (sel.name.value === '__schema' || sel.name.value === '__type') continue; + const d = sel.selectionSet ? depthOf(sel.selectionSet, level + 1, visiting) : level; + deepest = Math.max(deepest, d); + } else if (sel.kind === Kind.INLINE_FRAGMENT) { + deepest = Math.max(deepest, depthOf(sel.selectionSet, level, visiting)); + } else { + const name = sel.name.value; + const frag = fragments.get(name); + // Sirkulære fragmenter fanges av NoFragmentCycles; her må vi bare ikke løpe i ring. + if (!frag || visiting.has(name)) continue; + visiting.add(name); + deepest = Math.max(deepest, depthOf(frag.selectionSet, level, visiting)); + visiting.delete(name); + } + } + return deepest; + }; + + const depth = depthOf(operation.selectionSet, 0, new Set()); + if (depth > max) { + context.reportError( + new GraphQLError(`Spørringen er for dyp (${depth}); maks tillatt dybde er ${max}.`, { + nodes: [operation], + extensions: { code: 'BAD_USER_INPUT' }, + }), + ); + } + }, + }); +} diff --git a/backend/src/errors.ts b/backend/src/errors.ts new file mode 100644 index 0000000..d7ecb9d --- /dev/null +++ b/backend/src/errors.ts @@ -0,0 +1,11 @@ +import { GraphQLError } from 'graphql'; + +export type ErrorCode = 'BAD_USER_INPUT' | 'NOT_FOUND' | 'UNAUTHENTICATED'; + +function make(code: ErrorCode) { + return (message: string) => new GraphQLError(message, { extensions: { code } }); +} + +export const badInput = make('BAD_USER_INPUT'); +export const notFound = make('NOT_FOUND'); +export const unauthenticated = make('UNAUTHENTICATED'); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..b010f55 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,26 @@ +import { createServer } from 'node:http'; +import { createApp } from './app.js'; +import { config } from './config.js'; +import { createPool } from './db.js'; + +const pool = createPool(config.databaseUrl); +const yoga = createApp({ + pool, + production: config.isProduction, + corsOrigin: config.isProduction ? false : config.corsOrigin, +}); + +const server = createServer(yoga); +server.listen(config.port, () => { + console.log(`GraphQL-API på http://localhost:${config.port}/graphql`); +}); + +// Rydder opp så tilkoblinger lukkes pent (systemd sender SIGTERM, Ctrl+C sender SIGINT). +function shutdown() { + server.close(() => { + void pool.end().then(() => process.exit(0)); + }); + setTimeout(() => process.exit(1), 5000).unref(); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/backend/src/keyset.ts b/backend/src/keyset.ts new file mode 100644 index 0000000..339a894 --- /dev/null +++ b/backend/src/keyset.ts @@ -0,0 +1,91 @@ +import { badInput } from './errors.js'; + +/** + * Keyset-paginering (cursor) på en sammensatt sorteringsnøkkel. OFFSET ville blitt tregere jo + * lenger ned brukeren scroller og gir duplikater/hull når data endres mellom sidene. + * + * Alle nøkler sorteres i SAMMEN retning, slik at vi kan bruke radsammenligning + * `(a, b, id) < ($1, $2, $3)`. Det lar Postgres bruke en vanlig btree-indeks på (a, b, id) + * i stedet for en OR-kjede, og siste nøkkel er alltid unik slik at rekkefølgen er total. + */ +export interface KeyDef { + /** SQL-uttrykk som brukes i ORDER BY og i cursor-sammenligningen. */ + expr: string; + /** Uttrykk som leses ut til JS (f.eks. numeric -> float8, timestamptz -> text). */ + select?: string; + /** Postgres-typen cursor-verdien castes til. */ + cast: string; + kind: 'int' | 'float' | 'text'; + /** Ekstra formatkrav for tekstnøkler som castes til ikke-tekst (tidsstempel, bigint). */ + pattern?: RegExp; +} + +export type Direction = 'ASC' | 'DESC'; + +export function orderBy(keys: KeyDef[], dir: Direction): string { + return keys.map((k) => `${k.expr} ${dir}`).join(', '); +} + +export function projections(keys: KeyDef[]): string { + return keys.map((k, i) => `${k.select ?? k.expr} AS k${i}`).join(', '); +} + +/** Bygger `(expr...) < (cast params...)`. `addParam` registrerer en parameter og gir `$n`. */ +export function afterCondition( + keys: KeyDef[], + values: unknown[], + dir: Direction, + addParam: (v: unknown) => string, +): string { + const op = dir === 'DESC' ? '<' : '>'; + const left = keys.map((k) => k.expr).join(', '); + const right = keys.map((k, i) => `${addParam(values[i])}::${k.cast}`).join(', '); + return `(${left}) ${op} (${right})`; +} + +/** + * Cursoren er base64url av JSON. `s` er en signatur (sortering + retning) slik at en cursor fra + * én sortering avvises hvis den brukes på en annen i stedet for å gi tilfeldige resultater. + */ +export function encodeCursor(signature: string, values: unknown[]): string { + return Buffer.from(JSON.stringify({ s: signature, v: values }), 'utf8').toString('base64url'); +} + +const INT32 = 2_147_483_647; + +function validValue(k: KeyDef, v: unknown): boolean { + if (k.kind === 'text') { + return ( + typeof v === 'string' && + v.length <= 1000 && + !v.includes('\0') && + (k.pattern ? k.pattern.test(v) : true) + ); + } + if (typeof v !== 'number' || !Number.isFinite(v)) return false; + return k.kind === 'float' || (Number.isInteger(v) && Math.abs(v) <= INT32); +} + +/** + * Dekoder og validerer en cursor. All validering gjøres i JS: en verdi Postgres ikke kan caste + * ville ellers gitt en databasefeil (og dermed en maskert 500) i stedet for BAD_USER_INPUT. + */ +export function decodeCursor(cursor: string, signature: string, keys: KeyDef[]): unknown[] { + const fail = () => badInput('Ugyldig cursor.'); + if (cursor.length > 2000) throw fail(); + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw fail(); + } + if (typeof parsed !== 'object' || parsed === null) throw fail(); + const { s, v } = parsed as { s?: unknown; v?: unknown }; + if (s !== signature || !Array.isArray(v) || v.length !== keys.length) throw fail(); + if (!keys.every((k, i) => validValue(k, v[i]))) throw fail(); + return v; +} + +export const TIMESTAMP_PATTERN = + /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d(\.\d{1,6})?([+-]\d\d(:?\d\d)?|Z)?$/; +export const BIGINT_PATTERN = /^\d{1,18}$/; diff --git a/backend/src/loaders.ts b/backend/src/loaders.ts new file mode 100644 index 0000000..c7a6d6b --- /dev/null +++ b/backend/src/loaders.ts @@ -0,0 +1,36 @@ +/** + * Minimal DataLoader: samler alle load()-kall som skjer i samme tick til ett batch-kall. + * GraphQL kaller feltresolvere for alle elementene i en liste synkront etter hverandre, så + * en microtask-flush fanger dem alle. Resultatene caches per instans (= per request). + */ +export function batchLoader( + batchFn: (keys: string[]) => Promise>, + fallback: V, +): { load: (key: string) => Promise } { + const cache = new Map>(); + let pending: { key: string; resolve: (v: V) => void; reject: (e: unknown) => void }[] = []; + + const flush = async () => { + const batch = pending; + pending = []; + try { + const result = await batchFn([...new Set(batch.map((b) => b.key))]); + for (const b of batch) b.resolve(result.get(b.key) ?? fallback); + } catch (err) { + for (const b of batch) b.reject(err); + } + }; + + return { + load(key) { + const cached = cache.get(key); + if (cached) return cached; + const promise = new Promise((resolve, reject) => { + if (pending.length === 0) queueMicrotask(flush); + pending.push({ key, resolve, reject }); + }); + cache.set(key, promise); + return promise; + }, + }; +} diff --git a/backend/src/resolvers/index.ts b/backend/src/resolvers/index.ts new file mode 100644 index 0000000..0ffa673 --- /dev/null +++ b/backend/src/resolvers/index.ts @@ -0,0 +1,178 @@ +import { BACKDROP_WIDTHS, POSTER_WIDTHS, buildImageUrl } from '../artwork.js'; +import { notFound, unauthenticated } from '../errors.js'; +import type { Context } from '../context.js'; +import { + DEFAULT_DIRECTION, + getFacets, + getGenres, + getMyList, + getReviews, + getTitle, + searchTitles, + type Connection, + type SortField, +} from '../search.js'; +import { + isPlausibleTitleId, + validateFilters, + validateFirst, + validateQuery, + validateReview, + type FiltersInput, + type ReviewInput, +} from '../validation.js'; +import { mapReview, mapTitle, type TitleNode } from './mappers.js'; + +type Page = ReturnType; + +/** Mapper radene til API-form, men beholder lazy totalCount (kalles bare hvis feltet etterspørres). */ +function mapConnection(conn: Connection, map: (row: R) => N) { + return { + edges: conn.edges.map((e) => ({ cursor: e.cursor, node: map(e.node) })), + pageInfo: conn.pageInfo, + totalCount: conn.totalCount, + }; +} + +const connectionResolvers = { + totalCount: (page: Page) => page.totalCount(), +}; + +interface SearchArgs { + query?: string | null; + filters?: FiltersInput | null; + sort?: { field: SortField; direction?: 'ASC' | 'DESC' | null } | null; + first?: number | null; + after?: string | null; +} + +export const resolvers = { + Query: { + search: async (_: unknown, args: SearchArgs, ctx: Context) => { + const first = validateFirst(args.first); + const query = validateQuery(args.query); + const filters = validateFilters(args.filters); + const field = args.sort?.field ?? 'RELEVANCE'; + const direction = args.sort?.direction ?? DEFAULT_DIRECTION[field]; + const conn = await searchTitles(ctx.pool, { + query, + filters, + field, + direction, + first, + after: args.after ?? null, + }); + return mapConnection(conn, mapTitle); + }, + + title: async (_: unknown, args: { id: string }, ctx: Context) => { + if (!isPlausibleTitleId(args.id)) return null; + const row = await getTitle(ctx.pool, args.id); + return row ? mapTitle(row) : null; + }, + + facets: ( + _: unknown, + args: { query?: string | null; filters?: FiltersInput | null }, + ctx: Context, + ) => getFacets(ctx.pool, validateQuery(args.query), validateFilters(args.filters)), + + myList: async ( + _: unknown, + args: { first?: number | null; after?: string | null }, + ctx: Context, + ) => { + const conn = await getMyList( + ctx.pool, + ctx.userId, + validateFirst(args.first), + args.after ?? null, + ); + return mapConnection(conn, (row) => ({ ...mapTitle(row), inMyList: true })); + }, + + genres: (_: unknown, _args: unknown, ctx: Context) => getGenres(ctx.pool), + }, + + Mutation: { + addReview: async (_: unknown, args: { input: ReviewInput }, ctx: Context) => { + if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); + const input = validateReview(args.input); + if (!isPlausibleTitleId(input.titleId) || !(await getTitle(ctx.pool, input.titleId))) { + throw notFound('Fant ikke tittelen.'); + } + const { rows } = await ctx.pool.query( + `INSERT INTO reviews (title_id, user_id, author, rating, body) + VALUES ($1, $2, $3, $4, $5) + RETURNING id::text AS id, title_id, user_id, author, rating, body, created_at`, + [input.titleId, ctx.userId, input.author, input.rating, input.text], + ); + return mapReview(rows[0], ctx.userId); + }, + + toggleList: async (_: unknown, args: { titleId: string }, ctx: Context) => { + if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); + const title = isPlausibleTitleId(args.titleId) + ? await getTitle(ctx.pool, args.titleId) + : null; + if (!title) throw notFound('Fant ikke tittelen.'); + // Én atomisk setning: slett hvis den finnes, ellers legg til. Unngår at to raske klikk + // gir en unik-brudd-feil, og returnerer den nye tilstanden uten ekstra spørring. + const { rows } = await ctx.pool.query<{ in_list: boolean }>( + `WITH del AS ( + DELETE FROM list_items WHERE user_id = $1 AND title_id = $2 RETURNING 1 + ), ins AS ( + INSERT INTO list_items (user_id, title_id) + SELECT $1, $2 WHERE NOT EXISTS (SELECT 1 FROM del) + ON CONFLICT DO NOTHING + RETURNING 1 + ) + SELECT EXISTS (SELECT 1 FROM ins) AS in_list`, + [ctx.userId, title.id], + ); + return { ...mapTitle(title), inMyList: rows[0]?.in_list ?? false }; + }, + }, + + Title: { + userRating: async (t: TitleNode, _: unknown, ctx: Context) => + (await ctx.loaders.reviewStats.load(t.id)).average, + reviewCount: async (t: TitleNode, _: unknown, ctx: Context) => + (await ctx.loaders.reviewStats.load(t.id)).count, + inMyList: (t: TitleNode, _: unknown, ctx: Context) => + t.inMyList ?? ctx.loaders.inMyList.load(t.id), + // Feltene slår bare opp artwork når de er med i queryen (resolvere kjøres kun for valgte felt). + overview: async (t: TitleNode, _: unknown, ctx: Context) => + (await ctx.loaders.artwork.load(t.id, t.type))?.overview ?? null, + posterUrl: async (t: TitleNode, args: { width?: number | null }, ctx: Context) => { + const width = args.width ?? 342; + // Validerer før oppslaget, så en ugyldig bredde aldri koster et TMDB-kall. + buildImageUrl('', null, width, POSTER_WIDTHS); + const art = await ctx.loaders.artwork.load(t.id, t.type); + return buildImageUrl(ctx.artwork.imageUrl, art?.posterPath ?? null, width, POSTER_WIDTHS); + }, + backdropUrl: async (t: TitleNode, args: { width?: number | null }, ctx: Context) => { + const width = args.width ?? 1280; + buildImageUrl('', null, width, BACKDROP_WIDTHS); + const art = await ctx.loaders.artwork.load(t.id, t.type); + return buildImageUrl(ctx.artwork.imageUrl, art?.backdropPath ?? null, width, BACKDROP_WIDTHS); + }, + stream: (t: TitleNode, _: unknown, ctx: Context) => ctx.loaders.stream.load(t.id), + reviews: async ( + t: TitleNode, + args: { first?: number | null; after?: string | null }, + ctx: Context, + ) => { + const conn = await getReviews( + ctx.pool, + t.id, + validateFirst(args.first ?? 10), + args.after ?? null, + ); + return mapConnection(conn, (row) => mapReview(row, ctx.userId)); + }, + }, + + TitleConnection: connectionResolvers, + ReviewConnection: connectionResolvers, +}; diff --git a/backend/src/resolvers/mappers.ts b/backend/src/resolvers/mappers.ts new file mode 100644 index 0000000..d4dd64e --- /dev/null +++ b/backend/src/resolvers/mappers.ts @@ -0,0 +1,34 @@ +import type { ReviewRow, TitleRow } from '../search.js'; + +/** Radobjektet får valgfri forhåndsberegnet inMyList (brukes av toggleList). */ +export type TitleParent = TitleRow & { inMyList?: boolean }; + +export function mapTitle(row: TitleParent) { + return { + id: row.id, + primaryTitle: row.primary_title, + originalTitle: row.original_title, + type: (row.title_type === 'movie' ? 'MOVIE' : 'SERIES') as 'MOVIE' | 'SERIES', + startYear: row.start_year, + endYear: row.end_year, + runtimeMinutes: row.runtime_minutes, + genres: row.genres, + averageRating: row.average_rating === null ? null : Number(row.average_rating), + numVotes: row.num_votes, + inMyList: row.inMyList, + }; +} + +export type TitleNode = ReturnType; + +export function mapReview(row: ReviewRow, userId: string | null) { + return { + id: row.id, + titleId: row.title_id, + author: row.author, + rating: row.rating, + text: row.body, + createdAt: row.created_at.toISOString(), + isMine: userId !== null && row.user_id === userId, + }; +} diff --git a/backend/src/schema.ts b/backend/src/schema.ts new file mode 100644 index 0000000..7640a27 --- /dev/null +++ b/backend/src/schema.ts @@ -0,0 +1,5 @@ +import { createSchema } from 'graphql-yoga'; +import { resolvers } from './resolvers/index.js'; +import { typeDefs } from './typeDefs.js'; + +export const schema = createSchema({ typeDefs, resolvers }); diff --git a/backend/src/search.ts b/backend/src/search.ts new file mode 100644 index 0000000..3d1338d --- /dev/null +++ b/backend/src/search.ts @@ -0,0 +1,411 @@ +import type { Pool } from './db.js'; +import { + afterCondition, + BIGINT_PATTERN, + decodeCursor, + encodeCursor, + orderBy, + projections, + TIMESTAMP_PATTERN, + type Direction, + type KeyDef, +} from './keyset.js'; +import type { Filters } from './validation.js'; + +export interface TitleRow { + id: string; + title_type: 'movie' | 'series'; + primary_title: string; + original_title: string; + start_year: number | null; + end_year: number | null; + runtime_minutes: number | null; + average_rating: string | null; // numeric kommer som streng fra pg + num_votes: number; + genres: string[]; +} + +export const TITLE_COLUMNS = `t.id, t.title_type, t.primary_title, t.original_title, t.start_year, + t.end_year, t.runtime_minutes, t.average_rating, t.num_votes, t.genres`; + +export interface Connection { + edges: { cursor: string; node: T }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + /** Lazy: beregnes bare hvis klienten faktisk ber om totalCount. */ + totalCount: () => Promise; +} + +/** Samler parametere slik at all data går som $n – aldri limt inn i SQL-teksten. */ +class Params { + readonly values: unknown[] = []; + add(value: unknown): string { + this.values.push(value); + return `$${this.values.length}`; + } +} + +/** Gjør om brukerens tekst til et LIKE-mønster der %, _ og \ betyr seg selv. */ +export function escapeLike(text: string): string { + return text.replace(/[\\%_]/g, (c) => `\\${c}`); +} + +export type Dimension = 'genres' | 'decades' | 'types' | 'available'; + +// Ett PK-oppslag i title_streams per kandidatrad (semi-join), aldri en skanning av tabellen. +const HAS_STREAM = 'EXISTS (SELECT 1 FROM title_streams s WHERE s.title_id = t.id)'; + +/** + * WHERE-betingelser for søketekst + filtre. `skip` utelater ett filter, brukt av fasettene: + * en dimensjon telles med alle ANDRE filtre aktive. + */ +function filterConditions( + query: string | null, + filters: Filters, + p: Params, + skip?: Dimension, +): string[] { + const conds: string[] = []; + if (query) { + // lower() gjøres i Postgres på begge sider, så samme funksjon brukes som i indeksen. + const like = p.add(`%${escapeLike(query)}%`); + conds.push( + `(lower(t.primary_title) LIKE lower(${like}) OR lower(t.original_title) LIKE lower(${like}))`, + ); + } + if (skip !== 'genres' && filters.genres.length > 0) { + // @> = «inneholder alle»; bruker GIN-indeksen på titles.genres. + conds.push(`t.genres @> ${p.add(filters.genres)}::text[]`); + } + if (skip !== 'decades' && filters.decades.length > 0) { + conds.push(`t.start_decade = ANY(${p.add(filters.decades)}::int[])`); + } + if (skip !== 'types' && filters.types.length > 0) { + conds.push(`t.title_type = ANY(${p.add(filters.types)}::text[])`); + } + if (filters.minRating !== null) { + conds.push(`t.average_rating >= ${p.add(filters.minRating)}::numeric`); + } + if (skip !== 'available' && filters.availableOnly) conds.push(HAS_STREAM); + return conds; +} + +const where = (conds: string[]) => (conds.length ? `WHERE ${conds.join(' AND ')}` : ''); + +export type SortField = 'RELEVANCE' | 'RATING' | 'YEAR' | 'TITLE'; + +export const DEFAULT_DIRECTION: Record = { + RELEVANCE: 'DESC', + RATING: 'DESC', + YEAR: 'DESC', + TITLE: 'ASC', +}; + +const VOTES: KeyDef = { expr: 't.num_votes', cast: 'int', kind: 'int' }; +const ID: KeyDef = { expr: 't.id', cast: 'text', kind: 'text' }; + +/** + * Sorteringsnøkler per felt. Manglende verdier erstattes med en sentinel (COALESCE) slik at + * radsammenligningen i cursoren aldri møter NULL (NULL < x er ukjent, og rader ville forsvunnet). + * Konsekvens: titler uten rating/år havner sist i DESC og først i ASC. Uttrykkene må være + * identiske med indeksene i 001_init.sql. + */ +function sortKeys(field: SortField, query: string | null, p: Params): KeyDef[] { + switch (field) { + case 'RELEVANCE': { + if (!query) return [VOTES, ID]; + const q = `lower(${p.add(query)}::text)`; + // Likhet alene rangerer obskure titler med identisk navn over klassikerne brukeren som regel + // mener («Dark Knight», 1 500 stemmer, før «The Dark Knight»). word_similarity gir full score + // når søkeordene står i tittelen, likhet belønner eksakte treff, og popularitet (log10 av + // stemmer, mettet ved 10 mill.) skiller resten. Stemmer og id er tiebreak. + const wordSim = `GREATEST(word_similarity(${q}, lower(t.primary_title)), word_similarity(${q}, lower(t.original_title)))`; + const sim = `GREATEST(similarity(lower(t.primary_title), ${q}), similarity(lower(t.original_title), ${q}))`; + const pop = `LEAST(log(GREATEST(t.num_votes, 1)) / 7.0, 1.0)`; + const rel = `(0.6 * ${wordSim} + 0.2 * ${sim} + 0.2 * ${pop})`; + return [{ expr: rel, select: `(${rel})::float8`, cast: 'float8', kind: 'float' }, VOTES, ID]; + } + case 'RATING': + return [ + { + expr: 'COALESCE(t.average_rating, -1)', + select: '(COALESCE(t.average_rating, -1))::float8', + cast: 'numeric', + kind: 'float', + }, + VOTES, + ID, + ]; + case 'YEAR': + return [{ expr: 'COALESCE(t.start_year, 0)', cast: 'int', kind: 'int' }, VOTES, ID]; + case 'TITLE': + return [{ expr: 'lower(t.primary_title)', cast: 'text', kind: 'text' }, ID]; + } +} + +/** Kutter den ekstra raden (first+1) og bygger edges med en cursor per rad. */ +function buildPage( + rows: Row[], + first: number, + keys: KeyDef[], + signature: string, + toNode: (row: Row) => Node, + totalCount: () => Promise, +): Connection { + const hasNextPage = rows.length > first; + const edges = rows.slice(0, first).map((row) => ({ + cursor: encodeCursor( + signature, + keys.map((_, i) => row[`k${i}`]), + ), + node: toNode(row), + })); + return { + edges, + pageInfo: { hasNextPage, endCursor: edges.at(-1)?.cursor ?? null }, + totalCount, + }; +} + +export interface SearchArgs { + query: string | null; + filters: Filters; + field: SortField; + direction: Direction; + first: number; + after: string | null; +} + +type KeyedTitleRow = TitleRow & { [k: string]: unknown }; + +export async function searchTitles(pool: Pool, args: SearchArgs): Promise> { + const { query, filters, field, direction, first, after } = args; + const p = new Params(); + const conds = filterConditions(query, filters, p); + const keys = sortKeys(field, query, p); + const signature = `search:${field}:${direction}:${query ? 'q' : '-'}`; + const rowConds = [...conds]; + if (after !== null) { + const values = decodeCursor(after, signature, keys); + rowConds.push(afterCondition(keys, values, direction, (v) => p.add(v))); + } + const limit = p.add(first + 1); + const { rows } = await pool.query( + `SELECT ${TITLE_COLUMNS}, ${projections(keys)} + FROM titles t + ${where(rowConds)} + ORDER BY ${orderBy(keys, direction)} + LIMIT ${limit}`, + p.values, + ); + + const countTotal = async () => { + const cp = new Params(); + const { rows: c } = await pool.query<{ n: string }>( + `SELECT count(*) AS n FROM titles t ${where(filterConditions(query, filters, cp))}`, + cp.values, + ); + return Number(c[0]?.n ?? 0); + }; + return buildPage(rows, first, keys, signature, (r) => r, memoize(countTotal)); +} + +function memoize(fn: () => Promise): () => Promise { + let cached: Promise | undefined; + return () => (cached ??= fn()); +} + +export interface FacetCount { + value: string; + count: number; +} + +export interface FacetsResult { + genres: FacetCount[]; + decades: FacetCount[]; + types: FacetCount[]; + available: number; +} + +export async function getFacets( + pool: Pool, + query: string | null, + filters: Filters, +): Promise { + const run = async (dim: Dimension, select: string, from: string, extra: string, tail: string) => { + const p = new Params(); + const conds = filterConditions(query, filters, p, dim); + if (extra) conds.push(extra); + const { rows } = await pool.query<{ value: string; count: number }>( + `SELECT ${select} FROM ${from} ${where(conds)} ${tail}`, + p.values, + ); + return rows; + }; + const countAvailable = async () => { + const p = new Params(); + const conds = [...filterConditions(query, filters, p, 'available'), HAS_STREAM]; + const { rows } = await pool.query<{ n: number }>( + `SELECT count(*)::int AS n FROM titles t ${where(conds)}`, + p.values, + ); + return rows[0]?.n ?? 0; + }; + const [genres, decades, types, available] = await Promise.all([ + run( + 'genres', + 'g AS value, count(*)::int AS count', + 'titles t CROSS JOIN LATERAL unnest(t.genres) AS g', + '', + 'GROUP BY g ORDER BY count DESC, g', + ), + run( + 'decades', + 't.start_decade::text AS value, count(*)::int AS count', + 'titles t', + 't.start_decade IS NOT NULL', + 'GROUP BY t.start_decade ORDER BY t.start_decade', + ), + run( + 'types', + 't.title_type AS value, count(*)::int AS count', + 'titles t', + '', + 'GROUP BY t.title_type ORDER BY t.title_type', + ), + countAvailable(), + ]); + + // Valgte verdier vises alltid, også med 0 treff, slik at brukeren ser hva som er valgt. + const withSelected = (rows: FacetCount[], selected: string[]): FacetCount[] => { + const have = new Set(rows.map((r) => r.value)); + return [...rows, ...selected.filter((s) => !have.has(s)).map((value) => ({ value, count: 0 }))]; + }; + return { + genres: withSelected(genres, filters.genres), + decades: withSelected(decades, filters.decades.map(String)).sort( + (a, b) => Number(a.value) - Number(b.value), + ), + types: withSelected( + types.map((t) => ({ value: t.value.toUpperCase(), count: t.count })), + filters.types.map((t) => t.toUpperCase()), + ), + available, + }; +} + +export async function getTitle(pool: Pool, id: string): Promise { + const { rows } = await pool.query( + `SELECT ${TITLE_COLUMNS} FROM titles t WHERE t.id = $1`, + [id], + ); + return rows[0] ?? null; +} + +export async function getGenres(pool: Pool): Promise { + const { rows } = await pool.query<{ name: string }>('SELECT name FROM genres ORDER BY name'); + return rows.map((r) => r.name); +} + +const LIST_KEYS: KeyDef[] = [ + { + expr: 'li.added_at', + select: 'li.added_at::text', + cast: 'timestamptz', + kind: 'text', + pattern: TIMESTAMP_PATTERN, + }, + { expr: 'li.title_id', cast: 'text', kind: 'text' }, +]; + +export async function getMyList( + pool: Pool, + userId: string | null, + first: number, + after: string | null, +): Promise> { + if (!userId) { + return { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + totalCount: async () => 0, + }; + } + const p = new Params(); + const conds = [`li.user_id = ${p.add(userId)}::uuid`]; + if (after !== null) { + const values = decodeCursor(after, 'myList', LIST_KEYS); + conds.push(afterCondition(LIST_KEYS, values, 'DESC', (v) => p.add(v))); + } + const limit = p.add(first + 1); + const { rows } = await pool.query( + `SELECT ${TITLE_COLUMNS}, ${projections(LIST_KEYS)} + FROM list_items li JOIN titles t ON t.id = li.title_id + ${where(conds)} + ORDER BY ${orderBy(LIST_KEYS, 'DESC')} + LIMIT ${limit}`, + p.values, + ); + const total = memoize(async () => { + const { rows: c } = await pool.query<{ n: string }>( + 'SELECT count(*) AS n FROM list_items WHERE user_id = $1::uuid', + [userId], + ); + return Number(c[0]?.n ?? 0); + }); + return buildPage(rows, first, LIST_KEYS, 'myList', (r) => r, total); +} + +export interface ReviewRow { + id: string; + title_id: string; + user_id: string; + author: string; + rating: number; + body: string; + created_at: Date; +} + +const REVIEW_KEYS: KeyDef[] = [ + // Tidsstempelet leses som tekst: JS Date har bare millisekunder, Postgres mikrosekunder, og et + // avrundet tidsstempel i cursoren ville gitt hopp eller duplikater mellom sidene. + { + expr: 'r.created_at', + select: 'r.created_at::text', + cast: 'timestamptz', + kind: 'text', + pattern: TIMESTAMP_PATTERN, + }, + { expr: 'r.id', select: 'r.id::text', cast: 'bigint', kind: 'text', pattern: BIGINT_PATTERN }, +]; + +export async function getReviews( + pool: Pool, + titleId: string, + first: number, + after: string | null, +): Promise> { + const p = new Params(); + const conds = [`r.title_id = ${p.add(titleId)}`]; + if (after !== null) { + const values = decodeCursor(after, 'reviews', REVIEW_KEYS); + conds.push(afterCondition(REVIEW_KEYS, values, 'DESC', (v) => p.add(v))); + } + const limit = p.add(first + 1); + const { rows } = await pool.query( + `SELECT r.id::text AS id, r.title_id, r.user_id, r.author, r.rating, r.body, r.created_at, + ${projections(REVIEW_KEYS)} + FROM reviews r + ${where(conds)} + ORDER BY ${orderBy(REVIEW_KEYS, 'DESC')} + LIMIT ${limit}`, + p.values, + ); + const total = memoize(async () => { + const { rows: c } = await pool.query<{ n: string }>( + 'SELECT count(*) AS n FROM reviews WHERE title_id = $1', + [titleId], + ); + return Number(c[0]?.n ?? 0); + }); + return buildPage(rows, first, REVIEW_KEYS, 'reviews', (r) => r, total); +} diff --git a/backend/src/stream.ts b/backend/src/stream.ts new file mode 100644 index 0000000..de87fd4 --- /dev/null +++ b/backend/src/stream.ts @@ -0,0 +1,81 @@ +import { config } from './config.js'; +import type { Pool } from './db.js'; +import { batchLoader } from './loaders.js'; + +export interface StreamRow { + title_id: string; + archive_id: string; + file_name: string; + license: string; + license_url: string | null; + duration_seconds: number | null; + subtitles_file: string | null; +} + +export interface Stream { + url: string; + archiveUrl: string; + license: string; + licenseUrl: string | null; + durationSeconds: number | null; + subtitlesUrl: string | null; +} + +// Verdiene havner i URL-er klienten laster og spiller. Databasen er vår egen, men importen leser +// fra en tredjepart, så vi validerer også ved bruk: en ødelagt rad skal gi «ingen stream», ikke +// en URL som peker et annet sted enn archive.org/download//. +const ARCHIVE_ID = /^[A-Za-z0-9._-]+$/; + +export function isSafeArchiveId(id: string): boolean { + // «.» og «..» består tegnsjekken, men er stisegmenter. + return ARCHIVE_ID.test(id) && !id.includes('..') && id !== '.'; +} + +export function isSafeFileName(name: string): boolean { + if (name.length === 0 || name.length > 255) return false; + if (name.includes('/') || name.includes('\\') || name.includes('..')) return false; + for (const ch of name) { + const c = ch.charCodeAt(0); + if (c < 32 || c === 127) return false; + } + return true; +} + +function safeHttpUrl(value: string | null): string | null { + if (!value) return null; + try { + const u = new URL(value); + return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null; + } catch { + return null; + } +} + +export function buildStream(row: StreamRow, archiveUrl: string = config.archiveUrl): Stream | null { + if (!isSafeArchiveId(row.archive_id) || !isSafeFileName(row.file_name)) return null; + const base = archiveUrl.replace(/\/+$/, ''); + const file = (name: string) => `${base}/download/${row.archive_id}/${encodeURIComponent(name)}`; + return { + url: file(row.file_name), + archiveUrl: `${base}/details/${row.archive_id}`, + license: row.license, + licenseUrl: safeHttpUrl(row.license_url), + durationSeconds: row.duration_seconds, + subtitlesUrl: + row.subtitles_file && isSafeFileName(row.subtitles_file) ? file(row.subtitles_file) : null, + }; +} + +/** Per-request-loader: én spørring for hele resultatsiden (PK-oppslag), ikke én per tittel. */ +export function createStreamLoader(pool: Pool, archiveUrl: string) { + return batchLoader(async (ids) => { + const { rows } = await pool.query( + `SELECT title_id, archive_id, file_name, license, license_url, duration_seconds, subtitles_file + FROM title_streams WHERE title_id = ANY($1::text[])`, + [ids], + ); + return new Map(rows.map((r) => [r.title_id, buildStream(r, archiveUrl)])); + }, null); +} + +export type StreamLoader = ReturnType; diff --git a/backend/src/typeDefs.ts b/backend/src/typeDefs.ts new file mode 100644 index 0000000..d634d9b --- /dev/null +++ b/backend/src/typeDefs.ts @@ -0,0 +1,143 @@ +// Speiler GraphQL-kontrakten i docs/api.md. Endringer gjøres i docs/api.md først. +export const typeDefs = /* GraphQL */ ` + enum TitleType { + MOVIE + SERIES + } + + enum SortField { + RELEVANCE # likhet med søketeksten; uten søketekst: flest stemmer først + RATING + YEAR + TITLE + } + + enum SortDirection { + ASC + DESC + } + + input SortInput { + field: SortField! + # Standard: RELEVANCE DESC, RATING DESC, YEAR DESC, TITLE ASC + direction: SortDirection + } + + input SearchFilters { + genres: [String!] # tittelen må ha ALLE valgte sjangre + decades: [Int!] # f.eks. 1990 = 1990–1999; flere tiår = ELLER + types: [TitleType!] # flere typer = ELLER + minRating: Float # 0–10 + availableOnly: Boolean # bare titler med stream + } + + type PageInfo { + hasNextPage: Boolean! + endCursor: String + } + + type Review { + id: ID! + titleId: ID! + author: String! # visningsnavn, 1–50 tegn + rating: Int! # 1–5 + text: String! # 0–2000 tegn + createdAt: String! # ISO 8601 + isMine: Boolean! # skrevet av x-user-id + } + + type ReviewEdge { + cursor: String! + node: Review! + } + + type ReviewConnection { + edges: [ReviewEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + type Title { + id: ID! # IMDb tconst, f.eks. "tt0111161" + primaryTitle: String! + originalTitle: String! + type: TitleType! + startYear: Int + endYear: Int + runtimeMinutes: Int + genres: [String!]! + averageRating: Float # IMDb-snitt 1–10 + numVotes: Int! + userRating: Float # snitt av anmeldelser i vår app (1–5), null uten anmeldelser + reviewCount: Int! + inMyList: Boolean! + reviews(first: Int = 10, after: String): ReviewConnection! # nyeste først + # Bilder og handling fra TMDB (se «Bilder» i docs/api.md). null når TMDB mangler tittelen, + # når TMDB_API_KEY ikke er satt, eller når oppslaget feiler/tar for lang tid. + overview: String # engelsk handlingsbeskrivelse + posterUrl(width: Int = 342): String # 2:3; bredde snappes til 92, 154, 185, 342, 500 eller 780 + backdropUrl(width: Int = 1280): String # 16:9; bredde snappes til 300, 780 eller 1280 + stream: Stream # lovlig gratisversjon fra Internet Archive, null hvis ingen + } + + type Stream { + url: String! # direkte videofil (MP4/WebM) som nettleseren kan spille, støtter Range-requests + archiveUrl: String! # siden på archive.org (kilde) + license: String! # f.eks. "Public Domain" eller "CC BY 4.0" + licenseUrl: String + durationSeconds: Int + subtitlesUrl: String # WebVTT, bare hvis Internet Archive har en .vtt-fil + } + + type TitleEdge { + cursor: String! + node: Title! + } + + type TitleConnection { + edges: [TitleEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + type FacetCount { + value: String! # sjangernavn, tiår som tekst ("1990") eller TitleType-navn ("MOVIE") + count: Int! + } + + type Facets { + genres: [FacetCount!]! + decades: [FacetCount!]! + types: [FacetCount!]! + available: Int! # antall treff med stream, med alle andre filtre aktive + } + + input AddReviewInput { + titleId: ID! + author: String! + rating: Int! + text: String! + } + + type Query { + # query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel. + search( + query: String + filters: SearchFilters + sort: SortInput + first: Int = 20 + after: String + ): TitleConnection! + title(id: ID!): Title + # Antall treff per verdi. Hver dimensjon telles med alle ANDRE filtre aktive (vanlig + # fasettsøk), slik at brukeren ser hva hun får ved å legge til/bytte en verdi. + facets(query: String, filters: SearchFilters): Facets! + myList(first: Int = 20, after: String): TitleConnection! # sist lagt til først + genres: [String!]! # alle sjangre, alfabetisk + } + + type Mutation { + addReview(input: AddReviewInput!): Review! + toggleList(titleId: ID!): Title! # returnerer tittelen med oppdatert inMyList + } +`; diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts new file mode 100644 index 0000000..fb15d02 --- /dev/null +++ b/backend/src/validation.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + parseUserId, + validateFilters, + validateFirst, + validateQuery, + validateReview, +} from './validation.js'; + +describe('parseUserId', () => { + it('godtar UUID og normaliserer til små bokstaver', () => { + expect(parseUserId('3F2504E0-4F89-41D3-9A0C-0305E82C3301')).toBe( + '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + ); + }); + it.each([ + undefined, + null, + '', + 'abc', + '3f2504e0-4f89-41d3-9a0c-0305e82c330', + "'; DROP TABLE x;--", + ])('gir null for %j', (v) => expect(parseUserId(v)).toBeNull()); +}); + +describe('validateFirst', () => { + it('bruker 20 som standard og godtar 1–50', () => { + expect(validateFirst(undefined)).toBe(20); + expect(validateFirst(1)).toBe(1); + expect(validateFirst(50)).toBe(50); + }); + it.each([0, 51, -1, 1.5, NaN])('avviser %s', (n) => expect(() => validateFirst(n)).toThrow()); +}); + +describe('validateQuery', () => { + it('trimmer og gjør tom streng til null', () => { + expect(validateQuery(' dark ')).toBe('dark'); + expect(validateQuery(' ')).toBeNull(); + expect(validateQuery(null)).toBeNull(); + }); + it('avviser for lange strenger og NUL, men teller emoji som ett tegn', () => { + expect(() => validateQuery('a'.repeat(201))).toThrow(); + expect(() => validateQuery('a\0b')).toThrow(); + expect(validateQuery('🎬'.repeat(200))).not.toBeNull(); + }); +}); + +describe('validateFilters', () => { + it('normaliserer typer og fjerner duplikater', () => { + expect( + validateFilters({ types: ['MOVIE', 'MOVIE', 'SERIES'], genres: ['Drama', 'Drama'] }), + ).toMatchObject({ + types: ['movie', 'series'], + genres: ['Drama'], + }); + }); + it.each([1995, 1790, 2200])('avviser tiår %s', (d) => + expect(() => validateFilters({ decades: [d] })).toThrow(), + ); + it.each([-1, 10.1, NaN])('avviser minRating %s', (r) => + expect(() => validateFilters({ minRating: r })).toThrow(), + ); + it('minRating 0 betyr ingen grense', () => { + expect(validateFilters({ minRating: 0 }).minRating).toBeNull(); + }); +}); + +describe('validateReview', () => { + const ok = { titleId: 'tt1', author: ' Kari ', rating: 5, text: ' Bra! ' }; + it('trimmer', () => expect(validateReview(ok)).toMatchObject({ author: 'Kari', text: 'Bra!' })); + it('avviser ugyldige felt', () => { + expect(() => validateReview({ ...ok, author: ' ' })).toThrow(); + expect(() => validateReview({ ...ok, author: 'x'.repeat(51) })).toThrow(); + expect(() => validateReview({ ...ok, rating: 0 })).toThrow(); + expect(() => validateReview({ ...ok, rating: 6 })).toThrow(); + expect(() => validateReview({ ...ok, text: 'x'.repeat(2001) })).toThrow(); + }); +}); diff --git a/backend/src/validation.ts b/backend/src/validation.ts new file mode 100644 index 0000000..a7486cc --- /dev/null +++ b/backend/src/validation.ts @@ -0,0 +1,129 @@ +import { badInput } from './errors.js'; + +export const MAX_FIRST = 50; +export const MAX_QUERY_LENGTH = 200; +export const MAX_AUTHOR_LENGTH = 50; +export const MAX_REVIEW_TEXT_LENGTH = 2000; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Gyldig UUID -> normalisert (små bokstaver), ellers null («ingen bruker»). */ +export function parseUserId(header: string | null | undefined): string | null { + if (!header || !UUID_RE.test(header)) return null; + return header.toLowerCase(); +} + +/** Antall Unicode-tegn (ikke UTF-16-enheter), slik at en emoji teller som 1 for brukeren. */ +export function charLength(s: string): number { + let n = 0; + for (const _ of s) n++; + return n; +} + +/** Postgres kan ikke lagre NUL-byte i tekst; avvises tidlig i stedet for å bli en databasefeil. */ +function assertNoNul(s: string, field: string) { + if (s.includes('\0')) throw badInput(`${field} inneholder ugyldige tegn.`); +} + +export function validateFirst(first: number | null | undefined): number { + const n = first ?? 20; + if (!Number.isInteger(n) || n < 1 || n > MAX_FIRST) { + throw badInput(`first må være mellom 1 og ${MAX_FIRST}.`); + } + return n; +} + +/** Returnerer trimmet søketekst, eller null hvis tom (= alle titler). */ +export function validateQuery(query: string | null | undefined): string | null { + if (query == null) return null; + if (charLength(query) > MAX_QUERY_LENGTH) { + throw badInput(`query kan ikke være lengre enn ${MAX_QUERY_LENGTH} tegn.`); + } + assertNoNul(query, 'query'); + const trimmed = query.trim(); + return trimmed === '' ? null : trimmed; +} + +export interface FiltersInput { + genres?: string[] | null; + decades?: number[] | null; + types?: ('MOVIE' | 'SERIES')[] | null; + minRating?: number | null; + availableOnly?: boolean | null; +} + +export interface Filters { + genres: string[]; + decades: number[]; + types: ('movie' | 'series')[]; + minRating: number | null; + availableOnly: boolean; +} + +export function validateFilters(input: FiltersInput | null | undefined): Filters { + const genres = input?.genres ?? []; + if (genres.length > 30) throw badInput('For mange sjangre i filteret.'); + for (const g of genres) { + if (g.length === 0 || charLength(g) > 50) throw badInput('Ugyldig sjanger i filteret.'); + assertNoNul(g, 'genres'); + } + + const decades = input?.decades ?? []; + if (decades.length > 30) throw badInput('For mange tiår i filteret.'); + for (const d of decades) { + if (d < 1800 || d > 2100 || d % 10 !== 0) { + throw badInput('Ugyldig tiår: bruk hele tiår som 1990.'); + } + } + + const types = (input?.types ?? []).map((t): 'movie' | 'series' => + t === 'MOVIE' ? 'movie' : 'series', + ); + + const minRating = input?.minRating ?? null; + if (minRating !== null && (!Number.isFinite(minRating) || minRating < 0 || minRating > 10)) { + throw badInput('minRating må være mellom 0 og 10.'); + } + + return { + genres: [...new Set(genres)], + decades: [...new Set(decades)], + types: [...new Set(types)], + // 0 betyr «ingen grense»; `>= 0` ville ellers utelatt titler uten rating. + minRating: minRating && minRating > 0 ? minRating : null, + availableOnly: input?.availableOnly === true, + }; +} + +export interface ReviewInput { + titleId: string; + author: string; + rating: number; + text: string; +} + +export function validateReview(input: ReviewInput): ReviewInput { + const author = input.author.trim(); + const authorLen = charLength(author); + if (authorLen < 1 || authorLen > MAX_AUTHOR_LENGTH) { + throw badInput(`author må være 1–${MAX_AUTHOR_LENGTH} tegn.`); + } + if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) { + throw badInput('rating må være et heltall mellom 1 og 5.'); + } + const text = input.text.trim(); + if (charLength(text) > MAX_REVIEW_TEXT_LENGTH) { + throw badInput(`text kan ikke være lengre enn ${MAX_REVIEW_TEXT_LENGTH} tegn.`); + } + assertNoNul(author, 'author'); + assertNoNul(text, 'text'); + if (input.titleId.length === 0 || input.titleId.length > 30) { + throw badInput('Ugyldig titleId.'); + } + return { titleId: input.titleId, author, rating: input.rating, text }; +} + +/** ID-er er tconst («tt0111161»); alt annet kan vi avvise uten databasekall. */ +export function isPlausibleTitleId(id: string): boolean { + return id.length > 0 && id.length <= 30 && !id.includes('\0'); +} diff --git a/backend/test/archive.test.ts b/backend/test/archive.test.ts new file mode 100644 index 0000000..04421dc --- /dev/null +++ b/backend/test/archive.test.ts @@ -0,0 +1,490 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- GraphQL-svar er dynamiske; testene asserter formen */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_ITEMS, + parseRange, + startArchiveMock, + type ArchiveMock, + type MockItem, +} from '../scripts/archive-mock.js'; +import { importArchive, type ImportOptions } from '../scripts/import-archive.js'; +import { ArtworkService } from '../src/artwork.js'; +import { createApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { buildStream, isSafeArchiveId, isSafeFileName } from '../src/stream.js'; +import { resetData } from './fixtures.js'; + +let pool: Pool; +let mock: ArchiveMock; +let dir: string; +const VIDEO_BYTES = 10_000; + +// Titlene standard-mocken refererer til (de finnes også i project2 og E2E-basen). +const FAMOUS: [string, string, number][] = [ + ['tt0111161', 'The Shawshank Redemption', 1994], + ['tt0068646', 'The Godfather', 1972], + ['tt0133093', 'The Matrix', 1999], + ['tt0110912', 'Pulp Fiction', 1994], + ['tt0211915', 'Amélie', 2001], + ['tt0364569', 'Oldboy', 2003], + ['tt0095327', 'Grave of the Fireflies', 1988], +]; + +const run = (over: Partial = {}) => + importArchive({ + pool, + archiveUrl: `http://127.0.0.1:${mock.port}`, + paceMs: 0, + retryDelayMs: 5, + log: () => {}, + ...over, + }); + +const streams = async () => + (await pool.query('SELECT * FROM title_streams ORDER BY title_id')).rows as any[]; + +beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); + // Fixturen har en egen «Amélie» (2001): den ville gjort tittelkoblingen tvetydig, og det er + // riktig oppførsel, men her vil vi teste en vellykket kobling på aksenter. + await pool.query(`DELETE FROM titles WHERE id = 'tt0000005'`); + await pool.query( + `INSERT INTO titles (id, title_type, primary_title, original_title, start_year) + SELECT id, 'movie', t, t, y FROM unnest($1::text[], $2::text[], $3::int[]) AS x(id, t, y)`, + [FAMOUS.map((f) => f[0]), FAMOUS.map((f) => f[1]), FAMOUS.map((f) => f[2])], + ); + dir = await mkdtemp(path.join(tmpdir(), 'archive-test-')); + await writeFile(path.join(dir, 'v.webm'), Buffer.alloc(VIDEO_BYTES, 7)); + mock = await startArchiveMock(0, { videoPath: path.join(dir, 'v.webm'), pageSize: 3 }); +}); +afterAll(async () => { + await mock.close(); + await pool.end(); + await rm(dir, { recursive: true, force: true }); +}); +beforeEach(async () => { + await pool.query('DELETE FROM title_streams'); + mock.state.calls.length = 0; + mock.state.failOnce.clear(); + mock.state.slowOnce.clear(); + mock.state.alwaysFail.clear(); +}); + +describe('importArchive mot falsk Archive', () => { + it('lagrer lovlige, koblede titler med riktig lisens, varighet og undertekst', async () => { + const summary = await run(); + expect(summary).toMatchObject({ + scanned: DEFAULT_ITEMS.length, + unlawful: 1, + unlinked: 1, + noFile: 1, + failed: 0, + imported: 5, + }); + const rows = await streams(); + expect(rows.map((r) => r.title_id)).toEqual([ + 'tt0068646', + 'tt0110912', + 'tt0111161', + 'tt0133093', + 'tt0211915', + ]); + const by = Object.fromEntries(rows.map((r) => [r.title_id, r])); + expect(by.tt0111161).toMatchObject({ + archive_id: 'mock-shawshank', + file_name: 'mock-shawshank.webm', + license: 'CC BY 4.0', + license_url: 'https://creativecommons.org/licenses/by/4.0/', + duration_seconds: 9, + subtitles_file: null, + }); + expect(by.tt0068646).toMatchObject({ license: 'Public Domain', license_url: null }); // kuratert samling + expect(by.tt0133093).toMatchObject({ license: 'CC0', duration_seconds: 8 }); // tittel + år + expect(by.tt0110912).toMatchObject({ + license: 'Public Domain', + archive_id: 'mock-pulp-fiction', + }); // år ±1 + expect(by.tt0211915).toMatchObject({ subtitles_file: 'mock-amelie.en.vtt' }); // aksenter + // Ulovlig element (Oldboy) og element uten spillbar fil (Grave of the Fireflies) er ikke lagret. + expect(by.tt0364569).toBeUndefined(); + expect(by.tt0095327).toBeUndefined(); + }); + + it('leser flere sider via cursor', async () => { + await run(); + const scrapes = mock.state.calls.filter((c) => c.startsWith('/services/search/v1/scrape')); + expect(scrapes.length).toBe(Math.ceil(DEFAULT_ITEMS.length / 3)); + expect(scrapes.slice(1).every((c) => c.includes('cursor='))).toBe(true); + expect(decodeURIComponent(scrapes[0]!)).toContain('mediatype:movies'); + }); + + it('er idempotent: ny kjøring gir samme rader, ingen duplikater', async () => { + await run(); + const first = (await streams()).map(({ imported_at: _, ...r }) => r); + const again = await run(); + expect(again.imported).toBe(5); + const second = await streams(); + expect(second.map(({ imported_at: _, ...r }) => r)).toEqual(first); + }); + + it('oppdaterer en eksisterende rad i stedet for å feile', async () => { + await pool.query( + `INSERT INTO title_streams (title_id, archive_id, file_name, license) VALUES ('tt0111161', 'old', 'old.mp4', 'x')`, + ); + await run(); + const rows = await streams(); + expect(rows.find((r) => r.title_id === 'tt0111161')).toMatchObject({ + archive_id: 'mock-shawshank', + license: 'CC BY 4.0', + }); + }); + + it('respekterer limit', async () => { + const s = await run({ limit: 2 }); + expect(s.imported).toBe(2); + expect(await streams()).toHaveLength(2); + }); + + it('prøver på nytt én gang ved 5xx og ved timeout', async () => { + mock.state.failOnce.add('/metadata/mock-godfather'); + mock.state.slowOnce.add('/metadata/mock-matrix'); + const s = await run({ timeoutMs: 100, concurrency: 1 }); + expect(s.failed).toBe(0); + expect(s.imported).toBe(5); + }); + + it('teller vedvarende feil og fortsetter med resten', async () => { + mock.state.alwaysFail.add('/metadata/mock-matrix'); + const s = await run(); + expect(s.failed).toBe(1); + expect(s.imported).toBe(4); + expect((await streams()).some((r) => r.title_id === 'tt0133093')).toBe(false); + }); + + it('hopper over tvetydige titler, ugyldige id-er og duplikater (IMDb-ID vinner)', async () => { + await pool.query( + `INSERT INTO titles (id, title_type, primary_title, original_title, start_year) + VALUES ('tt9990001', 'movie', 'Twin Film', 'Twin Film', 1940), + ('tt9990002', 'movie', 'Twin Film', 'Twin Film', 1941), + ('tt9990003', 'movie', 'Solo Film', 'Solo Film', 1950) + ON CONFLICT DO NOTHING`, + ); + const file = (id: string) => [{ name: `${id}.webm`, format: 'WebM', length: '10' }]; + const item = (identifier: string, extra: Record): MockItem => ({ + scrape: { identifier, collection: 'feature_films', ...extra }, + files: file(identifier), + }); + const custom = await startArchiveMock(0, { + videoPath: path.join(dir, 'v.webm'), + items: [ + item('twin', { title: 'Twin Film', year: '1940' }), // tvetydig + item('solo-by-title', { title: 'Solo Film', year: '1950' }), + item('solo-by-imdb', { + title: 'Whatever', + year: '2000', + 'external-identifier': 'urn:imdb:tt9990003', + }), + item('bad/id', { title: 'Solo Film', year: '1950' }), // ugyldig identifikator + ], + }); + try { + const s = await run({ archiveUrl: `http://127.0.0.1:${custom.port}` }); + expect(s.unlinked).toBe(1); + expect(s.duplicates).toBe(1); + expect(s.imported).toBe(1); + const rows = await streams(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ title_id: 'tt9990003', archive_id: 'solo-by-imdb' }); + } finally { + await custom.close(); + await pool.query(`DELETE FROM titles WHERE id LIKE 'tt999000%'`); + } + }); +}); + +describe('falsk Archive: nedlasting', () => { + const url = () => `http://127.0.0.1:${mock.port}/download/mock-shawshank/mock-shawshank.webm`; + + it('serverer hele filen med riktig type og Accept-Ranges', async () => { + const res = await fetch(url()); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('video/webm'); + expect(res.headers.get('accept-ranges')).toBe('bytes'); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect((await res.arrayBuffer()).byteLength).toBe(VIDEO_BYTES); + }); + + it('svarer 206 med Content-Range på Range-forespørsler', async () => { + const res = await fetch(url(), { headers: { range: 'bytes=0-99' } }); + expect(res.status).toBe(206); + expect(res.headers.get('content-range')).toBe(`bytes 0-99/${VIDEO_BYTES}`); + expect(res.headers.get('content-length')).toBe('100'); + expect((await res.arrayBuffer()).byteLength).toBe(100); + + const open = await fetch(url(), { headers: { range: 'bytes=9990-' } }); + expect(open.status).toBe(206); + expect(open.headers.get('content-range')).toBe(`bytes 9990-9999/${VIDEO_BYTES}`); + + const suffix = await fetch(url(), { headers: { range: 'bytes=-10' } }); + expect(suffix.headers.get('content-range')).toBe(`bytes 9990-9999/${VIDEO_BYTES}`); + }); + + it('støtter HEAD med Range (uten kropp) og avviser ugyldig område med 416', async () => { + const head = await fetch(url(), { method: 'HEAD', headers: { range: 'bytes=0-99' } }); + expect(head.status).toBe(206); + expect(head.headers.get('content-length')).toBe('100'); + expect((await head.arrayBuffer()).byteLength).toBe(0); + + const bad = await fetch(url(), { headers: { range: `bytes=${VIDEO_BYTES}-` } }); + expect(bad.status).toBe(416); + expect(bad.headers.get('content-range')).toBe(`bytes */${VIDEO_BYTES}`); + expect((await fetch(url(), { headers: { range: 'lines=1-2' } })).status).toBe(416); + }); + + it('serverer undertekster som text/vtt og gir 404 for ukjente filer', async () => { + const base = `http://127.0.0.1:${mock.port}/download/mock-amelie`; + const vtt = await fetch(`${base}/mock-amelie.en.vtt`); + expect(vtt.headers.get('content-type')).toContain('text/vtt'); + expect((await vtt.text()).startsWith('WEBVTT')).toBe(true); + expect((await fetch(`${base}/nope.webm`)).status).toBe(404); + expect((await fetch(`http://127.0.0.1:${mock.port}/download/nope/x.webm`)).status).toBe(404); + }); + + it('parseRange håndterer kanttilfeller', () => { + expect(parseRange('bytes=0-0', 10)).toEqual({ start: 0, end: 0 }); + expect(parseRange('bytes=5-999', 10)).toEqual({ start: 5, end: 9 }); + expect(parseRange('bytes=-3', 10)).toEqual({ start: 7, end: 9 }); + expect(parseRange('bytes=-99', 10)).toEqual({ start: 0, end: 9 }); + expect(parseRange('bytes=-0', 10)).toBeNull(); + expect(parseRange('bytes=-', 10)).toBeNull(); + expect(parseRange('bytes=6-5', 10)).toBeNull(); + expect(parseRange('bytes=10-', 10)).toBeNull(); + expect(parseRange('bytes=0-1,4-5', 10)).toBeNull(); + }); +}); + +describe('buildStream / validering', () => { + const row = { + title_id: 'tt1', + archive_id: 'my-film_1944.v2', + file_name: 'My Film (1944) #1.mp4', + license: 'Public Domain', + license_url: 'https://creativecommons.org/publicdomain/mark/1.0/', + duration_seconds: 5400, + subtitles_file: 'My Film.en.vtt', + }; + + it('bygger URL-er fra ARCHIVE_URL', () => { + expect(buildStream(row, 'https://archive.org')).toEqual({ + url: 'https://archive.org/download/my-film_1944.v2/My%20Film%20(1944)%20%231.mp4', + archiveUrl: 'https://archive.org/details/my-film_1944.v2', + license: 'Public Domain', + licenseUrl: 'https://creativecommons.org/publicdomain/mark/1.0/', + durationSeconds: 5400, + subtitlesUrl: 'https://archive.org/download/my-film_1944.v2/My%20Film.en.vtt', + }); + expect(buildStream(row, 'http://localhost:3998/')?.archiveUrl).toBe( + 'http://localhost:3998/details/my-film_1944.v2', + ); + expect(buildStream(row)?.url.startsWith(config.archiveUrl)).toBe(true); + }); + + it('gir null for ugyldig element-id eller filnavn', () => { + const bad = (over: object) => buildStream({ ...row, ...over }, 'https://archive.org'); + for (const archive_id of ['a/b', '', '..', '.', 'a b', 'a?x=1', 'æ', 'a\n', 'x..y']) { + expect(bad({ archive_id })).toBeNull(); + } + for (const file_name of [ + '', + '../x.mp4', + 'a/b.mp4', + 'a\\b.mp4', + 'a\u0000.mp4', + 'a\u001f.mp4', + '..', + 'x'.repeat(300), + ]) { + expect(bad({ file_name })).toBeNull(); + } + }); + + it('dropper bare undertekst/lisenslenke når de er ugyldige', () => { + const s = buildStream( + { ...row, subtitles_file: '../evil.vtt', license_url: 'javascript:alert(1)' }, + 'https://archive.org', + ); + expect(s?.subtitlesUrl).toBeNull(); + expect(s?.licenseUrl).toBeNull(); + expect(s?.url).toContain('/download/'); + }); + + it('hjelpefunksjonene', () => { + expect(isSafeArchiveId('abc-1.2_x')).toBe(true); + expect(isSafeFileName('film.mp4')).toBe(true); + expect(isSafeFileName('film..mp4')).toBe(false); + }); +}); + +describe('API: stream, availableOnly og Facets.available', () => { + const ARCHIVE = 'https://archive.test'; + let gql: (q: string, v?: Record) => Promise; + + beforeAll(async () => { + const yoga = createApp({ + pool, + production: false, + archiveUrl: ARCHIVE, + artwork: new ArtworkService({ pool, apiKey: undefined }), + }); + gql = async (query, variables) => { + const res = await yoga.fetch('http://localhost/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + return res.json(); + }; + }); + beforeEach(async () => { + // tt0000001 The Dark Knight (2008, MOVIE, Action/Crime/Drama), tt0000002 Dark City (1998, Sci-Fi/Mystery), + // tt0000016 Dark (2017, SERIES), tt0111161 Shawshank (1994, ingen sjangre). + await pool.query( + `INSERT INTO title_streams (title_id, archive_id, file_name, license, duration_seconds, subtitles_file) + VALUES ('tt0000001', 'dk', 'The Dark Knight.mp4', 'CC BY 4.0', 9000, 'dk.vtt'), + ('tt0000002', 'dc', 'dc.mp4', 'Public Domain', NULL, NULL), + ('tt0000016', 'series', 'a/b.mp4', 'Public Domain', 60, NULL)`, + ); + }); + + it('Title.stream bygger URL-er, og gir null uten rad eller med ugyldig data', async () => { + const res = await gql(`{ + a: title(id: "tt0000001") { stream { url archiveUrl license licenseUrl durationSeconds subtitlesUrl } } + b: title(id: "tt0000003") { stream { url } } + c: title(id: "tt0000016") { stream { url } } + }`); + expect(res.errors).toBeUndefined(); + expect(res.data.a.stream).toEqual({ + url: `${ARCHIVE}/download/dk/The%20Dark%20Knight.mp4`, + archiveUrl: `${ARCHIVE}/details/dk`, + license: 'CC BY 4.0', + licenseUrl: null, + durationSeconds: 9000, + subtitlesUrl: `${ARCHIVE}/download/dk/dk.vtt`, + }); + expect(res.data.b.stream).toBeNull(); + expect(res.data.c.stream).toBeNull(); // filnavn med «/» + }); + + it('slår opp streams med én spørring per side (ikke N+1)', async () => { + const spy = vi.spyOn(pool, 'query'); + try { + const res = await gql(`{ search(first: 50) { edges { node { id stream { license } } } } }`); + expect(res.errors).toBeUndefined(); + const lookups = spy.mock.calls.filter(([sql]) => + String(typeof sql === 'string' ? sql : (sql as any).text).includes('FROM title_streams'), + ); + expect(lookups).toHaveLength(1); + const by = Object.fromEntries( + res.data.search.edges.map((e: any) => [e.node.id, e.node.stream]), + ); + expect(by.tt0000001.license).toBe('CC BY 4.0'); + expect(by.tt0000003).toBeNull(); + } finally { + spy.mockRestore(); + } + }); + + it('availableOnly gir bare titler med stream, i søk og totalCount', async () => { + const res = await gql( + `query($f: SearchFilters) { search(filters: $f, sort: {field: TITLE}) { totalCount edges { node { id } } } }`, + { f: { availableOnly: true } }, + ); + expect(res.errors).toBeUndefined(); + expect(res.data.search.totalCount).toBe(3); + expect(res.data.search.edges.map((e: any) => e.node.id)).toEqual([ + 'tt0000016', // Dark + 'tt0000002', // Dark City + 'tt0000001', // The Dark Knight + ]); + const off = await gql(`query($f: SearchFilters) { search(filters: $f) { totalCount } }`, { + f: { availableOnly: false }, + }); + expect(off.data.search.totalCount).toBeGreaterThan(40); + }); + + it('availableOnly kombineres med søk, andre filtre og paginering', async () => { + const q = `query($q: String, $f: SearchFilters, $after: String) { + search(query: $q, filters: $f, first: 1, sort: {field: TITLE}, after: $after) { + totalCount pageInfo { hasNextPage endCursor } edges { node { id } } } }`; + const p1 = await gql(q, { q: 'dark', f: { availableOnly: true, types: ['MOVIE'] } }); + expect(p1.data.search.totalCount).toBe(2); + expect(p1.data.search.edges[0].node.id).toBe('tt0000002'); + const p2 = await gql(q, { + q: 'dark', + f: { availableOnly: true, types: ['MOVIE'] }, + after: p1.data.search.pageInfo.endCursor, + }); + expect(p2.data.search.edges[0].node.id).toBe('tt0000001'); + expect(p2.data.search.pageInfo.hasNextPage).toBe(false); + const none = await gql(q, { q: 'no such thing', f: { availableOnly: true } }); + expect(none.data.search.totalCount).toBe(0); + expect(none.data.search.edges).toEqual([]); + }); + + it('facets: availableOnly begrenser alle fasetter, og available teller uten availableOnly', async () => { + const F = `query($q: String, $f: SearchFilters) { facets(query: $q, filters: $f) { + genres { value count } types { value count } available } }`; + const plain = await gql(F, {}); + expect(plain.data.facets.available).toBe(3); + const only = await gql(F, { f: { availableOnly: true } }); + expect(only.data.facets.available).toBe(3); + expect(only.data.facets.types).toEqual([ + { value: 'MOVIE', count: 2 }, + { value: 'SERIES', count: 1 }, + ]); + const drama = only.data.facets.genres.find((g: any) => g.value === 'Drama'); + expect(drama.count).toBe(2); // The Dark Knight og Dark (serie, ingen sjanger-endring i fixture) + }); + + it('Facets.available respekterer søk og andre filtre', async () => { + const F = `query($q: String, $f: SearchFilters) { facets(query: $q, filters: $f) { available } }`; + expect((await gql(F, { q: 'dark city' })).data.facets.available).toBe(1); + expect((await gql(F, { f: { types: ['SERIES'] } })).data.facets.available).toBe(1); + expect((await gql(F, { f: { types: ['MOVIE'], minRating: 8 } })).data.facets.available).toBe(1); + expect((await gql(F, { f: { decades: [1990] } })).data.facets.available).toBe(1); + expect((await gql(F, { q: 'zzzz' })).data.facets.available).toBe(0); + // Uavhengig av availableOnly. + expect( + (await gql(F, { f: { types: ['SERIES'], availableOnly: true } })).data.facets.available, + ).toBe(1); + }); + + it('spesialtegn i søket gir ingen SQL-feil sammen med availableOnly', async () => { + const res = await gql( + `query($q: String) { search(query: $q, filters: {availableOnly: true}) { totalCount } facets(query: $q, filters: {availableOnly: true}) { available } }`, + { q: `'; DROP TABLE title_streams; -- %_\\` }, + ); + expect(res.errors).toBeUndefined(); + expect(res.data.search.totalCount).toBe(0); + const { rows } = await pool.query('SELECT count(*)::int AS n FROM title_streams'); + expect(rows[0].n).toBe(3); + }); + + it('sletting av tittel fjerner streamen (ON DELETE CASCADE)', async () => { + await pool.query( + `INSERT INTO titles (id, title_type, primary_title, original_title, start_year) + VALUES ('tt9999999', 'movie', 'Temp', 'Temp', 2000)`, + ); + await pool.query( + `INSERT INTO title_streams (title_id, archive_id, file_name, license) VALUES ('tt9999999', 'x', 'x.mp4', 'CC0')`, + ); + await pool.query(`DELETE FROM titles WHERE id = 'tt9999999'`); + const { rows } = await pool.query(`SELECT 1 FROM title_streams WHERE title_id = 'tt9999999'`); + expect(rows).toHaveLength(0); + }); +}); diff --git a/backend/test/artwork.test.ts b/backend/test/artwork.test.ts new file mode 100644 index 0000000..a730a79 --- /dev/null +++ b/backend/test/artwork.test.ts @@ -0,0 +1,402 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- GraphQL-svar er dynamiske; testene asserter formen */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { prefetchArtwork } from '../scripts/prefetch-artwork.js'; +import { startTmdbMock, type TmdbMock } from '../scripts/tmdb-mock.js'; +import { ArtworkService, snapWidth, type ArtworkOptions } from '../src/artwork.js'; +import { createApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { resetData } from './fixtures.js'; + +let pool: Pool; +let mock: TmdbMock; +const API = () => `http://127.0.0.1:${mock.port}/3`; +const IMG = () => `http://127.0.0.1:${mock.port}/t/p`; +const quiet = () => {}; + +const service = (over: Partial = {}) => + new ArtworkService({ + pool, + apiKey: 'test', + apiUrl: API(), + imageUrl: IMG(), + log: quiet, + ...over, + }); + +async function gql(svc: ArtworkService, query: string) { + const yoga = createApp({ pool, production: false, artwork: svc }); + const res = await yoga.fetch('http://localhost/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query }), + }); + return (await res.json()) as { data?: any; errors?: { extensions?: { code?: string } }[] }; +} + +const rowFor = async (id: string) => + (await pool.query('SELECT * FROM title_artwork WHERE title_id = $1', [id])).rows[0]; + +// Ikke-10-multipler er «funnet» i mocken; tt0000010 og tt0000100 er ukjente hos TMDB. +const FOUND = 'tt0000001'; +const MISSING = 'tt0000010'; +const poster = (id: string) => `{ title(id: "${id}") { posterUrl overview } }`; + +beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); + mock = await startTmdbMock(0); +}); +afterAll(async () => { + await mock.close(); + await pool.end(); +}); +beforeEach(async () => { + await pool.query('DELETE FROM title_artwork'); + mock.state.calls.length = 0; + mock.state.delayMs = 0; + mock.state.failStatus = null; + mock.state.emptyIds.clear(); +}); + +describe('snapWidth', () => { + it('snapper til nærmeste tillatte bredde, og til den største ved likt avstand', () => { + const p = [92, 154, 185, 342, 500, 780]; + expect(snapWidth(100, p)).toBe(92); + expect(snapWidth(342, p)).toBe(342); + expect(snapWidth(420, p)).toBe(342); // 78 fra 342, 80 fra 500 + expect(snapWidth(421, p)).toBe(500); + expect(snapWidth(99999, p)).toBe(780); + expect(snapWidth(1, p)).toBe(92); + expect(snapWidth(1000, [300, 780, 1280])).toBe(780); // 220 fra 780, 280 fra 1280 + expect(snapWidth(1030, [300, 780, 1280])).toBe(1280); // likt avstand (250): den største + }); + it('avviser ≤ 0 og ikke-heltall', () => { + expect(() => snapWidth(0, [1])).toThrow(); + expect(() => snapWidth(-5, [1])).toThrow(); + expect(() => snapWidth(1.5, [1])).toThrow(); + expect(() => snapWidth(NaN, [1])).toThrow(); + }); +}); + +describe('uten API-nøkkel', () => { + it('gjør aldri TMDB-kall, men bruker det som ligger i databasen', async () => { + const svc = service({ apiKey: undefined }); + const empty = await gql(svc, poster(FOUND)); + expect(empty.data.title).toEqual({ posterUrl: null, overview: null }); + + await pool.query( + `INSERT INTO title_artwork (title_id, status, poster_path, overview) + VALUES ($1, 'found', '/cached.jpg', 'Cached.')`, + [FOUND], + ); + const cached = await gql(svc, poster(FOUND)); + expect(cached.data.title).toEqual({ + posterUrl: `${IMG()}/w342/cached.jpg`, + overview: 'Cached.', + }); + expect(mock.state.calls).toEqual([]); + }); +}); + +describe('oppslag og cache', () => { + it('første oppslag lagrer i databasen; neste request gjør ingen TMDB-kall', async () => { + const res = await gql( + service(), + `{ title(id: "${FOUND}") { posterUrl(width: 500) backdropUrl(width: 780) overview } }`, + ); + expect(res.data.title.posterUrl).toBe(`${IMG()}/w500/${FOUND}.svg`); + expect(res.data.title.backdropUrl).toBe(`${IMG()}/w780/${FOUND}-bg.svg`); + expect(res.data.title.overview).toMatch(/^A .+\.$/); + expect(mock.state.calls).toEqual([FOUND]); // tre felt, ett kall + expect((await rowFor(FOUND)).status).toBe('found'); + + // Ny tjeneste = tom minnecache, så treffet må komme fra databasen. + const again = await gql(service(), poster(FOUND)); + expect(again.data.title.posterUrl).toBe(`${IMG()}/w342/${FOUND}.svg`); + expect(mock.state.calls).toHaveLength(1); + }); + + it('bruker den andre resultatlisten når typen ikke har treff (film funnet under tv_results)', async () => { + // tt0000001 (film) ligger i tv_results i mocken. + const res = await gql(service(), poster('tt0000001')); + expect(res.data.title.posterUrl).not.toBeNull(); + }); + + it('lagrer «missing» og spør ikke TMDB igjen', async () => { + const svc = service(); + expect((await gql(svc, poster(MISSING))).data.title).toEqual({ + posterUrl: null, + overview: null, + }); + expect((await rowFor(MISSING)).status).toBe('missing'); + await gql(service(), poster(MISSING)); + expect(mock.state.calls).toEqual([MISSING]); + }); + + it('samtidige requests for samme tittel gir ett kall og ingen krasj', async () => { + const svc = service(); + const results = await Promise.all(Array.from({ length: 5 }, () => gql(svc, poster(FOUND)))); + for (const r of results) expect(r.data.title.posterUrl).not.toBeNull(); + expect(mock.state.calls).toHaveLength(1); + }); + + it('upsert tåler at en annen prosess allerede har lagret raden', async () => { + // Simulerer kappløp: raden dukker opp mellom cache-oppslaget og lagringen. + const svc = service({ + fetch: async (...args) => { + await pool.query( + `INSERT INTO title_artwork (title_id, status) VALUES ($1, 'missing') ON CONFLICT DO NOTHING`, + [FOUND], + ); + return fetch(...args); + }, + }); + const res = await gql(svc, poster(FOUND)); + expect(res.errors).toBeUndefined(); + expect((await rowFor(FOUND)).status).toBe('found'); + }); +}); + +describe('feil hos TMDB', () => { + it('500 lagres ikke, og negativ cache hindrer nytt kall', async () => { + mock.state.failStatus = 500; + const svc = service(); + const res = await gql(svc, poster(FOUND)); + expect(res.errors).toBeUndefined(); + expect(res.data.title.posterUrl).toBeNull(); + expect(await rowFor(FOUND)).toBeUndefined(); + + await gql(svc, poster(FOUND)); + expect(mock.state.calls).toHaveLength(1); + + // Ny tjeneste (tom negativ cache) og frisk TMDB: oppslaget lykkes. + mock.state.failStatus = null; + expect((await gql(service(), poster(FOUND))).data.title.posterUrl).not.toBeNull(); + }); + + it('429 behandles som forbigående', async () => { + mock.state.failStatus = 429; + await gql(service(), poster(FOUND)); + expect(await rowFor(FOUND)).toBeUndefined(); + }); + + it('timeout per kall lagres ikke', async () => { + mock.state.delayMs = 800; + const res = await gql(service({ callTimeoutMs: 150 }), poster(FOUND)); + expect(res.data.title.posterUrl).toBeNull(); + expect(await rowFor(FOUND)).toBeUndefined(); + }); + + it('nettverksfeil gir null uten GraphQL-feil', async () => { + const svc = service({ + fetch: () => Promise.reject(new TypeError('fetch failed')), + }); + const res = await gql(svc, poster(FOUND)); + expect(res.errors).toBeUndefined(); + expect(res.data.title.posterUrl).toBeNull(); + expect(await rowFor(FOUND)).toBeUndefined(); + }); + + it('ugyldig JSON fra TMDB er en forbigående feil', async () => { + const svc = service({ fetch: async () => new Response('oops', { status: 200 }) }); + expect((await gql(svc, poster(FOUND))).data.title.posterUrl).toBeNull(); + expect(await rowFor(FOUND)).toBeUndefined(); + }); + + it('401 logger én advarsel og stopper videre kall', async () => { + mock.state.failStatus = 401; + const logs: string[] = []; + const svc = service({ concurrency: 1, log: (m) => logs.push(m) }); + const ids = ['tt0000001', 'tt0000002', 'tt0000003', 'tt0000004', 'tt0000005']; + const q = `{ ${ids.map((id, i) => `t${i}: title(id: "${id}") { posterUrl }`).join(' ')} }`; + const res = await gql(svc, q); + expect(Object.values(res.data).every((v: any) => v.posterUrl === null)).toBe(true); + expect(mock.state.calls).toHaveLength(1); + expect(svc.enabled).toBe(false); + + await gql(svc, poster('tt0000006')); + expect(mock.state.calls).toHaveLength(1); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatch(/TMDB_API_KEY/); + expect(await rowFor('tt0000001')).toBeUndefined(); + }); +}); + +describe('autentisering mot TMDB', () => { + const capture = () => { + const seen: { url: URL; auth: string | null }[] = []; + const f: typeof fetch = async (input, init) => { + const url = new URL(String(input)); + seen.push({ url, auth: new Headers(init?.headers).get('authorization') }); + return new Response(JSON.stringify({ movie_results: [], tv_results: [] })); + }; + return { seen, f }; + }; + + it('v4-token (eyJ…) sendes som Bearer, uten api_key i URL-en', async () => { + const { seen, f } = capture(); + await gql(service({ apiKey: 'eyJhbGciOi.payload.sig', fetch: f }), poster(FOUND)); + expect(seen[0]!.auth).toBe('Bearer eyJhbGciOi.payload.sig'); + expect(seen[0]!.url.searchParams.has('api_key')).toBe(false); + expect(seen[0]!.url.searchParams.get('external_source')).toBe('imdb_id'); + expect(seen[0]!.url.pathname).toBe(`/3/find/${FOUND}`); + }); + + it('v3-nøkkel sendes som api_key', async () => { + const { seen, f } = capture(); + await gql(service({ apiKey: 'abc123', fetch: f }), poster(FOUND)); + expect(seen[0]!.auth).toBeNull(); + expect(seen[0]!.url.searchParams.get('api_key')).toBe('abc123'); + }); +}); + +describe('bilde-URL-er', () => { + it('snapper bredde til nærmeste tillatte', async () => { + const res = await gql( + service(), + `{ title(id: "${FOUND}") { a: posterUrl(width: 100) b: posterUrl(width: 400) c: posterUrl(width: 99999) + d: backdropUrl(width: 1) e: backdropUrl(width: 600) f: backdropUrl }}`, + ); + const t = res.data.title; + expect(t.a).toContain('/w92/'); + expect(t.b).toContain('/w342/'); + expect(t.c).toContain('/w780/'); + expect(t.d).toContain('/w300/'); + expect(t.e).toContain('/w780/'); + expect(t.f).toContain('/w1280/'); + }); + + it('ugyldig bredde gir BAD_USER_INPUT og ingen TMDB-kall', async () => { + for (const w of [0, -3]) { + const res = await gql(service(), `{ title(id: "${FOUND}") { posterUrl(width: ${w}) } }`); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + } + const frac = await gql(service(), `{ title(id: "${FOUND}") { posterUrl(width: 1.5) } }`); + expect(frac.errors).toBeDefined(); // avvises av GraphQL selv (Int) + expect(mock.state.calls).toEqual([]); + }); + + it('ugyldige stier fra databasen gir null', async () => { + const bad = [ + '/../etc/passwd.jpg', + 'no-slash.jpg', + '/a b.jpg', + '/x.gif', + '/a/b.jpg', + 'http://evil/x.jpg', + '/x.jpg?y=1', + '/', + ]; + for (const [i, path] of bad.entries()) { + const id = `tt000000${i + 1}`; + await pool.query( + `INSERT INTO title_artwork (title_id, status, poster_path, backdrop_path) VALUES ($1, 'found', $2, $2)`, + [id, path], + ); + const res = await gql( + service({ apiKey: undefined }), + `{ title(id: "${id}") { posterUrl backdropUrl } }`, + ); + expect(res.data.title, path).toEqual({ posterUrl: null, backdropUrl: null }); + } + }); +}); + +describe('tidsgrense', () => { + it('svarer innen ca. 2,5 s med null, og lagrer resultatet i bakgrunnen', async () => { + mock.state.delayMs = 3000; + const started = Date.now(); + const res = await gql(service(), poster(FOUND)); + const elapsed = Date.now() - started; + expect(res.data.title.posterUrl).toBeNull(); + expect(elapsed).toBeGreaterThanOrEqual(2400); + expect(elapsed).toBeLessThan(3000); + expect(await rowFor(FOUND)).toBeUndefined(); + + await new Promise((r) => setTimeout(r, 900)); + expect((await rowFor(FOUND)).status).toBe('found'); + expect((await gql(service(), poster(FOUND))).data.title.posterUrl).not.toBeNull(); + }, 15_000); +}); + +describe('antall kall per side', () => { + const PAGE = `{ search(first: 20) { edges { node { primaryTitle posterUrl overview } } } }`; + + it('20 titler gir ≤ 20 TMDB-kall, og ingen ved gjentakelse', async () => { + const svc = service(); + const res = await gql(svc, PAGE); + expect(res.data.search.edges).toHaveLength(20); + expect(mock.state.calls.length).toBeLessThanOrEqual(20); + expect(mock.state.calls.length).toBe(new Set(mock.state.calls).size); + const withPoster = res.data.search.edges.filter((e: any) => e.node.posterUrl).length; + expect(withPoster).toBeGreaterThan(15); + + mock.state.calls.length = 0; + await gql(svc, PAGE); + expect(mock.state.calls).toEqual([]); + }); + + it('søk uten artwork-felt gjør 0 kall', async () => { + const res = await gql( + service(), + `{ search(first: 20) { edges { node { primaryTitle averageRating } } } }`, + ); + expect(res.data.search.edges).toHaveLength(20); + expect(mock.state.calls).toEqual([]); + }); + + it('begrenser samtidigheten', async () => { + let active = 0; + let peak = 0; + const svc = service({ + concurrency: 3, + fetch: async (...args) => { + active++; + peak = Math.max(peak, active); + try { + await new Promise((r) => setTimeout(r, 30)); + return await fetch(...args); + } finally { + active--; + } + }, + }); + await gql(svc, PAGE); + expect(peak).toBe(3); + }); +}); + +describe('db:artwork (prefetch)', () => { + it('henter de mest populære uten rad, hopper over ferdige og kan kjøres på nytt', async () => { + const svc = service(); + const first = await prefetchArtwork(pool, svc, { limit: 4, ratePerSecond: 1000 }); + expect(first).toMatchObject({ stoppedBy: 'done' }); + expect(first.found + first.missing).toBe(4); + const called = [...mock.state.calls]; + // Populærest først: The Dark Knight (tt0000001, 2,8 M stemmer). + expect(called).toContain('tt0000001'); + + mock.state.calls.length = 0; + const second = await prefetchArtwork(pool, service(), { limit: 4, ratePerSecond: 1000 }); + expect(second.found + second.missing).toBe(4); + expect(mock.state.calls.filter((c) => called.includes(c))).toEqual([]); + }); + + it('stopper ved avbrudd og ved avvist nøkkel', async () => { + const aborted = await prefetchArtwork(pool, service(), { + limit: 10, + ratePerSecond: 1000, + signal: { aborted: true }, + }); + expect(aborted.stoppedBy).toBe('signal'); + expect(mock.state.calls).toEqual([]); + + mock.state.failStatus = 403; + const rejected = await prefetchArtwork(pool, service({ concurrency: 1 }), { + limit: 10, + ratePerSecond: 1000, + }); + expect(rejected.stoppedBy).toBe('rejected-key'); + }); +}); diff --git a/backend/test/facets.test.ts b/backend/test/facets.test.ts new file mode 100644 index 0000000..78ddb39 --- /dev/null +++ b/backend/test/facets.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { TITLES, TITLE_COUNT } from './fixtures.js'; +import { setupApi } from './helpers.js'; + +const env = setupApi(); + +const FACETS = /* GraphQL */ ` + query Facets($query: String, $filters: SearchFilters) { + facets(query: $query, filters: $filters) { + genres { + value + count + } + decades { + value + count + } + types { + value + count + } + } + } +`; + +const facets = async (vars: Record = {}) => { + const res = await env.gql(FACETS, vars); + expect(res.errors).toBeUndefined(); + const f = res.data!.facets; + const toMap = (rows: { value: string; count: number }[]) => + Object.fromEntries(rows.map((r) => [r.value, r.count])); + return { genres: toMap(f.genres), decades: toMap(f.decades), types: toMap(f.types), raw: f }; +}; + +describe('facets', () => { + it('teller alle verdier uten filter', async () => { + const f = await facets(); + expect(f.types).toEqual({ + MOVIE: TITLES.filter((t) => t.type === 'movie').length, + SERIES: TITLES.filter((t) => t.type === 'series').length, + }); + expect(f.genres.Drama).toBe(TITLES.filter((t) => t.genres.includes('Drama')).length); + expect(f.genres.Western).toBe(TITLES.filter((t) => t.genres.includes('Western')).length); + // Titler uten år har ingen tiår, så summen er én mindre enn totalen. + expect(Object.values(f.decades).reduce((a, b) => a + b, 0)).toBe(TITLE_COUNT - 1); + expect(f.decades['1990']).toBe( + TITLES.filter((t) => t.year !== null && t.year >= 1990 && t.year < 2000).length, + ); + }); + + it('sorterer sjangre etter antall, tiår kronologisk', async () => { + const { raw } = await facets(); + const counts = raw.genres.map((g: { count: number }) => g.count); + expect(counts).toEqual([...counts].sort((a: number, b: number) => b - a)); + const decades = raw.decades.map((d: { value: string }) => Number(d.value)); + expect(decades).toEqual([...decades].sort((a: number, b: number) => a - b)); + }); + + it('en dimensjon telles med alle ANDRE filtre, ikke sitt eget', async () => { + const f = await facets({ filters: { genres: ['Crime'], types: ['SERIES'] } }); + // genres: bare type-filteret gjelder -> alle serier + const series = TITLES.filter((t) => t.type === 'series'); + expect(f.genres.Drama).toBe(series.filter((t) => t.genres.includes('Drama')).length); + expect(f.genres.Adventure).toBe(series.filter((t) => t.genres.includes('Adventure')).length); + // types: bare sjanger-filteret gjelder -> både filmer og serier med Crime + const crime = TITLES.filter((t) => t.genres.includes('Crime')); + expect(f.types).toEqual({ + MOVIE: crime.filter((t) => t.type === 'movie').length, + SERIES: crime.filter((t) => t.type === 'series').length, + }); + // decades: begge filtre gjelder + const both = crime.filter((t) => t.type === 'series' && t.year !== null); + expect(Object.values(f.decades).reduce((a, b) => a + b, 0)).toBe(both.length); + }); + + it('tar hensyn til søketekst og minRating', async () => { + const f = await facets({ query: 'dark', filters: { minRating: 8 } }); + const expected = TITLES.filter( + (t) => + `${t.primary} ${t.original ?? ''}`.toLowerCase().includes('dark') && (t.rating ?? 0) >= 8, + ); + expect(Object.values(f.types).reduce((a, b) => a + b, 0)).toBe(expected.length); + expect(f.genres.Crime).toBe(expected.filter((t) => t.genres.includes('Crime')).length); + }); + + it('viser valgte verdier med 0 treff i stedet for å la dem forsvinne', async () => { + const f = await facets({ + filters: { genres: ['Western'], decades: [2020], types: ['SERIES'] }, + }); + expect(f.genres.Western).toBe(0); + expect(f.decades['2020']).toBe( + TITLES.filter( + (t) => t.genres.includes('Western') && t.type === 'series' && (t.year ?? 0) >= 2020, + ).length, + ); + expect(f.types.SERIES).toBeDefined(); + }); + + it('gir tomme lister (ikke feil) når ingenting treffer', async () => { + const f = await facets({ query: 'zzzqqq' }); + expect(f.raw).toEqual({ genres: [], decades: [], types: [] }); + }); + + it('validerer input som search', async () => { + const long = await env.gql(FACETS, { query: 'a'.repeat(201) }); + expect(long.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + const dec = await env.gql(FACETS, { filters: { decades: [1993] } }); + expect(dec.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); + + it('genres-spørringen gir alle sjangre alfabetisk', async () => { + const res = await env.gql('{ genres }'); + const all = [...new Set(TITLES.flatMap((t) => t.genres))].sort(); + expect(res.data!.genres).toEqual(all); + }); +}); diff --git a/backend/test/fixtures.ts b/backend/test/fixtures.ts new file mode 100644 index 0000000..656f5fd --- /dev/null +++ b/backend/test/fixtures.ts @@ -0,0 +1,106 @@ +import type { Pool } from '../src/db.js'; + +export interface FixtureTitle { + id: string; + type: 'movie' | 'series'; + primary: string; + original?: string; + year: number | null; + endYear?: number | null; + rating: number | null; + votes: number; + genres: string[]; +} + +const t = ( + id: number, + primary: string, + type: 'movie' | 'series', + year: number | null, + rating: number | null, + votes: number, + genres: string[], + extra: Partial = {}, +): FixtureTitle => ({ + id: `tt${String(id).padStart(7, '0')}`, + primary, + type, + year, + rating, + votes, + genres, + ...extra, +}); + +const named: FixtureTitle[] = [ + t(1, 'The Dark Knight', 'movie', 2008, 9.0, 2800000, ['Action', 'Crime', 'Drama']), + t(2, 'Dark City', 'movie', 1998, 7.6, 300000, ['Sci-Fi', 'Mystery']), + t(3, 'Dark Waters', 'movie', 2019, 7.6, 150000, ['Drama']), + t(4, 'Darkman', 'movie', 1990, 6.4, 90000, ['Action', 'Sci-Fi']), + t(5, 'Amélie', 'movie', 2001, 8.3, 800000, ['Comedy', 'Romance'], { + original: "Le Fabuleux Destin d'Amélie Poulain", + }), + t(6, 'Café Society', 'movie', 2016, 6.7, 100000, ['Comedy', 'Drama', 'Romance']), + t(7, 'It', 'movie', 2017, 7.3, 600000, ['Horror'], { original: 'Ça' }), + t(8, '100% Pure', 'movie', 2005, 5.0, 1200, ['Comedy']), + t(9, 'Under_score Story', 'movie', 2010, 5.5, 2000, ['Drama']), + t(10, 'UnderXscore Story', 'movie', 2010, 5.5, 1900, ['Drama']), + t(11, "Don't Look Up", 'movie', 2021, 7.2, 550000, ['Comedy', 'Drama']), + t(12, 'Say "Cheese"', 'movie', 2012, 4.1, 900, ['Comedy']), + t(13, 'Back\\Slash', 'movie', 2003, 4.8, 800, ['Drama']), + t(14, 'Emoji 🎬 Night', 'movie', 2020, 6.0, 700, ['Musical']), + t(15, 'Breaking Bad', 'series', 2008, 9.5, 2200000, ['Crime', 'Drama', 'Thriller'], { + endYear: 2013, + }), + t(16, 'Dark', 'series', 2017, 8.7, 500000, ['Crime', 'Drama', 'Mystery'], { endYear: 2020 }), + t(17, 'Bad Banks', 'series', 2018, 7.6, 20000, ['Drama', 'Thriller']), + t(18, 'Unknown Year Film', 'movie', null, 5.0, 3000, ['Drama']), + t(19, 'Unrated Film', 'movie', 1999, null, 4000, ['Comedy']), + t(20, "O'Brien's Odyssey", 'series', 1985, 6.9, 5000, ['Adventure'], { endYear: 1988 }), +]; + +// Fyllmateriale med bevisst mange likheter i rating/år/stemmer, slik at tiebreaks og +// paginering over like verdier blir testet. +const filler: FixtureTitle[] = Array.from({ length: 27 }, (_, i) => + t( + 100 + i, + `Filler ${String(i).padStart(2, '0')}`, + i % 4 === 0 ? 'series' : 'movie', + 1950 + (i % 5) * 10 + (i % 3), + 3 + (i % 6), + 1000 + (i % 4) * 100, + [['Drama'], ['Comedy', 'Drama'], ['Western'], ['War', 'History']][i % 4] as string[], + ), +); + +export const TITLES: FixtureTitle[] = [...named, ...filler]; +export const TITLE_COUNT = TITLES.length; + +/** Tømmer alle tabeller og setter inn det kontrollerte datasettet. */ +export async function resetData(pool: Pool): Promise { + await pool.query( + 'TRUNCATE reviews, list_items, title_genres, genres, titles RESTART IDENTITY CASCADE', + ); + // Genres sendes som kommaseparert tekst per rad og pakkes ut til text[] i SQL. + await pool.query( + `INSERT INTO titles (id, title_type, primary_title, original_title, start_year, end_year, + average_rating, num_votes, genres) + SELECT id, tt, pt, ot, sy, ey, r, v, string_to_array(g, ',') + FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::int[], $6::int[], + $7::numeric[], $8::int[], $9::text[]) + AS x(id, tt, pt, ot, sy, ey, r, v, g)`, + [ + TITLES.map((x) => x.id), + TITLES.map((x) => x.type), + TITLES.map((x) => x.primary), + TITLES.map((x) => x.original ?? x.primary), + TITLES.map((x) => x.year), + TITLES.map((x) => x.endYear ?? null), + TITLES.map((x) => x.rating), + TITLES.map((x) => x.votes), + TITLES.map((x) => x.genres.join(',')), + ], + ); + const all = [...new Set(TITLES.flatMap((x) => x.genres))]; + await pool.query('INSERT INTO genres (name) SELECT unnest($1::text[])', [all]); +} diff --git a/backend/test/global-setup.ts b/backend/test/global-setup.ts new file mode 100644 index 0000000..82a0092 --- /dev/null +++ b/backend/test/global-setup.ts @@ -0,0 +1,23 @@ +import { config } from '../src/config.js'; +import { createPool } from '../src/db.js'; +import { migrate } from '../scripts/migrate.js'; + +/** + * Kjøres én gang før alle testfiler: nullstiller testdatabasen og migrerer fra scratch, slik at + * testene også verifiserer at migrasjonene virker på en tom database (som i GitLab CI). + */ +export default async function setup() { + if (config.testDatabaseUrl === config.databaseUrl) { + throw new Error( + 'TEST_DATABASE_URL må være en annen database enn DATABASE_URL – testene tømmer den.', + ); + } + const pool = createPool(config.testDatabaseUrl); + try { + await pool.query('DROP SCHEMA public CASCADE'); + await pool.query('CREATE SCHEMA public'); + await migrate(pool); + } finally { + await pool.end(); + } +} diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts new file mode 100644 index 0000000..d1f2194 --- /dev/null +++ b/backend/test/helpers.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- GraphQL-svar er dynamiske; testene asserter formen */ +import { afterAll, beforeAll } from 'vitest'; +import { ArtworkService } from '../src/artwork.js'; +import { createApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { resetData } from './fixtures.js'; + +export const USER_A = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; +export const USER_B = '9b2f8a5c-1d3e-4c7a-8f60-2a4b6c8d0e1f'; + +export interface GqlResult> { + data?: T | null; + errors?: { message: string; extensions?: { code?: string } }[]; +} + +export interface TestEnv { + pool: Pool; + gql: ( + query: string, + variables?: Record, + userId?: string | null, + ) => Promise; +} + +/** + * Felles oppsett for API-tester: egen pool mot testdatabasen, friskt datasett før filen, + * og spørringer via yoga.fetch (ingen nettverksport nødvendig). + */ +export function setupApi(): TestEnv { + const env = {} as TestEnv; + beforeAll(async () => { + env.pool = createPool(config.testDatabaseUrl); + await resetData(env.pool); + const yoga = createApp({ + pool: env.pool, + production: false, + corsOrigin: 'http://localhost:5173', + // Uten nøkkel: testene skal aldri kunne nå et ekte TMDB, selv om utvikleren har en i .env. + artwork: new ArtworkService({ pool: env.pool, apiKey: undefined }), + }); + env.gql = async (query, variables, userId = null) => { + const res = await yoga.fetch('http://localhost/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json', ...(userId ? { 'x-user-id': userId } : {}) }, + body: JSON.stringify({ query, variables }), + }); + return (await res.json()) as GqlResult; + }; + }); + afterAll(async () => { + await env.pool.end(); + }); + return env; +} diff --git a/backend/test/mutations.test.ts b/backend/test/mutations.test.ts new file mode 100644 index 0000000..3c9917e --- /dev/null +++ b/backend/test/mutations.test.ts @@ -0,0 +1,245 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */ +import { describe, expect, it } from 'vitest'; +import { setupApi, type GqlResult, USER_A, USER_B } from './helpers.js'; + +const env = setupApi(); + +const ADD = /* GraphQL */ ` + mutation Add($input: AddReviewInput!) { + addReview(input: $input) { + id + titleId + author + rating + text + createdAt + isMine + } + } +`; +const TOGGLE = /* GraphQL */ ` + mutation Toggle($id: ID!) { + toggleList(titleId: $id) { + id + inMyList + primaryTitle + } + } +`; +const MY_LIST = /* GraphQL */ ` + query MyList($first: Int, $after: String) { + myList(first: $first, after: $after) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + inMyList + } + } + } + } +`; + +const valid = { + titleId: 'tt0000001', + author: 'Kari Nordmann', + rating: 4, + text: 'Veldig bra film.', +}; +const code = (r: { errors?: { extensions?: { code?: string } }[] }) => + r.errors?.[0]?.extensions?.code; + +describe('addReview', () => { + it('lagrer en gyldig anmeldelse og returnerer den', async () => { + const res = await env.gql(ADD, { input: valid }, USER_A); + expect(res.errors).toBeUndefined(); + const r = res.data!.addReview; + expect(r).toMatchObject({ + titleId: 'tt0000001', + author: 'Kari Nordmann', + rating: 4, + text: 'Veldig bra film.', + isMine: true, + }); + expect(new Date(r.createdAt).toISOString()).toBe(r.createdAt); + const { rows } = await env.pool.query('SELECT * FROM reviews WHERE id = $1', [r.id]); + expect(rows[0]).toMatchObject({ user_id: USER_A, title_id: 'tt0000001', rating: 4 }); + }); + + it('oppdaterer userRating og reviewCount på tittelen', async () => { + await env.gql(ADD, { input: { ...valid, rating: 2 } }, USER_B); + const res = await env.gql('{ title(id: "tt0000001") { userRating reviewCount } }'); + expect(res.data!.title).toEqual({ userRating: 3, reviewCount: 2 }); + }); + + it('trimmer forfatter og tekst, og tillater tom tekst', async () => { + const res = await env.gql(ADD, { input: { ...valid, author: ' Ola ', text: ' ' } }, USER_A); + expect(res.data!.addReview).toMatchObject({ author: 'Ola', text: '' }); + }); + + it('godtar grenseverdier: 50 tegn forfatter, 2000 tegn tekst, rating 1 og 5, emoji', async () => { + for (const input of [ + { ...valid, author: 'a'.repeat(50) }, + { ...valid, text: 'x'.repeat(2000) }, + { ...valid, rating: 1 }, + { ...valid, rating: 5 }, + { ...valid, author: '🎬'.repeat(50), text: '🎬'.repeat(2000) }, + ]) { + const res = await env.gql(ADD, { input }, USER_A); + expect(res.errors, JSON.stringify(input).slice(0, 60)).toBeUndefined(); + } + }); + + it('lagrer farlige strenger som ren tekst', async () => { + const text = `'); DROP TABLE reviews; -- "quoted" \\ %_`; + const res = await env.gql(ADD, { input: { ...valid, text } }, USER_A); + expect(res.data!.addReview.text).toBe(text); + const { rows } = await env.pool.query('SELECT count(*)::int AS n FROM reviews'); + expect(rows[0].n).toBeGreaterThan(0); + }); + + it.each([ + ['tom forfatter', { author: '' }], + ['forfatter med bare mellomrom', { author: ' ' }], + ['forfatter på 51 tegn', { author: 'a'.repeat(51) }], + ['rating 0', { rating: 0 }], + ['rating 6', { rating: 6 }], + ['negativ rating', { rating: -1 }], + ['tekst på 2001 tegn', { text: 'x'.repeat(2001) }], + ['NUL-tegn i tekst', { text: 'a\u0000b' }], + ['NUL-tegn i forfatter', { author: 'a\u0000b' }], + ])('avviser %s med BAD_USER_INPUT', async (_navn, patch) => { + const before = (await env.pool.query('SELECT count(*)::int AS n FROM reviews')).rows[0].n; + const res = await env.gql(ADD, { input: { ...valid, ...patch } }, USER_A); + expect(code(res)).toBe('BAD_USER_INPUT'); + const after = (await env.pool.query('SELECT count(*)::int AS n FROM reviews')).rows[0].n; + expect(after).toBe(before); + }); + + it('avviser ikke-heltall og manglende felt (skjemavalidering)', async () => { + expect((await env.gql(ADD, { input: { ...valid, rating: 2.5 } }, USER_A)).errors).toBeDefined(); + expect((await env.gql(ADD, { input: { titleId: 'tt0000001' } }, USER_A)).errors).toBeDefined(); + expect((await env.gql(ADD, { input: { ...valid, rating: '5' } }, USER_A)).errors).toBeDefined(); + }); + + it('gir NOT_FOUND for ukjent tittel', async () => { + expect(code(await env.gql(ADD, { input: { ...valid, titleId: 'tt9999999' } }, USER_A))).toBe( + 'NOT_FOUND', + ); + expect(code(await env.gql(ADD, { input: { ...valid, titleId: "x' OR 1=1" } }, USER_A))).toBe( + 'NOT_FOUND', + ); + }); + + it('gir UNAUTHENTICATED uten x-user-id eller med ugyldig UUID', async () => { + expect(code(await env.gql(ADD, { input: valid }))).toBe('UNAUTHENTICATED'); + expect(code(await env.gql(ADD, { input: valid }, 'ikke-en-uuid'))).toBe('UNAUTHENTICATED'); + expect(code(await env.gql(ADD, { input: valid }, "'; DROP TABLE reviews;--"))).toBe( + 'UNAUTHENTICATED', + ); + }); +}); + +describe('toggleList og myList', () => { + it('myList er tom uten x-user-id og for ny bruker', async () => { + for (const user of [null, USER_A, 'ugyldig']) { + const res = await env.gql(MY_LIST, {}, user); + expect(res.errors).toBeUndefined(); + expect(res.data!.myList).toEqual({ + totalCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + edges: [], + }); + } + }); + + it('slår av og på', async () => { + const on = await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A); + expect(on.data!.toggleList).toEqual({ + id: 'tt0000005', + inMyList: true, + primaryTitle: 'Amélie', + }); + expect( + (await env.gql(MY_LIST, {}, USER_A)).data!.myList.edges.map( + (e: { node: { id: string } }) => e.node.id, + ), + ).toEqual(['tt0000005']); + const detail = await env.gql('{ title(id: "tt0000005") { inMyList } }', undefined, USER_A); + expect(detail.data!.title.inMyList).toBe(true); + + const off = await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A); + expect(off.data!.toggleList.inMyList).toBe(false); + expect((await env.gql(MY_LIST, {}, USER_A)).data!.myList.totalCount).toBe(0); + const on2 = await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A); + expect(on2.data!.toggleList.inMyList).toBe(true); + await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A); + }); + + it('lister er separate per bruker', async () => { + await env.gql(TOGGLE, { id: 'tt0000006' }, USER_A); + const b = await env.gql(MY_LIST, {}, USER_B); + expect(b.data!.myList.totalCount).toBe(0); + const search = await env.gql( + '{ search(query: "Café") { edges { node { id inMyList } } } }', + undefined, + USER_B, + ); + expect(search.data!.search.edges[0].node.inMyList).toBe(false); + const searchA = await env.gql( + '{ search(query: "Café") { edges { node { id inMyList } } } }', + undefined, + USER_A, + ); + expect(searchA.data!.search.edges[0].node.inMyList).toBe(true); + await env.gql(TOGGLE, { id: 'tt0000006' }, USER_A); + }); + + it('myList: sist lagt til først, med paginering', async () => { + const order = ['tt0000001', 'tt0000002', 'tt0000003', 'tt0000004', 'tt0000005']; + for (const id of order) await env.gql(TOGGLE, { id }, USER_A); + const seen: string[] = []; + let after: string | null = null; + let pages = 0; + do { + const res: GqlResult = await env.gql(MY_LIST, { first: 2, after }, USER_A); + expect(res.errors).toBeUndefined(); + const list: Record = res.data!.myList; + expect(list.totalCount).toBe(5); + for (const e of list.edges) { + seen.push(e.node.id); + expect(e.node.inMyList).toBe(true); + } + after = list.pageInfo.hasNextPage ? list.pageInfo.endCursor : null; + pages++; + } while (after && pages < 10); + expect(seen).toEqual([...order].reverse()); + expect(pages).toBe(3); + }); + + it('myList validerer first og cursor', async () => { + expect(code(await env.gql(MY_LIST, { first: 0 }, USER_A))).toBe('BAD_USER_INPUT'); + expect(code(await env.gql(MY_LIST, { first: 51 }, USER_A))).toBe('BAD_USER_INPUT'); + expect(code(await env.gql(MY_LIST, { after: 'tull' }, USER_A))).toBe('BAD_USER_INPUT'); + const bad = Buffer.from('{"s":"myList","v":["ikke-dato","x"]}').toString('base64url'); + expect(code(await env.gql(MY_LIST, { after: bad }, USER_A))).toBe('BAD_USER_INPUT'); + }); + + it('krever x-user-id og eksisterende tittel', async () => { + expect(code(await env.gql(TOGGLE, { id: 'tt0000001' }))).toBe('UNAUTHENTICATED'); + expect(code(await env.gql(TOGGLE, { id: 'tt0000001' }, 'abc'))).toBe('UNAUTHENTICATED'); + expect(code(await env.gql(TOGGLE, { id: 'tt9999999' }, USER_A))).toBe('NOT_FOUND'); + }); + + it('to samtidige toggles gir ikke intern feil', async () => { + const results = await Promise.all([ + env.gql(TOGGLE, { id: 'tt0000020' }, USER_B), + env.gql(TOGGLE, { id: 'tt0000020' }, USER_B), + ]); + for (const r of results) expect(r.errors).toBeUndefined(); + }); +}); diff --git a/backend/test/pagination.test.ts b/backend/test/pagination.test.ts new file mode 100644 index 0000000..d3259ac --- /dev/null +++ b/backend/test/pagination.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { TITLE_COUNT } from './fixtures.js'; +import { setupApi } from './helpers.js'; +import { collectIds, SEARCH } from './queries.js'; + +const env = setupApi(); + +/** Fasit: rekkefølgen Postgres selv gir med samme sorteringsnøkler, uavhengig av cursor-koden. */ +const REFERENCE: Record = { + 'RATING DESC': 'COALESCE(average_rating,-1) DESC, num_votes DESC, id DESC', + 'RATING ASC': 'COALESCE(average_rating,-1) ASC, num_votes ASC, id ASC', + 'YEAR DESC': 'COALESCE(start_year,0) DESC, num_votes DESC, id DESC', + 'YEAR ASC': 'COALESCE(start_year,0) ASC, num_votes ASC, id ASC', + 'TITLE ASC': 'lower(primary_title) ASC, id ASC', + 'TITLE DESC': 'lower(primary_title) DESC, id DESC', + 'RELEVANCE DESC': 'num_votes DESC, id DESC', + 'RELEVANCE ASC': 'num_votes ASC, id ASC', +}; + +describe('paginering og sortering', () => { + it.each(Object.keys(REFERENCE))( + '%s: alle sider gir hele settet i riktig rekkefølge', + async (key) => { + const [field, direction] = key.split(' ') as [string, string]; + const { rows } = await env.pool.query<{ id: string }>( + `SELECT id FROM titles ORDER BY ${REFERENCE[key]}`, + ); + const expected = rows.map((r) => r.id); + for (const pageSize of [1, 7, 50]) { + const got = await collectIds(env, { sort: { field, direction } }, pageSize); + expect(got.ids, `pageSize ${pageSize}`).toEqual(expected); + expect(new Set(got.ids).size).toBe(TITLE_COUNT); + expect(got.total).toBe(TITLE_COUNT); + } + }, + ); + + it('standardretning per felt følger kontrakten', async () => { + const dir = async (field: string, direction?: string) => + (await collectIds(env, { sort: { field, direction } }, 50)).ids; + expect(await dir('RATING')).toEqual(await dir('RATING', 'DESC')); + expect(await dir('YEAR')).toEqual(await dir('YEAR', 'DESC')); + expect(await dir('TITLE')).toEqual(await dir('TITLE', 'ASC')); + expect(await dir('RELEVANCE')).toEqual(await dir('RELEVANCE', 'DESC')); + const noSort = (await collectIds(env, {}, 50)).ids; + expect(noSort).toEqual(await dir('RELEVANCE', 'DESC')); + }); + + it('titler uten rating/år er med i sorteringene (ingen forsvunne rader)', async () => { + for (const field of ['RATING', 'YEAR']) { + for (const direction of ['ASC', 'DESC']) { + const { ids } = await collectIds(env, { sort: { field, direction } }, 4); + expect(ids).toContain(field === 'RATING' ? 'tt0000019' : 'tt0000018'); + } + } + }); + + it.each(['RELEVANCE', 'RATING', 'YEAR', 'TITLE'])( + 'søk + filter + %s gir stabile sider uten duplikater', + async (field) => { + for (const direction of ['ASC', 'DESC']) { + const vars = { + query: 'a', + filters: { types: ['MOVIE', 'SERIES'], minRating: 3 }, + sort: { field, direction }, + }; + const single = await collectIds(env, vars, 50); + const paged = await collectIds(env, vars, 5); + expect(paged.ids).toEqual(single.ids); + expect(new Set(paged.ids).size).toBe(paged.ids.length); + expect(paged.total).toBe(single.ids.length); + } + }, + ); + + it('relevans med søketekst følger vektet ord-likhet, likhet og popularitet, deretter stemmer', async () => { + // Uavhengig gjengivelse av formelen i search.ts – fanger opp utilsiktede endringer i rangeringen. + const { rows } = await env.pool.query<{ id: string; rel: number; num_votes: number }>( + `SELECT id, num_votes, + (0.6 * GREATEST(word_similarity('dark', lower(primary_title)), word_similarity('dark', lower(original_title))) + + 0.2 * GREATEST(similarity(lower(primary_title), 'dark'), similarity(lower(original_title), 'dark')) + + 0.2 * LEAST(log(GREATEST(num_votes, 1)) / 7.0, 1.0))::float8 AS rel + FROM titles WHERE lower(primary_title) LIKE '%dark%' OR lower(original_title) LIKE '%dark%' + ORDER BY rel DESC, num_votes DESC, id DESC`, + ); + const paged = await collectIds(env, { query: 'dark', sort: { field: 'RELEVANCE' } }, 2); + expect(paged.ids).toEqual(rows.map((r) => r.id)); + const asc = await collectIds( + env, + { query: 'dark', sort: { field: 'RELEVANCE', direction: 'ASC' } }, + 2, + ); + expect(asc.ids).toEqual([...rows].reverse().map((r) => r.id)); + }); + + it('siste side har hasNextPage=false og endCursor for siste rad', async () => { + const r = await env.gql(SEARCH, { first: TITLE_COUNT }); + expect(r.data!.search.pageInfo.hasNextPage).toBe(false); + const edges = r.data!.search.edges; + expect(r.data!.search.pageInfo.endCursor).toBe(edges[edges.length - 1].cursor); + const beyond = await env.gql(SEARCH, { first: 5, after: edges[edges.length - 1].cursor }); + expect(beyond.data!.search.edges).toEqual([]); + expect(beyond.data!.search.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + }); + + it('exakt fullt sett (first == antall) gir hasNextPage=false', async () => { + const r = await env.gql(SEARCH, { first: 50, filters: { types: ['SERIES'] } }); + expect(r.data!.search.pageInfo.hasNextPage).toBe(false); + }); +}); diff --git a/backend/test/queries.ts b/backend/test/queries.ts new file mode 100644 index 0000000..991b050 --- /dev/null +++ b/backend/test/queries.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */ +import type { GqlResult, TestEnv } from './helpers.js'; + +export const SEARCH = /* GraphQL */ ` + query Search( + $query: String + $filters: SearchFilters + $sort: SortInput + $first: Int + $after: String + ) { + search(query: $query, filters: $filters, sort: $sort, first: $first, after: $after) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + primaryTitle + originalTitle + type + startYear + endYear + genres + averageRating + numVotes + } + } + } + } +`; + +export interface SearchVars { + query?: string | null; + filters?: Record | null; + sort?: { field: string; direction?: string } | null; + first?: number; + after?: string | null; +} + +/** Henter alle sider til pageInfo.hasNextPage er false, og returnerer id-ene i rekkefølge. */ +export async function collectIds(env: TestEnv, vars: SearchVars, pageSize = 50) { + const ids: string[] = []; + let after: string | null = null; + let pages = 0; + let total = -1; + for (;;) { + const res: GqlResult = await env.gql(SEARCH, { ...vars, first: pageSize, after }); + if (res.errors) throw new Error(JSON.stringify(res.errors)); + const conn: Record = res.data!.search; + total = conn.totalCount; + ids.push(...conn.edges.map((e: { node: { id: string } }) => e.node.id)); + pages++; + if (!conn.pageInfo.hasNextPage) break; + after = conn.pageInfo.endCursor; + if (pages > 200) throw new Error('For mange sider – mulig evig løkke'); + } + return { ids, pages, total }; +} diff --git a/backend/test/search.test.ts b/backend/test/search.test.ts new file mode 100644 index 0000000..17e690c --- /dev/null +++ b/backend/test/search.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import { TITLES, TITLE_COUNT } from './fixtures.js'; +import { setupApi } from './helpers.js'; +import { SEARCH } from './queries.js'; + +const env = setupApi(); + +const idsMatching = (pred: (t: (typeof TITLES)[number]) => boolean) => + TITLES.filter(pred) + .map((t) => t.id) + .sort(); + +const search = async (vars: Record) => { + const res = await env.gql(SEARCH, { first: 50, ...vars }); + const conn = res.data?.search; + return { + res, + ids: (conn?.edges ?? []).map((e: { node: { id: string } }) => e.node.id) as string[], + total: conn?.totalCount as number | undefined, + titles: (conn?.edges ?? []).map( + (e: { node: { primaryTitle: string } }) => e.node.primaryTitle, + ) as string[], + }; +}; + +describe('search: tekst', () => { + it('finner delstrenger case-insensitivt i primær- og originaltittel', async () => { + const expected = idsMatching((t) => + `${t.primary} ${t.original ?? ''}`.toLowerCase().includes('dark'), + ); + for (const q of ['dark', 'DARK', 'DaRk']) { + const r = await search({ query: q }); + expect([...r.ids].sort()).toEqual(expected); + expect(r.total).toBe(expected.length); + } + expect(expected.length).toBeGreaterThanOrEqual(5); + }); + + it('rangerer eksakt treff først ved relevans', async () => { + const r = await search({ query: 'dark' }); + expect(r.titles[0]).toBe('Dark'); + }); + + it('søker i originaltittel', async () => { + expect((await search({ query: 'fabuleux' })).titles).toEqual(['Amélie']); + expect((await search({ query: 'Ça' })).titles).toEqual(['It']); + }); + + it.each([null, '', ' ', '\t\n'])('tomt søk %j gir alle titler', async (q) => { + const r = await search({ query: q, first: 50 }); + expect(r.total).toBe(TITLE_COUNT); + expect(r.ids).toHaveLength(TITLE_COUNT); + }); + + it('gir tomt resultat uten feil når ingenting treffer', async () => { + const res = await env.gql(SEARCH, { query: 'zzzqqqxxx', first: 10 }); + expect(res.errors).toBeUndefined(); + expect(res.data!.search).toEqual({ + totalCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + edges: [], + }); + }); + + it.each([ + ['%', ['100% Pure']], + ['_', ['Under_score Story']], + ['0% P', ['100% Pure']], + ['er_sc', ['Under_score Story']], + ["'", ['Amélie', "Don't Look Up", "O'Brien's Odyssey"]], + ['"', ['Say "Cheese"']], + ['\\', ['Back\\Slash']], + ['🎬', ['Emoji 🎬 Night']], + ['amélie', ['Amélie']], + ['CAFÉ', ['Café Society']], + ["Don't", ["Don't Look Up"]], + ])('behandler spesialtegn %j som vanlig tekst', async (q, expected) => { + const r = await search({ query: q }); + expect(r.res.errors).toBeUndefined(); + expect([...r.titles].sort()).toEqual([...expected].sort()); + }); + + it('er immun mot SQL-injeksjon i søketeksten', async () => { + for (const q of ["'; DROP TABLE titles; --", "' OR '1'='1", '") OR 1=1 --']) { + const r = await search({ query: q }); + expect(r.res.errors).toBeUndefined(); + expect(r.total).toBe(0); + } + expect((await search({})).total).toBe(TITLE_COUNT); + }); + + it('avviser query over 200 tegn, godtar nøyaktig 200', async () => { + for (const len of [201, 10_000]) { + const res = await env.gql(SEARCH, { query: 'a'.repeat(len), first: 5 }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(res.data).toBeNull(); + } + const ok = await env.gql(SEARCH, { query: 'a'.repeat(200), first: 5 }); + expect(ok.errors).toBeUndefined(); + }); + + it('avviser NUL-tegn i søketeksten som brukerfeil', async () => { + const res = await env.gql(SEARCH, { query: 'a\u0000b', first: 5 }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); +}); + +describe('search: filtre', () => { + it('genres krever ALLE valgte sjangre', async () => { + const drama = await search({ filters: { genres: ['Drama'] } }); + expect([...drama.ids].sort()).toEqual(idsMatching((t) => t.genres.includes('Drama'))); + const both = await search({ filters: { genres: ['Crime', 'Drama'] } }); + expect([...both.ids].sort()).toEqual( + idsMatching((t) => t.genres.includes('Crime') && t.genres.includes('Drama')), + ); + expect(both.total).toBe(3); + }); + + it('ukjent sjanger gir 0 treff', async () => { + expect((await search({ filters: { genres: ['Finnes Ikke'] } })).total).toBe(0); + }); + + it('decades: flere tiår er ELLER', async () => { + const one = await search({ filters: { decades: [1990] } }); + expect([...one.ids].sort()).toEqual( + idsMatching((t) => t.year !== null && t.year >= 1990 && t.year < 2000), + ); + const two = await search({ filters: { decades: [1990, 2010] } }); + expect([...two.ids].sort()).toEqual( + idsMatching((t) => t.year !== null && [1990, 2010].includes(Math.floor(t.year / 10) * 10)), + ); + }); + + it('types: MOVIE, SERIES og begge', async () => { + const series = await search({ filters: { types: ['SERIES'] } }); + expect([...series.ids].sort()).toEqual(idsMatching((t) => t.type === 'series')); + expect((await search({ filters: { types: ['MOVIE', 'SERIES'] } })).total).toBe(TITLE_COUNT); + expect((await search({ filters: { types: ['MOVIE'] } })).total).toBe( + TITLES.filter((t) => t.type === 'movie').length, + ); + }); + + it('minRating er >= og utelater titler uten rating', async () => { + const r = await search({ filters: { minRating: 8 } }); + expect([...r.ids].sort()).toEqual(idsMatching((t) => t.rating !== null && t.rating >= 8)); + expect(r.ids).not.toContain('tt0000019'); + // 0 betyr ingen grense, også for titler uten rating + expect((await search({ filters: { minRating: 0 } })).total).toBe(TITLE_COUNT); + }); + + it('kombinerer søketekst og alle filtre', async () => { + const filters = { genres: ['Drama'], decades: [2000, 2010], types: ['MOVIE'], minRating: 7 }; + const r = await search({ query: 'dark', filters }); + expect(r.titles.sort()).toEqual(['Dark Waters', 'The Dark Knight']); + const none = await search({ query: 'dark', filters: { ...filters, minRating: 9.5 } }); + expect(none.total).toBe(0); + }); + + it.each([ + ['tiår som ikke er helt', { decades: [1995] }], + ['tiår utenfor rimelig område', { decades: [3000] }], + ['minRating over 10', { minRating: 10.5 }], + ['negativ minRating', { minRating: -1 }], + ['tom sjangerstreng', { genres: [''] }], + ])('avviser ugyldig filter: %s', async (_navn, filters) => { + const res = await env.gql(SEARCH, { filters, first: 5 }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); + + it('tomme filterlister betyr «ingen filter»', async () => { + const r = await search({ filters: { genres: [], decades: [], types: [] } }); + expect(r.total).toBe(TITLE_COUNT); + }); +}); + +describe('search: felter og feil', () => { + it('returnerer alle kontraktsfeltene', async () => { + const r = await env.gql(SEARCH, { query: 'Breaking Bad', first: 1 }); + expect(r.data!.search.edges[0].node).toEqual({ + id: 'tt0000015', + primaryTitle: 'Breaking Bad', + originalTitle: 'Breaking Bad', + type: 'SERIES', + startYear: 2008, + endYear: 2013, + genres: ['Crime', 'Drama', 'Thriller'], + averageRating: 9.5, + numVotes: 2200000, + }); + }); + + it('null i valgfrie felt (år, rating)', async () => { + const r = await search({ query: 'Unknown Year' }); + expect(r.ids).toEqual(['tt0000018']); + const res = await env.gql(SEARCH, { query: 'Unrated Film', first: 1 }); + expect(res.data!.search.edges[0].node.averageRating).toBeNull(); + }); + + it.each([0, 51, -3, 1000])('avviser first=%s', async (first) => { + const res = await env.gql(SEARCH, { first }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); + + it('bruker first=20 som standard, og godtar grensene 1 og 50', async () => { + const def = await env.gql(`{ search { edges { cursor } pageInfo { hasNextPage } } }`); + expect(def.data!.search.edges).toHaveLength(20); + expect(def.data!.search.pageInfo.hasNextPage).toBe(true); + expect((await search({ first: 1 })).ids).toHaveLength(1); + expect((await search({ first: 50 })).ids).toHaveLength(TITLE_COUNT); + }); + + it.each(['ikke-en-cursor', '%%%', 'e30', Buffer.from('{"s":"x","v":[]}').toString('base64url')])( + 'avviser ugyldig cursor %j', + async (after) => { + const res = await env.gql(SEARCH, { first: 5, after }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }, + ); + + it('avviser cursor brukt med en annen sortering eller søketekst', async () => { + const first = await env.gql(SEARCH, { first: 2, sort: { field: 'RATING' } }); + const cursor = first.data!.search.pageInfo.endCursor as string; + for (const other of [ + { sort: { field: 'YEAR' } }, + { sort: { field: 'RATING', direction: 'ASC' } }, + { sort: { field: 'RATING' }, query: 'dark' }, + ]) { + const res = await env.gql(SEARCH, { first: 2, after: cursor, ...other }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + } + }); + + it('gir syntaks- og valideringsfeil uten å krasje', async () => { + const syntax = await env.gql('{ search( '); + expect(syntax.errors).toBeDefined(); + const unknown = await env.gql('{ finnesIkke }'); + expect(unknown.errors).toBeDefined(); + const badEnum = await env.gql(SEARCH, { sort: { field: 'PRICE' } }); + expect(badEnum.errors).toBeDefined(); + }); +}); diff --git a/backend/test/server.test.ts b/backend/test/server.test.ts new file mode 100644 index 0000000..45aa49e --- /dev/null +++ b/backend/test/server.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import { createPool, type Pool } from '../src/db.js'; +import { resetData } from './fixtures.js'; + +const post = ( + yoga: ReturnType, + query: string, + headers: Record = {}, +) => + yoga.fetch('http://localhost/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify({ query }), + }); + +let pool: Pool; +beforeAll(async () => { + pool = createPool(config.testDatabaseUrl); + await resetData(pool); +}); +afterAll(async () => { + await pool.end(); +}); + +describe('servergrenser', () => { + it('slår av introspeksjon i produksjon, men ikke i utvikling', async () => { + const dev = createApp({ pool, production: false }); + const prod = createApp({ pool, production: true }); + const q = '{ __schema { queryType { name } } }'; + expect((await (await post(dev, q)).json()).data.__schema.queryType.name).toBe('Query'); + const res = await (await post(prod, q)).json(); + expect(res.data).toBeUndefined(); + expect(res.errors[0].message).toMatch(/introspection/i); + // vanlige spørringer virker fortsatt + expect((await (await post(prod, '{ genres }')).json()).data.genres.length).toBeGreaterThan(0); + }); + + it('maskerer uventede feil uten å lekke detaljer', async () => { + const broken = createPool(config.testDatabaseUrl); + await broken.end(); // alle spørringer feiler nå + const yoga = createApp({ pool: broken, production: false }); + const res = await (await post(yoga, '{ genres }')).json(); + expect(res.errors).toHaveLength(1); + expect(res.errors[0].extensions.code).toBe('INTERNAL_SERVER_ERROR'); + expect(JSON.stringify(res)).not.toMatch(/pool|postgres|ECONN|SELECT|stack/i); + }); + + it('sender CORS-headere bare når corsOrigin er satt', async () => { + const withCors = createApp({ pool, corsOrigin: 'http://localhost:5173' }); + const without = createApp({ pool }); + const preflight = (y: typeof withCors) => + y.fetch('http://localhost/graphql', { + method: 'OPTIONS', + headers: { + origin: 'http://localhost:5173', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type,x-user-id', + }, + }); + const a = await preflight(withCors); + expect(a.headers.get('access-control-allow-origin')).toBe('http://localhost:5173'); + expect(a.headers.get('access-control-allow-headers')).toMatch(/x-user-id/i); + expect((await preflight(without)).headers.get('access-control-allow-origin')).toBeNull(); + }); + + it('tåler ugyldig JSON-body og manglende query', async () => { + const yoga = createApp({ pool }); + const bad = await yoga.fetch('http://localhost/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{ikke json', + }); + expect(bad.status).toBeGreaterThanOrEqual(400); + expect(bad.status).toBeLessThan(500); + const empty = await yoga.fetch('http://localhost/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + expect(empty.status).toBeLessThan(500); + }); +}); diff --git a/backend/test/title.test.ts b/backend/test/title.test.ts new file mode 100644 index 0000000..9fe0425 --- /dev/null +++ b/backend/test/title.test.ts @@ -0,0 +1,194 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */ +import { describe, expect, it, vi } from 'vitest'; +import { setupApi, type GqlResult, USER_A, USER_B } from './helpers.js'; +import { SEARCH } from './queries.js'; + +const env = setupApi(); + +const TITLE = /* GraphQL */ ` + query Title($id: ID!, $first: Int, $after: String) { + title(id: $id) { + id + primaryTitle + type + startYear + endYear + runtimeMinutes + genres + averageRating + numVotes + userRating + reviewCount + inMyList + reviews(first: $first, after: $after) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + titleId + author + rating + text + createdAt + isMine + } + } + } + } + } +`; + +describe('title', () => { + it('returnerer detaljer', async () => { + const res = await env.gql(TITLE, { id: 'tt0000001' }); + expect(res.errors).toBeUndefined(); + expect(res.data!.title).toMatchObject({ + id: 'tt0000001', + primaryTitle: 'The Dark Knight', + type: 'MOVIE', + startYear: 2008, + endYear: null, + genres: ['Action', 'Crime', 'Drama'], + averageRating: 9, + numVotes: 2800000, + userRating: null, + reviewCount: 0, + inMyList: false, + }); + expect(res.data!.title.reviews).toEqual({ + totalCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + edges: [], + }); + }); + + it.each(['tt9999999', '', "'; DROP TABLE titles;--", 'x'.repeat(500), 'tt\u0000'])( + 'gir null (ikke feil) for ukjent id %j', + async (id) => { + const res = await env.gql(TITLE, { id }); + expect(res.errors).toBeUndefined(); + expect(res.data!.title).toBeNull(); + }, + ); +}); + +describe('title: anmeldelser', () => { + const N = 25; + + it('setter opp anmeldelser', async () => { + // Eksplisitte tidsstempler; hvert tredje deler tidsstempel for å teste tiebreak på id. + await env.pool.query( + `INSERT INTO reviews (title_id, user_id, author, rating, body, created_at) + SELECT 'tt0000002', CASE WHEN i % 5 = 0 THEN $1::uuid ELSE $2::uuid END, + 'Anmelder ' || i, 1 + i % 5, 'Tekst ' || i, + timestamptz '2025-01-01 12:00:00.123456+00' + ((i / 3) * interval '1 minute') + FROM generate_series(1, ${N}) AS i`, + [USER_A, USER_B], + ); + const res = await env.gql(TITLE, { id: 'tt0000002', first: 50 }, USER_A); + expect(res.data!.title.reviewCount).toBe(N); + expect(res.data!.title.reviews.totalCount).toBe(N); + }); + + it('userRating er snittet (1–5) og reviewCount antallet', async () => { + const { rows } = await env.pool.query<{ avg: string }>( + "SELECT avg(rating) AS avg FROM reviews WHERE title_id = 'tt0000002'", + ); + const res = await env.gql(TITLE, { id: 'tt0000002' }); + expect(res.data!.title.userRating).toBeCloseTo(Number(rows[0]!.avg), 2); + expect(res.data!.title.reviewCount).toBe(N); + }); + + it('paginerer nyeste først uten duplikater og hull, også ved like tidsstempler', async () => { + const ids: string[] = []; + const stamps: string[] = []; + let after: string | null = null; + let pages = 0; + do { + const res: GqlResult = await env.gql(TITLE, { id: 'tt0000002', first: 4, after }, USER_A); + expect(res.errors).toBeUndefined(); + const rev: Record = res.data!.title.reviews; + for (const e of rev.edges) { + ids.push(e.node.id); + stamps.push(e.node.createdAt); + expect(e.node.titleId).toBe('tt0000002'); + } + after = rev.pageInfo.hasNextPage ? rev.pageInfo.endCursor : null; + pages++; + } while (after && pages < 50); + expect(ids).toHaveLength(N); + expect(new Set(ids).size).toBe(N); + expect(pages).toBe(Math.ceil(N / 4)); + const { rows } = await env.pool.query<{ id: string }>( + "SELECT r.id::text AS id FROM reviews r WHERE r.title_id = 'tt0000002' ORDER BY r.created_at DESC, r.id DESC", + ); + expect(ids).toEqual(rows.map((r) => r.id)); + expect([...stamps].sort().reverse()).toEqual(stamps); + }); + + it('bruker first=10 som standard', async () => { + const res = await env.gql(TITLE, { id: 'tt0000002' }); + expect(res.data!.title.reviews.edges).toHaveLength(10); + expect(res.data!.title.reviews.pageInfo.hasNextPage).toBe(true); + }); + + it('isMine gjelder bare brukeren som skrev anmeldelsen', async () => { + const asA = (await env.gql(TITLE, { id: 'tt0000002', first: 50 }, USER_A)).data!.title.reviews + .edges; + const asB = (await env.gql(TITLE, { id: 'tt0000002', first: 50 }, USER_B)).data!.title.reviews + .edges; + const anon = (await env.gql(TITLE, { id: 'tt0000002', first: 50 })).data!.title.reviews.edges; + const mineA = asA.filter((e: { node: { isMine: boolean } }) => e.node.isMine).length; + const mineB = asB.filter((e: { node: { isMine: boolean } }) => e.node.isMine).length; + expect(mineA).toBe(5); + expect(mineB).toBe(N - 5); + expect(anon.some((e: { node: { isMine: boolean } }) => e.node.isMine)).toBe(false); + }); + + it('createdAt er ISO 8601', async () => { + const res = await env.gql(TITLE, { id: 'tt0000002', first: 1 }); + const iso = res.data!.title.reviews.edges[0].node.createdAt; + expect(new Date(iso).toISOString()).toBe(iso); + }); + + it.each([0, 51])('avviser reviews(first: %s)', async (first) => { + const res = await env.gql(TITLE, { id: 'tt0000002', first }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); + + it.each(['abc', 'e30', Buffer.from('{"s":"reviews","v":["nei","1"]}').toString('base64url')])( + 'avviser ugyldig reviews-cursor %j', + async (after) => { + const res = await env.gql(TITLE, { id: 'tt0000002', after }); + expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }, + ); +}); + +describe('N+1', () => { + it('henter statistikk og listestatus for en hel resultatside i én spørring hver', async () => { + const spy = vi.spyOn(env.pool, 'query'); + const res = await env.gql( + `{ search(first: 30) { totalCount edges { node { id genres userRating reviewCount inMyList } } } }`, + undefined, + USER_A, + ); + expect(res.errors).toBeUndefined(); + expect(res.data!.search.edges).toHaveLength(30); + // hovedspørring + count + reviewStats + inMyList + expect(spy.mock.calls.length).toBeLessThanOrEqual(4); + spy.mockRestore(); + }); + + it('gjør ikke ekstra spørringer for felt som ikke etterspørres', async () => { + const spy = vi.spyOn(env.pool, 'query'); + await env.gql(SEARCH, { first: 20 }); + expect(spy.mock.calls.length).toBe(2); // rader + totalCount + spy.mockRestore(); + }); +}); diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..42f867c --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": false, "sourceMap": true }, + "include": ["src", "scripts"], + "exclude": ["**/*.test.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..3f1c36a --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "outDir": "dist", + "rootDir": "." + }, + "include": ["src", "scripts", "test", "vitest.config.ts"] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..f7894cd --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'scripts/**/*.test.ts'], + globalSetup: ['test/global-setup.ts'], + // Testene deler én testdatabase; parallelle filer ville tråkket på hverandre. + fileParallelism: false, + passWithNoTests: true, + }, +}); diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf new file mode 100644 index 0000000..5fdcc44 --- /dev/null +++ b/deploy/apache-project2.conf @@ -0,0 +1,52 @@ +# Apache-konfig for http://it2810-17.idi.ntnu.no/project2/ +# Legg fila i /etc/apache2/conf-available/ og aktiver med: +# sudo a2enmod proxy proxy_http rewrite headers expires deflate +# sudo a2enconf apache-project2 && sudo systemctl reload apache2 + +# GraphQL-API: videresend til Node-backenden, som bare lytter lokalt. +ProxyPreserveHost On +ProxyPass /project2/graphql http://127.0.0.1:3001/graphql +ProxyPassReverse /project2/graphql http://127.0.0.1:3001/graphql + +Alias /project2 /var/www/html/project2 + + + Options -Indexes + AllowOverride None + Require all granted + + # SPA: ukjente stier (f.eks. /project2/title/tt0111161) skal gi index.html slik at + # React Router kan håndtere dem ved direkte lasting og refresh. + RewriteEngine On + RewriteBase /project2/ + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.html [L] + + +# Sikkerhetsheadere for hele appen. Ingen av dem påvirker funksjonaliteten: siden skal aldri +# rammes inn av andre sider (clickjacking), MIME-typer skal ikke gjettes, og adressen med søket +# skal ikke lekke til andre nettsteder. + + Header always set X-Frame-Options "DENY" + Header always set X-Content-Type-Options "nosniff" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Cross-Origin-Opener-Policy "same-origin" + + +# Komprimer tekstressurser – store besparelser på JS/CSS/JSON for lite CPU. + + AddOutputFilterByType DEFLATE text/html text/css text/plain application/javascript application/json image/svg+xml + + +# Vite gir filene i assets/ innholdshash i navnet, så de kan caches «for alltid». +# index.html må derimot alltid revalideres, ellers får brukerne gammel kode etter deploy. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "no-cache" + + + Header set Cache-Control "no-store" + diff --git a/deploy/oppdater.sh b/deploy/oppdater.sh new file mode 100755 index 0000000..e136a31 --- /dev/null +++ b/deploy/oppdater.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Én kommando for å oppdatere VM-en, laget for å kunne kjøres rett fra PowerShell: +# ssh -t @it2810-17.idi.ntnu.no bash T17-Project2/deploy/oppdater.sh [TMDB-nøkkel] +# Linja har bevisst ingen anførselstegn: PowerShell på Windows ødela dem ved innliming. +# Uten nøkkel beholdes den som allerede er lagret. +set -euo pipefail +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE=/opt/project2/.env +KEY="$(printf '%s' "${1:-}" | tr -d '\r[:space:]')" + +if [ -n "$KEY" ]; then + if [[ ! "$KEY" =~ ^[0-9a-fA-F]{32}$ && ! "$KEY" =~ ^eyJ[A-Za-z0-9._-]{40,}$ ]]; then + echo "Det ser ikke ut som en TMDB-nøkkel (${#KEY} tegn)." >&2 + exit 1 + fi + if sudo test -f "$ENV_FILE"; then + # Fjern gammel linje og legg til ny, så det virker både når nøkkelen finnes og når den mangler. + sudo sed -i '/^TMDB_API_KEY=/d' "$ENV_FILE" + echo "TMDB_API_KEY=$KEY" | sudo tee -a "$ENV_FILE" >/dev/null + else + # Første kjøring: setup-vm.sh lager .env og tar nøkkelen fra miljøet. + export TMDB_API_KEY="$KEY" + fi +fi + +cd "$REPO_DIR" +# Hent siste versjon selv, så brukeren slipper en egen git pull. setup-vm.sh leses først etter +# pull, så endringer i den tas med i samme kjøring. +git pull --ff-only +exec bash deploy/setup-vm.sh diff --git a/deploy/project2-backend.service b/deploy/project2-backend.service new file mode 100644 index 0000000..2fad3b6 --- /dev/null +++ b/deploy/project2-backend.service @@ -0,0 +1,30 @@ +# systemd-enhet for GraphQL-backenden på it2810-17.idi.ntnu.no. +# Installeres med: sudo cp deploy/project2-backend.service /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl enable --now project2-backend +[Unit] +Description=IT2810 gruppe 17 prosjekt 2 – GraphQL-backend +# Backenden er ubrukelig uten databasen, så vent på at Postgres er oppe. +After=network.target postgresql.service +Wants=postgresql.service + +[Service] +Type=simple +# Egen systembruker uten innlogging begrenser skaden hvis prosessen kompromitteres. +User=project2 +Group=project2 +WorkingDirectory=/opt/project2/backend +EnvironmentFile=/opt/project2/.env +Environment=NODE_ENV=production +ExecStart=/usr/bin/node dist/src/index.js +Restart=on-failure +RestartSec=5 + +# Herding: prosessen trenger bare å lese koden og snakke med Postgres over nettverket. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadOnlyPaths=/opt/project2 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/setup-vm.sh b/deploy/setup-vm.sh new file mode 100755 index 0000000..fcababd --- /dev/null +++ b/deploy/setup-vm.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Setter opp hele prosjektet på it2810-17.idi.ntnu.no (Ubuntu) i én kjøring. +# Kjøres fra roten av en klon av repoet: bash deploy/setup-vm.sh +# Trygt å kjøre på nytt: hvert steg hopper over det som allerede er gjort. +# Skriptet setter opp en Ubuntu-server; på Windows/macOS ville det feile halvveis. +if [ "$(uname -s)" != "Linux" ] || ! command -v apt-get >/dev/null; then + echo "Dette skriptet skal kjøres på VM-en, ikke på din egen maskin." >&2 + echo "Logg inn først: ssh @it2810-17.idi.ntnu.no" >&2 + exit 1 +fi +set -euo pipefail + +APP_DIR=/opt/project2 +WEB_DIR=/var/www/html/project2 +DB_NAME=project2 +DB_USER=project2 +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; } + +step "Installerer systempakker (Postgres, Apache, Node 22)" +sudo apt-get update -qq +sudo apt-get install -y -qq postgresql postgresql-contrib apache2 curl rsync +if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y -qq nodejs +fi + +step "Oppretter systembruker og kopierer koden til $APP_DIR" +id -u project2 >/dev/null 2>&1 || sudo useradd --system --no-create-home --shell /usr/sbin/nologin project2 +sudo mkdir -p "$APP_DIR" +sudo chown -R "$USER" "$APP_DIR" +# Kopien gjør at tjenesten ikke er avhengig av hjemmemappa til den som kjørte skriptet. +rsync -a --delete --exclude node_modules --exclude .env --exclude 'data/*.gz' "$REPO_DIR/" "$APP_DIR/" +cd "$APP_DIR" + +step "Oppretter database og bruker" +if [ ! -f .env ]; then + # Passordet genereres her og lagres bare i .env på VM-en – aldri i git. + DB_PASS="$(openssl rand -hex 24)" + sudo -u postgres psql -v ON_ERROR_STOP=1 -q < .env + echo "NODE_ENV=production" >> .env + chmod 600 .env +fi +# TMDB-nøkkel (for plakater): skriptet spør første gang. Kan også gis som miljøvariabel. +# Den skrives bare til .env på VM-en, aldri til git. Uten nøkkel vises plassholdere. +# En gyldig nøkkel er 32 hex-tegn (v3) eller en lang JWT (v4). En tidligere mislykket innliming +# (f.eks. bare ett tegn) skal ikke hindre at vi spør på nytt. +valid_key() { [[ "$1" =~ ^[0-9a-fA-F]{32}$ || "$1" =~ ^eyJ[A-Za-z0-9._-]{40,}$ ]]; } +CURRENT_KEY="$(sed -n 's/^TMDB_API_KEY=//p' .env | tr -d '\r')" +if [ -z "${TMDB_API_KEY:-}" ] && ! valid_key "$CURRENT_KEY" && [ -t 0 ]; then + for attempt in 1 2 3; do + # Synlig input: skjult innliming ga ingen tilbakemelding og feilet i praksis. + read -r -p "Lim inn TMDB API-nøkkel (32 tegn, eller Enter for å hoppe over): " TMDB_API_KEY || true + # Innliming fra Windows kan ta med \r og mellomrom. + TMDB_API_KEY="$(printf '%s' "$TMDB_API_KEY" | tr -d '\r[:space:]')" + [ -z "$TMDB_API_KEY" ] && break + valid_key "$TMDB_API_KEY" && break + echo "Det ser ikke ut som en TMDB-nøkkel (${#TMDB_API_KEY} tegn). Prøv igjen." + TMDB_API_KEY="" + done +fi +if [ -n "${TMDB_API_KEY:-}" ]; then + grep -v '^TMDB_API_KEY=' .env > .env.tmp || true + echo "TMDB_API_KEY=$TMDB_API_KEY" >> .env.tmp + mv .env.tmp .env + chmod 600 .env +fi +sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 \ + || sudo -u postgres createdb "$DB_NAME" --owner "$DB_USER" +# pg_trgm krever superbruker første gang; migreringen bruker IF NOT EXISTS. +sudo -u postgres psql -d "$DB_NAME" -qc 'CREATE EXTENSION IF NOT EXISTS pg_trgm;' + +step "Installerer avhengigheter og bygger" +HUSKY=0 npm ci --no-audit --no-fund +npm run build +npm run db:migrate + +step "Laster ned IMDb-data og importerer (tar noen minutter)" +mkdir -p data +for f in title.basics.tsv.gz title.ratings.tsv.gz; do + # -z: last bare ned på nytt hvis IMDb har en nyere fil enn den vi har. + curl -fsSL -z "data/$f" -o "data/$f" "https://datasets.imdbws.com/$f" +done +npm run db:seed + +if valid_key "$(sed -n 's/^TMDB_API_KEY=//p' .env | tr -d '\r')"; then + step "Henter plakater for de mest populære titlene (TMDB)" + # Varmer opp bildecachen så forsiden har plakater med en gang; resten hentes når de vises. + npm run db:artwork -- 2000 || echo "Advarsel: forhåndshenting av plakater feilet – de hentes ved visning i stedet." +fi + +step "Henter lovlige filmer fra Internet Archive" +# Kobler IMDb-titler til gratis public domain-/Creative Commons-versjoner. Feil her skal ikke +# stoppe deployen: appen fungerer, bare uten «Se filmen» for titler som mangler. +npm run db:archive || echo "Advarsel: henting fra Internet Archive feilet – prøv igjen senere med npm run db:archive." + +step "Starter backend som systemd-tjeneste" +sudo chown -R project2:project2 "$APP_DIR" +sudo cp deploy/project2-backend.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now project2-backend +sudo systemctl restart project2-backend + +step "Publiserer frontend og konfigurerer Apache" +sudo mkdir -p "$WEB_DIR" +sudo rsync -a --delete frontend/dist/ "$WEB_DIR/" +sudo cp deploy/apache-project2.conf /etc/apache2/conf-available/ +sudo a2enmod -q proxy proxy_http rewrite headers expires deflate +sudo a2enconf -q apache-project2 +sudo apachectl configtest +sudo systemctl reload apache2 + +step "Sjekker at alt svarer" +# Backenden trenger et par sekunder på å starte; prøv noen ganger før vi gir opp. +check() { + curl -fsS -m 5 -X POST "$1" -H 'content-type: application/json' \ + -d '{"query":"{ search(first: 1) { totalCount } }"}' +} +for i in 1 2 3 4 5 6 7 8 9 10; do + check http://127.0.0.1:3001/graphql >/dev/null 2>&1 && break + sleep 2 +done +# `if !` i stedet for `cmd && echo`: set -e overser feil i &&-lister, og da ville skriptet +# meldt «Ferdig!» selv om API-et ikke svarte. +if ! check http://127.0.0.1:3001/graphql; then + echo "FEIL: backenden svarer ikke på port 3001. Se: sudo journalctl -u project2-backend -n 50" >&2 + exit 1 +fi +echo +if ! check http://localhost/project2/graphql; then + echo "FEIL: backenden svarer, men ikke via Apache. Se: sudo tail -n 50 /var/log/apache2/error.log" >&2 + exit 1 +fi +echo +echo "Ferdig! Åpne http://it2810-17.idi.ntnu.no/project2/" diff --git a/deploy/sjekk.sh b/deploy/sjekk.sh new file mode 100755 index 0000000..130a6af --- /dev/null +++ b/deploy/sjekk.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Feilsøking på VM-en: kjører alle sjekkene og skriver resultatet samlet, så det kan limes inn +# i én melding. Endrer ingenting. +# Skriptet setter opp en Ubuntu-server; på Windows/macOS ville det feile halvveis. +if [ "$(uname -s)" != "Linux" ] || ! command -v apt-get >/dev/null; then + echo "Dette skriptet skal kjøres på VM-en, ikke på din egen maskin." >&2 + echo "Logg inn først: ssh @it2810-17.idi.ntnu.no" >&2 + exit 1 +fi +Q='{"query":"{ search(first: 1) { totalCount } }"}' +line() { printf '\n===== %s =====\n' "$1"; } + +line "1. Backend direkte (port 3001)" +curl -sS -m 10 -X POST http://127.0.0.1:3001/graphql -H 'content-type: application/json' -d "$Q" + +line "2. Via Apache (/project2/graphql)" +curl -sS -m 10 -i -X POST http://localhost/project2/graphql -H 'content-type: application/json' -d "$Q" | head -n 20 + +line "7. Samme spørringer som forsiden sender (via Apache)" +UID_HDR='x-user-id: 00000000-0000-4000-8000-000000000000' +curl -sS -m 20 -w '\n(HTTP %{http_code}, %{time_total}s)\n' -X POST http://localhost/project2/graphql \ + -H 'content-type: application/json' -H "$UID_HDR" \ + -d '{"query":"query Search($first:Int,$sort:SortInput){ search(first:$first, sort:$sort){ totalCount pageInfo{hasNextPage endCursor} edges{ cursor node{ id primaryTitle type startYear genres averageRating numVotes } } } }","variables":{"first":20,"sort":{"field":"RELEVANCE","direction":"DESC"}}}' | head -c 600 +echo +curl -sS -m 20 -w '\n(HTTP %{http_code}, %{time_total}s)\n' -X POST http://localhost/project2/graphql \ + -H 'content-type: application/json' -H "$UID_HDR" \ + -d '{"query":"{ facets { genres{value count} decades{value count} types{value count} } genres }"}' | head -c 600 +echo + +line "3. Forsiden via Apache" +curl -sS -m 10 -o /dev/null -w 'HTTP %{http_code}\n' http://localhost/project2/ + +line "4. Apache-moduler og konfig" +apache2ctl -M 2>/dev/null | grep -E 'proxy|rewrite|headers|deflate' +ls /etc/apache2/conf-enabled/ /etc/apache2/sites-enabled/ + +line "5. Apache-feillogg (siste 20)" +sudo tail -n 20 /var/log/apache2/error.log + +line "6. Backend-logg (siste 20)" +sudo journalctl -u project2-backend -n 20 --no-pager -o cat + +line "8. TMDB (plakater)" +# Nøkkelen skrives aldri ut, bare om den finnes og hvor lang den er. +KEYLEN=$(sudo sed -n 's/^TMDB_API_KEY=//p' /opt/project2/.env 2>/dev/null | tr -d '\r\n' | wc -c) +if [ "$KEYLEN" -gt 0 ]; then echo "Nøkkel: lagret ($KEYLEN tegn)"; else echo "Nøkkel: MANGLER – kjør bash deploy/setup-vm.sh på nytt"; fi +sudo -u postgres psql -d project2 -tAc "SELECT 'Plakater hentet: ' || count(*) FILTER (WHERE status = 'found') || ', ikke funnet hos TMDB: ' || count(*) FILTER (WHERE status = 'missing') FROM title_artwork" 2>/dev/null \ + || echo "(tabellen title_artwork finnes ikke ennå – kjør bash deploy/setup-vm.sh)" + diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..31e09e6 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,186 @@ +# GraphQL-API – kontrakt + +Denne fila er kontrakten mellom backend og frontend. Endringer skal gjøres her først. + +- Endepunkt: `POST /graphql` på port 3001. I produksjon og i Vite-dev når frontend det via + `/project2/graphql`. +- Anonym bruker: frontend lager en UUID v4 én gang, lagrer den i `localStorage` og sender den i + headeren `x-user-id` på alle requests. Backend avviser ugyldige UUID-er (behandles som «ingen + bruker»). Mutations krever gyldig `x-user-id`. + +```graphql +enum TitleType { + MOVIE + SERIES +} + +enum SortField { + RELEVANCE # likhet med søketeksten; uten søketekst: flest stemmer først + RATING + YEAR + TITLE +} + +enum SortDirection { + ASC + DESC +} + +input SortInput { + field: SortField! + # Standard: RELEVANCE DESC, RATING DESC, YEAR DESC, TITLE ASC + direction: SortDirection +} + +input SearchFilters { + genres: [String!] # tittelen må ha ALLE valgte sjangre + decades: [Int!] # f.eks. 1990 = 1990–1999; flere tiår = ELLER + types: [TitleType!] # flere typer = ELLER + minRating: Float # 0–10 + availableOnly: Boolean # bare titler med stream +} + +type PageInfo { + hasNextPage: Boolean! + endCursor: String +} + +type Review { + id: ID! + titleId: ID! + author: String! # visningsnavn, 1–50 tegn + rating: Int! # 1–5 + text: String! # 0–2000 tegn + createdAt: String! # ISO 8601 + isMine: Boolean! # skrevet av x-user-id +} + +type ReviewEdge { + cursor: String! + node: Review! +} + +type ReviewConnection { + edges: [ReviewEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type Title { + id: ID! # IMDb tconst, f.eks. "tt0111161" + primaryTitle: String! + originalTitle: String! + type: TitleType! + startYear: Int + endYear: Int + runtimeMinutes: Int + genres: [String!]! + averageRating: Float # IMDb-snitt 1–10 + numVotes: Int! + userRating: Float # snitt av anmeldelser i vår app (1–5), null uten anmeldelser + reviewCount: Int! + inMyList: Boolean! + reviews(first: Int = 10, after: String): ReviewConnection! # nyeste først + # Bilder og handling fra TMDB (se «Bilder» under). null når TMDB mangler tittelen, + # når TMDB_API_KEY ikke er satt, eller når oppslaget feiler/tar for lang tid. + overview: String # engelsk handlingsbeskrivelse + posterUrl(width: Int = 342): String # 2:3; bredde snappes til 92, 154, 185, 342, 500 eller 780 + backdropUrl(width: Int = 1280): String # 16:9; bredde snappes til 300, 780 eller 1280 + stream: Stream # lovlig gratisversjon fra Internet Archive, null hvis ingen +} + +type Stream { + url: String! # direkte videofil (MP4/WebM) som nettleseren kan spille, støtter Range-requests + archiveUrl: String! # siden på archive.org (kilde) + license: String! # f.eks. "Public Domain" eller "CC BY 4.0" + licenseUrl: String + durationSeconds: Int + subtitlesUrl: String # WebVTT, bare hvis Internet Archive har en .vtt-fil +} + +type TitleEdge { + cursor: String! + node: Title! +} + +type TitleConnection { + edges: [TitleEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type FacetCount { + value: String! # sjangernavn, tiår som tekst ("1990") eller TitleType-navn ("MOVIE") + count: Int! +} + +type Facets { + genres: [FacetCount!]! + decades: [FacetCount!]! + types: [FacetCount!]! + available: Int! # antall treff med stream, med alle andre filtre aktive +} + +input AddReviewInput { + titleId: ID! + author: String! + rating: Int! + text: String! +} + +type Query { + # query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel. + search( + query: String + filters: SearchFilters + sort: SortInput + first: Int = 20 + after: String + ): TitleConnection! + title(id: ID!): Title + # Antall treff per verdi. Hver dimensjon telles med alle ANDRE filtre aktive (vanlig + # fasettsøk), slik at brukeren ser hva hun får ved å legge til/bytte en verdi. + facets(query: String, filters: SearchFilters): Facets! + myList(first: Int = 20, after: String): TitleConnection! # sist lagt til først + genres: [String!]! # alle sjangre, alfabetisk +} + +type Mutation { + addReview(input: AddReviewInput!): Review! + toggleList(titleId: ID!): Title! # returnerer tittelen med oppdatert inMyList +} +``` + +## Regler + +- `first` må være 1–50, ellers `BAD_USER_INPUT`. `query` maks 200 tegn. Spørredybde maks 6. +- Cursorer er ugjennomsiktige strenger (base64). Ugyldig cursor gir `BAD_USER_INPUT`. +- Feil returneres som GraphQL-feil med `extensions.code`: `BAD_USER_INPUT`, `NOT_FOUND`, + `UNAUTHENTICATED` (mangler `x-user-id` på mutation), `INTERNAL_SERVER_ERROR` (uten detaljer). +- All søk, filtrering, sortering og paginering skjer i SQL. + +## Bilder (TMDB) + +- IMDb-datasettene har ingen bilder. Backenden slår opp `/find/{imdbId}?external_source=imdb_id` + hos TMDB første gang en tittel trenger bilde, og lagrer svaret (også «finnes ikke») i databasen. + Etterpå hentes bildene aldri fra TMDB-API-et igjen for den tittelen. +- Bilde-URL-ene peker direkte til TMDBs bilde-CDN (`TMDB_IMAGE_URL`, standard + `https://image.tmdb.org/t/p`). Backenden proxyer ikke bildene. +- Oppslag skjer samlet per request (én side med titler = ett DB-oppslag + parallelle TMDB-kall + med begrenset samtidighet). Et søk skal aldri vente mer enn ca. 2,5 s på bilder; titler som ikke + rakk å bli slått opp får `null` i denne responsen og prøves igjen senere. +- Miljøvariabler: `TMDB_API_KEY` (v3-nøkkel eller v4-token), `TMDB_API_URL` (standard + `https://api.themoviedb.org/3`), `TMDB_IMAGE_URL`. I utvikling og E2E brukes en lokal + falsk TMDB-server (`npm run tmdb:mock -w backend`) som lager plakater som SVG. + +## Strømming (Internet Archive) + +- Bare filmer Internet Archive markerer som fri bruk: `licenseurl` er Creative Commons eller + public domain, eller elementet ligger i en kuratert public domain-samling (se + `docs/beslutninger.md`). Alt annet hoppes over. +- `npm run db:archive` henter kandidater fra Internet Archive, kobler dem til IMDb-titler (IMDb-ID + i metadata, ellers entydig treff på normalisert tittel og år ±1) og lagrer én spillbar fil per + tittel i `title_streams`. Idempotent. +- Videoen strømmes direkte fra archive.org. Vi lagrer eller proxyer ingen video. +- `ARCHIVE_URL` (standard `https://archive.org`) kan pekes mot den falske serveren + (`npm run archive:mock -w backend`) i utvikling og E2E. diff --git a/docs/beslutninger.md b/docs/beslutninger.md new file mode 100644 index 0000000..5c7973d --- /dev/null +++ b/docs/beslutninger.md @@ -0,0 +1,171 @@ +# Beslutninger + +Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst. + +- **TypeScript 6.0 i stedet for 7.** `typescript-eslint` støtter bare `typescript <6.1`. Vi valgte + lint-støtte framfor nyeste kompilator. +- **ESLint 9 i stedet for 10.** `eslint-plugin-jsx-a11y` støtter ikke ESLint 10 ennå, og + tilgjengelighetslinting er et krav. +- **Git-grener.** CLAUDE.md sier at arbeidet skal skje på grener fra `mvp`. Sky-sesjonen kan bare + pushe til én gren (`claude/webutvikling-projekt-2-k5lq6q`), så den grenen spiller rollen til + `mvp`. Hver oppgave lages på en lokal `feat/…`-gren og merges inn med `--no-ff`, slik at historikken + viser grenene. Ingenting pushes til `main`. +- **Ingen GitLab-issues/MR-er.** `glab` er ikke tilgjengelig i miljøet, så issue- og review-flyten + loggføres i `docs/ki-logg.md` i stedet, slik CLAUDE.md beskriver. +- **Syntetisk testdatasett.** IMDb-nedlasting er blokkert i miljøet (se `BLOCKERS.md`). Et skript + genererer filer i nøyaktig samme TSV-format, så importkoden er den samme for ekte og syntetiske + data. +- **Egen migreringsrunner.** `backend/scripts/migrate.ts` (tabell `schema_migrations`, én transaksjon per + fil, advisory lock mot samtidige kjøringer) i stedet for et bibliotek: vi trenger bare «kjør nye + SQL-filer i rekkefølge», og testene gjenbruker samme funksjon til å migrere testdatabasen. +- **Denormalisert `titles.genres text[]` (GIN) og lagret `start_decade`.** `title_genres`/`genres` finnes + som normalisert kilde, men sjangerfilter (`genres @> ARRAY[…]`, «alle valgte») og fasettene blir + enklere og raskere uten join, og sjangre til en hel resultatside følger med raden (ingen N+1). + `start_decade` er en generert kolonne slik at tiårfilter er en likhet på en indeksert kolonne. + Prisen er at importen skriver sjangre to steder (i samme transaksjon). +- **Søk med `lower(col) LIKE lower('%…%')` og trigram-GIN på `lower(...)`-uttrykk,** ikke `ILIKE`. + Indeksuttrykket må være identisk med spørringen for å bli brukt. Brukerens `%`, `_` og `\` escapes + slik at de er vanlige tegn. Aksenter foldes ikke (`cafe` finner ikke `Café`); `unaccent` + er ikke tatt med for å slippe en ekstra utvidelse. +- **Keyset-paginering med radsammenligning.** Alle sorteringsnøkler går i samme retning + (`(a, b, id) < ($1,$2,$3)`), siste nøkkel er `id` (unik), og nullable kolonner erstattes av + sentinel via `COALESCE` (rating → -1, år → 0). Det gir stabil paginering uten hull/duplikater og lar + btree-indekser på samme uttrykk brukes. Konsekvens: titler uten rating/år havner sist ved DESC og + først ved ASC. Rating og år har `num_votes` som sekundær nøkkel (populære titler først blant like). + Cursoren inneholder en signatur (sortering/retning/om det finnes søketekst) og avvises + (`BAD_USER_INPUT`) hvis den brukes med en annen sortering. Tidsstempler i cursorer går som tekst + (mikrosekund-presisjon; JS `Date` har bare millisekunder). +- **Relevans = `GREATEST(similarity(primær), similarity(original))`,** tiebreak `num_votes`, så `id`. + Uten søketekst betyr «relevans» flest stemmer først. +- **`totalCount` er lazy** (beregnes bare hvis feltet etterspørres) og kjøres som egen `count(*)`. + `userRating`/`reviewCount`/`inMyList` hentes med en liten per-request batch-loader (`loaders.ts`), + så en side med 20 titler koster 1 spørring per felttype i stedet for 20. +- **Fasettene** gir kun verdier med treff > 0, pluss valgte verdier med 0 (så et valgt filter ikke + forsvinner fra UI-et). Sortering: sjangre etter antall (synkende), tiår kronologisk, typer + `MOVIE`, `SERIES`. Titler uten startår mangler i tiårsfasetten. +- **Feilkoder og maskering.** Egne feil er `GraphQLError` med `extensions.code`; uventede feil + (databasen nede, bugs) maskeres til «Intern feil.» med `INTERNAL_SERVER_ERROR`, og detaljene logges + bare på serveren. `title(id)` for ukjent id gir `null` (typen er nullable); `addReview`/`toggleList` + på ukjent tittel gir `NOT_FOUND`. `query` som inneholder NUL-tegn avvises som `BAD_USER_INPUT` + fordi Postgres ikke kan lagre dem. `minRating: 0` betyr «ingen grense». +- **Spørredybde 6 og introspeksjon.** Egen valideringsregel (`depthLimit.ts`, rotfelt = nivå 0, som + `graphql-depth-limit`), fordi dagens skjema ikke kan nøstes dypere enn 6; regelen er et vern mot + fremtidige sirkulære relasjoner og testes mot et sirkulært testskjema. Introspeksjon og GraphiQL + slås av med `NODE_ENV=production` (egen regel, fordi graphqls innebygde regel feiler når `graphql` + lastes både som ESM og CJS). CORS er kun på utenfor produksjon (Apache gir same-origin i prod). +- **Tester mot ekte Postgres** (`project2_test`), nullstilt og migrert i `globalSetup` – ingen + databasemocking, slik at SQL, indekser og migrasjoner testes sammen. Testene nekter å kjøre hvis + `TEST_DATABASE_URL` er lik `DATABASE_URL`. +- **Import: rating-fila leses først** og bare rader over stemmegrensen holdes i et `Map`; `basics` + strømmes deretter mot dette. Enklere enn staging-tabell/COPY og raskt nok (120 000 titler på ca. 17 s). + Titler som senere faller under stemmegrensen fjernes ikke ved omkjøring (upsert, ingen sletting). +- **Frontend: URL som eneste kilde til søketilstand.** `q`, `genres`, `decades`, `types` (`film`/`serie`), + `minRating`, `sort` (`relevans`/`rating`/`ar`/`tittel`), `dir` (`asc`/`desc`) leses og skrives med + `useSearchParams` (`replace`, så historikken ikke fylles). Søketeksten debounces i `SearchBox` og + skrives til URL-en først når den (trimmet) avviker fra forrige verdi; requesten følger av URL-en. +- **Frontend: scrollgjenoppretting med `ScrollRestoration`** fra react-router (data router). Apollo-cachen + (`relayStylePagination` med keyArgs `query/filters/sort`) holder alle innlastede sider, så «tilbake» + har full listehøyde å scrolle til uten ny request. +- **Frontend: fasetter og sjangerliste er egne queries** (`facets`, `genres`), atskilt fra `search`, slik at + sortering/sidebytte ikke henter fasetter på nytt. Forrige fasetter vises mens nye lastes. +- **Frontend: ny anmeldelse vises via `refetch` av tittelen** etter `addReview` (serveren er kilden til + snitt og antall). «Min liste» bruker `cache-and-network` og fjerner rader lokalt i cachen ved «Fjern». +- **Frontend: ingen nye biblioteker.** Bundle ca. 166 kB gzip initielt (React + Apollo + React Router); + detalj- og listesiden er lazy (ca. 4 kB gzip til sammen). +- **Bruker-ID uten `crypto.randomUUID`.** VM-en serverer appen over http, som ikke er en sikker + kontekst, og der finnes ikke `crypto.randomUUID`. Alle GraphQL-requests feilet derfor i + produksjon, selv om alle tester (som kjører på `localhost`, som regnes som sikker) var grønne. + UUID v4 lages nå med `crypto.getRandomValues`, som finnes overalt. Verifisert i Chromium mot en + ikke-localhost http-adresse. +- **Bilder fra TMDB, lagret i egen tabell, ikke proxyet.** IMDb-dataene har ingen bilder; TMDB har et + gratis API med IMDb-id-oppslag (`/find`) og et bilde-CDN. Svaret lagres i `title_artwork` (også + «finnes ikke»), fordi TMDB har rate-grenser og oppslag ellers ville skje ved hvert søk; egen tabell + fordi `titles` er en ren, reimporterbar IMDb-kopi. Klienten får CDN-URL-er og laster bildene + direkte: en proxy ville gitt oss båndbredde, cache og sikkerhetsansvar for ingen gevinst. + Oppslag skjer per request med samtidighetsgrense og ca. 2,5 s tidsfrist; det som ikke rekker + det blir `null` nå og lagres i bakgrunnen. Forbigående feil lagres aldri som «finnes ikke» + (5 min negativ cache i minnet), og 401/403 slår av oppslag i 10 min. Ugyldig `width` (≤ 0) gir + `BAD_USER_INPUT`; gyldige verdier snappes til nærmeste tillatte størrelse. Stier fra TMDB + valideres mot et strengt mønster før de brukes i en URL. Testes mot en lokal falsk TMDB + (`scripts/tmdb-mock.ts`), siden internett ikke er tilgjengelig i CI. +- **Frontend: kinoaktig redesign uten nye biblioteker.** Ren CSS med custom properties i `global.css`. + Mørkt er standard, lyst følger `prefers-color-scheme: light` med samme layout. Header, heltebilder og + plakat-overlegg er alltid mørke (egne `--on-media`-tokens), fordi de ligger over bilder; det gjør at + kontrasten ikke avhenger av bildet og at én stil fungerer i begge temaer. Aksentfargen er gull + (`#f5c518`, mørk tekst på knapper); i lyst tema brukes en mørkere gull (`#7a5200`) til tekst. +- **Forsiden har to moduser, styrt av URL-en.** Uten `q`, filtre og sortering (`isBrowseState`) vises + «bla-modus»: hero + horisontale rader (`lib/browseRows.ts`). Ellers vises trefflisten som før, som + rutenett av plakatkort. Hver rads variabler og «Se alle»-lenke utledes fra samme `SearchState`, så de + ikke kan bli ulike, og «Se alle» treffer Apollo-cachen. All sortering/filtrering er GraphQL-variabler. +- **Hero og toppraden deler én request.** `Featured`-spørringen er `search` med samme cache-nøkkel som + raden «Mest populære», bare med `overview` og bakgrunnsbilder på alle 20 titler (ca. 6 kB ekstra). + Det er billigere enn en egen `first: 1`-request som ville kollidert med radens cache-oppføring. + Hero = første tittel som har bakgrunnsbilde. Bildet har `fetchpriority="high"`, `srcset` (780/1280) og + fast størrelse via CSS, så det ikke gir CLS. +- **Rader under folden er lazy.** Bare toppraden hentes ved oppstart; de andre bruker + `IntersectionObserver` (`useNearViewport`, 400 px margin) og `skip` til de nærmer seg. Uten + IntersectionObserver hentes alt med en gang. Plakater har `loading="lazy"`, `srcset` 185/342. +- **Søkefeltet flyttet til headeren** (på alle sider). På forsiden endrer det bare `q`; andre steder + navigerer det til `/?q=…`. Layout flytter ikke fokus etter navigasjon hvis fokus står i søkefeltet. +- **Plakatkort:** tittelen er lenken og strekkes over kortet med `::after`, så lenkenavnet er bare + tittelen og fokusringen omslutter hele kortet. Plakaten har `alt=""` (tittelen står som tekst under). + Uten plakat: gradient med tittelen, farge fra `hueFromId`. Kort-ids bruker `useId` (samme tittel kan + stå i flere rader). `main` har `min-height: 100vh` slik at footeren aldri hopper opp i bildet + (CLS 0,27 på Min liste mobil før dette). +- **Header:** sticky og gjennomsiktig over heltebilde til man scroller (passiv scroll-lytter, state bare + ved skifte); sider med heltebilde setter `data-hero` på `` (`useHeaderOverlay`). +- **E2E bruker falsk TMDB.** `playwright.config.ts` starter `tmdb:mock` (port 3999) og gir backend + `TMDB_API_KEY/URL/IMAGE_URL`. Forsiden har ingen `.count` i bla-modus, så flyttesten søker først. +- **README skrevet av lederen.** CLAUDE.md sier at gruppa skriver README, men Nicolay ba eksplisitt + om det 2026-09-30. Innholdet bygger på fakta fra `docs/`, og tallene er fra siste testkjøring. +- **Strømming fra Internet Archive (M6): bare pekere, ingen video.** `title_streams` (migrering 003) + har én rad per tittel (PK `title_id`, `ON DELETE CASCADE`) med element-id, filnavn, lisens, + varighet og valgfri `.vtt`. Egen tabell av samme grunn som `title_artwork`: `titles` er en + reimporterbar IMDb-kopi. `availableOnly` er `EXISTS (SELECT 1 FROM title_streams s WHERE s.title_id = t.id)`, + som planleggeren kjører som PK-oppslag per kandidatrad; `Facets.available` teller med alle andre + filtre (og søketeksten) men uten `availableOnly`. `Title.stream` slås opp med én spørring per side + (samme loader-mønster som anmeldelser). URL-ene bygges ved lesing fra `ARCHIVE_URL`, ikke lagret, så + serveren kan byttes (mock/ekte) uten ny import. Element-id må matche `^[A-Za-z0-9._-]+$` (og ikke + `..`), filnavn kan ikke ha `/`, `\`, `..` eller kontrolltegn; ellers blir `stream` `null`, og + filnavnet URL-enkodes. +- **Lisensregel for importen (`isLawful` i `scripts/import-archive.ts`).** Et Archive-element er + lovlig hvis (a) `licenseurl` har verten `creativecommons.org` (eller subdomene), eller URL-en + inneholder `publicdomain` (CC0, Public Domain Mark, usa.gov-erklæringen), eller (b) elementet ligger + i en kuratert public domain-samling: `feature_films`, `film_noir` eller `silent_films`. Alt annet + hoppes over, også elementer i `classic_tv` uten lisens-URL, siden den samlingen blander opphavsrett + og public domain. Vi sjekker verten i stedet for å teste om URL-en «inneholder» creativecommons.org, + slik at `http://evil.example/creativecommons.org/…` ikke slipper gjennom. CC-varianter med NC/ND + godtas fordi vi bare lenker til og viser videoen på archive.org, uten å kopiere eller endre den. + Lisensteksten utledes fra URL-en (`CC BY 4.0`, `CC BY-NC-ND 3.0`, `CC0`, `Public Domain`); kuratert + samling uten URL gir `Public Domain`. Søket mot Scrape API er bredere enn regelen (det tar også med + `classic_tv`) og regelen brukes på hvert treff, så feil i søkespørringen aldri gir ulovlige titler. +- **Kobling Archive -> IMDb.** 1) `urn:imdb:ttNNNNNNN` i `external-identifier`, hvis id-en finnes i + `titles`. 2) Ellers normalisert tittel (Unicode NFKD uten aksenter, små bokstaver, tegnsetting til + mellomrom, ledende «the»/«a» fjernet) mot primær- og originaltittel for `movie`-titler, med år ±1 + (Archive og IMDb er sjelden enige), og bare ved nøyaktig ett treff. Tvetydige treff hoppes over: feil + film på feil side er verre enn ingen film. Flere Archive-elementer for samme tittel: IMDb-ID-koblingen + vinner, ellers det første (deterministisk). Filmer indekseres i minnet ved importstart (normaliseringen + skjer i JS), IMDb-id-er slås opp i én spørring per Scrape-side. +- **Filvalg.** Nettleserne spiller MP4 (H.264) overalt, så rekkefølgen er `.mp4` (formatene h.264 / + h.264 IA først, så 512Kb MPEG4, så MPEG4-originaler, så andre), deretter `.webm`, deretter `.ogv`. + Innen samme klasse velges den minste filen (bærekraft: ikke send en 4 GB-original til en + strømmer). Filer i undermapper hoppes over. Varighet leses fra `length` (sekunder eller tt:mm:ss) på + valgt fil, ellers fra en annen fil i elementet. `.vtt` med samme grunnnavn som videoen foretrekkes. +- **Importens oppførsel mot Archive.** Fire samtidige arbeidere, men felles taktgiver (150 ms mellom + kallstart), 20 s tidsgrense per kall, ett nytt forsøk ved 5xx/429/timeout/nettverksfeil (ikke ved 404), + upsert per tittel, og `--limit`/`ARCHIVE_LIMIT` som stopper innsamlingen etter N koblede titler. Et + element som feiler teller som `feilet` uten å stoppe resten; skriptet avslutter da med kode 1. +- **Falsk Archive (`scripts/archive-mock.ts`) serverer WebM.** Testvideoen er `.webm`, så de spillbare + filene i mocken heter `.webm` (ellers ville `Content-Type: video/mp4` løyet om bytene). Filvalg + mellom mp4/webm/ogv dekkes av enhetstester på `chooseFiles`. + +## M6 – Spiller og «Se gratis nå» (frontend) + +- **Egen spiller på native `