watchwhere 0.2.3 → 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.3",
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;
@@ -130,6 +129,10 @@ interface Catalog {
130
129
 
131
130
  // update notifier
132
131
  updateAvailable: (version: string) => string;
132
+
133
+ // physical releases
134
+ physicalLabel: string;
135
+ noPhysicalRelease: string;
133
136
  }
134
137
 
135
138
  const en: Catalog = {
@@ -163,7 +166,6 @@ const en: Catalog = {
163
166
  ads: "ads",
164
167
  rent: "rent",
165
168
  buy: "buy",
166
- link: "link",
167
169
 
168
170
  noConfigYet: "no config yet — run `ww init` first.",
169
171
  resolvingNames: "resolving subscription names… ",
@@ -235,6 +237,9 @@ const en: Catalog = {
235
237
  ambiguousQuery: (n) => `${n} matches, query is ambiguous. refine, or run interactively.`,
236
238
 
237
239
  updateAvailable: (version) => `new version available: ${version} (bun install -g watchwhere)`,
240
+
241
+ physicalLabel: "physical",
242
+ noPhysicalRelease: "no physical release in this region",
238
243
  };
239
244
 
240
245
  const tr: Catalog = {
@@ -270,7 +275,6 @@ const tr: Catalog = {
270
275
  ads: "reklamlı",
271
276
  rent: "kirala",
272
277
  buy: "satın al",
273
- link: "bağlantı",
274
278
 
275
279
  noConfigYet: "henüz config yok — önce `ww init` çalıştır.",
276
280
  resolvingNames: "abonelik isimleri çözümleniyor… ",
@@ -343,6 +347,9 @@ const tr: Catalog = {
343
347
  ambiguousQuery: (n) => `${n} eşleşme var, sorgu belirsiz. daralt veya interaktif çalıştır.`,
344
348
 
345
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",
346
353
  };
347
354
 
348
355
  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
  }
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
+ }