watchwhere 0.2.2 → 0.3.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/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.2",
3
+ "version": "0.3.0",
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) {
@@ -4,10 +4,12 @@ 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;
@@ -34,6 +36,54 @@ function joinNames(providers: ReadonlyArray<TmdbProvider> | undefined): string {
34
36
  return providers?.map((p) => p.provider_name).join(", ") ?? "";
35
37
  }
36
38
 
39
+ // pick at most one entry per modern format (4k / blu-ray / dvd), latest first.
40
+ // older formats (laserdisc, vhs, steelbook, etc.) are dropped to keep output tight.
41
+ const PHYSICAL_FORMATS: ReadonlyArray<{ key: string; match: RegExp }> = [
42
+ { key: "4k uhd", match: /\b(4k|uhd)\b/i },
43
+ { key: "blu-ray", match: /blu.?ray/i },
44
+ { key: "dvd", match: /\bdvd\b/i },
45
+ ];
46
+
47
+ function printPhysical(
48
+ releases: ReadonlyArray<TmdbReleaseDate>,
49
+ region: string,
50
+ m: ReturnType<typeof t>,
51
+ ): void {
52
+ const physical = releases
53
+ .filter((r) => r.type === 5 && r.note.trim() !== "")
54
+ .sort((a, b) => b.release_date.localeCompare(a.release_date));
55
+ if (physical.length === 0) return;
56
+
57
+ const byFormat = new Map<string, TmdbReleaseDate>();
58
+ for (const r of physical) {
59
+ for (const fmt of PHYSICAL_FORMATS) {
60
+ if (fmt.match.test(r.note) && !byFormat.has(fmt.key)) {
61
+ byFormat.set(fmt.key, r);
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ if (byFormat.size === 0) return;
67
+
68
+ const ordered = PHYSICAL_FORMATS.map((fmt) => byFormat.get(fmt.key)).filter(
69
+ (r): r is TmdbReleaseDate => r !== undefined,
70
+ );
71
+
72
+ console.log();
73
+ console.log(c.dim(` ${m.physicalLabel} (${region})`));
74
+ const width = Math.max(
75
+ ...PHYSICAL_FORMATS.filter((fmt) => byFormat.has(fmt.key)).map(
76
+ (fmt) => fmt.key.length,
77
+ ),
78
+ );
79
+ for (const r of ordered) {
80
+ const key = PHYSICAL_FORMATS.find((fmt) => fmt.match.test(r.note))!.key;
81
+ const label = pad(key, width + 2);
82
+ const date = r.release_date.slice(0, 10);
83
+ console.log(` ${c.dim(label)}${date}`);
84
+ }
85
+ }
86
+
37
87
  async function pickItem(
38
88
  results: ReadonlyArray<MediaItem>,
39
89
  m: ReturnType<typeof t>,
@@ -86,7 +136,13 @@ async function displayItem(
86
136
  m: ReturnType<typeof t>,
87
137
  ): Promise<void> {
88
138
  process.stdout.write(c.dim(` ${m.fetchingProviders(cfg.region)}`));
89
- const providers = await getWatchProviders(item.id, item.mediaType, cfg.tmdbToken);
139
+ const [providers, releases] = await Promise.all([
140
+ getWatchProviders(item.id, item.mediaType, cfg.tmdbToken),
141
+ // physical releases only exist for movies; failures don't break the search
142
+ item.mediaType === "movie"
143
+ ? getReleaseDates(item.id, cfg.tmdbToken).catch(() => null)
144
+ : Promise.resolve(null),
145
+ ]);
90
146
  console.log(c.dim(m.done));
91
147
 
92
148
  const regionData = providers.results[cfg.region];
@@ -142,9 +198,21 @@ async function displayItem(
142
198
  if (buy.length > 0) console.log(` ${pad(m.buy, 10)}${joinNames(buy)}`);
143
199
  }
144
200
 
145
- if (regionData.link) {
146
- console.log();
147
- console.log(` ${c.dim(m.link)} ${c.cyan(regionData.link)}`);
201
+ if (releases) {
202
+ // physical release data on TMDB is sparse outside of US; if the user's
203
+ // region has nothing labeled, fall back to US so the feature still works
204
+ const inRegion =
205
+ releases.results.find((r) => r.iso_3166_1 === cfg.region)?.release_dates ?? [];
206
+ const hasLabeled = inRegion.some(
207
+ (r) => r.type === 5 && r.note.trim() !== "",
208
+ );
209
+ if (hasLabeled) {
210
+ printPhysical(inRegion, cfg.region, m);
211
+ } else {
212
+ const usReleases =
213
+ releases.results.find((r) => r.iso_3166_1 === "US")?.release_dates ?? [];
214
+ printPhysical(usReleases, "US", m);
215
+ }
148
216
  }
149
217
  }
150
218
 
@@ -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) {
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;
@@ -127,6 +126,13 @@ interface Catalog {
127
126
  // non-TTY
128
127
  ttyRequired: (cmd: string) => string;
129
128
  ambiguousQuery: (n: number) => string;
129
+
130
+ // update notifier
131
+ updateAvailable: (version: string) => string;
132
+
133
+ // physical releases
134
+ physicalLabel: string;
135
+ noPhysicalRelease: string;
130
136
  }
131
137
 
132
138
  const en: Catalog = {
@@ -160,7 +166,6 @@ const en: Catalog = {
160
166
  ads: "ads",
161
167
  rent: "rent",
162
168
  buy: "buy",
163
- link: "link",
164
169
 
165
170
  noConfigYet: "no config yet — run `ww init` first.",
166
171
  resolvingNames: "resolving subscription names… ",
@@ -228,8 +233,13 @@ const en: Catalog = {
228
233
  relativeHours: (n) => `${n} hr ago`,
229
234
  relativeDays: (n) => `${n} day${n === 1 ? "" : "s"} ago`,
230
235
 
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.`,
236
+ ttyRequired: (cmd) => `\`ww ${cmd}\` is interactive. run it in a terminal, not a pipe.`,
237
+ ambiguousQuery: (n) => `${n} matches, query is ambiguous. refine, or run interactively.`,
238
+
239
+ updateAvailable: (version) => `new version available: ${version} (bun install -g watchwhere)`,
240
+
241
+ physicalLabel: "physical",
242
+ noPhysicalRelease: "no physical release in this region",
233
243
  };
234
244
 
235
245
  const tr: Catalog = {
@@ -265,7 +275,6 @@ const tr: Catalog = {
265
275
  ads: "reklamlı",
266
276
  rent: "kirala",
267
277
  buy: "satın al",
268
- link: "bağlantı",
269
278
 
270
279
  noConfigYet: "henüz config yok — önce `ww init` çalıştır.",
271
280
  resolvingNames: "abonelik isimleri çözümleniyor… ",
@@ -334,8 +343,13 @@ const tr: Catalog = {
334
343
  relativeHours: (n) => `${n} saat önce`,
335
344
  relativeDays: (n) => `${n} gün önce`,
336
345
 
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.`,
346
+ ttyRequired: (cmd) => `\`ww ${cmd}\` interaktif. pipe'da değil, terminalde çalıştır.`,
347
+ ambiguousQuery: (n) => `${n} eşleşme var, sorgu belirsiz. daralt veya interaktif çalıştır.`,
348
+
349
+ updateAvailable: (version) => `yeni sürüm var: ${version} (bun install -g watchwhere)`,
350
+
351
+ physicalLabel: "fiziksel",
352
+ noPhysicalRelease: "bu bölgede fiziksel release yok",
339
353
  };
340
354
 
341
355
  const CATALOGS: Record<Locale, Catalog> = { en, tr };
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { runSubs } from "./commands/subs.ts";
10
10
  import { runLang } from "./commands/lang.ts";
11
11
  import { runRegion } from "./commands/region.ts";
12
12
  import { TmdbError } from "./tmdb.ts";
13
+ import { checkForUpdate } from "./update-check.ts";
13
14
 
14
15
  function usage(m: ReturnType<typeof t>): string {
15
16
  return [
@@ -125,7 +126,31 @@ async function main(): Promise<void> {
125
126
  await runSearch(args.join(" "), cfg);
126
127
  }
127
128
 
128
- main().catch(async (err: unknown) => {
129
+ async function maybeNotifyUpdate(): Promise<void> {
130
+ // skip on quick lookups, only show after real work
131
+ const first = process.argv[2];
132
+ if (
133
+ !first ||
134
+ first === "--version" ||
135
+ first === "-v" ||
136
+ first === "--help" ||
137
+ first === "-h"
138
+ ) {
139
+ return;
140
+ }
141
+ try {
142
+ const newer = await checkForUpdate(pkg.version);
143
+ if (!newer) return;
144
+ const cfg = await loadConfig().catch(() => null);
145
+ const m = t(resolveLocale(cfg?.language));
146
+ console.log();
147
+ console.log(` ${c.yellow("›")} ${c.dim(m.updateAvailable(newer))}`);
148
+ } catch {
149
+ // best-effort, never block exit
150
+ }
151
+ }
152
+
153
+ main().then(maybeNotifyUpdate).catch(async (err: unknown) => {
129
154
  const cfg = await loadConfig().catch(() => null);
130
155
  const m = t(resolveLocale(cfg?.language));
131
156
 
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
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const CACHE_DIR = join(homedir(), ".watchwhere", "cache");
6
+ const CACHE_FILE = join(CACHE_DIR, "update.json");
7
+ const TTL_MS = 24 * 60 * 60 * 1000;
8
+ const REGISTRY_URL = "https://registry.npmjs.org/watchwhere/latest";
9
+ const FETCH_TIMEOUT_MS = 3000;
10
+
11
+ interface UpdateCache {
12
+ checkedAt: string;
13
+ latest: string;
14
+ }
15
+
16
+ function parseCache(raw: unknown): UpdateCache | null {
17
+ if (typeof raw !== "object" || raw === null) return null;
18
+ const v = raw as { checkedAt?: unknown; latest?: unknown };
19
+ if (typeof v.checkedAt !== "string" || typeof v.latest !== "string") return null;
20
+ return { checkedAt: v.checkedAt, latest: v.latest };
21
+ }
22
+
23
+ async function readCache(): Promise<UpdateCache | null> {
24
+ try {
25
+ const raw = await readFile(CACHE_FILE, "utf8");
26
+ const parsed = parseCache(JSON.parse(raw) as unknown);
27
+ if (!parsed) return null;
28
+ const age = Date.now() - Date.parse(parsed.checkedAt);
29
+ if (Number.isNaN(age) || age > TTL_MS || age < 0) return null;
30
+ return parsed;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ async function writeCache(latest: string): Promise<void> {
37
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
38
+ const entry: UpdateCache = { checkedAt: new Date().toISOString(), latest };
39
+ await writeFile(CACHE_FILE, JSON.stringify(entry), "utf8");
40
+ }
41
+
42
+ function isNewer(latest: string, current: string): boolean {
43
+ const a = latest.split(".").map((n) => Number.parseInt(n, 10));
44
+ const b = current.split(".").map((n) => Number.parseInt(n, 10));
45
+ for (let i = 0; i < 3; i++) {
46
+ const ai = a[i] ?? 0;
47
+ const bi = b[i] ?? 0;
48
+ if (Number.isNaN(ai) || Number.isNaN(bi)) return false;
49
+ if (ai > bi) return true;
50
+ if (ai < bi) return false;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export async function checkForUpdate(currentVersion: string): Promise<string | null> {
56
+ try {
57
+ const cached = await readCache();
58
+ if (cached) {
59
+ return isNewer(cached.latest, currentVersion) ? cached.latest : null;
60
+ }
61
+ const res = await fetch(REGISTRY_URL, {
62
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
63
+ headers: { Accept: "application/json" },
64
+ });
65
+ if (!res.ok) return null;
66
+ const data = (await res.json()) as { version?: string };
67
+ if (typeof data.version !== "string") return null;
68
+ writeCache(data.version).catch(() => {});
69
+ return isNewer(data.version, currentVersion) ? data.version : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }