watchwhere 0.2.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/LICENSE +20 -0
- package/README.md +63 -0
- package/package.json +51 -0
- package/src/cache.ts +66 -0
- package/src/colors.ts +20 -0
- package/src/commands/config.ts +58 -0
- package/src/commands/init.ts +86 -0
- package/src/commands/lang.ts +53 -0
- package/src/commands/region.ts +45 -0
- package/src/commands/search.ts +199 -0
- package/src/commands/subs.ts +48 -0
- package/src/config.ts +79 -0
- package/src/i18n.ts +345 -0
- package/src/index.ts +149 -0
- package/src/picker.ts +126 -0
- package/src/prompts.ts +50 -0
- package/src/tmdb.ts +234 -0
package/src/picker.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPrompt,
|
|
3
|
+
isDownKey,
|
|
4
|
+
isEnterKey,
|
|
5
|
+
isUpKey,
|
|
6
|
+
makeTheme,
|
|
7
|
+
useKeypress,
|
|
8
|
+
usePagination,
|
|
9
|
+
usePrefix,
|
|
10
|
+
useState,
|
|
11
|
+
} from "@inquirer/core";
|
|
12
|
+
import { c } from "./colors.ts";
|
|
13
|
+
|
|
14
|
+
export interface PickerChoice<Value> {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly value: Value;
|
|
17
|
+
readonly description?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PickerConfig<Value> {
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly choices: ReadonlyArray<PickerChoice<Value>>;
|
|
23
|
+
readonly pageSize?: number;
|
|
24
|
+
readonly extraKeys?: ReadonlyArray<readonly [string, string]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const pickerTheme = {
|
|
28
|
+
prefix: { idle: c.green("?"), done: c.green("✓") },
|
|
29
|
+
spinner: { interval: 80, frames: ["⠋"] },
|
|
30
|
+
style: {
|
|
31
|
+
answer: (text: string) => c.cyan(text),
|
|
32
|
+
message: (text: string) => c.bold(text),
|
|
33
|
+
error: (text: string) => c.red(text),
|
|
34
|
+
defaultAnswer: (text: string) => c.dim(text),
|
|
35
|
+
help: (text: string) => c.dim(text),
|
|
36
|
+
highlight: (text: string) => c.cyan(text),
|
|
37
|
+
key: (text: string) => c.cyan(text),
|
|
38
|
+
description: (text: string) => c.cyan(text),
|
|
39
|
+
disabled: (text: string) => c.dim(text),
|
|
40
|
+
},
|
|
41
|
+
helpMode: "always" as const,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
|
|
45
|
+
(config, done) => {
|
|
46
|
+
const pageSize = config.pageSize ?? 10;
|
|
47
|
+
const theme = makeTheme(pickerTheme, undefined);
|
|
48
|
+
const [status, setStatus] = useState<"idle" | "done">("idle");
|
|
49
|
+
const [cancelled, setCancelled] = useState(false);
|
|
50
|
+
const prefix = usePrefix({ status, theme });
|
|
51
|
+
const items = config.choices;
|
|
52
|
+
const [active, setActive] = useState(0);
|
|
53
|
+
|
|
54
|
+
useKeypress((key) => {
|
|
55
|
+
if (key.name === "escape") {
|
|
56
|
+
setCancelled(true);
|
|
57
|
+
setStatus("done");
|
|
58
|
+
done(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (isEnterKey(key)) {
|
|
62
|
+
setStatus("done");
|
|
63
|
+
const chosen = items[active];
|
|
64
|
+
if (chosen) done(chosen.value);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (isUpKey(key, [])) {
|
|
68
|
+
if (active > 0) setActive(active - 1);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (isDownKey(key, [])) {
|
|
72
|
+
if (active < items.length - 1) setActive(active + 1);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const message = theme.style.message(config.message);
|
|
78
|
+
|
|
79
|
+
if (status === "done") {
|
|
80
|
+
if (cancelled) return "";
|
|
81
|
+
const chosen = items[active];
|
|
82
|
+
return [prefix, message, theme.style.answer(chosen?.name ?? "")]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join(" ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const page = usePagination({
|
|
88
|
+
items,
|
|
89
|
+
active,
|
|
90
|
+
renderItem({ item, isActive }) {
|
|
91
|
+
const color = isActive ? theme.style.highlight : (x: string) => x;
|
|
92
|
+
const cursor = isActive ? ">" : " ";
|
|
93
|
+
return color(`${cursor} ${item.name}`);
|
|
94
|
+
},
|
|
95
|
+
pageSize,
|
|
96
|
+
loop: false,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const description = items[active]?.description;
|
|
100
|
+
const helpLine = [
|
|
101
|
+
["↑↓", "navigate"],
|
|
102
|
+
["⏎", "select"],
|
|
103
|
+
...(config.extraKeys ?? []),
|
|
104
|
+
]
|
|
105
|
+
.map(([k, a]) => `${c.bold(k!)} ${c.dim(a!)}`)
|
|
106
|
+
.join(c.dim(" • "));
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
[prefix, message].filter(Boolean).join(" "),
|
|
110
|
+
page,
|
|
111
|
+
description ? `\n${theme.style.description(description)}` : "",
|
|
112
|
+
helpLine,
|
|
113
|
+
]
|
|
114
|
+
.filter((line) => line !== "")
|
|
115
|
+
.join("\n");
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
export async function picker<Value>(
|
|
120
|
+
config: PickerConfig<Value>,
|
|
121
|
+
): Promise<Value | null> {
|
|
122
|
+
const result = (await promptImpl(
|
|
123
|
+
config as PickerConfig<unknown>,
|
|
124
|
+
)) as Value | null;
|
|
125
|
+
return result;
|
|
126
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import { c } from "./colors.ts";
|
|
3
|
+
|
|
4
|
+
export interface EditableInputOptions {
|
|
5
|
+
message: string;
|
|
6
|
+
prefill: string;
|
|
7
|
+
validate?: (value: string) => true | string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function editableInput(opts: EditableInputOptions): Promise<string> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let prefill = opts.prefill;
|
|
13
|
+
|
|
14
|
+
const ask = (): void => {
|
|
15
|
+
const rl = readline.createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let answered = false;
|
|
21
|
+
|
|
22
|
+
rl.on("SIGINT", () => {
|
|
23
|
+
if (answered) return;
|
|
24
|
+
answered = true;
|
|
25
|
+
rl.close();
|
|
26
|
+
const err = new Error("cancelled") as Error & { name: string };
|
|
27
|
+
err.name = "ExitPromptError";
|
|
28
|
+
reject(err);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
rl.question(`${c.green("?")} ${opts.message} `, (answer) => {
|
|
32
|
+
answered = true;
|
|
33
|
+
rl.close();
|
|
34
|
+
const validation = opts.validate?.(answer);
|
|
35
|
+
if (validation === undefined || validation === true) {
|
|
36
|
+
resolve(answer);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(` ${c.red("›")} ${validation}`);
|
|
40
|
+
prefill = answer;
|
|
41
|
+
ask();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// prompt needs to render first
|
|
45
|
+
setImmediate(() => rl.write(prefill));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
ask();
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/tmdb.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
const PROXY = process.env.WATCHWHERE_PROXY?.replace(/\/$/, "");
|
|
2
|
+
const TMDB_BASE = PROXY ? `${PROXY}/tmdb` : "https://api.themoviedb.org/3";
|
|
3
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
4
|
+
const DEFAULT_TMDB_LANGUAGE = "en-US";
|
|
5
|
+
|
|
6
|
+
export const usingProxy = PROXY !== undefined;
|
|
7
|
+
|
|
8
|
+
export type MediaType = "movie" | "tv";
|
|
9
|
+
|
|
10
|
+
export interface TmdbMovie {
|
|
11
|
+
readonly id: number;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly original_title: string;
|
|
14
|
+
readonly release_date: string | null;
|
|
15
|
+
readonly overview: string;
|
|
16
|
+
readonly popularity: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TmdbTv {
|
|
20
|
+
readonly id: number;
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly original_name: string;
|
|
23
|
+
readonly first_air_date: string | null;
|
|
24
|
+
readonly overview: string;
|
|
25
|
+
readonly popularity: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MediaItem {
|
|
29
|
+
readonly id: number;
|
|
30
|
+
readonly mediaType: MediaType;
|
|
31
|
+
readonly title: string;
|
|
32
|
+
readonly originalTitle: string;
|
|
33
|
+
readonly date: string | null;
|
|
34
|
+
readonly overview: string;
|
|
35
|
+
readonly popularity: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TmdbSearchResponse<T> {
|
|
39
|
+
readonly results: ReadonlyArray<T>;
|
|
40
|
+
readonly total_results: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TmdbProvider {
|
|
44
|
+
readonly provider_id: number;
|
|
45
|
+
readonly provider_name: string;
|
|
46
|
+
readonly logo_path: string | null;
|
|
47
|
+
readonly display_priority?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TmdbRegionProviders {
|
|
51
|
+
readonly link: string;
|
|
52
|
+
readonly flatrate?: ReadonlyArray<TmdbProvider>;
|
|
53
|
+
readonly rent?: ReadonlyArray<TmdbProvider>;
|
|
54
|
+
readonly buy?: ReadonlyArray<TmdbProvider>;
|
|
55
|
+
readonly ads?: ReadonlyArray<TmdbProvider>;
|
|
56
|
+
readonly free?: ReadonlyArray<TmdbProvider>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TmdbWatchProvidersResponse {
|
|
60
|
+
readonly id: number;
|
|
61
|
+
readonly results: Record<string, TmdbRegionProviders | undefined>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TmdbProvidersListResponse {
|
|
65
|
+
readonly results: ReadonlyArray<TmdbProvider>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class TmdbError extends Error {
|
|
69
|
+
constructor(
|
|
70
|
+
message: string,
|
|
71
|
+
readonly status: number,
|
|
72
|
+
) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "TmdbError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function tmdbFetch<T>(
|
|
79
|
+
path: string,
|
|
80
|
+
token: string,
|
|
81
|
+
params: Record<string, string> = {},
|
|
82
|
+
): Promise<T> {
|
|
83
|
+
const url = new URL(`${TMDB_BASE}${path}`);
|
|
84
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
85
|
+
|
|
86
|
+
let res: Response;
|
|
87
|
+
try {
|
|
88
|
+
res = await fetch(url, {
|
|
89
|
+
headers: {
|
|
90
|
+
Authorization: `Bearer ${token}`,
|
|
91
|
+
Accept: "application/json",
|
|
92
|
+
},
|
|
93
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// timeout → TimeoutError, otherwise DNS/offline/etc.
|
|
97
|
+
const name = err instanceof Error ? err.name : "Error";
|
|
98
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
99
|
+
if (name === "TimeoutError") {
|
|
100
|
+
throw new TmdbError(`TMDB ${path} timed out after ${FETCH_TIMEOUT_MS}ms`, 0);
|
|
101
|
+
}
|
|
102
|
+
throw new TmdbError(`TMDB ${path} network error: ${msg}`, 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const body = await res.text().catch(() => "");
|
|
107
|
+
throw new TmdbError(
|
|
108
|
+
`TMDB ${path} failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`,
|
|
109
|
+
res.status,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (await res.json()) as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function assertResultsArray(
|
|
117
|
+
data: unknown,
|
|
118
|
+
path: string,
|
|
119
|
+
): asserts data is { results: ReadonlyArray<unknown> } {
|
|
120
|
+
if (
|
|
121
|
+
typeof data !== "object" ||
|
|
122
|
+
data === null ||
|
|
123
|
+
!Array.isArray((data as { results?: unknown }).results)
|
|
124
|
+
) {
|
|
125
|
+
throw new TmdbError(`TMDB ${path} returned unexpected shape (no results[])`, 0);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function searchMovie(
|
|
130
|
+
query: string,
|
|
131
|
+
token: string,
|
|
132
|
+
opts: { language?: string; region?: string } = {},
|
|
133
|
+
): Promise<TmdbSearchResponse<TmdbMovie>> {
|
|
134
|
+
return tmdbFetch<TmdbSearchResponse<TmdbMovie>>("/search/movie", token, {
|
|
135
|
+
query,
|
|
136
|
+
include_adult: "false",
|
|
137
|
+
language: opts.language ?? DEFAULT_TMDB_LANGUAGE,
|
|
138
|
+
...(opts.region ? { region: opts.region } : {}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function searchTv(
|
|
143
|
+
query: string,
|
|
144
|
+
token: string,
|
|
145
|
+
opts: { language?: string } = {},
|
|
146
|
+
): Promise<TmdbSearchResponse<TmdbTv>> {
|
|
147
|
+
return tmdbFetch<TmdbSearchResponse<TmdbTv>>("/search/tv", token, {
|
|
148
|
+
query,
|
|
149
|
+
include_adult: "false",
|
|
150
|
+
language: opts.language ?? DEFAULT_TMDB_LANGUAGE,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getWatchProviders(
|
|
155
|
+
id: number,
|
|
156
|
+
mediaType: MediaType,
|
|
157
|
+
token: string,
|
|
158
|
+
): Promise<TmdbWatchProvidersResponse> {
|
|
159
|
+
return tmdbFetch<TmdbWatchProvidersResponse>(
|
|
160
|
+
`/${mediaType}/${id}/watch/providers`,
|
|
161
|
+
token,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function toMediaItem(
|
|
166
|
+
item: TmdbMovie | TmdbTv,
|
|
167
|
+
mediaType: MediaType,
|
|
168
|
+
): MediaItem {
|
|
169
|
+
if (mediaType === "movie") {
|
|
170
|
+
const m = item as TmdbMovie;
|
|
171
|
+
return {
|
|
172
|
+
id: m.id,
|
|
173
|
+
mediaType: "movie",
|
|
174
|
+
title: m.title,
|
|
175
|
+
originalTitle: m.original_title,
|
|
176
|
+
date: m.release_date,
|
|
177
|
+
overview: m.overview,
|
|
178
|
+
popularity: m.popularity,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const t = item as TmdbTv;
|
|
182
|
+
return {
|
|
183
|
+
id: t.id,
|
|
184
|
+
mediaType: "tv",
|
|
185
|
+
title: t.name,
|
|
186
|
+
originalTitle: t.original_name,
|
|
187
|
+
date: t.first_air_date,
|
|
188
|
+
overview: t.overview,
|
|
189
|
+
popularity: t.popularity,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function searchAll(
|
|
194
|
+
query: string,
|
|
195
|
+
token: string,
|
|
196
|
+
opts: { language?: string; region?: string } = {},
|
|
197
|
+
): Promise<ReadonlyArray<MediaItem>> {
|
|
198
|
+
const [movies, tv] = await Promise.all([
|
|
199
|
+
searchMovie(query, token, opts),
|
|
200
|
+
searchTv(query, token, opts),
|
|
201
|
+
]);
|
|
202
|
+
assertResultsArray(movies, "/search/movie");
|
|
203
|
+
assertResultsArray(tv, "/search/tv");
|
|
204
|
+
const items: MediaItem[] = [
|
|
205
|
+
...movies.results.map((m) => toMediaItem(m, "movie")),
|
|
206
|
+
...tv.results.map((t) => toMediaItem(t, "tv")),
|
|
207
|
+
];
|
|
208
|
+
return items.sort((a, b) => b.popularity - a.popularity);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function getRegionProviders(
|
|
212
|
+
region: string,
|
|
213
|
+
token: string,
|
|
214
|
+
language = DEFAULT_TMDB_LANGUAGE,
|
|
215
|
+
): Promise<ReadonlyArray<TmdbProvider>> {
|
|
216
|
+
const data = await tmdbFetch<TmdbProvidersListResponse>(
|
|
217
|
+
"/watch/providers/movie",
|
|
218
|
+
token,
|
|
219
|
+
{ watch_region: region, language },
|
|
220
|
+
);
|
|
221
|
+
assertResultsArray(data, "/watch/providers/movie");
|
|
222
|
+
return [...data.results].sort(
|
|
223
|
+
(a, b) => (a.display_priority ?? 999) - (b.display_priority ?? 999),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function verifyToken(token: string): Promise<boolean> {
|
|
228
|
+
try {
|
|
229
|
+
await tmdbFetch<{ success: boolean }>("/authentication", token);
|
|
230
|
+
return true;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|