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 +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 +21 -7
- package/src/index.ts +26 -1
- package/src/tmdb.ts +75 -0
- package/src/update-check.ts +73 -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;
|
|
@@ -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
|
|
232
|
-
ambiguousQuery: (n) => `${n} matches
|
|
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
|
|
338
|
-
ambiguousQuery: (n) => `${n} eşleşme var
|
|
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
|
-
|
|
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
|
+
}
|