watchwhere 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ ww init
44
44
  ## commands
45
45
 
46
46
  ```
47
- ww <title> search and show providers
47
+ ww <title> search a movie, show, or person
48
48
  ww init set up region, subscriptions (and token, if not using proxy)
49
49
  ww subs edit subscriptions
50
50
  ww lang change UI language (en / tr)
@@ -54,6 +54,21 @@ ww --help
54
54
  ww --version
55
55
  ```
56
56
 
57
+ ## search by people
58
+
59
+ sometimes you don't have a title in mind, you have a person. type a name:
60
+
61
+ ```bash
62
+ ww paul thomas anderson # films he directed
63
+ ww daniel day-lewis # films he's in
64
+ ```
65
+
66
+ pick the person from the list and you get their films that are on your subs,
67
+ in your region, first. one lookup, no clicking through each app. hit "see full
68
+ filmography" to browse everything they did.
69
+
70
+ directors get the films they directed, actors get the ones they acted in.
71
+
57
72
  ## notes
58
73
 
59
74
  - config lives in `~/.watchwhere/config.json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchwhere",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "region-aware streaming availability CLI — find which of your subs has a given title",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,262 @@
1
+ import {
2
+ createPrompt,
3
+ isDownKey,
4
+ isEnterKey,
5
+ isUpKey,
6
+ makeTheme,
7
+ useKeypress,
8
+ usePagination,
9
+ usePrefix,
10
+ useRef,
11
+ useState,
12
+ } from "@inquirer/core";
13
+ import { cursorHide } from "@inquirer/ansi";
14
+ import { c } from "./colors.ts";
15
+
16
+ const GG_TIMEOUT_MS = 500;
17
+
18
+ export interface CheckboxChoice<Value> {
19
+ readonly name: string;
20
+ readonly value: Value;
21
+ readonly checked?: boolean;
22
+ }
23
+
24
+ export interface CheckboxHints {
25
+ readonly navigate: string;
26
+ readonly jump: string;
27
+ readonly toggle: string;
28
+ readonly search: string;
29
+ readonly filter: string;
30
+ readonly apply: string;
31
+ readonly clear: string;
32
+ readonly save: string;
33
+ readonly cancel: string;
34
+ readonly searchLabel: string;
35
+ readonly filterLabel: string;
36
+ readonly selectedCount: (n: number) => string;
37
+ readonly noMatch: string;
38
+ }
39
+
40
+ export interface CheckboxConfig<Value> {
41
+ readonly message: string;
42
+ readonly choices: ReadonlyArray<CheckboxChoice<Value>>;
43
+ readonly pageSize?: number;
44
+ readonly hints: CheckboxHints;
45
+ }
46
+
47
+ const checkboxTheme = {
48
+ prefix: { idle: c.green("?"), done: c.green("✓") },
49
+ spinner: { interval: 80, frames: ["⠋"] },
50
+ style: {
51
+ answer: (text: string) => c.cyan(text),
52
+ message: (text: string) => c.bold(text),
53
+ error: (text: string) => c.red(text),
54
+ defaultAnswer: (text: string) => c.dim(text),
55
+ help: (text: string) => c.dim(text),
56
+ highlight: (text: string) => c.cyan(text),
57
+ key: (text: string) => c.cyan(text),
58
+ description: (text: string) => c.cyan(text),
59
+ disabled: (text: string) => c.dim(text),
60
+ },
61
+ helpMode: "always" as const,
62
+ };
63
+
64
+ // printable single character that should land in the search query.
65
+ // excludes space (reserved for toggle in nav mode) and control bytes.
66
+ function isTypeableChar(seq: string | undefined): seq is string {
67
+ return (
68
+ typeof seq === "string" &&
69
+ seq.length === 1 &&
70
+ seq !== " " &&
71
+ seq.charCodeAt(0) >= 0x20 &&
72
+ seq.charCodeAt(0) !== 0x7f
73
+ );
74
+ }
75
+
76
+ type Mode = "nav" | "search";
77
+
78
+ const promptImpl = createPrompt<unknown[] | null, CheckboxConfig<unknown>>(
79
+ (config, done) => {
80
+ const pageSize = config.pageSize ?? 12;
81
+ const theme = makeTheme(checkboxTheme, undefined);
82
+ const h = config.hints;
83
+ const [status, setStatus] = useState<"idle" | "done">("idle");
84
+ const [cancelled, setCancelled] = useState(false);
85
+ const prefix = usePrefix({ status, theme });
86
+
87
+ const all = config.choices;
88
+ const [mode, setMode] = useState<Mode>("nav");
89
+ const [query, setQuery] = useState("");
90
+ const [active, setActive] = useState(0);
91
+ const lastGAt = useRef(0);
92
+ // selection as a Set replaced on every change so state updates fire
93
+ const [selected, setSelected] = useState<ReadonlySet<unknown>>(
94
+ () => new Set(all.filter((ch) => ch.checked).map((ch) => ch.value)),
95
+ );
96
+
97
+ const needle = query.toLowerCase();
98
+ const filtered = needle
99
+ ? all.filter((ch) => ch.name.toLowerCase().includes(needle))
100
+ : all;
101
+
102
+ const clampedActive = Math.min(active, Math.max(0, filtered.length - 1));
103
+
104
+ const moveUp = () => setActive(clampedActive > 0 ? clampedActive - 1 : 0);
105
+ const moveDown = () =>
106
+ setActive(
107
+ clampedActive < filtered.length - 1 ? clampedActive + 1 : clampedActive,
108
+ );
109
+
110
+ useKeypress((key) => {
111
+ const seq = (key as unknown as { sequence?: string }).sequence;
112
+
113
+ // ---- search mode: keystrokes build the query ----
114
+ if (mode === "search") {
115
+ if (key.name === "escape") {
116
+ // clear the search and return to navigation
117
+ setQuery("");
118
+ setActive(0);
119
+ setMode("nav");
120
+ return;
121
+ }
122
+ if (isEnterKey(key)) {
123
+ // apply the filter, keep it, go navigate the matches
124
+ setMode("nav");
125
+ return;
126
+ }
127
+ if (key.name === "backspace") {
128
+ if (query.length > 0) {
129
+ setQuery(query.slice(0, -1));
130
+ setActive(0);
131
+ } else {
132
+ setMode("nav");
133
+ }
134
+ return;
135
+ }
136
+ // arrows still move the cursor while searching
137
+ if (isUpKey(key, [])) return moveUp();
138
+ if (isDownKey(key, [])) return moveDown();
139
+ if (isTypeableChar(seq)) {
140
+ setQuery(query + seq);
141
+ setActive(0);
142
+ }
143
+ return;
144
+ }
145
+
146
+ // ---- nav mode: vim-style movement, space toggles, / searches ----
147
+ if (key.name === "escape") {
148
+ setCancelled(true);
149
+ setStatus("done");
150
+ done(null);
151
+ return;
152
+ }
153
+ if (isEnterKey(key)) {
154
+ setStatus("done");
155
+ // preserve original choice order in the result
156
+ done(all.filter((ch) => selected.has(ch.value)).map((ch) => ch.value));
157
+ return;
158
+ }
159
+ if (seq === "/") {
160
+ setMode("search");
161
+ return;
162
+ }
163
+ if (key.name === "space" || seq === " ") {
164
+ const item = filtered[clampedActive];
165
+ if (item) {
166
+ const next = new Set(selected);
167
+ if (next.has(item.value)) next.delete(item.value);
168
+ else next.add(item.value);
169
+ setSelected(next);
170
+ }
171
+ return;
172
+ }
173
+ if (seq === "G") {
174
+ setActive(Math.max(0, filtered.length - 1));
175
+ return;
176
+ }
177
+ if (seq === "g") {
178
+ const now = Date.now();
179
+ if (now - lastGAt.current < GG_TIMEOUT_MS) {
180
+ setActive(0);
181
+ lastGAt.current = 0;
182
+ } else {
183
+ lastGAt.current = now;
184
+ }
185
+ return;
186
+ }
187
+ if (isUpKey(key, []) || seq === "k") return moveUp();
188
+ if (isDownKey(key, []) || seq === "j") return moveDown();
189
+ });
190
+
191
+ const message = theme.style.message(config.message);
192
+ const countStr = theme.style.help(h.selectedCount(selected.size));
193
+
194
+ if (status === "done") {
195
+ if (cancelled) return "";
196
+ return [prefix, message, theme.style.answer(h.selectedCount(selected.size))]
197
+ .filter(Boolean)
198
+ .join(" ");
199
+ }
200
+
201
+ const page = usePagination({
202
+ items: filtered,
203
+ active: clampedActive,
204
+ renderItem({ item, isActive }) {
205
+ const isChecked = selected.has(item.value);
206
+ const box = isChecked ? c.green("◉") : c.dim("◯");
207
+ const cursor = isActive ? c.cyan(">") : " ";
208
+ const label = isActive ? theme.style.highlight(item.name) : item.name;
209
+ return `${cursor} ${box} ${label}`;
210
+ },
211
+ pageSize,
212
+ loop: false,
213
+ });
214
+
215
+ // status line: search box while typing, applied-filter hint while navigating
216
+ let statusLine: string;
217
+ if (mode === "search") {
218
+ statusLine = ` ${c.dim(h.searchLabel)} ${query}${c.cyan("▏")} ${countStr}`;
219
+ } else if (query) {
220
+ statusLine = ` ${c.dim(h.filterLabel)} ${c.cyan(query)} ${countStr}`;
221
+ } else {
222
+ statusLine = ` ${countStr}`;
223
+ }
224
+
225
+ const body = filtered.length === 0 ? ` ${c.dim(h.noMatch)}` : page;
226
+
227
+ const keys: ReadonlyArray<readonly [string, string]> =
228
+ mode === "search"
229
+ ? [
230
+ ["↑↓", h.navigate],
231
+ ["a-z", h.filter],
232
+ ["⏎", h.apply],
233
+ ["esc", h.clear],
234
+ ]
235
+ : [
236
+ ["↑↓ jk", h.navigate],
237
+ ["gg/G", h.jump],
238
+ ["space", h.toggle],
239
+ ["/", h.search],
240
+ ["⏎", h.save],
241
+ ["esc", h.cancel],
242
+ ];
243
+ const helpLine = keys
244
+ .map(([k, a]) => `${c.bold(k)} ${c.dim(a)}`)
245
+ .join(c.dim(" • "));
246
+
247
+ return (
248
+ [[prefix, message].filter(Boolean).join(" "), statusLine, body, helpLine]
249
+ .filter((line) => line !== "")
250
+ .join("\n") + cursorHide
251
+ );
252
+ },
253
+ );
254
+
255
+ export async function filterableCheckbox<Value>(
256
+ config: CheckboxConfig<Value>,
257
+ ): Promise<Value[] | null> {
258
+ const result = (await promptImpl(
259
+ config as CheckboxConfig<unknown>,
260
+ )) as Value[] | null;
261
+ return result;
262
+ }
@@ -1,5 +1,6 @@
1
- import { checkbox, input, password } from "@inquirer/prompts";
1
+ import { input, password } from "@inquirer/prompts";
2
2
  import { c } from "../colors.ts";
3
+ import { filterableCheckbox } from "../checkbox.ts";
3
4
  import { loadConfig, saveConfig } from "../config.ts";
4
5
  import { resolveLocale, t } from "../i18n.ts";
5
6
  import { getCachedRegionProviders } from "../cache.ts";
@@ -72,13 +73,35 @@ export async function runInit(): Promise<void> {
72
73
  checked: existing?.subscriptions.includes(p.provider_id) ?? false,
73
74
  }));
74
75
 
75
- const subscriptions = await checkbox<number>({
76
- message: `${m.yourSubsLabel(region)} ${c.dim(m.toggleHintConfirm)}:`,
76
+ const subscriptions = await filterableCheckbox<number>({
77
+ message: `${m.yourSubsLabel(region)}:`,
77
78
  choices,
78
79
  pageSize: 15,
79
- loop: false,
80
+ hints: {
81
+ navigate: m.hintNavigate,
82
+ jump: m.hintJump,
83
+ toggle: m.hintToggle,
84
+ search: m.hintSearch,
85
+ filter: m.hintFilter,
86
+ apply: m.hintApply,
87
+ clear: m.hintClear,
88
+ save: m.hintSave,
89
+ cancel: m.hintCancel,
90
+ searchLabel: m.searchLabel,
91
+ filterLabel: m.filterLabel,
92
+ selectedCount: m.selectedCount,
93
+ noMatch: m.noFilterMatch,
94
+ },
80
95
  });
81
96
 
97
+ // esc at the subs step cancels init entirely — never overwrite an
98
+ // existing config with an empty subscription list.
99
+ if (subscriptions === null) {
100
+ console.log();
101
+ console.log(` ${c.dim(m.cancelled)}`);
102
+ return;
103
+ }
104
+
82
105
  const saved = await saveConfig({
83
106
  tmdbToken: tmdbToken.trim(),
84
107
  region,
@@ -4,10 +4,17 @@ import { resolveLocale, t } from "../i18n.ts";
4
4
  import { picker } from "../picker.ts";
5
5
  import { editableInput } from "../prompts.ts";
6
6
  import {
7
+ discoverPersonFilms,
8
+ getPersonCredits,
7
9
  getReleaseDates,
8
10
  getWatchProviders,
9
11
  searchAll,
12
+ searchPeople,
13
+ toFilmography,
14
+ type CreditRole,
10
15
  type MediaItem,
16
+ type PersonDepartment,
17
+ type PersonItem,
11
18
  type TmdbProvider,
12
19
  type TmdbReleaseDate,
13
20
  } from "../tmdb.ts";
@@ -15,6 +22,19 @@ import {
15
22
  const PAGE_SIZE = 10;
16
23
  const SOFT_LIMIT = 25;
17
24
  const OVERVIEW_MAX = 140;
25
+ const OLDEST_FILM_YEAR = 1888;
26
+
27
+ function parseQuery(input: string): { query: string; year?: number } {
28
+ const match = input.match(/^(.+?)\s+(\d{4})$/);
29
+ if (match && match[1] && match[2]) {
30
+ const year = Number.parseInt(match[2], 10);
31
+ const maxYear = new Date().getFullYear() + 2;
32
+ if (year >= OLDEST_FILM_YEAR && year <= maxYear) {
33
+ return { query: match[1].trim(), year };
34
+ }
35
+ }
36
+ return { query: input };
37
+ }
18
38
 
19
39
  function year(date: string | null): string {
20
40
  return date && date.length >= 4 ? date.slice(0, 4) : "????";
@@ -84,25 +104,101 @@ function printPhysical(
84
104
  }
85
105
  }
86
106
 
87
- async function pickItem(
88
- results: ReadonlyArray<MediaItem>,
107
+ type SearchHit =
108
+ | { readonly kind: "title"; readonly item: MediaItem }
109
+ | { readonly kind: "person"; readonly person: PersonItem };
110
+
111
+ // keep incidental name-collisions from flooding the list: searching "matrix"
112
+ // otherwise surfaces dozens of obscure people whose first name is Matrix.
113
+ const MAX_INCIDENTAL_PEOPLE = 5;
114
+
115
+ function normalize(s: string): string {
116
+ return s
117
+ .normalize("NFD")
118
+ .replace(/\p{Diacritic}/gu, "")
119
+ .toLowerCase()
120
+ .replace(/[\s._-]+/g, " ")
121
+ .trim();
122
+ }
123
+
124
+ // ordering: people whose full name you typed exactly lead (you clearly meant
125
+ // the person), then titles in their own relevance order, then a few incidental
126
+ // people whose name merely contains the query. titles and people sit on
127
+ // different popularity scales, so the two are never compared directly.
128
+ function rankHits(
129
+ titles: ReadonlyArray<MediaItem>,
130
+ people: ReadonlyArray<PersonItem>,
131
+ query: string,
132
+ ): ReadonlyArray<SearchHit> {
133
+ const q = normalize(query);
134
+ const isExactName = (p: PersonItem): boolean => normalize(p.name) === q;
135
+ const named = people.filter(isExactName);
136
+ const incidental = people
137
+ .filter((p) => !isExactName(p))
138
+ .slice(0, MAX_INCIDENTAL_PEOPLE);
139
+ return [
140
+ ...named.map((person): SearchHit => ({ kind: "person", person })),
141
+ ...titles.map((item): SearchHit => ({ kind: "title", item })),
142
+ ...incidental.map((person): SearchHit => ({ kind: "person", person })),
143
+ ];
144
+ }
145
+
146
+ function deptLabel(d: PersonDepartment, m: ReturnType<typeof t>): string {
147
+ if (d === "directing") return m.personDirector;
148
+ if (d === "acting") return m.personActor;
149
+ return m.personCrew;
150
+ }
151
+
152
+ function roleTag(role: CreditRole, m: ReturnType<typeof t>): string {
153
+ return c.dim(`[${role === "directing" ? m.roleDir : m.roleAct}]`);
154
+ }
155
+
156
+ function titleChoiceName(mi: MediaItem, m: ReturnType<typeof t>): string {
157
+ const orig = mi.title !== mi.originalTitle ? c.dim(` — ${mi.originalTitle}`) : "";
158
+ return `${tag(mi.mediaType, m)} ${mi.title} ${c.dim(`(${year(mi.date)})`)}${orig}`;
159
+ }
160
+
161
+ function personChoiceName(p: PersonItem, m: ReturnType<typeof t>): string {
162
+ const meta = [deptLabel(p.department, m), ...p.knownFor.slice(0, 2)]
163
+ .filter((s) => s !== "")
164
+ .join(" · ");
165
+ return `${c.dim(`[${m.tagPerson}]`)} ${p.name} ${c.dim(`(${meta})`)}`;
166
+ }
167
+
168
+ function filmChoiceName(item: MediaItem, m: ReturnType<typeof t>): string {
169
+ const prefix = item.role ? `${roleTag(item.role, m)} ` : "";
170
+ const orig =
171
+ item.originalTitle && item.title !== item.originalTitle
172
+ ? c.dim(` — ${item.originalTitle}`)
173
+ : "";
174
+ return `${prefix}${item.title} ${c.dim(`(${year(item.date)})`)}${orig}`;
175
+ }
176
+
177
+ async function pickHit(
178
+ hits: ReadonlyArray<SearchHit>,
89
179
  m: ReturnType<typeof t>,
90
- ): Promise<MediaItem | null> {
91
- const visible = results.slice(0, SOFT_LIMIT);
92
- const dropped = results.length - visible.length;
93
-
94
- const choices = visible.map((mi) => ({
95
- name: `${tag(mi.mediaType, m)} ${mi.title} ${c.dim(`(${year(mi.date)})`)}${mi.title !== mi.originalTitle ? c.dim(` — ${mi.originalTitle}`) : ""}`,
96
- value: mi,
97
- description: mi.overview.slice(0, OVERVIEW_MAX),
180
+ ): Promise<SearchHit | null> {
181
+ const visible = hits.slice(0, SOFT_LIMIT);
182
+ const dropped = hits.length - visible.length;
183
+
184
+ const choices = visible.map((hit) => ({
185
+ name:
186
+ hit.kind === "person"
187
+ ? personChoiceName(hit.person, m)
188
+ : titleChoiceName(hit.item, m),
189
+ value: hit,
190
+ description:
191
+ hit.kind === "person"
192
+ ? hit.person.knownFor.join(", ")
193
+ : hit.item.overview.slice(0, OVERVIEW_MAX),
98
194
  }));
99
195
 
100
196
  const headerParts: string[] = [m.whichOne];
101
197
  if (dropped > 0) {
102
- headerParts.push(c.dim(`(${visible.length}/${results.length})`));
198
+ headerParts.push(c.dim(`(${visible.length}/${hits.length})`));
103
199
  }
104
200
 
105
- return picker<MediaItem>({
201
+ return picker<SearchHit>({
106
202
  message: headerParts.join(" "),
107
203
  pageSize: PAGE_SIZE,
108
204
  choices,
@@ -216,19 +312,184 @@ async function displayItem(
216
312
  }
217
313
  }
218
314
 
315
+ type FilmChoice =
316
+ | { readonly kind: "film"; readonly item: MediaItem }
317
+ | { readonly kind: "full" }
318
+ | { readonly kind: "subs" };
319
+
320
+ async function pickFilm(
321
+ films: ReadonlyArray<MediaItem>,
322
+ total: number,
323
+ header: string,
324
+ action: { readonly value: FilmChoice; readonly label: string } | null,
325
+ m: ReturnType<typeof t>,
326
+ ): Promise<FilmChoice | null> {
327
+ const visible = films.slice(0, SOFT_LIMIT);
328
+ // `total` can exceed films.length when the source is paginated (discover
329
+ // returns one page); never report fewer than we actually hold.
330
+ const grandTotal = Math.max(total, films.length);
331
+
332
+ const choices = visible.map((item) => ({
333
+ name: filmChoiceName(item, m),
334
+ value: { kind: "film", item } as FilmChoice,
335
+ description: item.overview.slice(0, OVERVIEW_MAX),
336
+ }));
337
+ if (action) {
338
+ choices.push({ name: c.dim(`› ${action.label}`), value: action.value, description: "" });
339
+ }
340
+
341
+ const headerParts: string[] = [header];
342
+ if (grandTotal > visible.length) {
343
+ headerParts.push(c.dim(`(${visible.length}/${grandTotal})`));
344
+ }
345
+
346
+ return picker<FilmChoice>({
347
+ message: headerParts.join(" "),
348
+ pageSize: PAGE_SIZE,
349
+ choices,
350
+ extraKeys: [["esc", "back"]],
351
+ });
352
+ }
353
+
354
+ // a person opens to two views: what's on your subs right now (one discover
355
+ // call, region- and provider-filtered server-side) and their full filmography
356
+ // (combined credits). you can flip between them; picking a film drops into the
357
+ // usual provider breakdown.
358
+ async function runPerson(
359
+ person: PersonItem,
360
+ cfg: Config,
361
+ m: ReturnType<typeof t>,
362
+ ): Promise<void> {
363
+ const hasSubs = cfg.subscriptions.length > 0;
364
+ let view: "subs" | "full" = hasSubs ? "subs" : "full";
365
+ if (!hasSubs) {
366
+ console.log();
367
+ console.log(c.dim(` ${m.personNoSubsConfigured}`));
368
+ }
369
+
370
+ // fetched lazily, then reused as you flip between views.
371
+ let onSubs: { films: ReadonlyArray<MediaItem>; total: number } | null = null;
372
+ let filmography: ReadonlyArray<MediaItem> | null = null;
373
+
374
+ while (true) {
375
+ if (view === "subs") {
376
+ if (onSubs === null) {
377
+ process.stdout.write(c.dim(` ${m.loadingFilms(person.name)}`));
378
+ onSubs = await discoverPersonFilms({
379
+ personId: person.id,
380
+ department: person.department,
381
+ region: cfg.region,
382
+ providerIds: cfg.subscriptions,
383
+ token: cfg.tmdbToken,
384
+ language: cfg.language,
385
+ });
386
+ console.log(c.dim(m.resultsCount(onSubs.films.length)));
387
+ }
388
+ if (onSubs.films.length === 0) {
389
+ console.log();
390
+ console.log(
391
+ ` ${c.yellow("●")} ${m.personSubsEmpty(person.name, cfg.region)}`,
392
+ );
393
+ view = "full";
394
+ continue;
395
+ }
396
+ const picked = await pickFilm(
397
+ onSubs.films,
398
+ onSubs.total,
399
+ m.personSubsTitle(person.name, cfg.region),
400
+ { value: { kind: "full" }, label: m.personSeeFull },
401
+ m,
402
+ );
403
+ if (picked === null) return;
404
+ if (picked.kind === "film") {
405
+ await displayItem(picked.item, cfg, m);
406
+ return;
407
+ }
408
+ view = "full";
409
+ continue;
410
+ }
411
+
412
+ if (filmography === null) {
413
+ process.stdout.write(c.dim(` ${m.loadingFilms(person.name)}`));
414
+ const credits = await getPersonCredits(person.id, cfg.tmdbToken, {
415
+ language: cfg.language,
416
+ });
417
+ filmography = toFilmography(credits, person.department);
418
+ console.log(c.dim(m.resultsCount(filmography.length)));
419
+ }
420
+ if (filmography.length === 0) {
421
+ console.log();
422
+ console.log(` ${m.personNoCredits(person.name)}`);
423
+ return;
424
+ }
425
+ // only offer "back to your subs" when there's actually something there.
426
+ const canReturnToSubs =
427
+ hasSubs && onSubs !== null && onSubs.films.length > 0;
428
+ const picked = await pickFilm(
429
+ filmography,
430
+ filmography.length,
431
+ m.personFullTitle(person.name),
432
+ canReturnToSubs ? { value: { kind: "subs" }, label: m.personSeeSubs } : null,
433
+ m,
434
+ );
435
+ if (picked === null) return;
436
+ if (picked.kind === "film") {
437
+ await displayItem(picked.item, cfg, m);
438
+ return;
439
+ }
440
+ view = "subs";
441
+ continue;
442
+ }
443
+ }
444
+
445
+ async function searchTitles(
446
+ raw: string,
447
+ cfg: Config,
448
+ m: ReturnType<typeof t>,
449
+ ): Promise<ReadonlyArray<MediaItem>> {
450
+ const parsed = parseQuery(raw);
451
+ const displayQ = parsed.year ? `${parsed.query} (${parsed.year})` : parsed.query;
452
+ process.stdout.write(c.dim(` ${m.searching(displayQ)}`));
453
+ const all = await searchAll(parsed.query, cfg.tmdbToken, {
454
+ language: cfg.language,
455
+ });
456
+ const results = parsed.year
457
+ ? all.filter((r) => r.date?.slice(0, 4) === String(parsed.year))
458
+ : all;
459
+ console.log(c.dim(m.resultsCount(results.length)));
460
+ return results;
461
+ }
462
+
463
+ async function searchUnified(
464
+ raw: string,
465
+ cfg: Config,
466
+ m: ReturnType<typeof t>,
467
+ ): Promise<ReadonlyArray<SearchHit>> {
468
+ const parsed = parseQuery(raw);
469
+ const displayQ = parsed.year ? `${parsed.query} (${parsed.year})` : parsed.query;
470
+ process.stdout.write(c.dim(` ${m.searching(displayQ)}`));
471
+ const [titlesAll, people] = await Promise.all([
472
+ searchAll(parsed.query, cfg.tmdbToken, { language: cfg.language }),
473
+ // a trailing year filters titles; people don't have one, so the person
474
+ // lookup ignores it but still runs on the bare query.
475
+ searchPeople(parsed.query, cfg.tmdbToken, { language: cfg.language }),
476
+ ]);
477
+ const titles = parsed.year
478
+ ? titlesAll.filter((r) => r.date?.slice(0, 4) === String(parsed.year))
479
+ : titlesAll;
480
+ const hits = rankHits(titles, people, parsed.query);
481
+ console.log(c.dim(m.resultsCount(hits.length)));
482
+ return hits;
483
+ }
484
+
219
485
  export async function runSearch(query: string, cfg: Config): Promise<void> {
220
486
  const m = t(resolveLocale(cfg.language));
221
487
  let currentQuery = query.trim();
222
488
  if (!currentQuery) throw new Error(m.searchEmpty);
223
489
 
224
- // pipe mode: no prompts
490
+ // pipe mode: deterministic, titles only. person lookups are interactive.
225
491
  if (!process.stdin.isTTY) {
226
- process.stdout.write(c.dim(` ${m.searching(currentQuery)}`));
227
- const results = await searchAll(currentQuery, cfg.tmdbToken, {
228
- region: cfg.region,
229
- language: cfg.language,
230
- });
231
- console.log(c.dim(m.resultsCount(results.length)));
492
+ const results = await searchTitles(currentQuery, cfg, m);
232
493
  if (results.length === 0) throw new Error(m.noMatch);
233
494
  if (results.length > 1) throw new Error(m.ambiguousQuery(results.length));
234
495
  await displayItem(results[0]!, cfg, m);
@@ -236,22 +497,18 @@ export async function runSearch(query: string, cfg: Config): Promise<void> {
236
497
  }
237
498
 
238
499
  while (true) {
239
- process.stdout.write(c.dim(` ${m.searching(currentQuery)}`));
240
- const results = await searchAll(currentQuery, cfg.tmdbToken, {
241
- region: cfg.region,
242
- language: cfg.language,
243
- });
244
- console.log(c.dim(m.resultsCount(results.length)));
245
-
246
- let picked: MediaItem | null = null;
247
- if (results.length > 0) {
248
- picked = await pickItem(results, m);
500
+ const hits = await searchUnified(currentQuery, cfg, m);
501
+
502
+ let picked: SearchHit | null = null;
503
+ if (hits.length > 0) {
504
+ picked = await pickHit(hits, m);
249
505
  } else {
250
506
  console.log(`\n ${m.noMatch}`);
251
507
  }
252
508
 
253
509
  if (picked !== null) {
254
- await displayItem(picked, cfg, m);
510
+ if (picked.kind === "person") await runPerson(picked.person, cfg, m);
511
+ else await displayItem(picked.item, cfg, m);
255
512
  return;
256
513
  }
257
514
 
@@ -1,5 +1,5 @@
1
- import { checkbox } from "@inquirer/prompts";
2
1
  import { c } from "../colors.ts";
2
+ import { filterableCheckbox } from "../checkbox.ts";
3
3
  import { loadConfig, saveConfig } from "../config.ts";
4
4
  import { resolveLocale, t } from "../i18n.ts";
5
5
  import { getCachedRegionProviders } from "../cache.ts";
@@ -26,17 +26,38 @@ export async function runSubs(): Promise<void> {
26
26
 
27
27
  const current = new Set(cfg.subscriptions);
28
28
 
29
- const subscriptions = await checkbox<number>({
30
- message: `${m.yourSubsLabel(cfg.region)} ${c.dim(m.toggleHintSave)}:`,
29
+ const subscriptions = await filterableCheckbox<number>({
30
+ message: `${m.yourSubsLabel(cfg.region)}:`,
31
31
  choices: providers.map((p) => ({
32
32
  name: p.provider_name,
33
33
  value: p.provider_id,
34
34
  checked: current.has(p.provider_id),
35
35
  })),
36
36
  pageSize: 15,
37
- loop: false,
37
+ hints: {
38
+ navigate: m.hintNavigate,
39
+ jump: m.hintJump,
40
+ toggle: m.hintToggle,
41
+ search: m.hintSearch,
42
+ filter: m.hintFilter,
43
+ apply: m.hintApply,
44
+ clear: m.hintClear,
45
+ save: m.hintSave,
46
+ cancel: m.hintCancel,
47
+ searchLabel: m.searchLabel,
48
+ filterLabel: m.filterLabel,
49
+ selectedCount: m.selectedCount,
50
+ noMatch: m.noFilterMatch,
51
+ },
38
52
  });
39
53
 
54
+ // esc → cancel: leave the existing config untouched
55
+ if (subscriptions === null) {
56
+ console.log();
57
+ console.log(` ${c.dim(m.cancelled)}`);
58
+ return;
59
+ }
60
+
40
61
  const added = subscriptions.filter((id) => !current.has(id)).length;
41
62
  const removed = cfg.subscriptions.filter((id) => !subscriptions.includes(id)).length;
42
63
 
package/src/i18n.ts CHANGED
@@ -53,6 +53,22 @@ interface Catalog {
53
53
  rent: string;
54
54
  buy: string;
55
55
 
56
+ // person search / filmography
57
+ tagPerson: string;
58
+ personActor: string;
59
+ personDirector: string;
60
+ personCrew: string;
61
+ roleDir: string;
62
+ roleAct: string;
63
+ loadingFilms: (name: string) => string;
64
+ personSubsTitle: (name: string, region: string) => string;
65
+ personFullTitle: (name: string) => string;
66
+ personSubsEmpty: (name: string, region: string) => string;
67
+ personSeeFull: string;
68
+ personSeeSubs: string;
69
+ personNoCredits: (name: string) => string;
70
+ personNoSubsConfigured: string;
71
+
56
72
  // config (show)
57
73
  noConfigYet: string;
58
74
  resolvingNames: string;
@@ -82,11 +98,24 @@ interface Catalog {
82
98
  providersFound: (n: number) => string;
83
99
  noProviders: (region: string) => string;
84
100
  yourSubsLabel: (region: string) => string;
85
- toggleHintConfirm: string;
86
- toggleHintSave: string;
87
101
  savedTo: string;
88
102
  updatedConfigFile: string;
89
103
 
104
+ // filterable checkbox (subs picker)
105
+ filterLabel: string;
106
+ searchLabel: string;
107
+ selectedCount: (n: number) => string;
108
+ hintNavigate: string;
109
+ hintJump: string;
110
+ hintToggle: string;
111
+ hintSearch: string;
112
+ hintFilter: string;
113
+ hintApply: string;
114
+ hintClear: string;
115
+ hintSave: string;
116
+ hintCancel: string;
117
+ noFilterMatch: string;
118
+
90
119
  // validation
91
120
  corruptConfig: (path: string) => string;
92
121
 
@@ -118,6 +147,7 @@ interface Catalog {
118
147
  tokenExpired: string;
119
148
  networkOffline: string;
120
149
  networkTimeout: string;
150
+ rateLimited: string;
121
151
  relativeNow: string;
122
152
  relativeMinutes: (n: number) => string;
123
153
  relativeHours: (n: number) => string;
@@ -167,6 +197,23 @@ const en: Catalog = {
167
197
  rent: "rent",
168
198
  buy: "buy",
169
199
 
200
+ tagPerson: "person",
201
+ personActor: "actor",
202
+ personDirector: "director",
203
+ personCrew: "crew",
204
+ roleDir: "dir",
205
+ roleAct: "act",
206
+ loadingFilms: (name) => `loading ${name}'s films… `,
207
+ personSubsTitle: (name, region) => `${name} — on your subs in ${region}`,
208
+ personFullTitle: (name) => `${name} — full filmography`,
209
+ personSubsEmpty: (name, region) =>
210
+ `nothing of ${name}'s is on your subs in ${region} — showing full filmography.`,
211
+ personSeeFull: "see full filmography",
212
+ personSeeSubs: "back to your subs",
213
+ personNoCredits: (name) => `no filmography found for ${name}.`,
214
+ personNoSubsConfigured:
215
+ "no subscriptions set yet — showing full filmography. run `ww subs` to add some.",
216
+
170
217
  noConfigYet: "no config yet — run `ww init` first.",
171
218
  resolvingNames: "resolving subscription names… ",
172
219
  configTitle: "watchwhere config",
@@ -196,11 +243,23 @@ const en: Catalog = {
196
243
  noProviders: (region) =>
197
244
  `no providers found for region ${region}. check the code.`,
198
245
  yourSubsLabel: (region) => `your subscriptions in ${region}`,
199
- toggleHintConfirm: "(space to toggle, enter to confirm)",
200
- toggleHintSave: "(space to toggle, enter to save)",
201
246
  savedTo: "saved to",
202
247
  updatedConfigFile: "updated",
203
248
 
249
+ filterLabel: "filter:",
250
+ searchLabel: "search:",
251
+ selectedCount: (n) => `${n} selected`,
252
+ hintNavigate: "navigate",
253
+ hintJump: "top/bottom",
254
+ hintToggle: "toggle",
255
+ hintSearch: "search",
256
+ hintFilter: "filter",
257
+ hintApply: "apply",
258
+ hintClear: "clear",
259
+ hintSave: "save",
260
+ hintCancel: "cancel",
261
+ noFilterMatch: "no match — esc to clear the search",
262
+
204
263
  corruptConfig: (path) => `corrupt config file: ${path}`,
205
264
 
206
265
  languageUpdated: (code) => `language updated to ${code}`,
@@ -213,7 +272,7 @@ const en: Catalog = {
213
272
  usageTagline: "where can I stream it?",
214
273
  usageSectionUsage: "usage",
215
274
  usageSectionConfig: "config",
216
- usageDescTitle: "search a movie or show in your region",
275
+ usageDescTitle: "search a movie, show, or person in your region",
217
276
  usageDescInit: "set up token, region, language, subscriptions",
218
277
  usageDescSubs: "edit your subscriptions only",
219
278
  usageDescLang: "change display language",
@@ -228,6 +287,7 @@ const en: Catalog = {
228
287
  tokenExpired: "TMDB token rejected (401). run `ww init` to re-enter it.",
229
288
  networkOffline: "couldn't reach TMDB. check your internet connection.",
230
289
  networkTimeout: "TMDB request timed out. try again, or check your connection.",
290
+ rateLimited: "rate limit hit. try again in an hour, or set WATCHWHERE_PROXY=off to use your own TMDB token.",
231
291
  relativeNow: "just now",
232
292
  relativeMinutes: (n) => `${n} min ago`,
233
293
  relativeHours: (n) => `${n} hr ago`,
@@ -276,6 +336,23 @@ const tr: Catalog = {
276
336
  rent: "kirala",
277
337
  buy: "satın al",
278
338
 
339
+ tagPerson: "kişi",
340
+ personActor: "oyuncu",
341
+ personDirector: "yönetmen",
342
+ personCrew: "ekip",
343
+ roleDir: "yön",
344
+ roleAct: "oyun",
345
+ loadingFilms: (name) => `${name} filmleri yükleniyor… `,
346
+ personSubsTitle: (name, region) => `${name} — ${region} aboneliklerinde`,
347
+ personFullTitle: (name) => `${name} — tüm filmografi`,
348
+ personSubsEmpty: (name, region) =>
349
+ `${name} için ${region} aboneliklerinde bir şey yok — tüm filmografi gösteriliyor.`,
350
+ personSeeFull: "tüm filmografiyi gör",
351
+ personSeeSubs: "aboneliklerine dön",
352
+ personNoCredits: (name) => `${name} için filmografi bulunamadı.`,
353
+ personNoSubsConfigured:
354
+ "henüz abonelik yok — tüm filmografi gösteriliyor. eklemek için `ww subs`.",
355
+
279
356
  noConfigYet: "henüz config yok — önce `ww init` çalıştır.",
280
357
  resolvingNames: "abonelik isimleri çözümleniyor… ",
281
358
  configTitle: "watchwhere config",
@@ -306,11 +383,23 @@ const tr: Catalog = {
306
383
  noProviders: (region) =>
307
384
  `${region} bölgesi için sağlayıcı bulunamadı. kodu kontrol edin.`,
308
385
  yourSubsLabel: (region) => `${region} bölgesindeki aboneliklerin`,
309
- toggleHintConfirm: "(seçmek için space, onaylamak için enter)",
310
- toggleHintSave: "(seçmek için space, kaydetmek için enter)",
311
386
  savedTo: "kaydedildi:",
312
387
  updatedConfigFile: "güncellendi",
313
388
 
389
+ filterLabel: "filtre:",
390
+ searchLabel: "ara:",
391
+ selectedCount: (n) => `${n} seçili`,
392
+ hintNavigate: "gezin",
393
+ hintJump: "baş/son",
394
+ hintToggle: "seç",
395
+ hintSearch: "ara",
396
+ hintFilter: "filtrele",
397
+ hintApply: "uygula",
398
+ hintClear: "temizle",
399
+ hintSave: "kaydet",
400
+ hintCancel: "iptal",
401
+ noFilterMatch: "eşleşme yok — temizlemek için esc",
402
+
314
403
  corruptConfig: (path) => `bozuk config dosyası: ${path}`,
315
404
 
316
405
  languageUpdated: (code) => `dil ${code} olarak güncellendi`,
@@ -323,7 +412,7 @@ const tr: Catalog = {
323
412
  usageTagline: "nerede izleyebilirim?",
324
413
  usageSectionUsage: "kullanım",
325
414
  usageSectionConfig: "config",
326
- usageDescTitle: "bölgendeki bir film veya diziyi ara",
415
+ usageDescTitle: "bölgendeki bir film, dizi veya kişiyi ara",
327
416
  usageDescInit: "token, bölge, dil ve abonelikleri ayarla",
328
417
  usageDescSubs: "sadece abonelikleri düzenle",
329
418
  usageDescLang: "görüntüleme dilini değiştir",
@@ -338,6 +427,7 @@ const tr: Catalog = {
338
427
  tokenExpired: "TMDB token reddedildi (401). yenilemek için `ww init` çalıştır.",
339
428
  networkOffline: "TMDB'ye ulaşılamadı. internet bağlantını kontrol et.",
340
429
  networkTimeout: "TMDB isteği zaman aşımına uğradı. tekrar dene veya bağlantını kontrol et.",
430
+ rateLimited: "rate limit doldu. bir saat sonra dene veya WATCHWHERE_PROXY=off ile kendi TMDB token'ını kullan.",
341
431
  relativeNow: "şimdi",
342
432
  relativeMinutes: (n) => `${n} dk önce`,
343
433
  relativeHours: (n) => `${n} saat önce`,
package/src/index.ts CHANGED
@@ -162,6 +162,7 @@ main().then(maybeNotifyUpdate).catch(async (err: unknown) => {
162
162
  let displayMsg = err instanceof Error ? err.message : String(err);
163
163
  if (err instanceof TmdbError) {
164
164
  if (err.status === 401) displayMsg = m.tokenExpired;
165
+ else if (err.status === 429) displayMsg = m.rateLimited;
165
166
  else if (err.status === 0 && err.message.includes("timed out")) {
166
167
  displayMsg = m.networkTimeout;
167
168
  } else if (err.status === 0) {
package/src/picker.ts CHANGED
@@ -7,10 +7,14 @@ import {
7
7
  useKeypress,
8
8
  usePagination,
9
9
  usePrefix,
10
+ useRef,
10
11
  useState,
11
12
  } from "@inquirer/core";
13
+ import { cursorHide } from "@inquirer/ansi";
12
14
  import { c } from "./colors.ts";
13
15
 
16
+ const GG_TIMEOUT_MS = 500;
17
+
14
18
  export interface PickerChoice<Value> {
15
19
  readonly name: string;
16
20
  readonly value: Value;
@@ -50,9 +54,14 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
50
54
  const prefix = usePrefix({ status, theme });
51
55
  const items = config.choices;
52
56
  const [active, setActive] = useState(0);
57
+ const lastGAt = useRef(0);
53
58
 
54
59
  useKeypress((key) => {
55
- if (key.name === "escape") {
60
+ // sequence is present at runtime but not in the type; cast to read it
61
+ const seq = (key as unknown as { sequence?: string }).sequence;
62
+
63
+ // esc or q: cancel
64
+ if (key.name === "escape" || seq === "q") {
56
65
  setCancelled(true);
57
66
  setStatus("done");
58
67
  done(null);
@@ -64,11 +73,29 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
64
73
  if (chosen) done(chosen.value);
65
74
  return;
66
75
  }
67
- if (isUpKey(key, [])) {
76
+ // G (shift+g): bottom
77
+ if (seq === "G") {
78
+ setActive(items.length - 1);
79
+ return;
80
+ }
81
+ // gg: top (two g within timeout)
82
+ if (seq === "g") {
83
+ const now = Date.now();
84
+ if (now - lastGAt.current < GG_TIMEOUT_MS) {
85
+ setActive(0);
86
+ lastGAt.current = 0;
87
+ } else {
88
+ lastGAt.current = now;
89
+ }
90
+ return;
91
+ }
92
+ // up or k
93
+ if (isUpKey(key, []) || seq === "k") {
68
94
  if (active > 0) setActive(active - 1);
69
95
  return;
70
96
  }
71
- if (isDownKey(key, [])) {
97
+ // down or j
98
+ if (isDownKey(key, []) || seq === "j") {
72
99
  if (active < items.length - 1) setActive(active + 1);
73
100
  return;
74
101
  }
@@ -98,7 +125,7 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
98
125
 
99
126
  const description = items[active]?.description;
100
127
  const helpLine = [
101
- ["↑↓", "navigate"],
128
+ ["↑↓ jk", "navigate"],
102
129
  ["⏎", "select"],
103
130
  ...(config.extraKeys ?? []),
104
131
  ]
@@ -112,7 +139,7 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
112
139
  helpLine,
113
140
  ]
114
141
  .filter((line) => line !== "")
115
- .join("\n");
142
+ .join("\n") + cursorHide;
116
143
  },
117
144
  );
118
145
 
package/src/tmdb.ts CHANGED
@@ -32,6 +32,13 @@ export interface TmdbTv {
32
32
  readonly popularity: number;
33
33
  }
34
34
 
35
+ // how a person is credited on a title, used to tag filmography entries.
36
+ export type CreditRole = "acting" | "directing";
37
+
38
+ // a person's primary department on TMDB. drives whether we match them as
39
+ // cast or crew when discovering what's streamable.
40
+ export type PersonDepartment = "acting" | "directing" | "other";
41
+
35
42
  export interface MediaItem {
36
43
  readonly id: number;
37
44
  readonly mediaType: MediaType;
@@ -40,6 +47,16 @@ export interface MediaItem {
40
47
  readonly date: string | null;
41
48
  readonly overview: string;
42
49
  readonly popularity: number;
50
+ // set only on filmography results (person lookups); absent for title search.
51
+ readonly role?: CreditRole;
52
+ }
53
+
54
+ export interface PersonItem {
55
+ readonly id: number;
56
+ readonly name: string;
57
+ readonly department: PersonDepartment;
58
+ readonly knownFor: ReadonlyArray<string>;
59
+ readonly popularity: number;
43
60
  }
44
61
 
45
62
  export interface TmdbSearchResponse<T> {
@@ -47,6 +64,48 @@ export interface TmdbSearchResponse<T> {
47
64
  readonly total_results: number;
48
65
  }
49
66
 
67
+ export interface TmdbPerson {
68
+ readonly id: number;
69
+ readonly name: string;
70
+ readonly known_for_department: string | null;
71
+ readonly popularity: number;
72
+ readonly profile_path: string | null;
73
+ readonly known_for?: ReadonlyArray<{
74
+ readonly title?: string;
75
+ readonly name?: string;
76
+ }>;
77
+ }
78
+
79
+ // one entry from /person/{id}/combined_credits. cast entries carry a
80
+ // `character`, crew entries carry a `job` ("Director", "Writer", …).
81
+ export interface TmdbPersonCredit {
82
+ readonly id: number;
83
+ readonly media_type: MediaType;
84
+ readonly title?: string;
85
+ readonly original_title?: string;
86
+ readonly name?: string;
87
+ readonly original_name?: string;
88
+ readonly release_date?: string;
89
+ readonly first_air_date?: string;
90
+ readonly overview?: string;
91
+ readonly popularity?: number;
92
+ readonly character?: string;
93
+ readonly job?: string;
94
+ }
95
+
96
+ export interface TmdbCombinedCreditsResponse {
97
+ readonly id: number;
98
+ readonly cast: ReadonlyArray<TmdbPersonCredit>;
99
+ readonly crew: ReadonlyArray<TmdbPersonCredit>;
100
+ }
101
+
102
+ export interface TmdbDiscoverResponse {
103
+ readonly page: number;
104
+ readonly results: ReadonlyArray<TmdbMovie>;
105
+ readonly total_results: number;
106
+ readonly total_pages: number;
107
+ }
108
+
50
109
  export interface TmdbProvider {
51
110
  readonly provider_id: number;
52
111
  readonly provider_name: string;
@@ -243,6 +302,142 @@ export async function searchAll(
243
302
  return items.sort((a, b) => b.popularity - a.popularity);
244
303
  }
245
304
 
305
+ function toPersonDepartment(knownForDepartment: string | null): PersonDepartment {
306
+ switch ((knownForDepartment ?? "").toLowerCase()) {
307
+ case "acting":
308
+ return "acting";
309
+ case "directing":
310
+ return "directing";
311
+ default:
312
+ return "other";
313
+ }
314
+ }
315
+
316
+ function toPersonItem(p: TmdbPerson): PersonItem {
317
+ const knownFor = (p.known_for ?? [])
318
+ .map((k) => k.title ?? k.name ?? "")
319
+ .filter((s) => s !== "");
320
+ return {
321
+ id: p.id,
322
+ name: p.name,
323
+ department: toPersonDepartment(p.known_for_department),
324
+ knownFor,
325
+ popularity: p.popularity,
326
+ };
327
+ }
328
+
329
+ export async function searchPeople(
330
+ query: string,
331
+ token: string,
332
+ opts: { language?: string } = {},
333
+ ): Promise<ReadonlyArray<PersonItem>> {
334
+ const data = await tmdbFetch<TmdbSearchResponse<TmdbPerson>>(
335
+ "/search/person",
336
+ token,
337
+ {
338
+ query,
339
+ include_adult: "false",
340
+ language: opts.language ?? DEFAULT_TMDB_LANGUAGE,
341
+ },
342
+ );
343
+ assertResultsArray(data, "/search/person");
344
+ return data.results
345
+ .map(toPersonItem)
346
+ .sort((a, b) => b.popularity - a.popularity);
347
+ }
348
+
349
+ function creditToMediaItem(credit: TmdbPersonCredit, role: CreditRole): MediaItem {
350
+ const isMovie = credit.media_type === "movie";
351
+ return {
352
+ id: credit.id,
353
+ mediaType: credit.media_type,
354
+ title: (isMovie ? credit.title : credit.name) ?? "",
355
+ originalTitle: (isMovie ? credit.original_title : credit.original_name) ?? "",
356
+ date: (isMovie ? credit.release_date : credit.first_air_date) ?? null,
357
+ overview: credit.overview ?? "",
358
+ popularity: credit.popularity ?? 0,
359
+ role,
360
+ };
361
+ }
362
+
363
+ export function getPersonCredits(
364
+ id: number,
365
+ token: string,
366
+ opts: { language?: string } = {},
367
+ ): Promise<TmdbCombinedCreditsResponse> {
368
+ return tmdbFetch<TmdbCombinedCreditsResponse>(
369
+ `/person/${id}/combined_credits`,
370
+ token,
371
+ { language: opts.language ?? DEFAULT_TMDB_LANGUAGE },
372
+ );
373
+ }
374
+
375
+ // a person's filmography, scoped to their primary role so the list stays
376
+ // coherent: a director gets the films they directed, an actor gets the films
377
+ // they acted in. people with no clear department get both, and when someone
378
+ // directed and acted in the same title the directing credit wins.
379
+ export function toFilmography(
380
+ credits: TmdbCombinedCreditsResponse,
381
+ department: PersonDepartment,
382
+ ): ReadonlyArray<MediaItem> {
383
+ const byKey = new Map<string, MediaItem>();
384
+ const put = (item: MediaItem): void => {
385
+ const key = `${item.mediaType}:${item.id}`;
386
+ const existing = byKey.get(key);
387
+ // directing overrides a prior acting credit; never the reverse.
388
+ if (!existing || existing.role === "acting") byKey.set(key, item);
389
+ };
390
+ if (department !== "directing") {
391
+ for (const credit of credits.cast ?? []) {
392
+ put(creditToMediaItem(credit, "acting"));
393
+ }
394
+ }
395
+ if (department !== "acting") {
396
+ for (const credit of credits.crew ?? []) {
397
+ if (credit.job === "Director") put(creditToMediaItem(credit, "directing"));
398
+ }
399
+ }
400
+ return [...byKey.values()].sort((a, b) => b.popularity - a.popularity);
401
+ }
402
+
403
+ // what of a person's work is streamable on the given subs, in one request.
404
+ // TMDB's discover endpoint filters by provider + region server-side, so we
405
+ // avoid a watch/providers call per title. actors are matched as cast,
406
+ // everyone else (directors, writers) as crew. movies only — discover has no
407
+ // reliable person filter for tv.
408
+ //
409
+ // returns the first page (up to 20) plus the true total, so the caller can
410
+ // show "20 of N". providerIds must be non-empty: an empty list makes TMDB
411
+ // drop the provider filter and return everything.
412
+ export async function discoverPersonFilms(opts: {
413
+ personId: number;
414
+ department: PersonDepartment;
415
+ region: string;
416
+ providerIds: ReadonlyArray<number>;
417
+ token: string;
418
+ language?: string;
419
+ }): Promise<{ films: ReadonlyArray<MediaItem>; total: number }> {
420
+ const personParam = opts.department === "acting" ? "with_cast" : "with_crew";
421
+ const data = await tmdbFetch<TmdbDiscoverResponse>(
422
+ "/discover/movie",
423
+ opts.token,
424
+ {
425
+ [personParam]: String(opts.personId),
426
+ watch_region: opts.region,
427
+ with_watch_providers: opts.providerIds.join("|"),
428
+ with_watch_monetization_types: "flatrate",
429
+ sort_by: "popularity.desc",
430
+ include_adult: "false",
431
+ language: opts.language ?? DEFAULT_TMDB_LANGUAGE,
432
+ },
433
+ );
434
+ assertResultsArray(data, "/discover/movie");
435
+ return {
436
+ films: data.results.map((m) => toMediaItem(m, "movie")),
437
+ total: data.total_results ?? data.results.length,
438
+ };
439
+ }
440
+
246
441
  export async function getRegionProviders(
247
442
  region: string,
248
443
  token: string,
@@ -296,6 +491,18 @@ const REGION_PIN_ORDER: Record<string, ReadonlyArray<number>> = {
296
491
  11, // MUBI
297
492
  188, // YouTube Premium
298
493
  ],
494
+ US: [
495
+ 8, // Netflix
496
+ 9, // Amazon Prime Video
497
+ 1899, // HBO Max
498
+ 337, // Disney Plus
499
+ 15, // Hulu
500
+ 350, // Apple TV+
501
+ 386, // Peacock Premium
502
+ 2303, // Paramount Plus Premium
503
+ 43, // Starz
504
+ 526, // AMC+
505
+ ],
299
506
  };
300
507
 
301
508
  export function applyRegionPinning(