let's backfill your watched movies on popfeed
0

Configure Feed

Select the types of activity you want to include in your feed.

initial commit

karitham (May 24, 2026, 11:06 PM +0200) 1cd553fd

+2667
+2
.gitignore
··· 1 + popback 2 + watchlist.csv
+67
README.md
··· 1 + # popback 2 + 3 + Rate TMDB movies in a TUI, backfill ratings to popfeed. Resumes where you 4 + left off -- close it, come back, keep rating. 5 + 6 + ## Why 7 + 8 + Popfeed's own backfill is unreliable: duplicates, broken pagination, missing 9 + ratings. This reads your CSV of rated movies and backfills one entry at a 10 + time with proper error tracking. 11 + 12 + ## Usage 13 + 14 + export TMDB_API_KEY=... 15 + popback viewed [output.csv] 16 + 17 + export TMDB_API_KEY=... POPFEED_AUTH_TOKEN=... 18 + popback backfill [input.csv] 19 + 20 + Resume is automatic: `viewed` skips already-rated movies from the CSV. 21 + `backfill` skips entries already posted to popfeed. 22 + 23 + ## How it works 24 + 25 + ### viewed 26 + 27 + Streaming TUI: movies arrive from TMDB one page at a time (~20). A 28 + background fetch runs while you rate so the next movie is always ready. 29 + Press `b` to go back and re-rate. Results write to CSV on exit. 30 + 31 + ``` 32 + TMDB pages --> [pre-fetch buffer] --> movie at cursor --> rate/skip --> CSV 33 + ``` 34 + 35 + ### backfill 36 + 37 + Reads CSV, posts each entry to popfeed's internal API two-step: a review 38 + (rating + metadata) and an experience (watched/seen timestamp). Pure 39 + request builders are tested separately from HTTP I/O. 40 + 41 + ### popfeed internal API 42 + 43 + No public docs. Endpoints reverse-engineered from browser devtools: 44 + 45 + - `POST /api/rest/v1/review` 46 + - `POST /api/rest/v1/experience` 47 + 48 + Auth: JWT token from browser session, sent as cookie. 49 + 50 + ### Persistence 51 + 52 + CSV format: `id,rating,seen,skip`. Human-readable, append-only. The store 53 + interface is mocked in tests so the write path is tested without disk. 54 + 55 + ## Design 56 + 57 + - Sandwich pattern: impure gather (CSV, TMDB) -> pure logic (build 58 + requests, dedup) -> impure commit (write CSV, post HTTP). 59 + - Streaming TUI: no batch cap. Async pre-fetch hides network latency. 60 + - The CSV is the skip set. No in-memory state across restarts. 61 + - slog to stderr for ops, fmt to stdout for UX. No log noise. 62 + - Table-driven tests, no shared mutable state. 63 + 64 + ## Environment 65 + 66 + TMDB_API_KEY TMDB key or JWT bearer token 67 + POPFEED_AUTH_TOKEN Popfeed session JWT (for backfill)
+103
backfill.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "time" 9 + ) 10 + 11 + // backfillAction represents the action to take for a backfilled entry. 12 + type backfillAction int 13 + 14 + const ( 15 + actionSkip backfillAction = iota 16 + actionReview 17 + actionMarkExperienced 18 + ) 19 + 20 + // backfillDecision holds the result of deciding what to do with an entry. 21 + type backfillDecision struct { 22 + action backfillAction 23 + movie Movie 24 + rating int 25 + } 26 + 27 + // decideAction determines the action for a movie entry. 28 + // Pure function — no I/O, testable with simple values. 29 + func decideAction(e Entry, m *Movie) backfillDecision { 30 + if m == nil { 31 + return backfillDecision{action: actionSkip} 32 + } 33 + if e.Rating > 0 { 34 + return backfillDecision{action: actionReview, movie: *m, rating: e.Rating} 35 + } 36 + return backfillDecision{action: actionMarkExperienced, movie: *m} 37 + } 38 + 39 + func runBackfill(store Storer, tmdb MovieFetcher, pf Popfeeder, csvPath string) error { 40 + entries, err := store.ReadEntries(csvPath) 41 + if err != nil { 42 + return fmt.Errorf("read csv: %w", err) 43 + } 44 + if len(entries) == 0 { 45 + slog.Info("no entries to backfill") 46 + return nil 47 + } 48 + 49 + ctx := context.Background() 50 + 51 + var created, skipped, errored int 52 + for _, e := range entries { 53 + if !e.Seen { 54 + skipped++ 55 + continue 56 + } 57 + 58 + const maxAttempts = 3 59 + var m *Movie 60 + for attempt := range maxAttempts { 61 + m, err = tmdb.FetchMovieDetails(ctx, e.ID) 62 + if err == nil { 63 + break 64 + } 65 + if attempt < maxAttempts-1 { 66 + slog.Warn("retry fetch movie details", "id", e.ID, "attempt", attempt+1, "err", err) 67 + time.Sleep(time.Duration(attempt+1) * time.Second) 68 + } 69 + } 70 + if err != nil { 71 + slog.Error("fetch movie details", "id", e.ID, "err", err) 72 + errored++ 73 + continue 74 + } 75 + 76 + switch d := decideAction(e, m); d.action { 77 + case actionSkip: 78 + skipped++ 79 + case actionReview: 80 + if err := pf.Review(ctx, d.movie, d.rating); errors.Is(err, ErrAlreadyExists) { 81 + skipped++ 82 + continue 83 + } else if err != nil { 84 + slog.Error("process entry", "id", e.ID, "err", err) 85 + errored++ 86 + continue 87 + } 88 + created++ 89 + slog.Info("created", "title", d.movie.Title, "id", d.movie.ID) 90 + case actionMarkExperienced: 91 + if err := pf.MarkExperienced(ctx, d.movie); err != nil { 92 + slog.Error("process entry", "id", e.ID, "err", err) 93 + errored++ 94 + continue 95 + } 96 + created++ 97 + slog.Info("created", "title", d.movie.Title, "id", d.movie.ID) 98 + } 99 + } 100 + 101 + slog.Info("backfill complete", "created", created, "skipped", skipped, "errored", errored) 102 + return nil 103 + }
+211
backfill_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + "testing" 8 + ) 9 + 10 + type mockMovieFetcher struct { 11 + movies map[int]*Movie 12 + } 13 + 14 + func (m *mockMovieFetcher) FetchMovieDetails(_ context.Context, id int) (*Movie, error) { 15 + if id == 0 { 16 + return nil, fmt.Errorf("tmdb error") 17 + } 18 + movie, ok := m.movies[id] 19 + if !ok { 20 + return nil, nil 21 + } 22 + return movie, nil 23 + } 24 + 25 + type mockStorer struct { 26 + entries []Entry 27 + skipSet map[int]struct{} 28 + skipErr error 29 + readErr error 30 + writeErr error 31 + 32 + WrittenEntries []Entry // tracks calls to WriteEntries 33 + } 34 + 35 + func (m *mockStorer) ReadEntries(_ string) ([]Entry, error) { 36 + return m.entries, m.readErr 37 + } 38 + 39 + func (m *mockStorer) WriteEntries(_ string, entries []Entry) error { 40 + m.WrittenEntries = entries 41 + return m.writeErr 42 + } 43 + 44 + func (m *mockStorer) LoadSkipSet(_ string) (map[int]struct{}, error) { 45 + return m.skipSet, m.skipErr 46 + } 47 + 48 + type mockPopfeeder struct { 49 + storeErr error 50 + calledMovies []int 51 + experiencedIDs []int 52 + } 53 + 54 + func (m *mockPopfeeder) Review(_ context.Context, mv Movie, _ int) error { 55 + m.calledMovies = append(m.calledMovies, mv.ID) 56 + return m.storeErr 57 + } 58 + 59 + func (m *mockPopfeeder) MarkExperienced(_ context.Context, mv Movie) error { 60 + m.experiencedIDs = append(m.experiencedIDs, mv.ID) 61 + return nil 62 + } 63 + 64 + func TestDecideAction(t *testing.T) { 65 + movies := map[int]*Movie{ 66 + 1: {ID: 1, Title: "Alpha", Director: "Alice"}, 67 + 2: {ID: 2, Title: "Beta"}, 68 + 3: {ID: 3, Title: "Gamma"}, 69 + } 70 + tests := []struct { 71 + name string 72 + entry Entry 73 + movie *Movie 74 + want backfillDecision 75 + }{ 76 + { 77 + name: "nil movie skips", 78 + entry: Entry{ID: 1, Seen: true, Rating: 8}, 79 + movie: nil, 80 + want: backfillDecision{action: actionSkip}, 81 + }, 82 + { 83 + name: "seen with rating reviews", 84 + entry: Entry{ID: 1, Seen: true, Rating: 8}, 85 + movie: movies[1], 86 + want: backfillDecision{action: actionReview, movie: *movies[1], rating: 8}, 87 + }, 88 + { 89 + name: "seen without rating marks experienced", 90 + entry: Entry{ID: 2, Seen: true}, 91 + movie: movies[2], 92 + want: backfillDecision{action: actionMarkExperienced, movie: *movies[2]}, 93 + }, 94 + { 95 + name: "seen with zero rating marks experienced", 96 + entry: Entry{ID: 3, Seen: true, Rating: 0}, 97 + movie: movies[3], 98 + want: backfillDecision{action: actionMarkExperienced, movie: *movies[3]}, 99 + }, 100 + } 101 + for _, tt := range tests { 102 + t.Run(tt.name, func(t *testing.T) { 103 + got := decideAction(tt.entry, tt.movie) 104 + if got.action != tt.want.action || got.rating != tt.want.rating { 105 + t.Errorf("decideAction() = %+v, want %+v", got, tt.want) 106 + } 107 + if tt.want.action != actionSkip && got.movie.ID != tt.want.movie.ID { 108 + t.Errorf("decideAction() movie = %+v, want %+v", got.movie, tt.want.movie) 109 + } 110 + }) 111 + } 112 + } 113 + 114 + func TestRunBackfill(t *testing.T) { 115 + movies := map[int]*Movie{ 116 + 1: {ID: 1, Title: "Alpha", Director: "Alice"}, 117 + 2: {ID: 2, Title: "Beta", Director: "Bob", Genres: []string{"Drama"}}, 118 + } 119 + 120 + tests := []struct { 121 + name string 122 + entries []Entry 123 + storeErr error 124 + wantStored []int 125 + wantExperienced []int 126 + wantErr bool 127 + }{ 128 + { 129 + name: "no entries", 130 + entries: nil, 131 + wantStored: nil, 132 + }, 133 + { 134 + name: "all unseen", 135 + entries: []Entry{{ID: 1, Seen: false}}, 136 + wantStored: nil, 137 + }, 138 + { 139 + name: "seen no rating", 140 + entries: []Entry{{ID: 1, Seen: true}}, 141 + wantExperienced: []int{1}, 142 + }, 143 + { 144 + name: "seen with rating", 145 + entries: []Entry{{ID: 1, Seen: true, Rating: 8}}, 146 + wantStored: []int{1}, 147 + }, 148 + { 149 + name: "multiple entries", 150 + entries: []Entry{{ID: 1, Seen: true, Rating: 8}, {ID: 2, Seen: true, Rating: 5}}, 151 + wantStored: []int{1, 2}, 152 + }, 153 + { 154 + name: "tmdb not found", 155 + entries: []Entry{{ID: 999, Seen: true}}, 156 + wantStored: nil, 157 + }, 158 + { 159 + name: "tmdb error", 160 + entries: []Entry{{ID: 0, Seen: true}}, 161 + wantStored: nil, 162 + }, 163 + { 164 + name: "conflict skips", 165 + entries: []Entry{{ID: 1, Seen: true, Rating: 8}}, 166 + storeErr: ErrAlreadyExists, 167 + wantStored: []int{1}, 168 + }, 169 + { 170 + name: "review error", 171 + entries: []Entry{{ID: 1, Seen: true, Rating: 8}}, 172 + storeErr: fmt.Errorf("network error"), 173 + wantStored: []int{1}, 174 + wantErr: false, // backfill doesn't return error on individual failures 175 + }, 176 + { 177 + name: "mark experienced", 178 + entries: []Entry{{ID: 2, Seen: true}}, 179 + wantExperienced: []int{2}, 180 + }, 181 + { 182 + name: "mix seen and unseen", 183 + entries: []Entry{ 184 + {ID: 1, Seen: false}, 185 + {ID: 2, Seen: true, Rating: 7}, 186 + {ID: 999, Seen: true}, 187 + }, 188 + wantStored: []int{2}, 189 + }, 190 + } 191 + 192 + for _, tt := range tests { 193 + t.Run(tt.name, func(t *testing.T) { 194 + store := &mockStorer{entries: tt.entries} 195 + tmdb := &mockMovieFetcher{movies: movies} 196 + pf := &mockPopfeeder{storeErr: tt.storeErr} 197 + 198 + err := runBackfill(store, tmdb, pf, "test.csv") 199 + if (err != nil) != tt.wantErr { 200 + t.Fatalf("runBackfill() = %v, wantErr %v", err, tt.wantErr) 201 + } 202 + 203 + if !slices.Equal(pf.calledMovies, tt.wantStored) { 204 + t.Errorf("stored movies = %v, want %v", pf.calledMovies, tt.wantStored) 205 + } 206 + if !slices.Equal(pf.experiencedIDs, tt.wantExperienced) { 207 + t.Errorf("experienced movies = %v, want %v", pf.experiencedIDs, tt.wantExperienced) 208 + } 209 + }) 210 + } 211 + }
+61
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-parts": { 4 + "inputs": { 5 + "nixpkgs-lib": "nixpkgs-lib" 6 + }, 7 + "locked": { 8 + "lastModified": 1778716662, 9 + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", 10 + "owner": "hercules-ci", 11 + "repo": "flake-parts", 12 + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "hercules-ci", 17 + "repo": "flake-parts", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1779536132, 24 + "narHash": "sha256-q+fF42iv/geEbHfgSzy3tS0FF/EyD6XTZ98E6yxiBO8=", 25 + "owner": "NixOS", 26 + "repo": "nixpkgs", 27 + "rev": "3d8f0f3f72a6cd4d93d0ad13203f2ea1cb7e1456", 28 + "type": "github" 29 + }, 30 + "original": { 31 + "owner": "NixOS", 32 + "ref": "nixpkgs-unstable", 33 + "repo": "nixpkgs", 34 + "type": "github" 35 + } 36 + }, 37 + "nixpkgs-lib": { 38 + "locked": { 39 + "lastModified": 1779589704, 40 + "narHash": "sha256-/+BaktM3RbRxi3yoH852My6ewF7IQ72WxFIZ4S2MQYg=", 41 + "owner": "nix-community", 42 + "repo": "nixpkgs.lib", 43 + "rev": "2db1633d3742103a1eb856f5d479e6a0477ddc42", 44 + "type": "github" 45 + }, 46 + "original": { 47 + "owner": "nix-community", 48 + "repo": "nixpkgs.lib", 49 + "type": "github" 50 + } 51 + }, 52 + "root": { 53 + "inputs": { 54 + "flake-parts": "flake-parts", 55 + "nixpkgs": "nixpkgs" 56 + } 57 + } 58 + }, 59 + "root": "root", 60 + "version": 7 61 + }
+24
flake.nix
··· 1 + { 2 + inputs = { 3 + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 + flake-parts.url = "github:hercules-ci/flake-parts"; 5 + }; 6 + outputs = 7 + inputs@{ flake-parts, ... }: 8 + flake-parts.lib.mkFlake { inherit inputs; } { 9 + systems = [ 10 + "x86_64-linux" 11 + "aarch64-darwin" 12 + ]; 13 + perSystem = 14 + { pkgs, ... }: 15 + { 16 + devShells.default = pkgs.mkShell { 17 + nativeBuildInputs = with pkgs; [ 18 + go 19 + python3 20 + ]; 21 + }; 22 + }; 23 + }; 24 + }
+32
go.mod
··· 1 + module tangled.org/karitham.dev/popback 2 + 3 + go 1.26.0 4 + 5 + require ( 6 + github.com/charmbracelet/bubbletea v1.3.10 7 + github.com/charmbracelet/lipgloss v1.1.0 8 + golang.org/x/time v0.15.0 9 + ) 10 + 11 + require ( 12 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 13 + github.com/charmbracelet/colorprofile v0.4.3 // indirect 14 + github.com/charmbracelet/x/ansi v0.11.7 // indirect 15 + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect 16 + github.com/charmbracelet/x/term v0.2.2 // indirect 17 + github.com/clipperhouse/displaywidth v0.11.0 // indirect 18 + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect 19 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 20 + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect 21 + github.com/mattn/go-isatty v0.0.22 // indirect 22 + github.com/mattn/go-localereader v0.0.1 // indirect 23 + github.com/mattn/go-runewidth v0.0.23 // indirect 24 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 25 + github.com/muesli/cancelreader v0.2.2 // indirect 26 + github.com/muesli/termenv v0.16.0 // indirect 27 + github.com/rivo/uniseg v0.4.7 // indirect 28 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 29 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect 30 + golang.org/x/sys v0.45.0 // indirect 31 + golang.org/x/text v0.37.0 // indirect 32 + )
+67
go.sum
··· 1 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 + github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 4 + github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 5 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 6 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 7 + github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= 8 + github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= 9 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 10 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 11 + github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 12 + github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 13 + github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= 14 + github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= 15 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 16 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 17 + github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= 18 + github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 19 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 20 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 21 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 22 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 23 + github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= 24 + github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= 25 + github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= 26 + github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 27 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 28 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 29 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 30 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 31 + github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= 32 + github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 33 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 34 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 35 + github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= 36 + github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= 37 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 38 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 39 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 40 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 41 + github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= 42 + github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 43 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 44 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 45 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 46 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 47 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 48 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 49 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 51 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 52 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 53 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 54 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 55 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 56 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 + golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 59 + golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 60 + golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= 61 + golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 62 + golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 63 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 64 + golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= 65 + golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 66 + golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= 67 + golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+69
main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + "time" 10 + 11 + "golang.org/x/time/rate" 12 + ) 13 + 14 + func main() { 15 + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) 16 + if err := run(os.Args, os.Getenv); err != nil { 17 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 18 + os.Exit(1) 19 + } 20 + } 21 + 22 + // envLookup retrieves an environment variable. 23 + type envLookup func(string) string 24 + 25 + // run dispatches to the appropriate subcommand. 26 + // It is extracted from main to allow testing of the dispatch and validation logic. 27 + func run(args []string, lookupEnv envLookup) error { 28 + if len(args) < 2 { 29 + return errors.New("Usage: popback <viewed|backfill> [args...]") 30 + } 31 + 32 + switch args[1] { 33 + case "viewed": 34 + apiKey := lookupEnv("TMDB_API_KEY") 35 + if apiKey == "" { 36 + return errors.New("TMDB_API_KEY required") 37 + } 38 + output := "watchlist.csv" 39 + if len(args) > 2 { 40 + output = args[2] 41 + } 42 + tmdb := newTMDBClient(apiKey) 43 + store := newCSVStorer() 44 + return runViewed(context.Background(), tmdb, store, realTUIRunner{}, output) 45 + 46 + case "backfill": 47 + apiKey := lookupEnv("TMDB_API_KEY") 48 + if apiKey == "" { 49 + return errors.New("TMDB_API_KEY required") 50 + } 51 + authToken := lookupEnv("POPFEED_AUTH_TOKEN") 52 + if authToken == "" { 53 + return errors.New("POPFEED_AUTH_TOKEN required") 54 + } 55 + csvPath := "watchlist.csv" 56 + if len(args) > 2 { 57 + csvPath = args[2] 58 + } 59 + tmdb := newTMDBClient(apiKey) 60 + store := newCSVStorer() 61 + pf := newPopfeedClient(authToken) 62 + tmdb.http = WithRateLimit(tmdb.http, rate.NewLimiter(rate.Every(250*time.Millisecond), 1)) 63 + pf.http = WithRateLimit(pf.http, rate.NewLimiter(rate.Every(50*time.Millisecond), 1)) 64 + return runBackfill(store, tmdb, pf, csvPath) 65 + 66 + default: 67 + return fmt.Errorf("Unknown command: %s\nUsage: popback <viewed|backfill> [args...]", args[1]) 68 + } 69 + }
+62
main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestRun(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + args []string 11 + env map[string]string 12 + wantErr string 13 + }{ 14 + { 15 + name: "no args", 16 + args: []string{"popback"}, 17 + wantErr: "Usage: popback <viewed|backfill> [args...]", 18 + }, 19 + { 20 + name: "unknown command", 21 + args: []string{"popback", "unknown"}, 22 + wantErr: "Unknown command: unknown\nUsage: popback <viewed|backfill> [args...]", 23 + }, 24 + { 25 + name: "viewed missing api key", 26 + args: []string{"popback", "viewed"}, 27 + env: map[string]string{}, 28 + wantErr: "TMDB_API_KEY required", 29 + }, 30 + { 31 + name: "backfill missing api key", 32 + args: []string{"popback", "backfill"}, 33 + env: map[string]string{}, 34 + wantErr: "TMDB_API_KEY required", 35 + }, 36 + { 37 + name: "backfill missing auth token", 38 + args: []string{"popback", "backfill"}, 39 + env: map[string]string{ 40 + "TMDB_API_KEY": "key123", 41 + }, 42 + wantErr: "POPFEED_AUTH_TOKEN required", 43 + }, 44 + } 45 + for _, tt := range tests { 46 + t.Run(tt.name, func(t *testing.T) { 47 + lookupEnv := func(key string) string { 48 + if tt.env == nil { 49 + return "" 50 + } 51 + return tt.env[key] 52 + } 53 + err := run(tt.args, lookupEnv) 54 + if err == nil { 55 + t.Fatal("expected error, got nil") 56 + } 57 + if err.Error() != tt.wantErr { 58 + t.Errorf("run() = %q, want %q", err.Error(), tt.wantErr) 59 + } 60 + }) 61 + } 62 + }
+22
models.go
··· 1 + package main 2 + 3 + type Movie struct { 4 + ID int 5 + Title string 6 + Year string 7 + Score float64 8 + 9 + Genres []string 10 + 11 + PosterPath string 12 + BackdropPath string 13 + Director string 14 + ReleaseDate string 15 + ImdbID string 16 + } 17 + 18 + type Entry struct { 19 + ID int 20 + Rating int 21 + Seen bool 22 + }
+197
popfeed.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "net/http" 11 + "time" 12 + ) 13 + 14 + var ErrAlreadyExists = errors.New("record already exists") 15 + 16 + type Popfeeder interface { 17 + Review(ctx context.Context, m Movie, rating int) error 18 + MarkExperienced(ctx context.Context, m Movie) error 19 + } 20 + 21 + type popfeedClient struct { 22 + token string 23 + baseURL string 24 + http httpDoer 25 + } 26 + 27 + func newPopfeedClient(token string) *popfeedClient { 28 + return &popfeedClient{ 29 + token: token, 30 + baseURL: "https://popsphere-api.onrender.com", 31 + http: &http.Client{Timeout: 30 * time.Second}, 32 + } 33 + } 34 + 35 + type reviewRequest struct { 36 + CreativeWorkType string `json:"creativeWorkType"` 37 + Identifiers map[string]string `json:"identifiers"` 38 + Rating *int `json:"rating,omitempty"` 39 + ReviewText string `json:"reviewText"` 40 + CreatedAt string `json:"createdAt"` 41 + Facets []string `json:"facets"` 42 + IsRevisit bool `json:"isRevisit"` 43 + ContainsSpoilers bool `json:"containsSpoilers"` 44 + Tags []string `json:"tags"` 45 + Title string `json:"title"` 46 + PosterURL string `json:"posterUrl,omitempty"` 47 + BackdropURL string `json:"backdropUrl,omitempty"` 48 + ReleaseDate string `json:"releaseDate,omitempty"` 49 + Genres []string `json:"genres,omitempty"` 50 + MainCredit string `json:"mainCredit,omitempty"` 51 + MainCreditRole string `json:"mainCreditRole,omitempty"` 52 + CrosspostTo []string `json:"crosspostTo"` 53 + MarkAsExperienced bool `json:"markAsExperienced"` 54 + } 55 + 56 + func movieIdentifiers(m Movie) map[string]string { 57 + ids := map[string]string{"tmdbId": fmt.Sprintf("%d", m.ID)} 58 + if m.ImdbID != "" { 59 + ids["imdbId"] = m.ImdbID 60 + } 61 + return ids 62 + } 63 + 64 + func moviePosterURL(m Movie) string { 65 + if m.PosterPath == "" { 66 + return "" 67 + } 68 + return "https://image.tmdb.org/t/p/w500" + m.PosterPath 69 + } 70 + 71 + func movieBackdropURL(m Movie) string { 72 + if m.BackdropPath == "" { 73 + return "" 74 + } 75 + return "https://image.tmdb.org/t/p/original" + m.BackdropPath 76 + } 77 + 78 + func newReviewRequest(m Movie, rating int, createdAt time.Time) reviewRequest { 79 + var ratingPtr *int 80 + if rating > 0 { 81 + ratingPtr = &rating 82 + } 83 + return reviewRequest{ 84 + CreativeWorkType: "movie", 85 + Identifiers: movieIdentifiers(m), 86 + Rating: ratingPtr, 87 + ReviewText: "", 88 + CreatedAt: createdAt.UTC().Format("2006-01-02T15:04:05.000Z"), 89 + Facets: []string{}, 90 + IsRevisit: false, 91 + ContainsSpoilers: false, 92 + Tags: []string{}, 93 + Title: m.Title, 94 + PosterURL: moviePosterURL(m), 95 + BackdropURL: movieBackdropURL(m), 96 + ReleaseDate: formatReleaseDate(m.ReleaseDate), 97 + Genres: m.Genres, 98 + MainCredit: m.Director, 99 + MainCreditRole: "Directed by", 100 + CrosspostTo: []string{}, 101 + MarkAsExperienced: true, 102 + } 103 + } 104 + 105 + func newExperienceRequest(m Movie) experienceRequest { 106 + return experienceRequest{ 107 + Status: "experienced", 108 + Identifiers: movieIdentifiers(m), 109 + CreativeWorkType: "movie", 110 + Title: m.Title, 111 + PosterURL: moviePosterURL(m), 112 + BackdropURL: movieBackdropURL(m), 113 + } 114 + } 115 + 116 + func (p *popfeedClient) Review(ctx context.Context, m Movie, rating int) error { 117 + now := time.Now() 118 + body := newReviewRequest(m, rating, now) 119 + 120 + data, err := json.Marshal(body) 121 + if err != nil { 122 + return fmt.Errorf("encode request: %w", err) 123 + } 124 + 125 + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/reviews", bytes.NewReader(data)) 126 + if err != nil { 127 + return fmt.Errorf("create request: %w", err) 128 + } 129 + req.Header.Set("Authorization", "Bearer "+p.token) 130 + req.Header.Set("Content-Type", "application/json") 131 + 132 + resp, err := p.http.Do(req) 133 + if err != nil { 134 + return fmt.Errorf("post review: %w", err) 135 + } 136 + defer resp.Body.Close() 137 + 138 + switch resp.StatusCode { 139 + case http.StatusOK, http.StatusCreated: 140 + return nil 141 + case http.StatusConflict: 142 + return ErrAlreadyExists 143 + default: 144 + respBody, _ := io.ReadAll(resp.Body) 145 + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, bytes.TrimSpace(respBody)) 146 + } 147 + } 148 + 149 + type experienceRequest struct { 150 + Status string `json:"status"` 151 + Identifiers map[string]string `json:"identifiers"` 152 + CreativeWorkType string `json:"creativeWorkType"` 153 + Title string `json:"title"` 154 + PosterURL string `json:"posterUrl,omitempty"` 155 + BackdropURL string `json:"backdropUrl,omitempty"` 156 + } 157 + 158 + func (p *popfeedClient) MarkExperienced(ctx context.Context, m Movie) error { 159 + body := newExperienceRequest(m) 160 + 161 + data, err := json.Marshal(body) 162 + if err != nil { 163 + return fmt.Errorf("encode request: %w", err) 164 + } 165 + 166 + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/lists/change-status", bytes.NewReader(data)) 167 + if err != nil { 168 + return fmt.Errorf("create request: %w", err) 169 + } 170 + req.Header.Set("Authorization", "Bearer "+p.token) 171 + req.Header.Set("Content-Type", "application/json") 172 + 173 + resp, err := p.http.Do(req) 174 + if err != nil { 175 + return fmt.Errorf("mark experienced: %w", err) 176 + } 177 + defer resp.Body.Close() 178 + 179 + switch resp.StatusCode { 180 + case http.StatusOK, http.StatusCreated: 181 + return nil 182 + default: 183 + respBody, _ := io.ReadAll(resp.Body) 184 + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, bytes.TrimSpace(respBody)) 185 + } 186 + } 187 + 188 + func formatReleaseDate(date string) string { 189 + if date == "" { 190 + return "" 191 + } 192 + t, err := time.Parse("2006-01-02", date) 193 + if err != nil { 194 + return date 195 + } 196 + return t.Format("2006-01-02T15:04:05.000Z") 197 + }
+393
popfeed_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "net/http" 9 + "net/http/httptest" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + func TestPopfeedReview(t *testing.T) { 15 + tests := []struct { 16 + name string 17 + movie Movie 18 + rating int 19 + statusCode int 20 + wantErr bool 21 + wantExists bool 22 + }{ 23 + { 24 + name: "success no rating", 25 + movie: Movie{ID: 1, Title: "Alpha", Director: "Alice", Genres: []string{"Drama"}}, 26 + statusCode: http.StatusCreated, 27 + }, 28 + { 29 + name: "success with rating", 30 + movie: Movie{ID: 2, Title: "Beta", Director: "Bob", ImdbID: "tt123"}, 31 + rating: 8, 32 + statusCode: http.StatusOK, 33 + }, 34 + { 35 + name: "conflict", 36 + movie: Movie{ID: 3, Title: "Gamma"}, 37 + statusCode: http.StatusConflict, 38 + wantExists: true, 39 + }, 40 + { 41 + name: "unauthorized", 42 + movie: Movie{ID: 4, Title: "Delta"}, 43 + statusCode: http.StatusUnauthorized, 44 + wantErr: true, 45 + }, 46 + { 47 + name: "server error", 48 + movie: Movie{ID: 5, Title: "Epsilon"}, 49 + statusCode: http.StatusInternalServerError, 50 + wantErr: true, 51 + }, 52 + } 53 + 54 + for _, tt := range tests { 55 + t.Run(tt.name, func(t *testing.T) { 56 + var gotBody map[string]any 57 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 + if r.Method != http.MethodPost { 59 + t.Errorf("method = %s, want POST", r.Method) 60 + } 61 + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { 62 + t.Errorf("Authorization = %q, want 'Bearer test-token'", got) 63 + } 64 + if got := r.Header.Get("Content-Type"); got != "application/json" { 65 + t.Errorf("Content-Type = %q, want 'application/json'", got) 66 + } 67 + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { 68 + t.Fatalf("decode body: %v", err) 69 + } 70 + w.WriteHeader(tt.statusCode) 71 + })) 72 + defer srv.Close() 73 + 74 + p := &popfeedClient{ 75 + token: "test-token", 76 + baseURL: srv.URL, 77 + http: http.DefaultClient, 78 + } 79 + err := p.Review(context.Background(), tt.movie, tt.rating) 80 + 81 + switch { 82 + case tt.wantErr: 83 + if err == nil { 84 + t.Fatal("expected error, got nil") 85 + } 86 + case tt.wantExists: 87 + if !errors.Is(err, ErrAlreadyExists) { 88 + t.Fatalf("expected ErrAlreadyExists, got %v", err) 89 + } 90 + default: 91 + if err != nil { 92 + t.Fatalf("unexpected error: %v", err) 93 + } 94 + } 95 + 96 + // Verify request body fields 97 + if got := gotBody["creativeWorkType"]; got != "movie" { 98 + t.Errorf("creativeWorkType = %v, want movie", got) 99 + } 100 + if got := gotBody["title"]; got != tt.movie.Title { 101 + t.Errorf("title = %v, want %s", got, tt.movie.Title) 102 + } 103 + if got, ok := gotBody["markAsExperienced"].(bool); !ok || !got { 104 + t.Errorf("markAsExperienced = %v, want true", gotBody["markAsExperienced"]) 105 + } 106 + 107 + ids, ok := gotBody["identifiers"].(map[string]any) 108 + if !ok { 109 + t.Fatal("identifiers not a map") 110 + } 111 + wantTmdb := fmt.Sprintf("%d", tt.movie.ID) 112 + if got := ids["tmdbId"]; got != wantTmdb { 113 + t.Errorf("tmdbId = %v, want %s", got, wantTmdb) 114 + } 115 + 116 + if tt.movie.ImdbID != "" { 117 + if got := ids["imdbId"]; got != tt.movie.ImdbID { 118 + t.Errorf("imdbId = %v, want %s", got, tt.movie.ImdbID) 119 + } 120 + } 121 + 122 + cp, ok := gotBody["crosspostTo"].([]any) 123 + if !ok || len(cp) != 0 { 124 + t.Errorf("crosspostTo = %v (%T), want empty array", gotBody["crosspostTo"], gotBody["crosspostTo"]) 125 + } 126 + 127 + if rd, ok := gotBody["releaseDate"].(string); tt.movie.ReleaseDate != "" && ok { 128 + _, err := time.Parse("2006-01-02T15:04:05.000Z", rd) 129 + if err != nil { 130 + t.Errorf("releaseDate = %q, want RFC3339 format with millis", rd) 131 + } 132 + } 133 + 134 + if tt.rating > 0 { 135 + gotRating, _ := gotBody["rating"].(float64) 136 + if int(gotRating) != tt.rating { 137 + t.Errorf("rating = %v, want %d", gotRating, tt.rating) 138 + } 139 + } else if _, ok := gotBody["rating"]; ok { 140 + t.Errorf("rating = %v, want absent", gotBody["rating"]) 141 + } 142 + }) 143 + } 144 + } 145 + 146 + func TestPopfeedMarkExperienced(t *testing.T) { 147 + tests := []struct { 148 + name string 149 + movie Movie 150 + statusCode int 151 + wantErr bool 152 + }{ 153 + { 154 + name: "success", 155 + movie: Movie{ID: 1, Title: "Alpha", PosterPath: "/alpha.jpg", BackdropPath: "/beta.jpg", ImdbID: "tt001"}, 156 + statusCode: http.StatusOK, 157 + }, 158 + { 159 + name: "created", 160 + movie: Movie{ID: 2, Title: "Beta"}, 161 + statusCode: http.StatusCreated, 162 + }, 163 + { 164 + name: "server error", 165 + movie: Movie{ID: 3, Title: "Gamma"}, 166 + statusCode: http.StatusInternalServerError, 167 + wantErr: true, 168 + }, 169 + } 170 + 171 + for _, tt := range tests { 172 + t.Run(tt.name, func(t *testing.T) { 173 + var gotBody map[string]any 174 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 175 + if r.Method != http.MethodPost { 176 + t.Errorf("method = %s, want POST", r.Method) 177 + } 178 + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { 179 + t.Errorf("Authorization = %q, want 'Bearer test-token'", got) 180 + } 181 + if got := r.Header.Get("Content-Type"); got != "application/json" { 182 + t.Errorf("Content-Type = %q, want 'application/json'", got) 183 + } 184 + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { 185 + t.Fatalf("decode body: %v", err) 186 + } 187 + w.WriteHeader(tt.statusCode) 188 + })) 189 + defer srv.Close() 190 + 191 + p := &popfeedClient{ 192 + token: "test-token", 193 + baseURL: srv.URL, 194 + http: http.DefaultClient, 195 + } 196 + err := p.MarkExperienced(context.Background(), tt.movie) 197 + 198 + if (err != nil) != tt.wantErr { 199 + t.Fatalf("MarkExperienced() = %v, wantErr %v", err, tt.wantErr) 200 + } 201 + if err != nil { 202 + return 203 + } 204 + 205 + if got := gotBody["status"]; got != "experienced" { 206 + t.Errorf("status = %v, want 'experienced'", got) 207 + } 208 + if got := gotBody["creativeWorkType"]; got != "movie" { 209 + t.Errorf("creativeWorkType = %v, want movie", got) 210 + } 211 + if got := gotBody["title"]; got != tt.movie.Title { 212 + t.Errorf("title = %v, want %s", got, tt.movie.Title) 213 + } 214 + 215 + ids, ok := gotBody["identifiers"].(map[string]any) 216 + if !ok { 217 + t.Fatal("identifiers not a map") 218 + } 219 + wantTmdb := fmt.Sprintf("%d", tt.movie.ID) 220 + if got := ids["tmdbId"]; got != wantTmdb { 221 + t.Errorf("tmdbId = %v, want %s", got, wantTmdb) 222 + } 223 + 224 + if tt.movie.ImdbID != "" { 225 + if got := ids["imdbId"]; got != tt.movie.ImdbID { 226 + t.Errorf("imdbId = %v, want %s", got, tt.movie.ImdbID) 227 + } 228 + } 229 + 230 + if tt.movie.PosterPath != "" { 231 + wantPoster := "https://image.tmdb.org/t/p/w500" + tt.movie.PosterPath 232 + if got := gotBody["posterUrl"]; got != wantPoster { 233 + t.Errorf("posterUrl = %v, want %s", got, wantPoster) 234 + } 235 + } 236 + if tt.movie.BackdropPath != "" { 237 + wantBackdrop := "https://image.tmdb.org/t/p/original" + tt.movie.BackdropPath 238 + if got := gotBody["backdropUrl"]; got != wantBackdrop { 239 + t.Errorf("backdropUrl = %v, want %s", got, wantBackdrop) 240 + } 241 + } 242 + }) 243 + } 244 + } 245 + 246 + func TestNewReviewRequest(t *testing.T) { 247 + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) 248 + 249 + tests := []struct { 250 + name string 251 + movie Movie 252 + rating int 253 + check func(*testing.T, reviewRequest) 254 + }{ 255 + { 256 + name: "full movie with rating", 257 + movie: Movie{ID: 1, Title: "Alpha", Director: "Alice", Genres: []string{"Drama"}, PosterPath: "/p.jpg", BackdropPath: "/b.jpg", ReleaseDate: "2024-06-15", ImdbID: "tt001"}, 258 + rating: 8, 259 + check: func(t *testing.T, r reviewRequest) { 260 + if *r.Rating != 8 { 261 + t.Errorf("Rating = %d, want 8", *r.Rating) 262 + } 263 + if r.Title != "Alpha" { 264 + t.Errorf("Title = %q", r.Title) 265 + } 266 + if r.CreatedAt != "2026-05-24T12:00:00.000Z" { 267 + t.Errorf("CreatedAt = %q", r.CreatedAt) 268 + } 269 + if r.Identifiers["tmdbId"] != "1" { 270 + t.Errorf("tmdbId = %q", r.Identifiers["tmdbId"]) 271 + } 272 + if r.Identifiers["imdbId"] != "tt001" { 273 + t.Errorf("imdbId = %q", r.Identifiers["imdbId"]) 274 + } 275 + if r.PosterURL != "https://image.tmdb.org/t/p/w500/p.jpg" { 276 + t.Errorf("PosterURL = %q", r.PosterURL) 277 + } 278 + if r.BackdropURL != "https://image.tmdb.org/t/p/original/b.jpg" { 279 + t.Errorf("BackdropURL = %q", r.BackdropURL) 280 + } 281 + if r.MainCredit != "Alice" { 282 + t.Errorf("MainCredit = %q", r.MainCredit) 283 + } 284 + if r.ReleaseDate != "2024-06-15T00:00:00.000Z" { 285 + t.Errorf("ReleaseDate = %q", r.ReleaseDate) 286 + } 287 + if !r.MarkAsExperienced { 288 + t.Error("MarkAsExperienced is false") 289 + } 290 + }, 291 + }, 292 + { 293 + name: "zero rating", 294 + movie: Movie{ID: 2, Title: "Beta"}, 295 + rating: 0, 296 + check: func(t *testing.T, r reviewRequest) { 297 + if r.Rating != nil { 298 + t.Errorf("Rating = %v, want nil", r.Rating) 299 + } 300 + if r.Identifiers["tmdbId"] != "2" { 301 + t.Errorf("tmdbId = %q", r.Identifiers["tmdbId"]) 302 + } 303 + if _, ok := r.Identifiers["imdbId"]; ok { 304 + t.Error("imdbId should be absent") 305 + } 306 + if r.PosterURL != "" { 307 + t.Errorf("PosterURL = %q, want empty", r.PosterURL) 308 + } 309 + if r.MainCredit != "" { 310 + t.Errorf("MainCredit = %q, want empty", r.MainCredit) 311 + } 312 + if !r.MarkAsExperienced { 313 + t.Error("MarkAsExperienced is false") 314 + } 315 + }, 316 + }, 317 + } 318 + 319 + for _, tt := range tests { 320 + t.Run(tt.name, func(t *testing.T) { 321 + got := newReviewRequest(tt.movie, tt.rating, now) 322 + tt.check(t, got) 323 + }) 324 + } 325 + } 326 + 327 + func TestNewExperienceRequest(t *testing.T) { 328 + tests := []struct { 329 + name string 330 + movie Movie 331 + check func(*testing.T, experienceRequest) 332 + }{ 333 + { 334 + name: "full movie", 335 + movie: Movie{ID: 1, Title: "Alpha", PosterPath: "/p.jpg", BackdropPath: "/b.jpg", ImdbID: "tt001"}, 336 + check: func(t *testing.T, r experienceRequest) { 337 + if r.Status != "experienced" { 338 + t.Errorf("Status = %q", r.Status) 339 + } 340 + if r.Title != "Alpha" { 341 + t.Errorf("Title = %q", r.Title) 342 + } 343 + if r.Identifiers["tmdbId"] != "1" { 344 + t.Errorf("tmdbId = %q", r.Identifiers["tmdbId"]) 345 + } 346 + if r.PosterURL != "https://image.tmdb.org/t/p/w500/p.jpg" { 347 + t.Errorf("PosterURL = %q", r.PosterURL) 348 + } 349 + }, 350 + }, 351 + { 352 + name: "minimal movie", 353 + movie: Movie{ID: 2, Title: "Beta"}, 354 + check: func(t *testing.T, r experienceRequest) { 355 + if r.PosterURL != "" { 356 + t.Errorf("PosterURL = %q, want empty", r.PosterURL) 357 + } 358 + if r.BackdropURL != "" { 359 + t.Errorf("BackdropURL = %q, want empty", r.BackdropURL) 360 + } 361 + }, 362 + }, 363 + } 364 + 365 + for _, tt := range tests { 366 + t.Run(tt.name, func(t *testing.T) { 367 + got := newExperienceRequest(tt.movie) 368 + tt.check(t, got) 369 + }) 370 + } 371 + } 372 + 373 + func TestFormatReleaseDate(t *testing.T) { 374 + tests := []struct { 375 + name string 376 + date string 377 + want string 378 + }{ 379 + {name: "empty", date: "", want: ""}, 380 + {name: "valid date", date: "2024-06-15", want: "2024-06-15T00:00:00.000Z"}, 381 + {name: "valid date with single digits", date: "2024-01-05", want: "2024-01-05T00:00:00.000Z"}, 382 + {name: "unparseable returns as-is", date: "unknown", want: "unknown"}, 383 + {name: "partial date returns as-is", date: "2024", want: "2024"}, 384 + } 385 + 386 + for _, tt := range tests { 387 + t.Run(tt.name, func(t *testing.T) { 388 + if got := formatReleaseDate(tt.date); got != tt.want { 389 + t.Errorf("formatReleaseDate(%q) = %q, want %q", tt.date, got, tt.want) 390 + } 391 + }) 392 + } 393 + }
+127
store.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/csv" 5 + "fmt" 6 + "log/slog" 7 + "os" 8 + "strconv" 9 + ) 10 + 11 + // Storer persists entry data. 12 + type Storer interface { 13 + ReadEntries(path string) ([]Entry, error) 14 + WriteEntries(path string, entries []Entry) error 15 + LoadSkipSet(path string) (map[int]struct{}, error) 16 + } 17 + 18 + // csvStorer implements Storer with CSV files. 19 + type csvStorer struct{} 20 + 21 + func newCSVStorer() csvStorer { return csvStorer{} } 22 + 23 + // parseCSV reads all records from a CSV file. 24 + func (csvStorer) parseCSV(path string) ([][]string, error) { 25 + f, err := os.Open(path) 26 + if err != nil { 27 + return nil, err 28 + } 29 + defer f.Close() 30 + return csv.NewReader(f).ReadAll() 31 + } 32 + 33 + // LoadSkipSet returns the set of movie IDs in the first column of a CSV file. 34 + // Returns an empty set if the file does not exist. 35 + func (s csvStorer) LoadSkipSet(path string) (map[int]struct{}, error) { 36 + records, err := s.parseCSV(path) 37 + if os.IsNotExist(err) { 38 + return nil, nil 39 + } 40 + if err != nil { 41 + return nil, err 42 + } 43 + 44 + skip := make(map[int]struct{}, len(records)) 45 + for i, row := range records { 46 + if i == 0 { 47 + continue 48 + } 49 + if len(row) == 0 { 50 + continue 51 + } 52 + id, err := strconv.Atoi(row[0]) 53 + if err != nil { 54 + continue 55 + } 56 + skip[id] = struct{}{} 57 + } 58 + return skip, nil 59 + } 60 + 61 + // ReadEntries reads all entries from a CSV file. Returns an empty slice if 62 + // the file does not exist; returns an error for any other I/O failure. 63 + func (s csvStorer) ReadEntries(path string) ([]Entry, error) { 64 + records, err := s.parseCSV(path) 65 + if os.IsNotExist(err) { 66 + return nil, nil 67 + } 68 + if err != nil { 69 + return nil, fmt.Errorf("read entries: %w", err) 70 + } 71 + 72 + var entries []Entry 73 + for _, row := range records { 74 + if len(row) < 1 { 75 + continue 76 + } 77 + id, err := strconv.Atoi(row[0]) 78 + if err != nil { 79 + continue 80 + } 81 + e := Entry{ID: id} 82 + if len(row) >= 2 && row[1] != "" { 83 + rating, parseErr := strconv.Atoi(row[1]) 84 + if parseErr != nil { 85 + slog.Warn("parse rating", "id", id, "value", row[1], "err", parseErr) 86 + } else { 87 + e.Rating = rating 88 + } 89 + } 90 + if len(row) >= 3 { 91 + e.Seen = row[2] == "true" 92 + } else if e.Rating > 0 { 93 + e.Seen = true 94 + } 95 + entries = append(entries, e) 96 + } 97 + return entries, nil 98 + } 99 + 100 + // WriteEntries overwrites path with entries in tmdb_id,rating,seen format. 101 + func (csvStorer) WriteEntries(path string, entries []Entry) error { 102 + f, err := os.Create(path) 103 + if err != nil { 104 + return err 105 + } 106 + defer f.Close() 107 + 108 + w := csv.NewWriter(f) 109 + 110 + if err := w.Write([]string{"tmdb_id", "rating", "seen"}); err != nil { 111 + return err 112 + } 113 + for _, e := range entries { 114 + ratingStr := "" 115 + if e.Rating > 0 { 116 + ratingStr = strconv.Itoa(e.Rating) 117 + } 118 + if err := w.Write([]string{strconv.Itoa(e.ID), ratingStr, strconv.FormatBool(e.Seen)}); err != nil { 119 + return err 120 + } 121 + } 122 + w.Flush() 123 + if err := w.Error(); err != nil { 124 + return err 125 + } 126 + return f.Sync() 127 + }
+106
store_test.go
··· 1 + package main 2 + 3 + import ( 4 + "maps" 5 + "os" 6 + "reflect" 7 + "testing" 8 + ) 9 + 10 + func TestCSVStorer_LoadSkipSet(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + csv string 14 + want map[int]struct{} 15 + }{ 16 + {name: "empty", csv: "tmdb_id,rating,seen\n", want: map[int]struct{}{}}, 17 + {name: "single", csv: "tmdb_id,rating,seen\n123,8,true\n", want: map[int]struct{}{123: {}}}, 18 + {name: "multiple", csv: "tmdb_id,rating,seen\n1,,\n2,5,true\n", want: map[int]struct{}{1: {}, 2: {}}}, 19 + {name: "skips header", csv: "tmdb_id,rating,seen\n1,,\n", want: map[int]struct{}{1: {}}}, 20 + {name: "missing file", csv: "", want: map[int]struct{}{}}, 21 + } 22 + for _, tt := range tests { 23 + t.Run(tt.name, func(t *testing.T) { 24 + var path string 25 + if tt.csv == "" { 26 + path = "/nonexistent" 27 + } else { 28 + path = writeTempFile(t, tt.csv) 29 + } 30 + store := newCSVStorer() 31 + got, err := store.LoadSkipSet(path) 32 + if err != nil { 33 + t.Fatal(err) 34 + } 35 + if !maps.Equal(got, tt.want) { 36 + t.Errorf("got %v, want %v", got, tt.want) 37 + } 38 + }) 39 + } 40 + } 41 + 42 + func TestCSVStorer_ReadWriteRoundtrip(t *testing.T) { 43 + tests := []struct { 44 + name string 45 + entries []Entry 46 + }{ 47 + {name: "empty", entries: nil}, 48 + {name: "seen with rating", entries: []Entry{{ID: 1, Rating: 8, Seen: true}}}, 49 + {name: "unseen", entries: []Entry{{ID: 1}}}, 50 + {name: "seen no rating", entries: []Entry{{ID: 1, Seen: true}}}, 51 + {name: "multiple", entries: []Entry{ 52 + {ID: 1, Rating: 7, Seen: true}, 53 + {ID: 2, Seen: false}, 54 + {ID: 3, Rating: 9, Seen: true}, 55 + }}, 56 + } 57 + for _, tt := range tests { 58 + t.Run(tt.name, func(t *testing.T) { 59 + f := tempFilePath(t) 60 + store := newCSVStorer() 61 + 62 + if err := store.WriteEntries(f, tt.entries); err != nil { 63 + t.Fatal(err) 64 + } 65 + got, err := store.ReadEntries(f) 66 + if err != nil { 67 + t.Fatal(err) 68 + } 69 + if tt.entries == nil && got != nil { 70 + t.Fatalf("want nil, got %v", got) 71 + } 72 + if tt.entries == nil { 73 + return 74 + } 75 + if !reflect.DeepEqual(tt.entries, got) { 76 + t.Errorf("mismatch:\n want %+v\n got %+v", tt.entries, got) 77 + } 78 + }) 79 + } 80 + } 81 + 82 + func writeTempFile(t *testing.T, content string) string { 83 + t.Helper() 84 + f, err := os.CreateTemp("", "popback-test-*.csv") 85 + if err != nil { 86 + t.Fatal(err) 87 + } 88 + t.Cleanup(func() { os.Remove(f.Name()) }) 89 + if _, err := f.WriteString(content); err != nil { 90 + f.Close() 91 + t.Fatal(err) 92 + } 93 + f.Close() 94 + return f.Name() 95 + } 96 + 97 + func tempFilePath(t *testing.T) string { 98 + t.Helper() 99 + f, err := os.CreateTemp("", "popback-test-*.csv") 100 + if err != nil { 101 + t.Fatal(err) 102 + } 103 + f.Close() 104 + t.Cleanup(func() { os.Remove(f.Name()) }) 105 + return f.Name() 106 + }
+329
tmdb.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/url" 11 + "runtime/debug" 12 + "strconv" 13 + "strings" 14 + "time" 15 + 16 + "golang.org/x/time/rate" 17 + ) 18 + 19 + type httpDoer interface { 20 + Do(*http.Request) (*http.Response, error) 21 + } 22 + 23 + // rateLimitedDoer wraps an httpDoer and rate-limits requests via a shared token bucket. 24 + type rateLimitedDoer struct { 25 + doer httpDoer 26 + limiter *rate.Limiter 27 + } 28 + 29 + // WithRateLimit wraps d with a rate limiter. The same limiter can be shared 30 + // across multiple doers to enforce a single global rate. If limiter is nil, d 31 + // is returned unwrapped. 32 + func WithRateLimit(d httpDoer, limiter *rate.Limiter) httpDoer { 33 + if limiter == nil { 34 + return d 35 + } 36 + return &rateLimitedDoer{doer: d, limiter: limiter} 37 + } 38 + 39 + func (r *rateLimitedDoer) Do(req *http.Request) (*http.Response, error) { 40 + if err := r.limiter.Wait(req.Context()); err != nil { 41 + return nil, err 42 + } 43 + return r.doer.Do(req) 44 + } 45 + 46 + type MovieFetcher interface { 47 + FetchMovieDetails(ctx context.Context, id int) (*Movie, error) 48 + } 49 + 50 + const ( 51 + tmdbBaseURL = "https://api.themoviedb.org" 52 + tmdbTimeout = 10 * time.Second 53 + maxPages = 50 54 + ) 55 + 56 + var tmdbLists = []string{"top_rated", "popular", "now_playing", "upcoming"} 57 + 58 + // TMDBError carries the HTTP status code from a failed TMDB API response. 59 + type TMDBError struct { 60 + Path string 61 + StatusCode int 62 + Status string 63 + Body string 64 + } 65 + 66 + func (e *TMDBError) Error() string { 67 + return fmt.Sprintf("tmdb %s: %s: %s", e.Path, e.Status, e.Body) 68 + } 69 + 70 + type tmdbClient struct { 71 + apiKey string 72 + bearerToken string 73 + http httpDoer 74 + } 75 + 76 + func newTMDBClient(key string) tmdbClient { 77 + c := tmdbClient{http: &http.Client{Timeout: tmdbTimeout}} 78 + if strings.HasPrefix(key, "eyJ") { 79 + c.bearerToken = key 80 + } else { 81 + c.apiKey = key 82 + } 83 + return c 84 + } 85 + 86 + type tmdbRawMovie struct { 87 + ID int `json:"id"` 88 + Title string `json:"title"` 89 + ReleaseDate string `json:"release_date"` 90 + VoteAverage float64 `json:"vote_average"` 91 + GenreIDs []int `json:"genre_ids"` 92 + } 93 + 94 + type tmdbMovieDetail struct { 95 + ID int `json:"id"` 96 + Title string `json:"title"` 97 + ReleaseDate string `json:"release_date"` 98 + VoteAverage float64 `json:"vote_average"` 99 + Genres []struct { 100 + ID int `json:"id"` 101 + Name string `json:"name"` 102 + } `json:"genres"` 103 + PosterPath string `json:"poster_path"` 104 + BackdropPath string `json:"backdrop_path"` 105 + Credits *tmdbCredits `json:"credits,omitempty"` 106 + ExternalIDs *tmdbExternalIDs `json:"external_ids,omitempty"` 107 + } 108 + 109 + type tmdbExternalIDs struct { 110 + ImdbID string `json:"imdb_id"` 111 + } 112 + 113 + type tmdbCredits struct { 114 + Crew []struct { 115 + Job string `json:"job"` 116 + Name string `json:"name"` 117 + } `json:"crew"` 118 + } 119 + 120 + func (c tmdbClient) get(ctx context.Context, path string, query map[string]string) (*http.Response, error) { 121 + u, err := url.Parse(tmdbBaseURL + path) 122 + if err != nil { 123 + return nil, fmt.Errorf("build URL: %w", err) 124 + } 125 + q := u.Query() 126 + for k, v := range query { 127 + q.Set(k, v) 128 + } 129 + if c.apiKey != "" { 130 + q.Set("api_key", c.apiKey) 131 + } 132 + u.RawQuery = q.Encode() 133 + 134 + req, err := http.NewRequest(http.MethodGet, u.String(), nil) 135 + if err != nil { 136 + return nil, err 137 + } 138 + req.Header.Set("User-Agent", userAgent()) 139 + if c.bearerToken != "" { 140 + req.Header.Set("Authorization", "Bearer "+c.bearerToken) 141 + } 142 + 143 + resp, err := c.http.Do(req) 144 + if err != nil { 145 + return nil, err 146 + } 147 + if resp.StatusCode != http.StatusOK { 148 + body, _ := io.ReadAll(resp.Body) 149 + resp.Body.Close() 150 + return nil, &TMDBError{Path: path, StatusCode: resp.StatusCode, Status: resp.Status, Body: strings.TrimSpace(string(body))} 151 + } 152 + return resp, nil 153 + } 154 + 155 + func fetchGenreMap(ctx context.Context, c tmdbClient) (map[int]string, error) { 156 + resp, err := c.get(ctx, "/3/genre/movie/list", nil) 157 + if err != nil { 158 + return nil, err 159 + } 160 + defer resp.Body.Close() 161 + var result struct { 162 + Genres []struct { 163 + ID int `json:"id"` 164 + Name string `json:"name"` 165 + } `json:"genres"` 166 + } 167 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 168 + return nil, err 169 + } 170 + m := make(map[int]string, len(result.Genres)) 171 + for _, g := range result.Genres { 172 + m[g.ID] = g.Name 173 + } 174 + return m, nil 175 + } 176 + 177 + func fetchRawPage(ctx context.Context, c tmdbClient, list string, page int) ([]tmdbRawMovie, bool, error) { 178 + resp, err := c.get(ctx, "/3/movie/"+list, map[string]string{"page": strconv.Itoa(page)}) 179 + if err != nil { 180 + return nil, false, err 181 + } 182 + defer resp.Body.Close() 183 + var result struct { 184 + Results []tmdbRawMovie `json:"results"` 185 + } 186 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 187 + return nil, false, err 188 + } 189 + if len(result.Results) == 0 { 190 + return nil, false, nil 191 + } 192 + return result.Results, true, nil 193 + } 194 + 195 + type tmdbMovieStream struct { 196 + client tmdbClient 197 + seen map[int]struct{} 198 + listIdx int 199 + pageIdx int 200 + exhausted bool 201 + 202 + genresOnce bool 203 + genresMap map[int]string 204 + genresErr error 205 + } 206 + 207 + func newTMDBMovieStream(client tmdbClient, skip map[int]struct{}) *tmdbMovieStream { 208 + seen := make(map[int]struct{}, len(skip)) 209 + for id := range skip { 210 + seen[id] = struct{}{} 211 + } 212 + return &tmdbMovieStream{client: client, seen: seen} 213 + } 214 + 215 + // FetchNextPage returns up to a page of unseen movies from TMDB. 216 + // Returns (nil, nil) when the stream is exhausted. 217 + func (s *tmdbMovieStream) FetchNextPage(ctx context.Context) ([]Movie, error) { 218 + if !s.genresOnce { 219 + s.genresOnce = true 220 + s.genresMap, s.genresErr = fetchGenreMap(ctx, s.client) 221 + } 222 + if s.genresErr != nil { 223 + s.genresOnce = false 224 + return nil, s.genresErr 225 + } 226 + 227 + const perFetch = 20 228 + var movies []Movie 229 + 230 + for len(movies) < perFetch && s.listIdx < len(tmdbLists) { 231 + list := tmdbLists[s.listIdx] 232 + s.pageIdx++ 233 + raw, hasMore, err := fetchRawPage(ctx, s.client, list, s.pageIdx) 234 + if err != nil { 235 + return nil, fmt.Errorf("fetch %s page %d: %w", list, s.pageIdx, err) 236 + } 237 + for _, r := range raw { 238 + if _, ok := s.seen[r.ID]; ok { 239 + continue 240 + } 241 + if len(movies) >= perFetch { 242 + break 243 + } 244 + s.seen[r.ID] = struct{}{} 245 + movies = append(movies, Movie{ 246 + ID: r.ID, 247 + Title: r.Title, 248 + Year: releaseYear(r.ReleaseDate), 249 + Score: r.VoteAverage, 250 + Genres: lookupGenres(r.GenreIDs, s.genresMap), 251 + }) 252 + } 253 + if !hasMore { 254 + s.listIdx++ 255 + s.pageIdx = 0 256 + } 257 + } 258 + 259 + if len(movies) == 0 { 260 + s.exhausted = true 261 + return nil, nil 262 + } 263 + return movies, nil 264 + } 265 + func (c tmdbClient) FetchMovieDetails(ctx context.Context, id int) (*Movie, error) { 266 + path := fmt.Sprintf("/3/movie/%d?append_to_response=credits,external_ids", id) 267 + resp, err := c.get(ctx, path, nil) 268 + if err != nil { 269 + var tmdbErr *TMDBError 270 + if errors.As(err, &tmdbErr) && tmdbErr.StatusCode == http.StatusNotFound { 271 + return nil, nil 272 + } 273 + return nil, err 274 + } 275 + defer resp.Body.Close() 276 + var detail tmdbMovieDetail 277 + if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { 278 + return nil, fmt.Errorf("decode movie %d: %w", id, err) 279 + } 280 + m := &Movie{ 281 + ID: detail.ID, 282 + Title: detail.Title, 283 + Year: releaseYear(detail.ReleaseDate), 284 + Score: detail.VoteAverage, 285 + PosterPath: detail.PosterPath, 286 + BackdropPath: detail.BackdropPath, 287 + ReleaseDate: detail.ReleaseDate, 288 + } 289 + for _, g := range detail.Genres { 290 + m.Genres = append(m.Genres, g.Name) 291 + } 292 + if detail.Credits != nil { 293 + for _, c := range detail.Credits.Crew { 294 + if c.Job == "Director" { 295 + m.Director = c.Name 296 + break 297 + } 298 + } 299 + } 300 + if detail.ExternalIDs != nil { 301 + m.ImdbID = detail.ExternalIDs.ImdbID 302 + } 303 + return m, nil 304 + } 305 + 306 + func lookupGenres(ids []int, genres map[int]string) []string { 307 + var out []string 308 + for _, id := range ids { 309 + if name, ok := genres[id]; ok { 310 + out = append(out, name) 311 + } 312 + } 313 + return out 314 + } 315 + 316 + func releaseYear(date string) string { 317 + if date == "" { 318 + return "????" 319 + } 320 + return strings.SplitN(date, "-", 2)[0] 321 + } 322 + 323 + func userAgent() string { 324 + bi, ok := debug.ReadBuildInfo() 325 + if ok { 326 + return bi.Main.Path 327 + } 328 + return "popback" 329 + }
+293
tmdb_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "reflect" 11 + "slices" 12 + "testing" 13 + ) 14 + 15 + func TestReleaseYear(t *testing.T) { 16 + tests := []struct { 17 + date string 18 + want string 19 + }{ 20 + {date: "2024-03-15", want: "2024"}, 21 + {date: "", want: "????"}, 22 + {date: "1999-01-01", want: "1999"}, 23 + } 24 + for _, tt := range tests { 25 + got := releaseYear(tt.date) 26 + if got != tt.want { 27 + t.Errorf("releaseYear(%q) = %q, want %q", tt.date, got, tt.want) 28 + } 29 + } 30 + } 31 + 32 + func TestLookupGenres(t *testing.T) { 33 + m := map[int]string{18: "Drama", 28: "Action", 35: "Comedy"} 34 + tests := []struct { 35 + name string 36 + ids []int 37 + want []string 38 + }{ 39 + {name: "empty", ids: nil, want: nil}, 40 + {name: "known genres", ids: []int{18, 35}, want: []string{"Drama", "Comedy"}}, 41 + {name: "unknown genre", ids: []int{999}, want: nil}, 42 + {name: "mixed", ids: []int{28, 999, 18}, want: []string{"Action", "Drama"}}, 43 + } 44 + for _, tt := range tests { 45 + t.Run(tt.name, func(t *testing.T) { 46 + got := lookupGenres(tt.ids, m) 47 + if !slices.Equal(got, tt.want) { 48 + t.Errorf("got %v, want %v", got, tt.want) 49 + } 50 + }) 51 + } 52 + } 53 + 54 + func TestNewTMDBClient(t *testing.T) { 55 + tests := []struct { 56 + name string 57 + key string 58 + wantAK string 59 + wantBT string 60 + }{ 61 + {name: "api key", key: "abc123", wantAK: "abc123", wantBT: ""}, 62 + {name: "bearer token", key: "eyJhbGciOiJIUzI1NiJ9.xxx", wantAK: "", wantBT: "eyJhbGciOiJIUzI1NiJ9.xxx"}, 63 + } 64 + for _, tt := range tests { 65 + t.Run(tt.name, func(t *testing.T) { 66 + c := newTMDBClient(tt.key) 67 + if c.apiKey != tt.wantAK || c.bearerToken != tt.wantBT { 68 + t.Errorf("newTMDBClient(%q) = {apiKey: %q, bearerToken: %q}, want {%q, %q}", 69 + tt.key, c.apiKey, c.bearerToken, tt.wantAK, tt.wantBT) 70 + } 71 + }) 72 + } 73 + } 74 + 75 + // httpResponse builds an *http.Response with the given status and JSON body. 76 + func httpResponse(t *testing.T, status int, v any) *http.Response { 77 + t.Helper() 78 + data, err := json.Marshal(v) 79 + if err != nil { 80 + t.Fatal(err) 81 + } 82 + return &http.Response{ 83 + StatusCode: status, 84 + Body: io.NopCloser(bytes.NewReader(data)), 85 + Header: make(http.Header), 86 + } 87 + } 88 + 89 + type mockDoer struct { 90 + responses []*http.Response 91 + index int 92 + } 93 + 94 + func (m *mockDoer) Do(req *http.Request) (*http.Response, error) { 95 + if m.index >= len(m.responses) { 96 + return nil, fmt.Errorf("unexpected call %d", m.index) 97 + } 98 + resp := m.responses[m.index] 99 + m.index++ 100 + return resp, nil 101 + } 102 + 103 + func TestFetchMovieDetails(t *testing.T) { 104 + tests := []struct { 105 + name string 106 + id int 107 + resp *http.Response 108 + want *Movie 109 + wantErr bool 110 + }{ 111 + { 112 + name: "full movie", 113 + id: 123, 114 + resp: httpResponse(t, http.StatusOK, map[string]any{ 115 + "id": 123, 116 + "title": "Test Movie", 117 + "release_date": "2024-01-01", 118 + "vote_average": 8.5, 119 + "poster_path": "/p.jpg", 120 + "backdrop_path": "/b.jpg", 121 + "genres": []any{map[string]any{"id": 18, "name": "Drama"}}, 122 + "credits": map[string]any{"crew": []any{map[string]any{"job": "Director", "name": "Alice"}}}, 123 + "external_ids": map[string]any{"imdb_id": "tt123"}, 124 + }), 125 + want: &Movie{ 126 + ID: 123, Title: "Test Movie", Year: "2024", Score: 8.5, 127 + PosterPath: "/p.jpg", BackdropPath: "/b.jpg", ReleaseDate: "2024-01-01", 128 + Genres: []string{"Drama"}, Director: "Alice", ImdbID: "tt123", 129 + }, 130 + }, 131 + { 132 + name: "minimal movie", 133 + id: 456, 134 + resp: httpResponse(t, http.StatusOK, map[string]any{ 135 + "id": 456, 136 + "title": "Minimal", 137 + }), 138 + want: &Movie{ID: 456, Title: "Minimal", Year: "????"}, 139 + }, 140 + { 141 + name: "not found returns nil nil", 142 + id: 999, 143 + resp: &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(bytes.NewReader(nil)), Header: make(http.Header)}, 144 + want: nil, 145 + wantErr: false, 146 + }, 147 + { 148 + name: "server error", 149 + id: 500, 150 + resp: &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewReader(nil)), Header: make(http.Header)}, 151 + want: nil, wantErr: true, 152 + }, 153 + { 154 + name: "bad JSON", 155 + id: 999, 156 + resp: httpResponse(t, http.StatusOK, "not json"), 157 + want: nil, wantErr: true, 158 + }, 159 + } 160 + for _, tt := range tests { 161 + t.Run(tt.name, func(t *testing.T) { 162 + c := tmdbClient{http: &mockDoer{responses: []*http.Response{tt.resp}}} 163 + got, err := c.FetchMovieDetails(context.Background(), tt.id) 164 + if (err != nil) != tt.wantErr { 165 + t.Fatalf("FetchMovieDetails() = %v, wantErr %v", err, tt.wantErr) 166 + } 167 + if !moviesEqual(got, tt.want) { 168 + t.Errorf("FetchMovieDetails() = %+v, want %+v", got, tt.want) 169 + } 170 + }) 171 + } 172 + } 173 + 174 + func moviesEqual(a, b *Movie) bool { 175 + if a == nil && b == nil { 176 + return true 177 + } 178 + if a == nil || b == nil { 179 + return false 180 + } 181 + return a.ID == b.ID && a.Title == b.Title && a.Year == b.Year && 182 + a.Score == b.Score && a.Director == b.Director && 183 + a.ReleaseDate == b.ReleaseDate && a.ImdbID == b.ImdbID && 184 + a.PosterPath == b.PosterPath && a.BackdropPath == b.BackdropPath && 185 + slices.Equal(a.Genres, b.Genres) 186 + } 187 + 188 + func TestFetchNextPage(t *testing.T) { 189 + tests := []struct { 190 + name string 191 + seen map[int]struct{} 192 + genres map[int]string 193 + genrErr error 194 + setup func() *mockDoer 195 + want []Movie 196 + wantErr bool 197 + wantExh bool 198 + }{ 199 + { 200 + name: "genre error", 201 + genres: nil, 202 + genrErr: fmt.Errorf("api down"), 203 + wantErr: true, 204 + }, 205 + { 206 + name: "fetches one page", 207 + seen: map[int]struct{}{}, 208 + genres: map[int]string{18: "Drama"}, 209 + setup: func() *mockDoer { 210 + page1 := httpResponse(t, http.StatusOK, map[string]any{ 211 + "results": []any{ 212 + map[string]any{"id": 1, "title": "A", "release_date": "2023-01-01", "vote_average": 7.0, "genre_ids": []int{18}}, 213 + map[string]any{"id": 2, "title": "B", "release_date": "2023-02-01", "vote_average": 8.0}, 214 + }, 215 + }) 216 + return &mockDoer{ 217 + responses: []*http.Response{page1, emptyResp(), emptyResp(), emptyResp(), emptyResp()}, 218 + } 219 + }, 220 + want: []Movie{ 221 + {ID: 1, Title: "A", Year: "2023", Score: 7.0, Genres: []string{"Drama"}}, 222 + {ID: 2, Title: "B", Year: "2023", Score: 8.0}, 223 + }, 224 + wantExh: false, 225 + }, 226 + { 227 + name: "skips seen movies", 228 + seen: map[int]struct{}{1: {}}, 229 + genres: map[int]string{}, 230 + setup: func() *mockDoer { 231 + page1 := httpResponse(t, http.StatusOK, map[string]any{ 232 + "results": []any{ 233 + map[string]any{"id": 1, "title": "A", "release_date": "2023-01-01", "vote_average": 7.0}, 234 + map[string]any{"id": 2, "title": "B", "release_date": "2023-02-01", "vote_average": 8.0}, 235 + }, 236 + }) 237 + return &mockDoer{ 238 + responses: []*http.Response{page1, emptyResp(), emptyResp(), emptyResp(), emptyResp(), emptyResp()}, 239 + } 240 + }, 241 + want: []Movie{ 242 + {ID: 2, Title: "B", Year: "2023", Score: 8.0}, 243 + }, 244 + wantExh: false, 245 + }, 246 + { 247 + name: "exhausted", 248 + seen: map[int]struct{}{}, 249 + genres: map[int]string{}, 250 + setup: func() *mockDoer { 251 + return &mockDoer{ 252 + responses: []*http.Response{emptyResp(), emptyResp(), emptyResp(), emptyResp()}, 253 + } 254 + }, 255 + want: nil, 256 + wantExh: true, 257 + }, 258 + } 259 + for _, tt := range tests { 260 + t.Run(tt.name, func(t *testing.T) { 261 + var client tmdbClient 262 + if tt.setup != nil { 263 + client = tmdbClient{http: tt.setup()} 264 + } 265 + stream := newTMDBMovieStream(client, tt.seen) 266 + stream.genresOnce = true 267 + stream.genresMap = tt.genres 268 + stream.genresErr = tt.genrErr 269 + 270 + got, err := stream.FetchNextPage(context.Background()) 271 + if (err != nil) != tt.wantErr { 272 + t.Fatalf("FetchNextPage() = %v, wantErr %v", err, tt.wantErr) 273 + } 274 + if err != nil { 275 + return 276 + } 277 + if !reflect.DeepEqual(got, tt.want) { 278 + t.Errorf("FetchNextPage() = %+v, want %+v", got, tt.want) 279 + } 280 + if stream.exhausted != tt.wantExh { 281 + t.Errorf("exhausted = %v, want %v", stream.exhausted, tt.wantExh) 282 + } 283 + }) 284 + } 285 + } 286 + 287 + func emptyResp() *http.Response { 288 + return &http.Response{ 289 + StatusCode: http.StatusOK, 290 + Body: io.NopCloser(bytes.NewReader([]byte(`{"results":[]}`))), 291 + Header: make(http.Header), 292 + } 293 + }
+221
viewed.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "sort" 8 + "strconv" 9 + "strings" 10 + "time" 11 + 12 + tea "github.com/charmbracelet/bubbletea" 13 + "github.com/charmbracelet/lipgloss" 14 + ) 15 + 16 + type tuiRunner interface { 17 + Run(stream *tmdbMovieStream, ctx context.Context) (results map[int]Entry, quit bool, err error) 18 + } 19 + 20 + type realTUIRunner struct{} 21 + 22 + func (realTUIRunner) Run(stream *tmdbMovieStream, ctx context.Context) (map[int]Entry, bool, error) { 23 + p := tea.NewProgram(newTUIModel(stream, ctx)) 24 + result, err := p.Run() 25 + if err != nil { 26 + return nil, false, fmt.Errorf("TUI: %w", err) 27 + } 28 + model, ok := result.(tuiModel) 29 + if !ok { 30 + return nil, false, fmt.Errorf("unexpected TUI result type: %T", result) 31 + } 32 + return model.results, model.quitting, nil 33 + } 34 + 35 + var boldStyle = lipgloss.NewStyle().Bold(true) 36 + 37 + type moviesMsg []Movie 38 + 39 + type fetchErrMsg struct{ err error } 40 + 41 + type tuiModel struct { 42 + ctx context.Context 43 + stream *tmdbMovieStream 44 + movies []Movie 45 + pos int // -1 until first movie arrives 46 + results map[int]Entry 47 + fetching bool 48 + exhausted bool 49 + quitting bool 50 + err error 51 + } 52 + 53 + func newTUIModel(stream *tmdbMovieStream, ctx context.Context) tuiModel { 54 + return tuiModel{ 55 + ctx: ctx, 56 + stream: stream, 57 + pos: -1, 58 + results: make(map[int]Entry), 59 + fetching: true, 60 + } 61 + } 62 + 63 + func (m tuiModel) Init() tea.Cmd { 64 + return m.fetchCmd() 65 + } 66 + 67 + func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 68 + switch msg := msg.(type) { 69 + case moviesMsg: 70 + m.fetching = false 71 + if len(msg) == 0 { 72 + m.exhausted = true 73 + if m.pos < 0 { 74 + return m, tea.Quit 75 + } 76 + return m, nil 77 + } 78 + m.movies = append(m.movies, msg...) 79 + if m.pos < 0 { 80 + m.pos = 0 81 + } 82 + return m, nil 83 + 84 + case fetchErrMsg: 85 + m.fetching = false 86 + m.err = msg.err 87 + return m, tea.Quit 88 + 89 + case tea.KeyMsg: 90 + key := msg.String() 91 + if key == "q" || key == "ctrl+c" { 92 + m.quitting = true 93 + return m, tea.Quit 94 + } 95 + if m.pos < 0 || m.pos >= len(m.movies) { 96 + return m, nil 97 + } 98 + if key == "b" { 99 + if m.pos > 0 { 100 + m.pos-- 101 + } 102 + return m, nil 103 + } 104 + updated, ok := m.handleAction(key) 105 + if !ok { 106 + return m, nil 107 + } 108 + return updated.advanceCmd() 109 + } 110 + return m, nil 111 + } 112 + 113 + func (m tuiModel) advanceCmd() (tuiModel, tea.Cmd) { 114 + m.pos++ 115 + 116 + if m.pos >= len(m.movies) { 117 + if m.exhausted { 118 + return m, tea.Quit 119 + } 120 + if !m.fetching { 121 + m.fetching = true 122 + return m, m.fetchCmd() 123 + } 124 + return m, nil 125 + } 126 + 127 + if !m.fetching && !m.exhausted && len(m.movies)-m.pos < 10 { 128 + m.fetching = true 129 + return m, m.fetchCmd() 130 + } 131 + return m, nil 132 + } 133 + 134 + func (m tuiModel) fetchCmd() tea.Cmd { 135 + return func() tea.Msg { 136 + ctx, cancel := context.WithTimeout(m.ctx, 10*time.Second) 137 + defer cancel() 138 + movies, err := m.stream.FetchNextPage(ctx) 139 + if err != nil { 140 + return fetchErrMsg{err} 141 + } 142 + return moviesMsg(movies) 143 + } 144 + } 145 + 146 + func (m tuiModel) handleAction(key string) (tuiModel, bool) { 147 + id := m.movies[m.pos].ID 148 + 149 + switch key { 150 + case "n": 151 + m.results[id] = Entry{ID: id, Seen: false} 152 + return m, true 153 + case "s": 154 + m.results[id] = Entry{ID: id, Seen: true} 155 + return m, true 156 + case "0": 157 + m.results[id] = Entry{ID: id, Rating: 10, Seen: true} 158 + return m, true 159 + default: 160 + r, err := strconv.Atoi(key) 161 + if err != nil || r < 1 || r > 9 { 162 + return m, false 163 + } 164 + m.results[id] = Entry{ID: id, Rating: r, Seen: true} 165 + return m, true 166 + } 167 + } 168 + 169 + func (m tuiModel) View() string { 170 + if m.err != nil { 171 + return fmt.Sprintf("\n Error: %v\n\n", m.err) 172 + } 173 + if m.pos >= 0 && m.pos < len(m.movies) { 174 + mv := m.movies[m.pos] 175 + return fmt.Sprintf( 176 + "\n %s (%s)\n %.1f ★ · %s\n\n [0=10,1-9] Rate [s] Seen [n] Skip [b] Back [q] Quit\n %d / %d\n\n", 177 + boldStyle.Render(mv.Title), mv.Year, mv.Score, strings.Join(mv.Genres, ", "), 178 + m.pos+1, len(m.movies), 179 + ) 180 + } 181 + if m.fetching { 182 + return "\n Loading...\n\n" 183 + } 184 + if m.exhausted { 185 + return fmt.Sprintf("\n No more movies · rated %d\n\n", len(m.results)) 186 + } 187 + return "\n" 188 + } 189 + 190 + func runViewed(ctx context.Context, tmdb tmdbClient, store Storer, tui tuiRunner, output string) error { 191 + skip, err := store.LoadSkipSet(output) 192 + if err != nil { 193 + return fmt.Errorf("load skip set: %w", err) 194 + } 195 + stream := newTMDBMovieStream(tmdb, skip) 196 + 197 + results, _, err := tui.Run(stream, ctx) 198 + if err != nil { 199 + return err 200 + } 201 + if len(results) == 0 { 202 + return nil 203 + } 204 + 205 + newEntries := make([]Entry, 0, len(results)) 206 + for _, e := range results { 207 + newEntries = append(newEntries, e) 208 + } 209 + sort.Slice(newEntries, func(i, j int) bool { return newEntries[i].ID < newEntries[j].ID }) 210 + 211 + existing, err := store.ReadEntries(output) 212 + if err != nil { 213 + return fmt.Errorf("read existing entries: %w", err) 214 + } 215 + existing = append(existing, newEntries...) 216 + if err := store.WriteEntries(output, existing); err != nil { 217 + return fmt.Errorf("write CSV: %w", err) 218 + } 219 + slog.Info("wrote entries", "count", len(newEntries), "output", output) 220 + return nil 221 + }
+281
viewed_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + "testing" 8 + 9 + tea "github.com/charmbracelet/bubbletea" 10 + ) 11 + 12 + func TestHandleAction(t *testing.T) { 13 + tests := []struct { 14 + name string 15 + movies []Movie 16 + pos int 17 + results map[int]Entry 18 + key string 19 + wantOK bool 20 + wantEntry Entry 21 + }{ 22 + { 23 + name: "skip with n", 24 + movies: []Movie{{ID: 1, Title: "Alpha"}}, 25 + pos: 0, 26 + results: map[int]Entry{}, 27 + key: "n", 28 + wantOK: true, 29 + wantEntry: Entry{ID: 1, Seen: false}, 30 + }, 31 + { 32 + name: "seen with s", 33 + movies: []Movie{{ID: 2, Title: "Beta"}}, 34 + pos: 0, 35 + results: map[int]Entry{}, 36 + key: "s", 37 + wantOK: true, 38 + wantEntry: Entry{ID: 2, Seen: true}, 39 + }, 40 + { 41 + name: "rate with 8", 42 + movies: []Movie{{ID: 3, Title: "Gamma"}}, 43 + pos: 0, 44 + results: map[int]Entry{}, 45 + key: "8", 46 + wantOK: true, 47 + wantEntry: Entry{ID: 3, Rating: 8, Seen: true}, 48 + }, 49 + { 50 + name: "rate 1", 51 + movies: []Movie{{ID: 4, Title: "Delta"}}, 52 + pos: 0, 53 + results: map[int]Entry{}, 54 + key: "1", 55 + wantOK: true, 56 + wantEntry: Entry{ID: 4, Rating: 1, Seen: true}, 57 + }, 58 + { 59 + name: "rate 9", 60 + movies: []Movie{{ID: 5, Title: "Epsilon"}}, 61 + pos: 0, 62 + results: map[int]Entry{}, 63 + key: "9", 64 + wantOK: true, 65 + wantEntry: Entry{ID: 5, Rating: 9, Seen: true}, 66 + }, 67 + { 68 + name: "zero maps to rating 10", 69 + movies: []Movie{{ID: 6, Title: "Zero"}}, 70 + pos: 0, 71 + results: map[int]Entry{}, 72 + key: "0", 73 + wantOK: true, 74 + wantEntry: Entry{ID: 6, Rating: 10, Seen: true}, 75 + }, 76 + { 77 + name: "invalid key returns false", 78 + movies: []Movie{{ID: 6, Title: "Zeta"}}, 79 + pos: 0, 80 + results: map[int]Entry{}, 81 + key: "x", 82 + wantOK: false, 83 + }, 84 + { 85 + name: "empty key not accepted", 86 + movies: []Movie{{ID: 7, Title: "Theta"}}, 87 + pos: 0, 88 + results: map[int]Entry{}, 89 + key: "", 90 + wantOK: false, 91 + }, 92 + { 93 + name: "overwrite existing result", 94 + movies: []Movie{{ID: 9, Title: "Iota"}}, 95 + pos: 0, 96 + results: map[int]Entry{9: {ID: 9, Seen: false}}, 97 + key: "s", 98 + wantOK: true, 99 + wantEntry: Entry{ID: 9, Seen: true}, 100 + }, 101 + { 102 + name: "multiple movies, second position", 103 + movies: []Movie{{ID: 10, Title: "Kappa"}, {ID: 11, Title: "Lambda"}}, 104 + pos: 1, 105 + results: map[int]Entry{}, 106 + key: "5", 107 + wantOK: true, 108 + wantEntry: Entry{ID: 11, Rating: 5, Seen: true}, 109 + }, 110 + } 111 + 112 + for _, tt := range tests { 113 + t.Run(tt.name, func(t *testing.T) { 114 + m := tuiModel{ 115 + movies: tt.movies, 116 + pos: tt.pos, 117 + results: tt.results, 118 + } 119 + got, ok := m.handleAction(tt.key) 120 + if ok != tt.wantOK { 121 + t.Fatalf("handleAction(%q) = %v, wantOK %v", tt.key, ok, tt.wantOK) 122 + } 123 + if !ok { 124 + return 125 + } 126 + gotEntry, exists := got.results[tt.movies[tt.pos].ID] 127 + if !exists { 128 + t.Fatal("result not stored for movie id") 129 + } 130 + if gotEntry != tt.wantEntry { 131 + t.Errorf("result = %+v, want %+v", gotEntry, tt.wantEntry) 132 + } 133 + }) 134 + } 135 + } 136 + 137 + func TestAdvance(t *testing.T) { 138 + tests := []struct { 139 + name string 140 + model tuiModel 141 + wantPos int 142 + wantQuit bool 143 + wantFetch bool 144 + }{ 145 + { 146 + name: "advances within movies", 147 + model: tuiModel{ 148 + movies: []Movie{{ID: 1}, {ID: 2}, {ID: 3}}, 149 + pos: 0, 150 + exhausted: true, 151 + }, 152 + wantPos: 1, 153 + }, 154 + { 155 + name: "hits end exhausted quits", 156 + model: tuiModel{ 157 + movies: []Movie{{ID: 1}}, 158 + pos: 0, 159 + exhausted: true, 160 + }, 161 + wantPos: 1, 162 + wantQuit: true, 163 + }, 164 + { 165 + name: "hits end while fetching waits", 166 + model: tuiModel{ 167 + movies: []Movie{{ID: 1}}, 168 + pos: 0, 169 + fetching: true, 170 + }, 171 + wantPos: 1, 172 + }, 173 + { 174 + name: "exhausted skips pre-fetch", 175 + model: tuiModel{ 176 + movies: []Movie{{ID: 1}, {ID: 2}}, 177 + pos: 0, 178 + exhausted: true, 179 + }, 180 + wantPos: 1, 181 + }, 182 + { 183 + name: "fetching skips pre-fetch", 184 + model: tuiModel{ 185 + movies: []Movie{{ID: 1}, {ID: 2}}, 186 + pos: 0, 187 + fetching: true, 188 + }, 189 + wantPos: 1, 190 + }, 191 + } 192 + for _, tt := range tests { 193 + t.Run(tt.name, func(t *testing.T) { 194 + nm, cmd := tt.model.advanceCmd() 195 + if nm.pos != tt.wantPos { 196 + t.Errorf("pos = %d, want %d", nm.pos, tt.wantPos) 197 + } 198 + gotQuit := cmd != nil && fmt.Sprintf("%p", cmd) == fmt.Sprintf("%p", tea.Quit) 199 + if gotQuit != tt.wantQuit { 200 + t.Errorf("quit = %v, want %v", gotQuit, tt.wantQuit) 201 + } 202 + gotFetch := cmd != nil && !gotQuit 203 + if gotFetch != tt.wantFetch { 204 + t.Errorf("fetch cmd = %v, want %v", gotFetch, tt.wantFetch) 205 + } 206 + }) 207 + } 208 + } 209 + 210 + func TestRunViewed(t *testing.T) { 211 + tests := []struct { 212 + name string 213 + skipSet map[int]struct{} 214 + skipErr error 215 + tuiResults map[int]Entry 216 + tuiErr error 217 + tuiQuit bool 218 + existing []Entry 219 + wantErr bool 220 + wantWrite []Entry 221 + }{ 222 + { 223 + name: "success with ratings", 224 + skipSet: map[int]struct{}{}, 225 + tuiResults: map[int]Entry{1: {ID: 1, Rating: 8, Seen: true}, 2: {ID: 2, Rating: 5, Seen: true}}, 226 + tuiQuit: true, 227 + wantWrite: []Entry{ 228 + {ID: 1, Rating: 8, Seen: true}, 229 + {ID: 2, Rating: 5, Seen: true}, 230 + }, 231 + }, 232 + { 233 + name: "no movies fetched", 234 + tuiResults: nil, 235 + tuiQuit: true, 236 + wantWrite: nil, 237 + }, 238 + { 239 + name: "load skip set error", 240 + skipErr: fmt.Errorf("file error"), 241 + wantErr: true, 242 + }, 243 + { 244 + name: "tui error", 245 + tuiErr: fmt.Errorf("tui error"), 246 + tuiResults: nil, 247 + wantErr: true, 248 + }, 249 + } 250 + for _, tt := range tests { 251 + t.Run(tt.name, func(t *testing.T) { 252 + store := &mockStorer{ 253 + entries: tt.existing, 254 + skipSet: tt.skipSet, 255 + skipErr: tt.skipErr, 256 + } 257 + tui := &mockTUIRunner{ 258 + results: tt.tuiResults, 259 + err: tt.tuiErr, 260 + quit: tt.tuiQuit, 261 + } 262 + err := runViewed(context.Background(), tmdbClient{}, store, tui, "test.csv") 263 + if (err != nil) != tt.wantErr { 264 + t.Fatalf("runViewed() = %v, wantErr %v", err, tt.wantErr) 265 + } 266 + if !slices.Equal(store.WrittenEntries, tt.wantWrite) { 267 + t.Errorf("written entries = %+v, want %+v", store.WrittenEntries, tt.wantWrite) 268 + } 269 + }) 270 + } 271 + } 272 + 273 + type mockTUIRunner struct { 274 + results map[int]Entry 275 + err error 276 + quit bool 277 + } 278 + 279 + func (m *mockTUIRunner) Run(_ *tmdbMovieStream, _ context.Context) (map[int]Entry, bool, error) { 280 + return m.results, m.quit, m.err 281 + }