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 +4 -0
- package/package.json +1 -1
- package/src/commands/init.ts +12 -2
- package/src/commands/search.ts +106 -15
- package/src/commands/subs.ts +7 -1
- package/src/i18n.ts +13 -3
- package/src/index.ts +2 -1
- package/src/picker.ts +32 -5
- 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) {
|
|
@@ -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({
|
package/src/commands/search.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
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
|
-
|
|
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) {
|
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) {
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|