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 +16 -1
- package/package.json +1 -1
- package/src/checkbox.ts +262 -0
- package/src/commands/init.ts +27 -5
- package/src/commands/search.ts +273 -39
- package/src/commands/subs.ts +25 -5
- package/src/i18n.ts +95 -8
- 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,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
|
|
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
|
-
|
|
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,
|
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";
|
|
@@ -97,25 +104,101 @@ function printPhysical(
|
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
|
|
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<
|
|
104
|
-
const visible =
|
|
105
|
-
const dropped =
|
|
106
|
-
|
|
107
|
-
const choices = visible.map((
|
|
108
|
-
name:
|
|
109
|
-
|
|
110
|
-
|
|
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}/${
|
|
198
|
+
headerParts.push(c.dim(`(${visible.length}/${hits.length})`));
|
|
116
199
|
}
|
|
117
200
|
|
|
118
|
-
return picker<
|
|
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
|
-
|
|
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
|
|
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
|
|
500
|
+
const hits = await searchUnified(currentQuery, cfg, m);
|
|
268
501
|
|
|
269
|
-
let picked:
|
|
270
|
-
if (
|
|
271
|
-
picked = await
|
|
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
|
|
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
|
|
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,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
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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(
|