···11+# popback
22+33+Rate TMDB movies in a TUI, backfill ratings to popfeed. Resumes where you
44+left off -- close it, come back, keep rating.
55+66+## Why
77+88+Popfeed's own backfill is unreliable: duplicates, broken pagination, missing
99+ratings. This reads your CSV of rated movies and backfills one entry at a
1010+time with proper error tracking.
1111+1212+## Usage
1313+1414+ export TMDB_API_KEY=...
1515+ popback viewed [output.csv]
1616+1717+ export TMDB_API_KEY=... POPFEED_AUTH_TOKEN=...
1818+ popback backfill [input.csv]
1919+2020+Resume is automatic: `viewed` skips already-rated movies from the CSV.
2121+`backfill` skips entries already posted to popfeed.
2222+2323+## How it works
2424+2525+### viewed
2626+2727+Streaming TUI: movies arrive from TMDB one page at a time (~20). A
2828+background fetch runs while you rate so the next movie is always ready.
2929+Press `b` to go back and re-rate. Results write to CSV on exit.
3030+3131+```
3232+TMDB pages --> [pre-fetch buffer] --> movie at cursor --> rate/skip --> CSV
3333+```
3434+3535+### backfill
3636+3737+Reads CSV, posts each entry to popfeed's internal API two-step: a review
3838+(rating + metadata) and an experience (watched/seen timestamp). Pure
3939+request builders are tested separately from HTTP I/O.
4040+4141+### popfeed internal API
4242+4343+No public docs. Endpoints reverse-engineered from browser devtools:
4444+4545+- `POST /api/rest/v1/review`
4646+- `POST /api/rest/v1/experience`
4747+4848+Auth: JWT token from browser session, sent as cookie.
4949+5050+### Persistence
5151+5252+CSV format: `id,rating,seen,skip`. Human-readable, append-only. The store
5353+interface is mocked in tests so the write path is tested without disk.
5454+5555+## Design
5656+5757+- Sandwich pattern: impure gather (CSV, TMDB) -> pure logic (build
5858+ requests, dedup) -> impure commit (write CSV, post HTTP).
5959+- Streaming TUI: no batch cap. Async pre-fetch hides network latency.
6060+- The CSV is the skip set. No in-memory state across restarts.
6161+- slog to stderr for ops, fmt to stdout for UX. No log noise.
6262+- Table-driven tests, no shared mutable state.
6363+6464+## Environment
6565+6666+ TMDB_API_KEY TMDB key or JWT bearer token
6767+ POPFEED_AUTH_TOKEN Popfeed session JWT (for backfill)
+103
backfill.go
···11+package main
22+33+import (
44+ "context"
55+ "errors"
66+ "fmt"
77+ "log/slog"
88+ "time"
99+)
1010+1111+// backfillAction represents the action to take for a backfilled entry.
1212+type backfillAction int
1313+1414+const (
1515+ actionSkip backfillAction = iota
1616+ actionReview
1717+ actionMarkExperienced
1818+)
1919+2020+// backfillDecision holds the result of deciding what to do with an entry.
2121+type backfillDecision struct {
2222+ action backfillAction
2323+ movie Movie
2424+ rating int
2525+}
2626+2727+// decideAction determines the action for a movie entry.
2828+// Pure function — no I/O, testable with simple values.
2929+func decideAction(e Entry, m *Movie) backfillDecision {
3030+ if m == nil {
3131+ return backfillDecision{action: actionSkip}
3232+ }
3333+ if e.Rating > 0 {
3434+ return backfillDecision{action: actionReview, movie: *m, rating: e.Rating}
3535+ }
3636+ return backfillDecision{action: actionMarkExperienced, movie: *m}
3737+}
3838+3939+func runBackfill(store Storer, tmdb MovieFetcher, pf Popfeeder, csvPath string) error {
4040+ entries, err := store.ReadEntries(csvPath)
4141+ if err != nil {
4242+ return fmt.Errorf("read csv: %w", err)
4343+ }
4444+ if len(entries) == 0 {
4545+ slog.Info("no entries to backfill")
4646+ return nil
4747+ }
4848+4949+ ctx := context.Background()
5050+5151+ var created, skipped, errored int
5252+ for _, e := range entries {
5353+ if !e.Seen {
5454+ skipped++
5555+ continue
5656+ }
5757+5858+ const maxAttempts = 3
5959+ var m *Movie
6060+ for attempt := range maxAttempts {
6161+ m, err = tmdb.FetchMovieDetails(ctx, e.ID)
6262+ if err == nil {
6363+ break
6464+ }
6565+ if attempt < maxAttempts-1 {
6666+ slog.Warn("retry fetch movie details", "id", e.ID, "attempt", attempt+1, "err", err)
6767+ time.Sleep(time.Duration(attempt+1) * time.Second)
6868+ }
6969+ }
7070+ if err != nil {
7171+ slog.Error("fetch movie details", "id", e.ID, "err", err)
7272+ errored++
7373+ continue
7474+ }
7575+7676+ switch d := decideAction(e, m); d.action {
7777+ case actionSkip:
7878+ skipped++
7979+ case actionReview:
8080+ if err := pf.Review(ctx, d.movie, d.rating); errors.Is(err, ErrAlreadyExists) {
8181+ skipped++
8282+ continue
8383+ } else if err != nil {
8484+ slog.Error("process entry", "id", e.ID, "err", err)
8585+ errored++
8686+ continue
8787+ }
8888+ created++
8989+ slog.Info("created", "title", d.movie.Title, "id", d.movie.ID)
9090+ case actionMarkExperienced:
9191+ if err := pf.MarkExperienced(ctx, d.movie); err != nil {
9292+ slog.Error("process entry", "id", e.ID, "err", err)
9393+ errored++
9494+ continue
9595+ }
9696+ created++
9797+ slog.Info("created", "title", d.movie.Title, "id", d.movie.ID)
9898+ }
9999+ }
100100+101101+ slog.Info("backfill complete", "created", created, "skipped", skipped, "errored", errored)
102102+ return nil
103103+}
···11+package main
22+33+type Movie struct {
44+ ID int
55+ Title string
66+ Year string
77+ Score float64
88+99+ Genres []string
1010+1111+ PosterPath string
1212+ BackdropPath string
1313+ Director string
1414+ ReleaseDate string
1515+ ImdbID string
1616+}
1717+1818+type Entry struct {
1919+ ID int
2020+ Rating int
2121+ Seen bool
2222+}