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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { checkbox } from "@inquirer/prompts";
|
|
2
|
+
import { c } from "../colors.ts";
|
|
3
|
+
import { loadConfig, saveConfig } from "../config.ts";
|
|
4
|
+
import { resolveLocale, t } from "../i18n.ts";
|
|
5
|
+
import { getCachedRegionProviders } from "../cache.ts";
|
|
6
|
+
|
|
7
|
+
export async function runSubs(): Promise<void> {
|
|
8
|
+
const cfg = await loadConfig();
|
|
9
|
+
if (!cfg) {
|
|
10
|
+
throw new Error(t(resolveLocale()).noConfigYet);
|
|
11
|
+
}
|
|
12
|
+
const m = t(resolveLocale(cfg.language));
|
|
13
|
+
|
|
14
|
+
process.stdout.write(c.dim(` ${m.loadingProviders(cfg.region)}`));
|
|
15
|
+
const providers = await getCachedRegionProviders(cfg.region, cfg.tmdbToken);
|
|
16
|
+
console.log(c.dim(m.providersFound(providers.length)));
|
|
17
|
+
|
|
18
|
+
if (providers.length === 0) {
|
|
19
|
+
throw new Error(m.noProviders(cfg.region));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const current = new Set(cfg.subscriptions);
|
|
23
|
+
|
|
24
|
+
const subscriptions = await checkbox<number>({
|
|
25
|
+
message: `${m.yourSubsLabel(cfg.region)} ${c.dim(m.toggleHintSave)}:`,
|
|
26
|
+
choices: providers.map((p) => ({
|
|
27
|
+
name: p.provider_name,
|
|
28
|
+
value: p.provider_id,
|
|
29
|
+
checked: current.has(p.provider_id),
|
|
30
|
+
})),
|
|
31
|
+
pageSize: 15,
|
|
32
|
+
loop: false,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const added = subscriptions.filter((id) => !current.has(id)).length;
|
|
36
|
+
const removed = cfg.subscriptions.filter((id) => !subscriptions.includes(id)).length;
|
|
37
|
+
|
|
38
|
+
const saved = await saveConfig({
|
|
39
|
+
tmdbToken: cfg.tmdbToken,
|
|
40
|
+
region: cfg.region,
|
|
41
|
+
language: cfg.language,
|
|
42
|
+
subscriptions,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(` ${c.green("✓")} ${m.updatedConfigFile} ${c.dim("~/.watchwhere/config.json")}`);
|
|
47
|
+
console.log(` ${m.subscriptionsLabel.padEnd(13)} ${saved.subscriptions.length} ${c.dim(`(+${added} / -${removed})`)}`);
|
|
48
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveLocale, t } from "./i18n.ts";
|
|
5
|
+
|
|
6
|
+
export interface Config {
|
|
7
|
+
readonly tmdbToken: string;
|
|
8
|
+
readonly region: string;
|
|
9
|
+
readonly language: string;
|
|
10
|
+
readonly subscriptions: ReadonlyArray<number>;
|
|
11
|
+
readonly updatedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LANGUAGE = "en-US";
|
|
15
|
+
|
|
16
|
+
const CONFIG_DIR = join(homedir(), ".watchwhere");
|
|
17
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
18
|
+
|
|
19
|
+
export const configPath = CONFIG_FILE;
|
|
20
|
+
|
|
21
|
+
interface RawConfig {
|
|
22
|
+
tmdbToken: unknown;
|
|
23
|
+
region: unknown;
|
|
24
|
+
language?: unknown;
|
|
25
|
+
subscriptions: unknown;
|
|
26
|
+
updatedAt?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseConfig(value: unknown): Config | null {
|
|
30
|
+
if (typeof value !== "object" || value === null) return null;
|
|
31
|
+
const v = value as RawConfig;
|
|
32
|
+
if (
|
|
33
|
+
typeof v.tmdbToken !== "string" ||
|
|
34
|
+
typeof v.region !== "string" ||
|
|
35
|
+
!Array.isArray(v.subscriptions) ||
|
|
36
|
+
!v.subscriptions.every((n) => typeof n === "number")
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
tmdbToken: v.tmdbToken,
|
|
42
|
+
region: v.region,
|
|
43
|
+
language: typeof v.language === "string" ? v.language : DEFAULT_LANGUAGE,
|
|
44
|
+
subscriptions: v.subscriptions,
|
|
45
|
+
updatedAt: typeof v.updatedAt === "string" ? v.updatedAt : new Date(0).toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function loadConfig(): Promise<Config | null> {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await readFile(CONFIG_FILE, "utf8");
|
|
52
|
+
const cfg = parseConfig(JSON.parse(raw) as unknown);
|
|
53
|
+
if (!cfg) throw new Error(t(resolveLocale()).corruptConfig(CONFIG_FILE));
|
|
54
|
+
return cfg;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function saveConfig(
|
|
62
|
+
cfg: Omit<Config, "updatedAt">,
|
|
63
|
+
): Promise<Config> {
|
|
64
|
+
const full: Config = { ...cfg, updatedAt: new Date().toISOString() };
|
|
65
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
66
|
+
|
|
67
|
+
// write to tmp then rename — avoids half-written config on crash
|
|
68
|
+
const tmp = `${CONFIG_FILE}.tmp.${process.pid}`;
|
|
69
|
+
await writeFile(tmp, JSON.stringify(full, null, 2), {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
mode: 0o600,
|
|
72
|
+
});
|
|
73
|
+
if (process.platform !== "win32") {
|
|
74
|
+
// windows ignores chmod
|
|
75
|
+
await chmod(tmp, 0o600);
|
|
76
|
+
}
|
|
77
|
+
await rename(tmp, CONFIG_FILE);
|
|
78
|
+
return full;
|
|
79
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
export type Locale = "en" | "tr";
|
|
2
|
+
|
|
3
|
+
const SUPPORTED: ReadonlySet<Locale> = new Set<Locale>(["en", "tr"]);
|
|
4
|
+
const DEFAULT_LOCALE: Locale = "en";
|
|
5
|
+
|
|
6
|
+
function head(input: string): string {
|
|
7
|
+
// tr-TR / tr_TR.UTF-8 → tr
|
|
8
|
+
return input.toLowerCase().split(/[-_.]/)[0] ?? "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fromEnv(): Locale {
|
|
12
|
+
const raw = process.env.LANG ?? process.env.LC_ALL ?? "";
|
|
13
|
+
const h = head(raw);
|
|
14
|
+
return SUPPORTED.has(h as Locale) ? (h as Locale) : DEFAULT_LOCALE;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveLocale(input?: string): Locale {
|
|
18
|
+
if (!input) return fromEnv();
|
|
19
|
+
const h = head(input);
|
|
20
|
+
return SUPPORTED.has(h as Locale) ? (h as Locale) : DEFAULT_LOCALE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Catalog {
|
|
24
|
+
// generic
|
|
25
|
+
error: string;
|
|
26
|
+
ok: string;
|
|
27
|
+
done: string;
|
|
28
|
+
offline: string;
|
|
29
|
+
failed: string;
|
|
30
|
+
|
|
31
|
+
// media tags
|
|
32
|
+
tagMovie: string;
|
|
33
|
+
tagTv: string;
|
|
34
|
+
|
|
35
|
+
// search
|
|
36
|
+
searchEmpty: string;
|
|
37
|
+
searching: (query: string) => string;
|
|
38
|
+
resultsCount: (n: number) => string;
|
|
39
|
+
noMatch: string;
|
|
40
|
+
whichOne: string;
|
|
41
|
+
emptyResult: string;
|
|
42
|
+
fetchingProviders: (region: string) => string;
|
|
43
|
+
notAvailable: (region: string) => string;
|
|
44
|
+
notAvailableHint: string;
|
|
45
|
+
onYourSubsPrefix: string;
|
|
46
|
+
streamingNotOnSubs: (region: string) => string;
|
|
47
|
+
noStreamingSub: (region: string) => string;
|
|
48
|
+
owned: string;
|
|
49
|
+
notOwned: string;
|
|
50
|
+
onPrefix: string;
|
|
51
|
+
free: string;
|
|
52
|
+
ads: string;
|
|
53
|
+
rent: string;
|
|
54
|
+
buy: string;
|
|
55
|
+
link: string;
|
|
56
|
+
|
|
57
|
+
// config (show)
|
|
58
|
+
noConfigYet: string;
|
|
59
|
+
resolvingNames: string;
|
|
60
|
+
configTitle: string;
|
|
61
|
+
regionLabel: string;
|
|
62
|
+
languageLabel: string;
|
|
63
|
+
tokenLabel: string;
|
|
64
|
+
updatedLabel: string;
|
|
65
|
+
pathLabel: string;
|
|
66
|
+
subscriptionsLabel: string;
|
|
67
|
+
noSubs: string;
|
|
68
|
+
|
|
69
|
+
// init
|
|
70
|
+
firstTimeSetup: string;
|
|
71
|
+
configNotWritten: string;
|
|
72
|
+
existingConfig: (region: string) => string;
|
|
73
|
+
tokenPrompt: string;
|
|
74
|
+
tokenHint: string;
|
|
75
|
+
invalidToken: string;
|
|
76
|
+
tokenTooShort: string;
|
|
77
|
+
verifying: string;
|
|
78
|
+
usingProxyNotice: string;
|
|
79
|
+
regionPrompt: string;
|
|
80
|
+
regionInvalid: string;
|
|
81
|
+
displayLanguagePrompt: string;
|
|
82
|
+
loadingProviders: (region: string) => string;
|
|
83
|
+
providersFound: (n: number) => string;
|
|
84
|
+
noProviders: (region: string) => string;
|
|
85
|
+
yourSubsLabel: (region: string) => string;
|
|
86
|
+
toggleHintConfirm: string;
|
|
87
|
+
toggleHintSave: string;
|
|
88
|
+
savedTo: string;
|
|
89
|
+
updatedConfigFile: string;
|
|
90
|
+
|
|
91
|
+
// validation
|
|
92
|
+
corruptConfig: (path: string) => string;
|
|
93
|
+
|
|
94
|
+
// lang / region commands + cancellation
|
|
95
|
+
languageUpdated: (code: string) => string;
|
|
96
|
+
cancelled: string;
|
|
97
|
+
currentLanguage: (code: string) => string;
|
|
98
|
+
currentRegion: (code: string) => string;
|
|
99
|
+
regionUpdated: (code: string) => string;
|
|
100
|
+
regionHintReviewSubs: string;
|
|
101
|
+
|
|
102
|
+
// usage / --help
|
|
103
|
+
usageTagline: string;
|
|
104
|
+
usageSectionUsage: string;
|
|
105
|
+
usageSectionConfig: string;
|
|
106
|
+
usageDescTitle: string;
|
|
107
|
+
usageDescInit: string;
|
|
108
|
+
usageDescSubs: string;
|
|
109
|
+
usageDescLang: string;
|
|
110
|
+
usageDescRegion: string;
|
|
111
|
+
usageDescConfig: string;
|
|
112
|
+
usageDescHelp: string;
|
|
113
|
+
|
|
114
|
+
// routing / hints
|
|
115
|
+
unknownOption: (opt: string) => string;
|
|
116
|
+
unknownCommand: (cmd: string) => string;
|
|
117
|
+
didYouMean: (cmd: string) => string;
|
|
118
|
+
noConfigHint: string;
|
|
119
|
+
tokenExpired: string;
|
|
120
|
+
networkOffline: string;
|
|
121
|
+
networkTimeout: string;
|
|
122
|
+
relativeNow: string;
|
|
123
|
+
relativeMinutes: (n: number) => string;
|
|
124
|
+
relativeHours: (n: number) => string;
|
|
125
|
+
relativeDays: (n: number) => string;
|
|
126
|
+
|
|
127
|
+
// non-TTY
|
|
128
|
+
ttyRequired: (cmd: string) => string;
|
|
129
|
+
ambiguousQuery: (n: number) => string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const en: Catalog = {
|
|
133
|
+
error: "error",
|
|
134
|
+
ok: "ok",
|
|
135
|
+
done: "done",
|
|
136
|
+
offline: "offline",
|
|
137
|
+
failed: "failed",
|
|
138
|
+
|
|
139
|
+
tagMovie: "movie",
|
|
140
|
+
tagTv: "tv",
|
|
141
|
+
|
|
142
|
+
searchEmpty: "search title is empty",
|
|
143
|
+
searching: (q) => `searching "${q}"… `,
|
|
144
|
+
resultsCount: (n) => `${n} result${n === 1 ? "" : "s"}`,
|
|
145
|
+
noMatch: "no match. try a different spelling or original title.",
|
|
146
|
+
whichOne: "which one?",
|
|
147
|
+
emptyResult: "empty result",
|
|
148
|
+
fetchingProviders: (region) => `fetching providers (${region})… `,
|
|
149
|
+
notAvailable: (region) => `not available in ${region}.`,
|
|
150
|
+
notAvailableHint:
|
|
151
|
+
"(TMDB has no data for this region — VPN to another region may help.)",
|
|
152
|
+
onYourSubsPrefix: "on your subs:",
|
|
153
|
+
streamingNotOnSubs: (region) =>
|
|
154
|
+
`streaming in ${region}, but not on your subs.`,
|
|
155
|
+
noStreamingSub: (region) => `no streaming subscription in ${region}.`,
|
|
156
|
+
owned: "owned",
|
|
157
|
+
notOwned: "not owned",
|
|
158
|
+
onPrefix: "on",
|
|
159
|
+
free: "free",
|
|
160
|
+
ads: "ads",
|
|
161
|
+
rent: "rent",
|
|
162
|
+
buy: "buy",
|
|
163
|
+
link: "link",
|
|
164
|
+
|
|
165
|
+
noConfigYet: "no config yet — run `ww init` first.",
|
|
166
|
+
resolvingNames: "resolving subscription names… ",
|
|
167
|
+
configTitle: "watchwhere config",
|
|
168
|
+
regionLabel: "region",
|
|
169
|
+
languageLabel: "language",
|
|
170
|
+
tokenLabel: "token",
|
|
171
|
+
updatedLabel: "updated",
|
|
172
|
+
pathLabel: "path",
|
|
173
|
+
subscriptionsLabel: "subscriptions",
|
|
174
|
+
noSubs: "none — run `ww subs` to add some",
|
|
175
|
+
|
|
176
|
+
firstTimeSetup: "first-time setup",
|
|
177
|
+
configNotWritten: "config not written",
|
|
178
|
+
existingConfig: (region) =>
|
|
179
|
+
`existing config found (${region}) — will overwrite`,
|
|
180
|
+
tokenPrompt: "TMDB Read Access Token (v4)",
|
|
181
|
+
tokenHint: "themoviedb.org/settings/api",
|
|
182
|
+
invalidToken: "invalid TMDB token. use the v4 'Read Access Token'.",
|
|
183
|
+
tokenTooShort: "token looks too short",
|
|
184
|
+
verifying: "verifying token… ",
|
|
185
|
+
usingProxyNotice: "using proxy — skipping token setup",
|
|
186
|
+
regionPrompt: "region code (ISO-3166-1, e.g. TR / US / DE):",
|
|
187
|
+
regionInvalid: "two-letter region code (e.g. TR)",
|
|
188
|
+
displayLanguagePrompt: "display language:",
|
|
189
|
+
loadingProviders: (region) => `loading providers for ${region}… `,
|
|
190
|
+
providersFound: (n) => `${n} found`,
|
|
191
|
+
noProviders: (region) =>
|
|
192
|
+
`no providers found for region ${region}. check the code.`,
|
|
193
|
+
yourSubsLabel: (region) => `your subscriptions in ${region}`,
|
|
194
|
+
toggleHintConfirm: "(space to toggle, enter to confirm)",
|
|
195
|
+
toggleHintSave: "(space to toggle, enter to save)",
|
|
196
|
+
savedTo: "saved to",
|
|
197
|
+
updatedConfigFile: "updated",
|
|
198
|
+
|
|
199
|
+
corruptConfig: (path) => `corrupt config file: ${path}`,
|
|
200
|
+
|
|
201
|
+
languageUpdated: (code) => `language updated to ${code}`,
|
|
202
|
+
cancelled: "cancelled",
|
|
203
|
+
currentLanguage: (code) => `current: ${code}`,
|
|
204
|
+
currentRegion: (code) => `current: ${code}`,
|
|
205
|
+
regionUpdated: (code) => `region updated to ${code}`,
|
|
206
|
+
regionHintReviewSubs: "providers vary by region — run `ww subs` to review your list.",
|
|
207
|
+
|
|
208
|
+
usageTagline: "where can I stream it?",
|
|
209
|
+
usageSectionUsage: "usage",
|
|
210
|
+
usageSectionConfig: "config",
|
|
211
|
+
usageDescTitle: "search a movie or show in your region",
|
|
212
|
+
usageDescInit: "set up token, region, language, subscriptions",
|
|
213
|
+
usageDescSubs: "edit your subscriptions only",
|
|
214
|
+
usageDescLang: "change display language",
|
|
215
|
+
usageDescRegion: "change region",
|
|
216
|
+
usageDescConfig: "show current config",
|
|
217
|
+
usageDescHelp: "this message",
|
|
218
|
+
|
|
219
|
+
unknownOption: (opt) => `unknown option: ${opt}. run \`ww --help\`.`,
|
|
220
|
+
unknownCommand: (cmd) => `unknown command: ${cmd}`,
|
|
221
|
+
didYouMean: (cmd) => `did you mean \`ww ${cmd}\`?`,
|
|
222
|
+
noConfigHint: "no config yet — run `ww init` to get started.",
|
|
223
|
+
tokenExpired: "TMDB token rejected (401). run `ww init` to re-enter it.",
|
|
224
|
+
networkOffline: "couldn't reach TMDB. check your internet connection.",
|
|
225
|
+
networkTimeout: "TMDB request timed out. try again, or check your connection.",
|
|
226
|
+
relativeNow: "just now",
|
|
227
|
+
relativeMinutes: (n) => `${n} min ago`,
|
|
228
|
+
relativeHours: (n) => `${n} hr ago`,
|
|
229
|
+
relativeDays: (n) => `${n} day${n === 1 ? "" : "s"} ago`,
|
|
230
|
+
|
|
231
|
+
ttyRequired: (cmd) => `\`ww ${cmd}\` is interactive — run it in a terminal, not a pipe.`,
|
|
232
|
+
ambiguousQuery: (n) => `${n} matches — query is ambiguous. refine, or run interactively.`,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const tr: Catalog = {
|
|
236
|
+
error: "hata",
|
|
237
|
+
ok: "tamam",
|
|
238
|
+
done: "tamam",
|
|
239
|
+
offline: "çevrimdışı",
|
|
240
|
+
failed: "başarısız",
|
|
241
|
+
|
|
242
|
+
tagMovie: "film",
|
|
243
|
+
tagTv: "dizi",
|
|
244
|
+
|
|
245
|
+
searchEmpty: "aranacak başlık boş",
|
|
246
|
+
searching: (q) => `"${q}" aranıyor… `,
|
|
247
|
+
resultsCount: (n) => `${n} sonuç`,
|
|
248
|
+
noMatch: "eşleşme yok. farklı bir yazım veya orijinal başlık deneyin.",
|
|
249
|
+
whichOne: "hangisi?",
|
|
250
|
+
emptyResult: "boş sonuç",
|
|
251
|
+
fetchingProviders: (region) =>
|
|
252
|
+
`sağlayıcılar getiriliyor (${region})… `,
|
|
253
|
+
notAvailable: (region) => `${region} bölgesinde mevcut değil.`,
|
|
254
|
+
notAvailableHint:
|
|
255
|
+
"(TMDB'de bu bölge için veri yok — başka bir bölgeye VPN yardımcı olabilir.)",
|
|
256
|
+
onYourSubsPrefix: "aboneliklerinde:",
|
|
257
|
+
streamingNotOnSubs: (region) =>
|
|
258
|
+
`${region} bölgesinde yayında, ama aboneliklerinde yok.`,
|
|
259
|
+
noStreamingSub: (region) =>
|
|
260
|
+
`${region} bölgesinde streaming aboneliği yok.`,
|
|
261
|
+
owned: "var",
|
|
262
|
+
notOwned: "yok",
|
|
263
|
+
onPrefix: "şurada",
|
|
264
|
+
free: "ücretsiz",
|
|
265
|
+
ads: "reklamlı",
|
|
266
|
+
rent: "kirala",
|
|
267
|
+
buy: "satın al",
|
|
268
|
+
link: "bağlantı",
|
|
269
|
+
|
|
270
|
+
noConfigYet: "henüz config yok — önce `ww init` çalıştır.",
|
|
271
|
+
resolvingNames: "abonelik isimleri çözümleniyor… ",
|
|
272
|
+
configTitle: "watchwhere config",
|
|
273
|
+
regionLabel: "bölge",
|
|
274
|
+
languageLabel: "dil",
|
|
275
|
+
tokenLabel: "token",
|
|
276
|
+
updatedLabel: "güncellendi",
|
|
277
|
+
pathLabel: "yol",
|
|
278
|
+
subscriptionsLabel: "abonelikler",
|
|
279
|
+
noSubs: "yok — eklemek için `ww subs` çalıştır",
|
|
280
|
+
|
|
281
|
+
firstTimeSetup: "ilk kurulum",
|
|
282
|
+
configNotWritten: "config yazılmadı",
|
|
283
|
+
existingConfig: (region) =>
|
|
284
|
+
`mevcut config bulundu (${region}) — üzerine yazılacak`,
|
|
285
|
+
tokenPrompt: "TMDB Read Access Token (v4)",
|
|
286
|
+
tokenHint: "themoviedb.org/settings/api",
|
|
287
|
+
invalidToken: "geçersiz TMDB token. v4 'Read Access Token' kullanın.",
|
|
288
|
+
tokenTooShort: "token çok kısa görünüyor",
|
|
289
|
+
verifying: "token doğrulanıyor… ",
|
|
290
|
+
usingProxyNotice: "proxy kullanılıyor — token kurulumu atlanıyor",
|
|
291
|
+
regionPrompt: "bölge kodu (ISO-3166-1, örn. TR / US / DE):",
|
|
292
|
+
regionInvalid: "iki harfli bölge kodu (örn. TR)",
|
|
293
|
+
displayLanguagePrompt: "görüntüleme dili:",
|
|
294
|
+
loadingProviders: (region) =>
|
|
295
|
+
`${region} için sağlayıcılar yükleniyor… `,
|
|
296
|
+
providersFound: (n) => `${n} bulundu`,
|
|
297
|
+
noProviders: (region) =>
|
|
298
|
+
`${region} bölgesi için sağlayıcı bulunamadı. kodu kontrol edin.`,
|
|
299
|
+
yourSubsLabel: (region) => `${region} bölgesindeki aboneliklerin`,
|
|
300
|
+
toggleHintConfirm: "(seçmek için space, onaylamak için enter)",
|
|
301
|
+
toggleHintSave: "(seçmek için space, kaydetmek için enter)",
|
|
302
|
+
savedTo: "kaydedildi:",
|
|
303
|
+
updatedConfigFile: "güncellendi",
|
|
304
|
+
|
|
305
|
+
corruptConfig: (path) => `bozuk config dosyası: ${path}`,
|
|
306
|
+
|
|
307
|
+
languageUpdated: (code) => `dil ${code} olarak güncellendi`,
|
|
308
|
+
cancelled: "iptal edildi",
|
|
309
|
+
currentLanguage: (code) => `mevcut: ${code}`,
|
|
310
|
+
currentRegion: (code) => `mevcut: ${code}`,
|
|
311
|
+
regionUpdated: (code) => `bölge ${code} olarak güncellendi`,
|
|
312
|
+
regionHintReviewSubs: "sağlayıcılar bölgeye göre değişir — `ww subs` ile gözden geçir.",
|
|
313
|
+
|
|
314
|
+
usageTagline: "nerede izleyebilirim?",
|
|
315
|
+
usageSectionUsage: "kullanım",
|
|
316
|
+
usageSectionConfig: "config",
|
|
317
|
+
usageDescTitle: "bölgendeki bir film veya diziyi ara",
|
|
318
|
+
usageDescInit: "token, bölge, dil ve abonelikleri ayarla",
|
|
319
|
+
usageDescSubs: "sadece abonelikleri düzenle",
|
|
320
|
+
usageDescLang: "görüntüleme dilini değiştir",
|
|
321
|
+
usageDescRegion: "bölgeyi değiştir",
|
|
322
|
+
usageDescConfig: "mevcut config'i göster",
|
|
323
|
+
usageDescHelp: "bu mesaj",
|
|
324
|
+
|
|
325
|
+
unknownOption: (opt) => `bilinmeyen seçenek: ${opt}. \`ww --help\` çalıştır.`,
|
|
326
|
+
unknownCommand: (cmd) => `bilinmeyen komut: ${cmd}`,
|
|
327
|
+
didYouMean: (cmd) => `\`ww ${cmd}\` mi demek istedin?`,
|
|
328
|
+
noConfigHint: "henüz config yok — başlamak için `ww init` çalıştır.",
|
|
329
|
+
tokenExpired: "TMDB token reddedildi (401). yenilemek için `ww init` çalıştır.",
|
|
330
|
+
networkOffline: "TMDB'ye ulaşılamadı. internet bağlantını kontrol et.",
|
|
331
|
+
networkTimeout: "TMDB isteği zaman aşımına uğradı. tekrar dene veya bağlantını kontrol et.",
|
|
332
|
+
relativeNow: "şimdi",
|
|
333
|
+
relativeMinutes: (n) => `${n} dk önce`,
|
|
334
|
+
relativeHours: (n) => `${n} saat önce`,
|
|
335
|
+
relativeDays: (n) => `${n} gün önce`,
|
|
336
|
+
|
|
337
|
+
ttyRequired: (cmd) => `\`ww ${cmd}\` interaktif — pipe'da değil, terminalde çalıştır.`,
|
|
338
|
+
ambiguousQuery: (n) => `${n} eşleşme var — sorgu belirsiz. daralt veya interaktif çalıştır.`,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const CATALOGS: Record<Locale, Catalog> = { en, tr };
|
|
342
|
+
|
|
343
|
+
export function t(locale: Locale): Catalog {
|
|
344
|
+
return CATALOGS[locale];
|
|
345
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import pkg from "../package.json" with { type: "json" };
|
|
3
|
+
import { c } from "./colors.ts";
|
|
4
|
+
import { loadConfig } from "./config.ts";
|
|
5
|
+
import { resolveLocale, t } from "./i18n.ts";
|
|
6
|
+
import { runInit } from "./commands/init.ts";
|
|
7
|
+
import { runSearch } from "./commands/search.ts";
|
|
8
|
+
import { runConfig } from "./commands/config.ts";
|
|
9
|
+
import { runSubs } from "./commands/subs.ts";
|
|
10
|
+
import { runLang } from "./commands/lang.ts";
|
|
11
|
+
import { runRegion } from "./commands/region.ts";
|
|
12
|
+
import { TmdbError } from "./tmdb.ts";
|
|
13
|
+
|
|
14
|
+
function usage(m: ReturnType<typeof t>): string {
|
|
15
|
+
return [
|
|
16
|
+
` ${c.bold("ww")} ${c.dim(`/ watchwhere — ${m.usageTagline}`)}`,
|
|
17
|
+
``,
|
|
18
|
+
` ${c.dim(m.usageSectionUsage)}`,
|
|
19
|
+
` ww ${c.dim("<title>")} ${m.usageDescTitle}`,
|
|
20
|
+
` ww init ${m.usageDescInit}`,
|
|
21
|
+
` ww subs ${m.usageDescSubs}`,
|
|
22
|
+
` ww lang ${m.usageDescLang}`,
|
|
23
|
+
` ww region ${m.usageDescRegion}`,
|
|
24
|
+
` ww config ${m.usageDescConfig}`,
|
|
25
|
+
` ww --help ${m.usageDescHelp}`,
|
|
26
|
+
``,
|
|
27
|
+
` ${c.dim(m.usageSectionConfig)} ~/.watchwhere/config.json`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COMMANDS = ["init", "subs", "lang", "region", "config"] as const;
|
|
32
|
+
type Command = (typeof COMMANDS)[number];
|
|
33
|
+
const INTERACTIVE_COMMANDS: ReadonlySet<Command> = new Set(["init", "subs", "lang", "region"]);
|
|
34
|
+
|
|
35
|
+
function levenshtein(a: string, b: string): number {
|
|
36
|
+
if (a === b) return 0;
|
|
37
|
+
if (a.length === 0) return b.length;
|
|
38
|
+
if (b.length === 0) return a.length;
|
|
39
|
+
const dp = Array.from({ length: a.length + 1 }, () =>
|
|
40
|
+
new Array<number>(b.length + 1).fill(0),
|
|
41
|
+
);
|
|
42
|
+
for (let i = 0; i <= a.length; i++) dp[i]![0] = i;
|
|
43
|
+
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
|
|
44
|
+
for (let i = 1; i <= a.length; i++) {
|
|
45
|
+
for (let j = 1; j <= b.length; j++) {
|
|
46
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
47
|
+
dp[i]![j] = Math.min(
|
|
48
|
+
dp[i - 1]![j]! + 1,
|
|
49
|
+
dp[i]![j - 1]! + 1,
|
|
50
|
+
dp[i - 1]![j - 1]! + cost,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return dp[a.length]![b.length]!;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nearestCommand(input: string): Command | null {
|
|
58
|
+
let best: { cmd: Command; dist: number } | null = null;
|
|
59
|
+
for (const cmd of COMMANDS) {
|
|
60
|
+
const d = levenshtein(input, cmd);
|
|
61
|
+
if (d <= 2 && (best === null || d < best.dist)) {
|
|
62
|
+
best = { cmd, dist: d };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return best?.cmd ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main(): Promise<void> {
|
|
69
|
+
const args = process.argv.slice(2);
|
|
70
|
+
const first = args[0];
|
|
71
|
+
|
|
72
|
+
let cfg = await loadConfig();
|
|
73
|
+
const m = t(resolveLocale(cfg?.language));
|
|
74
|
+
|
|
75
|
+
if (first === "--version" || first === "-v") {
|
|
76
|
+
console.log(`watchwhere ${pkg.version}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!first || first === "--help" || first === "-h") {
|
|
81
|
+
console.log(usage(m));
|
|
82
|
+
if (!cfg) console.log(`\n ${c.yellow("›")} ${m.noConfigHint}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (first.startsWith("-")) {
|
|
87
|
+
throw new Error(m.unknownOption(first));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if ((COMMANDS as ReadonlyArray<string>).includes(first)) {
|
|
91
|
+
const cmd = first as Command;
|
|
92
|
+
if (INTERACTIVE_COMMANDS.has(cmd) && !process.stdin.isTTY) {
|
|
93
|
+
throw new Error(m.ttyRequired(cmd));
|
|
94
|
+
}
|
|
95
|
+
switch (cmd) {
|
|
96
|
+
case "init":
|
|
97
|
+
return runInit();
|
|
98
|
+
case "subs":
|
|
99
|
+
return runSubs();
|
|
100
|
+
case "lang":
|
|
101
|
+
return runLang();
|
|
102
|
+
case "region":
|
|
103
|
+
return runRegion();
|
|
104
|
+
case "config":
|
|
105
|
+
return runConfig();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (args.length === 1 && !first.includes(" ")) {
|
|
110
|
+
const suggestion = nearestCommand(first);
|
|
111
|
+
if (suggestion && first.length <= suggestion.length + 1 && first !== suggestion) {
|
|
112
|
+
console.log(c.dim(` ${c.yellow("›")} ${m.didYouMean(suggestion)}`));
|
|
113
|
+
console.log();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!cfg) {
|
|
118
|
+
console.log(c.dim(` ${m.firstTimeSetup}\n`));
|
|
119
|
+
await runInit();
|
|
120
|
+
cfg = await loadConfig();
|
|
121
|
+
if (!cfg) throw new Error(m.configNotWritten);
|
|
122
|
+
console.log();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await runSearch(args.join(" "), cfg);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
main().catch(async (err: unknown) => {
|
|
129
|
+
const cfg = await loadConfig().catch(() => null);
|
|
130
|
+
const m = t(resolveLocale(cfg?.language));
|
|
131
|
+
|
|
132
|
+
if (err instanceof Error && err.name === "ExitPromptError") {
|
|
133
|
+
console.log(c.dim(`\n ${m.cancelled}`));
|
|
134
|
+
process.exit(130);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let displayMsg = err instanceof Error ? err.message : String(err);
|
|
138
|
+
if (err instanceof TmdbError) {
|
|
139
|
+
if (err.status === 401) displayMsg = m.tokenExpired;
|
|
140
|
+
else if (err.status === 0 && err.message.includes("timed out")) {
|
|
141
|
+
displayMsg = m.networkTimeout;
|
|
142
|
+
} else if (err.status === 0) {
|
|
143
|
+
displayMsg = m.networkOffline;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.error(`\n ${c.red(m.error)} ${displayMsg}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|