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 +16 -1
- package/package.json +1 -1
- package/src/checkbox.ts +262 -0
- package/src/commands/init.ts +27 -4
- package/src/commands/search.ts +287 -30
- package/src/commands/subs.ts +25 -4
- package/src/i18n.ts +98 -8
- package/src/index.ts +1 -0
- package/src/picker.ts +32 -5
- package/src/tmdb.ts +207 -0
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ ww init
|
|
|
44
44
|
## commands
|
|
45
45
|
|
|
46
46
|
```
|
|
47
|
-
ww <title> search
|
|
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
package/src/checkbox.ts
ADDED
|
@@ -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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
76
|
-
message: `${m.yourSubsLabel(region)}
|
|
76
|
+
const subscriptions = await filterableCheckbox<number>({
|
|
77
|
+
message: `${m.yourSubsLabel(region)}:`,
|
|
77
78
|
choices,
|
|
78
79
|
pageSize: 15,
|
|
79
|
-
|
|
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,
|
package/src/commands/search.ts
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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<
|
|
91
|
-
const visible =
|
|
92
|
-
const dropped =
|
|
93
|
-
|
|
94
|
-
const choices = visible.map((
|
|
95
|
-
name:
|
|
96
|
-
|
|
97
|
-
|
|
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}/${
|
|
198
|
+
headerParts.push(c.dim(`(${visible.length}/${hits.length})`));
|
|
103
199
|
}
|
|
104
200
|
|
|
105
|
-
return picker<
|
|
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:
|
|
490
|
+
// pipe mode: deterministic, titles only. person lookups are interactive.
|
|
225
491
|
if (!process.stdin.isTTY) {
|
|
226
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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
|
|
package/src/commands/subs.ts
CHANGED
|
@@ -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
|
|
30
|
-
message: `${m.yourSubsLabel(cfg.region)}
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|