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 +4 -0
- package/package.json +1 -1
- package/src/commands/init.ts +11 -2
- package/src/commands/search.ts +72 -4
- package/src/commands/subs.ts +6 -1
- package/src/i18n.ts +10 -3
- package/src/index.ts +1 -1
- package/src/tmdb.ts +75 -0
package/README.md
CHANGED
package/package.json
CHANGED
package/src/commands/init.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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) {
|
package/src/commands/search.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
package/src/commands/subs.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
+
}
|