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/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Esma Oruç
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# watchwhere
|
|
2
|
+
|
|
3
|
+
CLI to check which of your streaming subs has a movie, in your region.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## why
|
|
8
|
+
|
|
9
|
+
I have a handful of streaming subs and I always forget which one has what.
|
|
10
|
+
Got tired of opening JustWatch every time — or clicking through each app
|
|
11
|
+
one by one to search. Terminal version of that lookup.
|
|
12
|
+
|
|
13
|
+
## quick start
|
|
14
|
+
|
|
15
|
+
Needs [Bun](https://bun.sh) (≥ 1.1).
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun install -g watchwhere
|
|
19
|
+
export WATCHWHERE_PROXY=https://watchwhere-proxy.ethsmaa.workers.dev
|
|
20
|
+
ww init # asks for region + your subscriptions
|
|
21
|
+
ww matrix # search a movie
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That's it. The hosted proxy handles TMDB calls — **no API token needed**.
|
|
25
|
+
|
|
26
|
+
To make the proxy stick across terminal sessions, add the `export` line to
|
|
27
|
+
your `~/.zshrc` or `~/.bashrc`.
|
|
28
|
+
|
|
29
|
+
## with your own TMDB token (no proxy)
|
|
30
|
+
|
|
31
|
+
Prefer to talk to TMDB directly? Get a free v4 Read Access Token from
|
|
32
|
+
[themoviedb.org/settings/api](https://www.themoviedb.org/settings/api)
|
|
33
|
+
(pick the **v4 Read Access Token**, not the v3 API key), then:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun install -g watchwhere
|
|
37
|
+
ww init # paste your token, then region + subs
|
|
38
|
+
ww matrix
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## commands
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
ww <title> search and show providers
|
|
45
|
+
ww init set up token, region, subscriptions
|
|
46
|
+
ww subs edit subscriptions
|
|
47
|
+
ww lang change UI language (en / tr)
|
|
48
|
+
ww region change region
|
|
49
|
+
ww config show current config
|
|
50
|
+
ww --help
|
|
51
|
+
ww --version
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## notes
|
|
55
|
+
|
|
56
|
+
- config lives in `~/.watchwhere/config.json`
|
|
57
|
+
- ui defaults to english, turkish available via `ww lang`
|
|
58
|
+
- self-host your own proxy from [`proxy/`](./proxy) if you'd rather not use
|
|
59
|
+
the hosted one
|
|
60
|
+
|
|
61
|
+
## license
|
|
62
|
+
|
|
63
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "watchwhere",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "region-aware streaming availability CLI — find which of your subs has a given title",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"watchwhere": "./src/index.ts",
|
|
8
|
+
"ww": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "bun run src/index.ts",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"prepare": "lefthook install"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"cli",
|
|
17
|
+
"tmdb",
|
|
18
|
+
"streaming",
|
|
19
|
+
"movies",
|
|
20
|
+
"bun",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
"author": "Esma Oruç",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/ethsmaa/watchwhere.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/ethsmaa/watchwhere/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/ethsmaa/watchwhere#readme",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@inquirer/prompts": "^7.2.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@commitlint/cli": "^21.0.1",
|
|
38
|
+
"@commitlint/config-conventional": "^21.0.1",
|
|
39
|
+
"@types/bun": "latest",
|
|
40
|
+
"lefthook": "^2.1.8",
|
|
41
|
+
"typescript": "^5.6.3"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"bun": ">=1.1.0"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"src",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
]
|
|
51
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getRegionProviders, type TmdbProvider } from "./tmdb.ts";
|
|
5
|
+
|
|
6
|
+
const CACHE_DIR = join(homedir(), ".watchwhere", "cache");
|
|
7
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
interface CacheEntry {
|
|
10
|
+
fetchedAt: string;
|
|
11
|
+
providers: ReadonlyArray<TmdbProvider>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cachePath(region: string): string {
|
|
15
|
+
return join(CACHE_DIR, `providers-${region.toUpperCase()}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseEntry(raw: unknown): CacheEntry | null {
|
|
19
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
20
|
+
const v = raw as { fetchedAt?: unknown; providers?: unknown };
|
|
21
|
+
if (typeof v.fetchedAt !== "string" || !Array.isArray(v.providers)) return null;
|
|
22
|
+
return { fetchedAt: v.fetchedAt, providers: v.providers as TmdbProvider[] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readCache(region: string): Promise<CacheEntry | null> {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(cachePath(region), "utf8");
|
|
28
|
+
const parsed = parseEntry(JSON.parse(raw) as unknown);
|
|
29
|
+
if (!parsed) return null;
|
|
30
|
+
const age = Date.now() - Date.parse(parsed.fetchedAt);
|
|
31
|
+
if (Number.isNaN(age) || age > TTL_MS || age < 0) return null;
|
|
32
|
+
return parsed;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeCache(region: string, providers: ReadonlyArray<TmdbProvider>): Promise<void> {
|
|
39
|
+
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
40
|
+
const entry: CacheEntry = { fetchedAt: new Date().toISOString(), providers };
|
|
41
|
+
const path = cachePath(region);
|
|
42
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
43
|
+
await writeFile(tmp, JSON.stringify(entry), "utf8");
|
|
44
|
+
await rename(tmp, path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getCachedRegionProviders(
|
|
48
|
+
region: string,
|
|
49
|
+
token: string,
|
|
50
|
+
language?: string,
|
|
51
|
+
): Promise<ReadonlyArray<TmdbProvider>> {
|
|
52
|
+
const cached = await readCache(region);
|
|
53
|
+
if (cached) return cached.providers;
|
|
54
|
+
const fresh = await getRegionProviders(region, token, language);
|
|
55
|
+
writeCache(region, fresh).catch(() => {});
|
|
56
|
+
return fresh;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function isCacheFresh(region: string): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
const s = await stat(cachePath(region));
|
|
62
|
+
return Date.now() - s.mtimeMs < TTL_MS;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/colors.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const enabled =
|
|
2
|
+
process.stdout.isTTY === true &&
|
|
3
|
+
process.env.NO_COLOR === undefined &&
|
|
4
|
+
process.env.TERM !== "dumb";
|
|
5
|
+
|
|
6
|
+
function wrap(code: string): (s: string) => string {
|
|
7
|
+
if (!enabled) return (s) => s;
|
|
8
|
+
return (s) => `\x1b[${code}m${s}\x1b[0m`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const c = {
|
|
12
|
+
bold: wrap("1"),
|
|
13
|
+
dim: wrap("2"),
|
|
14
|
+
red: wrap("31"),
|
|
15
|
+
green: wrap("32"),
|
|
16
|
+
yellow: wrap("33"),
|
|
17
|
+
blue: wrap("34"),
|
|
18
|
+
cyan: wrap("36"),
|
|
19
|
+
gray: wrap("90"),
|
|
20
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { c } from "../colors.ts";
|
|
2
|
+
import { configPath, loadConfig } from "../config.ts";
|
|
3
|
+
import { resolveLocale, t } from "../i18n.ts";
|
|
4
|
+
import { getCachedRegionProviders } from "../cache.ts";
|
|
5
|
+
|
|
6
|
+
function redact(token: string): string {
|
|
7
|
+
if (token.length <= 8) return "*".repeat(token.length);
|
|
8
|
+
return `${"*".repeat(token.length - 4)}${token.slice(-4)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function relativeTime(iso: string, m: ReturnType<typeof t>): string {
|
|
12
|
+
const then = Date.parse(iso);
|
|
13
|
+
if (Number.isNaN(then)) return iso;
|
|
14
|
+
const seconds = Math.floor((Date.now() - then) / 1000);
|
|
15
|
+
if (seconds < 60) return m.relativeNow;
|
|
16
|
+
const minutes = Math.floor(seconds / 60);
|
|
17
|
+
if (minutes < 60) return m.relativeMinutes(minutes);
|
|
18
|
+
const hours = Math.floor(minutes / 60);
|
|
19
|
+
if (hours < 24) return m.relativeHours(hours);
|
|
20
|
+
const days = Math.floor(hours / 24);
|
|
21
|
+
return m.relativeDays(days);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runConfig(): Promise<void> {
|
|
25
|
+
const cfg = await loadConfig();
|
|
26
|
+
if (!cfg) {
|
|
27
|
+
throw new Error(t(resolveLocale()).noConfigYet);
|
|
28
|
+
}
|
|
29
|
+
const m = t(resolveLocale(cfg.language));
|
|
30
|
+
|
|
31
|
+
process.stdout.write(c.dim(` ${m.resolvingNames}`));
|
|
32
|
+
let subNames: ReadonlyArray<string> = [];
|
|
33
|
+
try {
|
|
34
|
+
const providers = await getCachedRegionProviders(cfg.region, cfg.tmdbToken);
|
|
35
|
+
const byId = new Map(providers.map((p) => [p.provider_id, p.provider_name]));
|
|
36
|
+
subNames = cfg.subscriptions.map((id) => byId.get(id) ?? `#${id}`);
|
|
37
|
+
console.log(c.dim(m.done));
|
|
38
|
+
} catch {
|
|
39
|
+
subNames = cfg.subscriptions.map((id) => `#${id}`);
|
|
40
|
+
console.log(c.yellow(m.offline));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(` ${c.bold(m.configTitle)}`);
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(` ${m.regionLabel.padEnd(13)} ${cfg.region}`);
|
|
47
|
+
console.log(` ${m.languageLabel.padEnd(13)} ${cfg.language}`);
|
|
48
|
+
console.log(` ${m.tokenLabel.padEnd(13)} ${c.dim(redact(cfg.tmdbToken))}`);
|
|
49
|
+
console.log(` ${m.updatedLabel.padEnd(13)} ${c.dim(relativeTime(cfg.updatedAt, m))}`);
|
|
50
|
+
console.log(` ${m.pathLabel.padEnd(13)} ${c.dim(configPath)}`);
|
|
51
|
+
console.log();
|
|
52
|
+
console.log(` ${c.dim(`${m.subscriptionsLabel} (${subNames.length})`)}`);
|
|
53
|
+
if (subNames.length === 0) {
|
|
54
|
+
console.log(` ${c.dim(m.noSubs)}`);
|
|
55
|
+
} else {
|
|
56
|
+
for (const name of subNames) console.log(` ${c.green("●")} ${name}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { checkbox, input, password } 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
|
+
import { usingProxy, verifyToken } from "../tmdb.ts";
|
|
7
|
+
import { pickLanguage } from "./lang.ts";
|
|
8
|
+
|
|
9
|
+
export async function runInit(): Promise<void> {
|
|
10
|
+
const existing = await loadConfig();
|
|
11
|
+
const m = t(resolveLocale(existing?.language));
|
|
12
|
+
|
|
13
|
+
if (existing) {
|
|
14
|
+
console.log(c.dim(` ${m.existingConfig(existing.region)}`));
|
|
15
|
+
console.log();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let tmdbToken: string;
|
|
19
|
+
if (usingProxy) {
|
|
20
|
+
console.log(c.dim(` ${m.usingProxyNotice}`));
|
|
21
|
+
console.log();
|
|
22
|
+
tmdbToken = existing?.tmdbToken ?? "proxy";
|
|
23
|
+
} else {
|
|
24
|
+
tmdbToken = await password({
|
|
25
|
+
message: `${m.tokenPrompt} ${c.dim(`— ${m.tokenHint}`)}`,
|
|
26
|
+
mask: "*",
|
|
27
|
+
validate: (v) => (v.trim().length > 20 ? true : m.tokenTooShort),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
process.stdout.write(c.dim(` ${m.verifying}`));
|
|
31
|
+
const ok = await verifyToken(tmdbToken.trim());
|
|
32
|
+
if (!ok) {
|
|
33
|
+
console.log(c.red(m.failed));
|
|
34
|
+
throw new Error(m.invalidToken);
|
|
35
|
+
}
|
|
36
|
+
console.log(c.green(m.ok));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const region = (
|
|
40
|
+
await input({
|
|
41
|
+
message: m.regionPrompt,
|
|
42
|
+
default: existing?.region ?? "TR",
|
|
43
|
+
validate: (v) =>
|
|
44
|
+
/^[A-Za-z]{2}$/.test(v.trim()) ? true : m.regionInvalid,
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
.trim()
|
|
48
|
+
.toUpperCase();
|
|
49
|
+
|
|
50
|
+
const language = await pickLanguage(m, existing?.language);
|
|
51
|
+
|
|
52
|
+
process.stdout.write(c.dim(` ${m.loadingProviders(region)}`));
|
|
53
|
+
const providers = await getCachedRegionProviders(region, tmdbToken.trim());
|
|
54
|
+
console.log(c.dim(m.providersFound(providers.length)));
|
|
55
|
+
|
|
56
|
+
if (providers.length === 0) {
|
|
57
|
+
throw new Error(m.noProviders(region));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const choices = providers.map((p) => ({
|
|
61
|
+
name: p.provider_name,
|
|
62
|
+
value: p.provider_id,
|
|
63
|
+
checked: existing?.subscriptions.includes(p.provider_id) ?? false,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const subscriptions = await checkbox<number>({
|
|
67
|
+
message: `${m.yourSubsLabel(region)} ${c.dim(m.toggleHintConfirm)}:`,
|
|
68
|
+
choices,
|
|
69
|
+
pageSize: 15,
|
|
70
|
+
loop: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const saved = await saveConfig({
|
|
74
|
+
tmdbToken: tmdbToken.trim(),
|
|
75
|
+
region,
|
|
76
|
+
language,
|
|
77
|
+
subscriptions,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const m2 = t(resolveLocale(language));
|
|
81
|
+
console.log();
|
|
82
|
+
console.log(` ${c.green("✓")} ${m2.savedTo} ${c.dim("~/.watchwhere/config.json")}`);
|
|
83
|
+
console.log(` ${m2.regionLabel.padEnd(13)} ${saved.region}`);
|
|
84
|
+
console.log(` ${m2.languageLabel.padEnd(13)} ${saved.language}`);
|
|
85
|
+
console.log(` ${m2.subscriptionsLabel.padEnd(13)} ${saved.subscriptions.length}`);
|
|
86
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { select } from "@inquirer/prompts";
|
|
2
|
+
import { c } from "../colors.ts";
|
|
3
|
+
import { loadConfig, saveConfig } from "../config.ts";
|
|
4
|
+
import { resolveLocale, t } from "../i18n.ts";
|
|
5
|
+
|
|
6
|
+
export const LANGUAGE_PRESETS: ReadonlyArray<{ code: string; label: string }> = [
|
|
7
|
+
{ code: "tr-TR", label: "Türkçe" },
|
|
8
|
+
{ code: "en-US", label: "English" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export async function pickLanguage(
|
|
12
|
+
m: ReturnType<typeof t>,
|
|
13
|
+
current?: string,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
return select<string>({
|
|
16
|
+
message: m.displayLanguagePrompt,
|
|
17
|
+
choices: LANGUAGE_PRESETS.map((l) => ({
|
|
18
|
+
name: `${l.label} ${c.dim(`(${l.code})`)}`,
|
|
19
|
+
value: l.code,
|
|
20
|
+
})),
|
|
21
|
+
default: current ?? "en-US",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runLang(): Promise<void> {
|
|
26
|
+
const cfg = await loadConfig();
|
|
27
|
+
if (!cfg) {
|
|
28
|
+
throw new Error(t(resolveLocale()).noConfigYet);
|
|
29
|
+
}
|
|
30
|
+
const m = t(resolveLocale(cfg.language));
|
|
31
|
+
|
|
32
|
+
console.log(c.dim(` ${m.currentLanguage(cfg.language)}`));
|
|
33
|
+
console.log();
|
|
34
|
+
|
|
35
|
+
const language = await pickLanguage(m, cfg.language);
|
|
36
|
+
|
|
37
|
+
if (language === cfg.language) {
|
|
38
|
+
console.log();
|
|
39
|
+
console.log(c.dim(` ${m.currentLanguage(language)}`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await saveConfig({
|
|
44
|
+
tmdbToken: cfg.tmdbToken,
|
|
45
|
+
region: cfg.region,
|
|
46
|
+
language,
|
|
47
|
+
subscriptions: cfg.subscriptions,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const m2 = t(resolveLocale(language));
|
|
51
|
+
console.log();
|
|
52
|
+
console.log(` ${c.green("✓")} ${m2.languageUpdated(language)}`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { input } from "@inquirer/prompts";
|
|
2
|
+
import { c } from "../colors.ts";
|
|
3
|
+
import { loadConfig, saveConfig } from "../config.ts";
|
|
4
|
+
import { resolveLocale, t } from "../i18n.ts";
|
|
5
|
+
|
|
6
|
+
export async function runRegion(): Promise<void> {
|
|
7
|
+
const cfg = await loadConfig();
|
|
8
|
+
if (!cfg) {
|
|
9
|
+
throw new Error(t(resolveLocale()).noConfigYet);
|
|
10
|
+
}
|
|
11
|
+
const m = t(resolveLocale(cfg.language));
|
|
12
|
+
|
|
13
|
+
console.log(c.dim(` ${m.currentRegion(cfg.region)}`));
|
|
14
|
+
console.log();
|
|
15
|
+
|
|
16
|
+
const region = (
|
|
17
|
+
await input({
|
|
18
|
+
message: m.regionPrompt,
|
|
19
|
+
default: cfg.region,
|
|
20
|
+
validate: (v) =>
|
|
21
|
+
/^[A-Za-z]{2}$/.test(v.trim()) ? true : m.regionInvalid,
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
.trim()
|
|
25
|
+
.toUpperCase();
|
|
26
|
+
|
|
27
|
+
if (region === cfg.region) {
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(c.dim(` ${m.currentRegion(region)}`));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// subs are kept — some provider IDs may not exist in the new region
|
|
34
|
+
// but ww subs lets the user prune; don't destroy data silently
|
|
35
|
+
await saveConfig({
|
|
36
|
+
tmdbToken: cfg.tmdbToken,
|
|
37
|
+
region,
|
|
38
|
+
language: cfg.language,
|
|
39
|
+
subscriptions: cfg.subscriptions,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
console.log();
|
|
43
|
+
console.log(` ${c.green("✓")} ${m.regionUpdated(region)}`);
|
|
44
|
+
console.log(c.dim(` ${m.regionHintReviewSubs}`));
|
|
45
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { c } from "../colors.ts";
|
|
2
|
+
import type { Config } from "../config.ts";
|
|
3
|
+
import { resolveLocale, t } from "../i18n.ts";
|
|
4
|
+
import { picker } from "../picker.ts";
|
|
5
|
+
import { editableInput } from "../prompts.ts";
|
|
6
|
+
import {
|
|
7
|
+
getWatchProviders,
|
|
8
|
+
searchAll,
|
|
9
|
+
type MediaItem,
|
|
10
|
+
type TmdbProvider,
|
|
11
|
+
} from "../tmdb.ts";
|
|
12
|
+
|
|
13
|
+
const PAGE_SIZE = 10;
|
|
14
|
+
const SOFT_LIMIT = 25;
|
|
15
|
+
const OVERVIEW_MAX = 140;
|
|
16
|
+
|
|
17
|
+
function year(date: string | null): string {
|
|
18
|
+
return date && date.length >= 4 ? date.slice(0, 4) : "????";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tag(type: MediaItem["mediaType"], m: ReturnType<typeof t>): string {
|
|
22
|
+
return c.dim(`[${type === "tv" ? m.tagTv : m.tagMovie}]`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pad(s: string, n: number): string {
|
|
26
|
+
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mark(owned: boolean): string {
|
|
30
|
+
return owned ? c.green("✓") : c.red("✗");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function joinNames(providers: ReadonlyArray<TmdbProvider> | undefined): string {
|
|
34
|
+
return providers?.map((p) => p.provider_name).join(", ") ?? "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function pickItem(
|
|
38
|
+
results: ReadonlyArray<MediaItem>,
|
|
39
|
+
m: ReturnType<typeof t>,
|
|
40
|
+
): Promise<MediaItem | null> {
|
|
41
|
+
const visible = results.slice(0, SOFT_LIMIT);
|
|
42
|
+
const dropped = results.length - visible.length;
|
|
43
|
+
|
|
44
|
+
const choices = visible.map((mi) => ({
|
|
45
|
+
name: `${tag(mi.mediaType, m)} ${mi.title} ${c.dim(`(${year(mi.date)})`)}${mi.title !== mi.originalTitle ? c.dim(` — ${mi.originalTitle}`) : ""}`,
|
|
46
|
+
value: mi,
|
|
47
|
+
description: mi.overview.slice(0, OVERVIEW_MAX),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const headerParts: string[] = [m.whichOne];
|
|
51
|
+
if (dropped > 0) {
|
|
52
|
+
headerParts.push(c.dim(`(${visible.length}/${results.length})`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return picker<MediaItem>({
|
|
56
|
+
message: headerParts.join(" "),
|
|
57
|
+
pageSize: PAGE_SIZE,
|
|
58
|
+
choices,
|
|
59
|
+
extraKeys: [["esc", "edit"]],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printOwnership(
|
|
64
|
+
flat: ReadonlyArray<TmdbProvider>,
|
|
65
|
+
owned: ReadonlySet<number>,
|
|
66
|
+
m: ReturnType<typeof t>,
|
|
67
|
+
): void {
|
|
68
|
+
if (flat.length === 0) return;
|
|
69
|
+
const sorted = [...flat].sort((a, b) => {
|
|
70
|
+
const ao = owned.has(a.provider_id) ? 0 : 1;
|
|
71
|
+
const bo = owned.has(b.provider_id) ? 0 : 1;
|
|
72
|
+
return ao - bo || a.provider_name.localeCompare(b.provider_name);
|
|
73
|
+
});
|
|
74
|
+
const width = Math.max(...sorted.map((p) => p.provider_name.length));
|
|
75
|
+
for (const p of sorted) {
|
|
76
|
+
const isOwned = owned.has(p.provider_id);
|
|
77
|
+
const name = pad(p.provider_name, width);
|
|
78
|
+
const status = isOwned ? c.green(m.owned) : c.dim(m.notOwned);
|
|
79
|
+
console.log(` ${c.dim(m.onPrefix)} ${name} ${mark(isOwned)} ${status}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function displayItem(
|
|
84
|
+
item: MediaItem,
|
|
85
|
+
cfg: Config,
|
|
86
|
+
m: ReturnType<typeof t>,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
process.stdout.write(c.dim(` ${m.fetchingProviders(cfg.region)}`));
|
|
89
|
+
const providers = await getWatchProviders(item.id, item.mediaType, cfg.tmdbToken);
|
|
90
|
+
console.log(c.dim(m.done));
|
|
91
|
+
|
|
92
|
+
const regionData = providers.results[cfg.region];
|
|
93
|
+
const owned = new Set(cfg.subscriptions);
|
|
94
|
+
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(
|
|
97
|
+
` ${c.bold(item.title)} ${c.dim(`(${year(item.date)})`)} ${tag(item.mediaType, m)} ${c.dim(cfg.region)}`,
|
|
98
|
+
);
|
|
99
|
+
console.log();
|
|
100
|
+
|
|
101
|
+
if (!regionData) {
|
|
102
|
+
console.log(` ${c.yellow(m.notAvailable(cfg.region))}`);
|
|
103
|
+
console.log(c.dim(` ${m.notAvailableHint}`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const flat = regionData.flatrate ?? [];
|
|
108
|
+
const ownedHits = flat.filter((p) => owned.has(p.provider_id));
|
|
109
|
+
|
|
110
|
+
if (ownedHits.length > 0) {
|
|
111
|
+
const names = ownedHits.map((p) => p.provider_name).join(", ");
|
|
112
|
+
console.log(` ${c.green("●")} ${m.onYourSubsPrefix} ${c.bold(names)}`);
|
|
113
|
+
} else if (flat.length > 0) {
|
|
114
|
+
console.log(` ${c.yellow("●")} ${m.streamingNotOnSubs(cfg.region)}`);
|
|
115
|
+
} else {
|
|
116
|
+
console.log(` ${c.yellow("●")} ${m.noStreamingSub(cfg.region)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (flat.length > 0) {
|
|
120
|
+
console.log();
|
|
121
|
+
printOwnership(flat, owned, m);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const free = regionData.free ?? [];
|
|
125
|
+
if (free.length > 0) {
|
|
126
|
+
console.log();
|
|
127
|
+
console.log(c.dim(` ${m.free}`));
|
|
128
|
+
printOwnership(free, owned, m);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ads = regionData.ads ?? [];
|
|
132
|
+
if (ads.length > 0) {
|
|
133
|
+
console.log();
|
|
134
|
+
console.log(` ${pad(m.ads, 6)}${joinNames(ads)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rent = regionData.rent ?? [];
|
|
138
|
+
const buy = regionData.buy ?? [];
|
|
139
|
+
if (rent.length > 0 || buy.length > 0) {
|
|
140
|
+
if (ads.length === 0) console.log();
|
|
141
|
+
if (rent.length > 0) console.log(` ${pad(m.rent, 6)}${joinNames(rent)}`);
|
|
142
|
+
if (buy.length > 0) console.log(` ${pad(m.buy, 6)}${joinNames(buy)}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (regionData.link) {
|
|
146
|
+
console.log();
|
|
147
|
+
console.log(` ${c.dim(m.link)} ${c.cyan(regionData.link)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function runSearch(query: string, cfg: Config): Promise<void> {
|
|
152
|
+
const m = t(resolveLocale(cfg.language));
|
|
153
|
+
let currentQuery = query.trim();
|
|
154
|
+
if (!currentQuery) throw new Error(m.searchEmpty);
|
|
155
|
+
|
|
156
|
+
// pipe mode: no prompts
|
|
157
|
+
if (!process.stdin.isTTY) {
|
|
158
|
+
process.stdout.write(c.dim(` ${m.searching(currentQuery)}`));
|
|
159
|
+
const results = await searchAll(currentQuery, cfg.tmdbToken, {
|
|
160
|
+
region: cfg.region,
|
|
161
|
+
language: cfg.language,
|
|
162
|
+
});
|
|
163
|
+
console.log(c.dim(m.resultsCount(results.length)));
|
|
164
|
+
if (results.length === 0) throw new Error(m.noMatch);
|
|
165
|
+
if (results.length > 1) throw new Error(m.ambiguousQuery(results.length));
|
|
166
|
+
await displayItem(results[0]!, cfg, m);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
while (true) {
|
|
171
|
+
process.stdout.write(c.dim(` ${m.searching(currentQuery)}`));
|
|
172
|
+
const results = await searchAll(currentQuery, cfg.tmdbToken, {
|
|
173
|
+
region: cfg.region,
|
|
174
|
+
language: cfg.language,
|
|
175
|
+
});
|
|
176
|
+
console.log(c.dim(m.resultsCount(results.length)));
|
|
177
|
+
|
|
178
|
+
let picked: MediaItem | null = null;
|
|
179
|
+
if (results.length > 0) {
|
|
180
|
+
picked = await pickItem(results, m);
|
|
181
|
+
} else {
|
|
182
|
+
console.log(`\n ${m.noMatch}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (picked !== null) {
|
|
186
|
+
await displayItem(picked, cfg, m);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const next = (
|
|
191
|
+
await editableInput({
|
|
192
|
+
message: c.dim("ww"),
|
|
193
|
+
prefill: currentQuery,
|
|
194
|
+
})
|
|
195
|
+
).trim();
|
|
196
|
+
if (!next) throw new Error(m.searchEmpty);
|
|
197
|
+
currentQuery = next;
|
|
198
|
+
}
|
|
199
|
+
}
|