watchwhere 0.3.1 → 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.1",
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,14 +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
- theme: { keybindings: ["vim"] },
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
+ },
81
95
  });
82
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
+
83
105
  const saved = await saveConfig({
84
106
  tmdbToken: tmdbToken.trim(),
85
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";
@@ -97,25 +104,101 @@ function printPhysical(
97
104
  }
98
105
  }
99
106
 
100
- async function pickItem(
101
- 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>,
102
179
  m: ReturnType<typeof t>,
103
- ): Promise<MediaItem | null> {
104
- const visible = results.slice(0, SOFT_LIMIT);
105
- const dropped = results.length - visible.length;
106
-
107
- const choices = visible.map((mi) => ({
108
- name: `${tag(mi.mediaType, m)} ${mi.title} ${c.dim(`(${year(mi.date)})`)}${mi.title !== mi.originalTitle ? c.dim(` — ${mi.originalTitle}`) : ""}`,
109
- value: mi,
110
- 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),
111
194
  }));
112
195
 
113
196
  const headerParts: string[] = [m.whichOne];
114
197
  if (dropped > 0) {
115
- headerParts.push(c.dim(`(${visible.length}/${results.length})`));
198
+ headerParts.push(c.dim(`(${visible.length}/${hits.length})`));
116
199
  }
117
200
 
118
- return picker<MediaItem>({
201
+ return picker<SearchHit>({
119
202
  message: headerParts.join(" "),
120
203
  pageSize: PAGE_SIZE,
121
204
  choices,
@@ -229,34 +312,184 @@ async function displayItem(
229
312
  }
230
313
  }
231
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
+
232
485
  export async function runSearch(query: string, cfg: Config): Promise<void> {
233
486
  const m = t(resolveLocale(cfg.language));
234
487
  let currentQuery = query.trim();
235
488
  if (!currentQuery) throw new Error(m.searchEmpty);
236
489
 
237
- const runSearchOnce = async (
238
- raw: string,
239
- ): Promise<ReadonlyArray<MediaItem>> => {
240
- const parsed = parseQuery(raw);
241
- const displayQ = parsed.year
242
- ? `${parsed.query} (${parsed.year})`
243
- : parsed.query;
244
- process.stdout.write(c.dim(` ${m.searching(displayQ)}`));
245
- // no region: TMDB returns regional release dates with region param,
246
- // but year filter expects original release year
247
- const all = await searchAll(parsed.query, cfg.tmdbToken, {
248
- language: cfg.language,
249
- });
250
- const results = parsed.year
251
- ? all.filter((r) => r.date?.slice(0, 4) === String(parsed.year))
252
- : all;
253
- console.log(c.dim(m.resultsCount(results.length)));
254
- return results;
255
- };
256
-
257
- // pipe mode: no prompts
490
+ // pipe mode: deterministic, titles only. person lookups are interactive.
258
491
  if (!process.stdin.isTTY) {
259
- const results = await runSearchOnce(currentQuery);
492
+ const results = await searchTitles(currentQuery, cfg, m);
260
493
  if (results.length === 0) throw new Error(m.noMatch);
261
494
  if (results.length > 1) throw new Error(m.ambiguousQuery(results.length));
262
495
  await displayItem(results[0]!, cfg, m);
@@ -264,17 +497,18 @@ export async function runSearch(query: string, cfg: Config): Promise<void> {
264
497
  }
265
498
 
266
499
  while (true) {
267
- const results = await runSearchOnce(currentQuery);
500
+ const hits = await searchUnified(currentQuery, cfg, m);
268
501
 
269
- let picked: MediaItem | null = null;
270
- if (results.length > 0) {
271
- picked = await pickItem(results, m);
502
+ let picked: SearchHit | null = null;
503
+ if (hits.length > 0) {
504
+ picked = await pickHit(hits, m);
272
505
  } else {
273
506
  console.log(`\n ${m.noMatch}`);
274
507
  }
275
508
 
276
509
  if (picked !== null) {
277
- await displayItem(picked, cfg, m);
510
+ if (picked.kind === "person") await runPerson(picked.person, cfg, m);
511
+ else await displayItem(picked.item, cfg, m);
278
512
  return;
279
513
  }
280
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,18 +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,
38
- theme: { keybindings: ["vim"] },
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
+ },
39
52
  });
40
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
+
41
61
  const added = subscriptions.filter((id) => !current.has(id)).length;
42
62
  const removed = cfg.subscriptions.filter((id) => !subscriptions.includes(id)).length;
43
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
 
@@ -168,6 +197,23 @@ const en: Catalog = {
168
197
  rent: "rent",
169
198
  buy: "buy",
170
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
+
171
217
  noConfigYet: "no config yet — run `ww init` first.",
172
218
  resolvingNames: "resolving subscription names… ",
173
219
  configTitle: "watchwhere config",
@@ -197,11 +243,23 @@ const en: Catalog = {
197
243
  noProviders: (region) =>
198
244
  `no providers found for region ${region}. check the code.`,
199
245
  yourSubsLabel: (region) => `your subscriptions in ${region}`,
200
- toggleHintConfirm: "(space to toggle, enter to confirm)",
201
- toggleHintSave: "(space to toggle, enter to save)",
202
246
  savedTo: "saved to",
203
247
  updatedConfigFile: "updated",
204
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
+
205
263
  corruptConfig: (path) => `corrupt config file: ${path}`,
206
264
 
207
265
  languageUpdated: (code) => `language updated to ${code}`,
@@ -214,7 +272,7 @@ const en: Catalog = {
214
272
  usageTagline: "where can I stream it?",
215
273
  usageSectionUsage: "usage",
216
274
  usageSectionConfig: "config",
217
- usageDescTitle: "search a movie or show in your region",
275
+ usageDescTitle: "search a movie, show, or person in your region",
218
276
  usageDescInit: "set up token, region, language, subscriptions",
219
277
  usageDescSubs: "edit your subscriptions only",
220
278
  usageDescLang: "change display language",
@@ -278,6 +336,23 @@ const tr: Catalog = {
278
336
  rent: "kirala",
279
337
  buy: "satın al",
280
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
+
281
356
  noConfigYet: "henüz config yok — önce `ww init` çalıştır.",
282
357
  resolvingNames: "abonelik isimleri çözümleniyor… ",
283
358
  configTitle: "watchwhere config",
@@ -308,11 +383,23 @@ const tr: Catalog = {
308
383
  noProviders: (region) =>
309
384
  `${region} bölgesi için sağlayıcı bulunamadı. kodu kontrol edin.`,
310
385
  yourSubsLabel: (region) => `${region} bölgesindeki aboneliklerin`,
311
- toggleHintConfirm: "(seçmek için space, onaylamak için enter)",
312
- toggleHintSave: "(seçmek için space, kaydetmek için enter)",
313
386
  savedTo: "kaydedildi:",
314
387
  updatedConfigFile: "güncellendi",
315
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
+
316
403
  corruptConfig: (path) => `bozuk config dosyası: ${path}`,
317
404
 
318
405
  languageUpdated: (code) => `dil ${code} olarak güncellendi`,
@@ -325,7 +412,7 @@ const tr: Catalog = {
325
412
  usageTagline: "nerede izleyebilirim?",
326
413
  usageSectionUsage: "kullanım",
327
414
  usageSectionConfig: "config",
328
- usageDescTitle: "bölgendeki bir film veya diziyi ara",
415
+ usageDescTitle: "bölgendeki bir film, dizi veya kişiyi ara",
329
416
  usageDescInit: "token, bölge, dil ve abonelikleri ayarla",
330
417
  usageDescSubs: "sadece abonelikleri düzenle",
331
418
  usageDescLang: "görüntüleme dilini değiştir",
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(