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 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
+ ![demo](docs/demo.gif)
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
+ }