watchwhere 0.2.3 → 0.3.1

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 CHANGED
@@ -61,6 +61,10 @@ ww --version
61
61
  - self-host your own proxy from [`proxy/`](./proxy) if you'd rather not use
62
62
  the hosted one
63
63
 
64
+ ## attribution
65
+
66
+ this product uses the TMDB API but is not endorsed or certified by TMDB.
67
+
64
68
  ## license
65
69
 
66
70
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchwhere",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "region-aware streaming availability CLI — find which of your subs has a given title",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,12 @@ import { c } from "../colors.ts";
3
3
  import { loadConfig, saveConfig } from "../config.ts";
4
4
  import { resolveLocale, t } from "../i18n.ts";
5
5
  import { getCachedRegionProviders } from "../cache.ts";
6
- import { usingProxy, verifyToken } from "../tmdb.ts";
6
+ import {
7
+ applyRegionPinning,
8
+ isSubscriptionProvider,
9
+ usingProxy,
10
+ verifyToken,
11
+ } from "../tmdb.ts";
7
12
  import { pickLanguage } from "./lang.ts";
8
13
 
9
14
  export async function runInit(): Promise<void> {
@@ -50,7 +55,11 @@ export async function runInit(): Promise<void> {
50
55
  const language = await pickLanguage(m, existing?.language);
51
56
 
52
57
  process.stdout.write(c.dim(` ${m.loadingProviders(region)}`));
53
- const providers = await getCachedRegionProviders(region, tmdbToken.trim());
58
+ const allProviders = await getCachedRegionProviders(region, tmdbToken.trim());
59
+ const providers = applyRegionPinning(
60
+ allProviders.filter(isSubscriptionProvider),
61
+ region,
62
+ );
54
63
  console.log(c.dim(m.providersFound(providers.length)));
55
64
 
56
65
  if (providers.length === 0) {
@@ -68,6 +77,7 @@ export async function runInit(): Promise<void> {
68
77
  choices,
69
78
  pageSize: 15,
70
79
  loop: false,
80
+ theme: { keybindings: ["vim"] },
71
81
  });
72
82
 
73
83
  const saved = await saveConfig({
@@ -4,15 +4,30 @@ import { resolveLocale, t } from "../i18n.ts";
4
4
  import { picker } from "../picker.ts";
5
5
  import { editableInput } from "../prompts.ts";
6
6
  import {
7
+ getReleaseDates,
7
8
  getWatchProviders,
8
9
  searchAll,
9
10
  type MediaItem,
10
11
  type TmdbProvider,
12
+ type TmdbReleaseDate,
11
13
  } from "../tmdb.ts";
12
14
 
13
15
  const PAGE_SIZE = 10;
14
16
  const SOFT_LIMIT = 25;
15
17
  const OVERVIEW_MAX = 140;
18
+ const OLDEST_FILM_YEAR = 1888;
19
+
20
+ function parseQuery(input: string): { query: string; year?: number } {
21
+ const match = input.match(/^(.+?)\s+(\d{4})$/);
22
+ if (match && match[1] && match[2]) {
23
+ const year = Number.parseInt(match[2], 10);
24
+ const maxYear = new Date().getFullYear() + 2;
25
+ if (year >= OLDEST_FILM_YEAR && year <= maxYear) {
26
+ return { query: match[1].trim(), year };
27
+ }
28
+ }
29
+ return { query: input };
30
+ }
16
31
 
17
32
  function year(date: string | null): string {
18
33
  return date && date.length >= 4 ? date.slice(0, 4) : "????";
@@ -34,6 +49,54 @@ function joinNames(providers: ReadonlyArray<TmdbProvider> | undefined): string {
34
49
  return providers?.map((p) => p.provider_name).join(", ") ?? "";
35
50
  }
36
51
 
52
+ // pick at most one entry per modern format (4k / blu-ray / dvd), latest first.
53
+ // older formats (laserdisc, vhs, steelbook, etc.) are dropped to keep output tight.
54
+ const PHYSICAL_FORMATS: ReadonlyArray<{ key: string; match: RegExp }> = [
55
+ { key: "4k uhd", match: /\b(4k|uhd)\b/i },
56
+ { key: "blu-ray", match: /blu.?ray/i },
57
+ { key: "dvd", match: /\bdvd\b/i },
58
+ ];
59
+
60
+ function printPhysical(
61
+ releases: ReadonlyArray<TmdbReleaseDate>,
62
+ region: string,
63
+ m: ReturnType<typeof t>,
64
+ ): void {
65
+ const physical = releases
66
+ .filter((r) => r.type === 5 && r.note.trim() !== "")
67
+ .sort((a, b) => b.release_date.localeCompare(a.release_date));
68
+ if (physical.length === 0) return;
69
+
70
+ const byFormat = new Map<string, TmdbReleaseDate>();
71
+ for (const r of physical) {
72
+ for (const fmt of PHYSICAL_FORMATS) {
73
+ if (fmt.match.test(r.note) && !byFormat.has(fmt.key)) {
74
+ byFormat.set(fmt.key, r);
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ if (byFormat.size === 0) return;
80
+
81
+ const ordered = PHYSICAL_FORMATS.map((fmt) => byFormat.get(fmt.key)).filter(
82
+ (r): r is TmdbReleaseDate => r !== undefined,
83
+ );
84
+
85
+ console.log();
86
+ console.log(c.dim(` ${m.physicalLabel} (${region})`));
87
+ const width = Math.max(
88
+ ...PHYSICAL_FORMATS.filter((fmt) => byFormat.has(fmt.key)).map(
89
+ (fmt) => fmt.key.length,
90
+ ),
91
+ );
92
+ for (const r of ordered) {
93
+ const key = PHYSICAL_FORMATS.find((fmt) => fmt.match.test(r.note))!.key;
94
+ const label = pad(key, width + 2);
95
+ const date = r.release_date.slice(0, 10);
96
+ console.log(` ${c.dim(label)}${date}`);
97
+ }
98
+ }
99
+
37
100
  async function pickItem(
38
101
  results: ReadonlyArray<MediaItem>,
39
102
  m: ReturnType<typeof t>,
@@ -86,7 +149,13 @@ async function displayItem(
86
149
  m: ReturnType<typeof t>,
87
150
  ): Promise<void> {
88
151
  process.stdout.write(c.dim(` ${m.fetchingProviders(cfg.region)}`));
89
- const providers = await getWatchProviders(item.id, item.mediaType, cfg.tmdbToken);
152
+ const [providers, releases] = await Promise.all([
153
+ getWatchProviders(item.id, item.mediaType, cfg.tmdbToken),
154
+ // physical releases only exist for movies; failures don't break the search
155
+ item.mediaType === "movie"
156
+ ? getReleaseDates(item.id, cfg.tmdbToken).catch(() => null)
157
+ : Promise.resolve(null),
158
+ ]);
90
159
  console.log(c.dim(m.done));
91
160
 
92
161
  const regionData = providers.results[cfg.region];
@@ -142,9 +211,21 @@ async function displayItem(
142
211
  if (buy.length > 0) console.log(` ${pad(m.buy, 10)}${joinNames(buy)}`);
143
212
  }
144
213
 
145
- if (regionData.link) {
146
- console.log();
147
- console.log(` ${c.dim(m.link)} ${c.cyan(regionData.link)}`);
214
+ if (releases) {
215
+ // physical release data on TMDB is sparse outside of US; if the user's
216
+ // region has nothing labeled, fall back to US so the feature still works
217
+ const inRegion =
218
+ releases.results.find((r) => r.iso_3166_1 === cfg.region)?.release_dates ?? [];
219
+ const hasLabeled = inRegion.some(
220
+ (r) => r.type === 5 && r.note.trim() !== "",
221
+ );
222
+ if (hasLabeled) {
223
+ printPhysical(inRegion, cfg.region, m);
224
+ } else {
225
+ const usReleases =
226
+ releases.results.find((r) => r.iso_3166_1 === "US")?.release_dates ?? [];
227
+ printPhysical(usReleases, "US", m);
228
+ }
148
229
  }
149
230
  }
150
231
 
@@ -153,14 +234,29 @@ export async function runSearch(query: string, cfg: Config): Promise<void> {
153
234
  let currentQuery = query.trim();
154
235
  if (!currentQuery) throw new Error(m.searchEmpty);
155
236
 
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,
237
+ const runSearchOnce = async (
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, {
161
248
  language: cfg.language,
162
249
  });
250
+ const results = parsed.year
251
+ ? all.filter((r) => r.date?.slice(0, 4) === String(parsed.year))
252
+ : all;
163
253
  console.log(c.dim(m.resultsCount(results.length)));
254
+ return results;
255
+ };
256
+
257
+ // pipe mode: no prompts
258
+ if (!process.stdin.isTTY) {
259
+ const results = await runSearchOnce(currentQuery);
164
260
  if (results.length === 0) throw new Error(m.noMatch);
165
261
  if (results.length > 1) throw new Error(m.ambiguousQuery(results.length));
166
262
  await displayItem(results[0]!, cfg, m);
@@ -168,12 +264,7 @@ export async function runSearch(query: string, cfg: Config): Promise<void> {
168
264
  }
169
265
 
170
266
  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)));
267
+ const results = await runSearchOnce(currentQuery);
177
268
 
178
269
  let picked: MediaItem | null = null;
179
270
  if (results.length > 0) {
@@ -3,6 +3,7 @@ import { c } from "../colors.ts";
3
3
  import { loadConfig, saveConfig } from "../config.ts";
4
4
  import { resolveLocale, t } from "../i18n.ts";
5
5
  import { getCachedRegionProviders } from "../cache.ts";
6
+ import { applyRegionPinning, isSubscriptionProvider } from "../tmdb.ts";
6
7
 
7
8
  export async function runSubs(): Promise<void> {
8
9
  const cfg = await loadConfig();
@@ -12,7 +13,11 @@ export async function runSubs(): Promise<void> {
12
13
  const m = t(resolveLocale(cfg.language));
13
14
 
14
15
  process.stdout.write(c.dim(` ${m.loadingProviders(cfg.region)}`));
15
- const providers = await getCachedRegionProviders(cfg.region, cfg.tmdbToken);
16
+ const allProviders = await getCachedRegionProviders(cfg.region, cfg.tmdbToken);
17
+ const providers = applyRegionPinning(
18
+ allProviders.filter(isSubscriptionProvider),
19
+ cfg.region,
20
+ );
16
21
  console.log(c.dim(m.providersFound(providers.length)));
17
22
 
18
23
  if (providers.length === 0) {
@@ -30,6 +35,7 @@ export async function runSubs(): Promise<void> {
30
35
  })),
31
36
  pageSize: 15,
32
37
  loop: false,
38
+ theme: { keybindings: ["vim"] },
33
39
  });
34
40
 
35
41
  const added = subscriptions.filter((id) => !current.has(id)).length;
package/src/i18n.ts CHANGED
@@ -52,7 +52,6 @@ interface Catalog {
52
52
  ads: string;
53
53
  rent: string;
54
54
  buy: string;
55
- link: string;
56
55
 
57
56
  // config (show)
58
57
  noConfigYet: string;
@@ -119,6 +118,7 @@ interface Catalog {
119
118
  tokenExpired: string;
120
119
  networkOffline: string;
121
120
  networkTimeout: string;
121
+ rateLimited: string;
122
122
  relativeNow: string;
123
123
  relativeMinutes: (n: number) => string;
124
124
  relativeHours: (n: number) => string;
@@ -130,6 +130,10 @@ interface Catalog {
130
130
 
131
131
  // update notifier
132
132
  updateAvailable: (version: string) => string;
133
+
134
+ // physical releases
135
+ physicalLabel: string;
136
+ noPhysicalRelease: string;
133
137
  }
134
138
 
135
139
  const en: Catalog = {
@@ -163,7 +167,6 @@ const en: Catalog = {
163
167
  ads: "ads",
164
168
  rent: "rent",
165
169
  buy: "buy",
166
- link: "link",
167
170
 
168
171
  noConfigYet: "no config yet — run `ww init` first.",
169
172
  resolvingNames: "resolving subscription names… ",
@@ -226,6 +229,7 @@ const en: Catalog = {
226
229
  tokenExpired: "TMDB token rejected (401). run `ww init` to re-enter it.",
227
230
  networkOffline: "couldn't reach TMDB. check your internet connection.",
228
231
  networkTimeout: "TMDB request timed out. try again, or check your connection.",
232
+ rateLimited: "rate limit hit. try again in an hour, or set WATCHWHERE_PROXY=off to use your own TMDB token.",
229
233
  relativeNow: "just now",
230
234
  relativeMinutes: (n) => `${n} min ago`,
231
235
  relativeHours: (n) => `${n} hr ago`,
@@ -235,6 +239,9 @@ const en: Catalog = {
235
239
  ambiguousQuery: (n) => `${n} matches, query is ambiguous. refine, or run interactively.`,
236
240
 
237
241
  updateAvailable: (version) => `new version available: ${version} (bun install -g watchwhere)`,
242
+
243
+ physicalLabel: "physical",
244
+ noPhysicalRelease: "no physical release in this region",
238
245
  };
239
246
 
240
247
  const tr: Catalog = {
@@ -270,7 +277,6 @@ const tr: Catalog = {
270
277
  ads: "reklamlı",
271
278
  rent: "kirala",
272
279
  buy: "satın al",
273
- link: "bağlantı",
274
280
 
275
281
  noConfigYet: "henüz config yok — önce `ww init` çalıştır.",
276
282
  resolvingNames: "abonelik isimleri çözümleniyor… ",
@@ -334,6 +340,7 @@ const tr: Catalog = {
334
340
  tokenExpired: "TMDB token reddedildi (401). yenilemek için `ww init` çalıştır.",
335
341
  networkOffline: "TMDB'ye ulaşılamadı. internet bağlantını kontrol et.",
336
342
  networkTimeout: "TMDB isteği zaman aşımına uğradı. tekrar dene veya bağlantını kontrol et.",
343
+ rateLimited: "rate limit doldu. bir saat sonra dene veya WATCHWHERE_PROXY=off ile kendi TMDB token'ını kullan.",
337
344
  relativeNow: "şimdi",
338
345
  relativeMinutes: (n) => `${n} dk önce`,
339
346
  relativeHours: (n) => `${n} saat önce`,
@@ -343,6 +350,9 @@ const tr: Catalog = {
343
350
  ambiguousQuery: (n) => `${n} eşleşme var, sorgu belirsiz. daralt veya interaktif çalıştır.`,
344
351
 
345
352
  updateAvailable: (version) => `yeni sürüm var: ${version} (bun install -g watchwhere)`,
353
+
354
+ physicalLabel: "fiziksel",
355
+ noPhysicalRelease: "bu bölgede fiziksel release yok",
346
356
  };
347
357
 
348
358
  const CATALOGS: Record<Locale, Catalog> = { en, tr };
package/src/index.ts CHANGED
@@ -144,7 +144,7 @@ async function maybeNotifyUpdate(): Promise<void> {
144
144
  const cfg = await loadConfig().catch(() => null);
145
145
  const m = t(resolveLocale(cfg?.language));
146
146
  console.log();
147
- console.log(c.dim(` ${c.yellow("›")} ${m.updateAvailable(newer)}`));
147
+ console.log(` ${c.yellow("›")} ${c.dim(m.updateAvailable(newer))}`);
148
148
  } catch {
149
149
  // best-effort, never block exit
150
150
  }
@@ -162,6 +162,7 @@ main().then(maybeNotifyUpdate).catch(async (err: unknown) => {
162
162
  let displayMsg = err instanceof Error ? err.message : String(err);
163
163
  if (err instanceof TmdbError) {
164
164
  if (err.status === 401) displayMsg = m.tokenExpired;
165
+ else if (err.status === 429) displayMsg = m.rateLimited;
165
166
  else if (err.status === 0 && err.message.includes("timed out")) {
166
167
  displayMsg = m.networkTimeout;
167
168
  } else if (err.status === 0) {
package/src/picker.ts CHANGED
@@ -7,10 +7,14 @@ import {
7
7
  useKeypress,
8
8
  usePagination,
9
9
  usePrefix,
10
+ useRef,
10
11
  useState,
11
12
  } from "@inquirer/core";
13
+ import { cursorHide } from "@inquirer/ansi";
12
14
  import { c } from "./colors.ts";
13
15
 
16
+ const GG_TIMEOUT_MS = 500;
17
+
14
18
  export interface PickerChoice<Value> {
15
19
  readonly name: string;
16
20
  readonly value: Value;
@@ -50,9 +54,14 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
50
54
  const prefix = usePrefix({ status, theme });
51
55
  const items = config.choices;
52
56
  const [active, setActive] = useState(0);
57
+ const lastGAt = useRef(0);
53
58
 
54
59
  useKeypress((key) => {
55
- if (key.name === "escape") {
60
+ // sequence is present at runtime but not in the type; cast to read it
61
+ const seq = (key as unknown as { sequence?: string }).sequence;
62
+
63
+ // esc or q: cancel
64
+ if (key.name === "escape" || seq === "q") {
56
65
  setCancelled(true);
57
66
  setStatus("done");
58
67
  done(null);
@@ -64,11 +73,29 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
64
73
  if (chosen) done(chosen.value);
65
74
  return;
66
75
  }
67
- if (isUpKey(key, [])) {
76
+ // G (shift+g): bottom
77
+ if (seq === "G") {
78
+ setActive(items.length - 1);
79
+ return;
80
+ }
81
+ // gg: top (two g within timeout)
82
+ if (seq === "g") {
83
+ const now = Date.now();
84
+ if (now - lastGAt.current < GG_TIMEOUT_MS) {
85
+ setActive(0);
86
+ lastGAt.current = 0;
87
+ } else {
88
+ lastGAt.current = now;
89
+ }
90
+ return;
91
+ }
92
+ // up or k
93
+ if (isUpKey(key, []) || seq === "k") {
68
94
  if (active > 0) setActive(active - 1);
69
95
  return;
70
96
  }
71
- if (isDownKey(key, [])) {
97
+ // down or j
98
+ if (isDownKey(key, []) || seq === "j") {
72
99
  if (active < items.length - 1) setActive(active + 1);
73
100
  return;
74
101
  }
@@ -98,7 +125,7 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
98
125
 
99
126
  const description = items[active]?.description;
100
127
  const helpLine = [
101
- ["↑↓", "navigate"],
128
+ ["↑↓ jk", "navigate"],
102
129
  ["⏎", "select"],
103
130
  ...(config.extraKeys ?? []),
104
131
  ]
@@ -112,7 +139,7 @@ const promptImpl = createPrompt<unknown | null, PickerConfig<unknown>>(
112
139
  helpLine,
113
140
  ]
114
141
  .filter((line) => line !== "")
115
- .join("\n");
142
+ .join("\n") + cursorHide;
116
143
  },
117
144
  );
118
145
 
package/src/tmdb.ts CHANGED
@@ -72,6 +72,24 @@ export interface TmdbProvidersListResponse {
72
72
  readonly results: ReadonlyArray<TmdbProvider>;
73
73
  }
74
74
 
75
+ export interface TmdbReleaseDate {
76
+ readonly certification: string;
77
+ readonly iso_639_1: string;
78
+ readonly note: string;
79
+ readonly release_date: string;
80
+ readonly type: number;
81
+ }
82
+
83
+ export interface TmdbReleaseDatesByRegion {
84
+ readonly iso_3166_1: string;
85
+ readonly release_dates: ReadonlyArray<TmdbReleaseDate>;
86
+ }
87
+
88
+ export interface TmdbReleaseDatesResponse {
89
+ readonly id: number;
90
+ readonly results: ReadonlyArray<TmdbReleaseDatesByRegion>;
91
+ }
92
+
75
93
  export class TmdbError extends Error {
76
94
  constructor(
77
95
  message: string,
@@ -169,6 +187,16 @@ export function getWatchProviders(
169
187
  );
170
188
  }
171
189
 
190
+ export function getReleaseDates(
191
+ id: number,
192
+ token: string,
193
+ ): Promise<TmdbReleaseDatesResponse> {
194
+ return tmdbFetch<TmdbReleaseDatesResponse>(
195
+ `/movie/${id}/release_dates`,
196
+ token,
197
+ );
198
+ }
199
+
172
200
  export function toMediaItem(
173
201
  item: TmdbMovie | TmdbTv,
174
202
  mediaType: MediaType,
@@ -239,3 +267,50 @@ export async function verifyToken(token: string): Promise<boolean> {
239
267
  return false;
240
268
  }
241
269
  }
270
+
271
+ // rental-only storefronts on TMDB. these aren't subscriptions, so they
272
+ // shouldn't pollute the "your subs" picker. they still appear correctly
273
+ // under rent/buy sections for individual movies.
274
+ const RENTAL_ONLY_IDS: ReadonlySet<number> = new Set([
275
+ 2, // Apple TV (storefront, not Apple TV+)
276
+ 3, // Google Play Movies
277
+ 7, // Fandango at Home (formerly Vudu)
278
+ 10, // Amazon Video (a-la-carte rental, separate from Prime Video)
279
+ 68, // Microsoft Store
280
+ 192, // YouTube (rental, separate from YouTube Premium)
281
+ ]);
282
+
283
+ export function isSubscriptionProvider(provider: TmdbProvider): boolean {
284
+ return !RENTAL_ONLY_IDS.has(provider.provider_id);
285
+ }
286
+
287
+ // per-region pin order. provider ids listed here float to the top in the
288
+ // listed order; everything else falls back to TMDB's display_priority.
289
+ // regions not listed use TMDB order unchanged.
290
+ const REGION_PIN_ORDER: Record<string, ReadonlyArray<number>> = {
291
+ TR: [
292
+ 8, // Netflix
293
+ 119, // Amazon Prime Video
294
+ 337, // Disney Plus
295
+ 1899, // Max (HBO Max)
296
+ 11, // MUBI
297
+ 188, // YouTube Premium
298
+ ],
299
+ };
300
+
301
+ export function applyRegionPinning(
302
+ providers: ReadonlyArray<TmdbProvider>,
303
+ region: string,
304
+ ): ReadonlyArray<TmdbProvider> {
305
+ const pinned = REGION_PIN_ORDER[region];
306
+ if (!pinned || pinned.length === 0) return providers;
307
+ const orderById = new Map(pinned.map((id, i) => [id, i]));
308
+ const top: TmdbProvider[] = [];
309
+ const rest: TmdbProvider[] = [];
310
+ for (const p of providers) {
311
+ if (orderById.has(p.provider_id)) top.push(p);
312
+ else rest.push(p);
313
+ }
314
+ top.sort((a, b) => orderById.get(a.provider_id)! - orderById.get(b.provider_id)!);
315
+ return [...top, ...rest];
316
+ }