mcp-metacritic 1.0.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.
@@ -0,0 +1,889 @@
1
+ // src/version.ts
2
+ var PKG_VERSION = "1.0.0";
3
+ var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
4
+
5
+ // src/config.ts
6
+ var DEFAULT_USER_AGENT = `mcp-metacritic v${PKG_VERSION} (${REPO_URL})`;
7
+ var DEFAULTS = {
8
+ minIntervalMs: 1e3,
9
+ timeoutMs: 15e3,
10
+ maxRetries: 3,
11
+ // A catalogue entry moves when a title is edited, which is rare, so a day is
12
+ // safe. The cache lives in memory and dies with the process, so this is a
13
+ // ceiling on one session rather than a stored copy of the site.
14
+ cacheTtlMs: 24 * 60 * 60 * 1e3,
15
+ // Scores and reviews move as reviews come in, especially around a release,
16
+ // so they get their own much shorter lifetime.
17
+ scoresCacheTtlMs: 60 * 60 * 1e3,
18
+ cacheMaxEntries: 200,
19
+ logLevel: "error"
20
+ };
21
+ var MIN_ALLOWED_INTERVAL_MS = 500;
22
+ var MAX_ALLOWED_INTERVAL_MS = 6e4;
23
+ var LOG_LEVELS = ["silent", "error", "info", "debug"];
24
+ function readNumber(name, env, range) {
25
+ const raw = env[name];
26
+ if (raw === void 0 || raw.trim() === "") return range.fallback;
27
+ const parsed = Number(raw);
28
+ if (!Number.isFinite(parsed)) {
29
+ warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
30
+ return range.fallback;
31
+ }
32
+ const rounded = Math.round(parsed);
33
+ if (rounded < range.min || rounded > range.max) {
34
+ warn(
35
+ `${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; using ${range.fallback}`
36
+ );
37
+ return range.fallback;
38
+ }
39
+ return rounded;
40
+ }
41
+ function warn(message) {
42
+ process.stderr.write(`[mcp-metacritic] ${message}
43
+ `);
44
+ }
45
+ function readInterval(env) {
46
+ const raw = env.MC_MIN_INTERVAL_MS;
47
+ if (raw === void 0 || raw.trim() === "") return DEFAULTS.minIntervalMs;
48
+ const parsed = Number(raw);
49
+ if (!Number.isFinite(parsed)) {
50
+ warn(`MC_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
51
+ return DEFAULTS.minIntervalMs;
52
+ }
53
+ const rounded = Math.round(parsed);
54
+ if (rounded < MIN_ALLOWED_INTERVAL_MS) {
55
+ warn(
56
+ `MC_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; using ${DEFAULTS.minIntervalMs}ms. Metacritic publishes no rate limit, so this client sets its own.`
57
+ );
58
+ return DEFAULTS.minIntervalMs;
59
+ }
60
+ if (rounded > MAX_ALLOWED_INTERVAL_MS) {
61
+ warn(
62
+ `MC_MIN_INTERVAL_MS=${raw} exceeds the ${MAX_ALLOWED_INTERVAL_MS}ms ceiling; using ${MAX_ALLOWED_INTERVAL_MS}ms.`
63
+ );
64
+ return MAX_ALLOWED_INTERVAL_MS;
65
+ }
66
+ return rounded;
67
+ }
68
+ function loadConfig(env = process.env) {
69
+ const rawUserAgent = env.MC_USER_AGENT?.trim();
70
+ const rawLogLevel = env.MC_LOG_LEVEL?.trim().toLowerCase();
71
+ let logLevel = DEFAULTS.logLevel;
72
+ if (rawLogLevel) {
73
+ if (LOG_LEVELS.includes(rawLogLevel)) {
74
+ logLevel = rawLogLevel;
75
+ } else {
76
+ warn(`MC_LOG_LEVEL="${rawLogLevel}" is unknown, using "${DEFAULTS.logLevel}"`);
77
+ }
78
+ }
79
+ return {
80
+ userAgent: rawUserAgent || DEFAULT_USER_AGENT,
81
+ minIntervalMs: readInterval(env),
82
+ timeoutMs: readNumber("MC_TIMEOUT_MS", env, {
83
+ min: 1e3,
84
+ max: 12e4,
85
+ fallback: DEFAULTS.timeoutMs
86
+ }),
87
+ maxRetries: readNumber("MC_MAX_RETRIES", env, {
88
+ min: 0,
89
+ max: 10,
90
+ fallback: DEFAULTS.maxRetries
91
+ }),
92
+ cacheTtlMs: readNumber("MC_CACHE_TTL_MS", env, {
93
+ min: 0,
94
+ max: 7 * 24 * 60 * 60 * 1e3,
95
+ fallback: DEFAULTS.cacheTtlMs
96
+ }),
97
+ scoresCacheTtlMs: readNumber("MC_SCORES_CACHE_TTL_MS", env, {
98
+ min: 0,
99
+ max: 24 * 60 * 60 * 1e3,
100
+ fallback: DEFAULTS.scoresCacheTtlMs
101
+ }),
102
+ cacheMaxEntries: readNumber("MC_CACHE_MAX_ENTRIES", env, {
103
+ min: 0,
104
+ max: 1e4,
105
+ fallback: DEFAULTS.cacheMaxEntries
106
+ }),
107
+ logLevel
108
+ };
109
+ }
110
+ var LEVEL_RANK = { silent: 0, error: 1, info: 2, debug: 3 };
111
+ function createLogger(level) {
112
+ const emit = (at, message) => {
113
+ if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {
114
+ process.stderr.write(`[mcp-metacritic] ${message}
115
+ `);
116
+ }
117
+ };
118
+ return {
119
+ error: (message) => emit("error", message),
120
+ info: (message) => emit("info", message),
121
+ debug: (message) => emit("debug", message)
122
+ };
123
+ }
124
+
125
+ // src/mc/cache.ts
126
+ var TtlLruCache = class {
127
+ constructor(maxEntries, ttlMs) {
128
+ this.maxEntries = maxEntries;
129
+ this.ttlMs = ttlMs;
130
+ }
131
+ maxEntries;
132
+ ttlMs;
133
+ store = /* @__PURE__ */ new Map();
134
+ get(key) {
135
+ const entry = this.store.get(key);
136
+ if (!entry) return void 0;
137
+ if (Date.now() >= entry.expiresAt) {
138
+ this.store.delete(key);
139
+ return void 0;
140
+ }
141
+ this.store.delete(key);
142
+ this.store.set(key, entry);
143
+ return entry.value;
144
+ }
145
+ set(key, value) {
146
+ if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
147
+ this.store.delete(key);
148
+ this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
149
+ while (this.store.size > this.maxEntries) {
150
+ const oldest = this.store.keys().next();
151
+ if (oldest.done) break;
152
+ this.store.delete(oldest.value);
153
+ }
154
+ }
155
+ get size() {
156
+ return this.store.size;
157
+ }
158
+ clear() {
159
+ this.store.clear();
160
+ }
161
+ };
162
+
163
+ // src/errors.ts
164
+ var McError = class extends Error {
165
+ constructor(code, message, details = {}) {
166
+ super(message);
167
+ this.code = code;
168
+ this.details = details;
169
+ this.name = "McError";
170
+ }
171
+ code;
172
+ details;
173
+ };
174
+ var ISSUES_URL = "https://github.com/smeet666/mcp-metacritic/issues";
175
+ function notFound(url, what) {
176
+ return new McError("not_found", `Metacritic has no entry for ${what}.`, {
177
+ url,
178
+ status: 404,
179
+ hint: "Use search_titles to find an entry, then pass its slug and kind to this tool."
180
+ });
181
+ }
182
+ function invalidInput(message, hint) {
183
+ return new McError("invalid_input", message, hint ? { hint } : {});
184
+ }
185
+ function rateLimited(url, retryAfterMs) {
186
+ return new McError(
187
+ "rate_limited",
188
+ "Metacritic is refusing requests from this client for now. This does NOT mean the title is missing.",
189
+ {
190
+ url,
191
+ retryAfterMs,
192
+ hint: `Wait about ${Math.ceil(retryAfterMs / 1e3)} seconds, then call the same tool again with the same arguments. If it keeps happening, raise MC_MIN_INTERVAL_MS.`
193
+ }
194
+ );
195
+ }
196
+ function parseFailure(url, what) {
197
+ return new McError(
198
+ "parse_failure",
199
+ `Metacritic answered, but the response did not have the expected shape (${what}).`,
200
+ { url, hint: `Please report this, with the request you made, at ${ISSUES_URL}` }
201
+ );
202
+ }
203
+ function upstreamError(url, status) {
204
+ return new McError("network_error", `Metacritic returned HTTP ${status}.`, {
205
+ url,
206
+ status,
207
+ ...status >= 500 ? { hint: "This is a problem on Metacritic's side. Try again shortly." } : {}
208
+ });
209
+ }
210
+
211
+ // src/mc/rateLimiter.ts
212
+ var DEFAULT_MAX_INTERVAL_MS = 1e4;
213
+ var RateLimiter = class {
214
+ baseIntervalMs;
215
+ maxIntervalMs;
216
+ intervalMs;
217
+ /** Tail of the queue: each task chains onto the previous one. */
218
+ tail = Promise.resolve();
219
+ lastStart = 0;
220
+ constructor(options) {
221
+ this.baseIntervalMs = Math.max(0, options.minIntervalMs);
222
+ this.maxIntervalMs = Math.max(
223
+ this.baseIntervalMs,
224
+ options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS
225
+ );
226
+ this.intervalMs = this.baseIntervalMs;
227
+ }
228
+ get currentIntervalMs() {
229
+ return this.intervalMs;
230
+ }
231
+ /**
232
+ * Queue a task. Tasks run in call order, one at a time.
233
+ *
234
+ * A task may send several requests, since the retry loop lives inside its
235
+ * slot. It must call `beforeRequest` around each of them: the pacing is
236
+ * measured between requests, and stamping only the start of the task would
237
+ * let the next one follow a long retry chain's final request immediately.
238
+ */
239
+ schedule(task) {
240
+ const run = this.tail.then(async () => task());
241
+ this.tail = run.then(
242
+ () => void 0,
243
+ () => void 0
244
+ );
245
+ return run;
246
+ }
247
+ /**
248
+ * Wait for this request's slot, then claim it.
249
+ *
250
+ * Called once per upstream request rather than once per task, so every
251
+ * request is paced from the previous one whether or not they belong to the
252
+ * same retry chain.
253
+ */
254
+ async beforeRequest() {
255
+ await this.waitForSlot();
256
+ this.lastStart = Date.now();
257
+ }
258
+ /** Called after the site signals it is under load. */
259
+ penalize() {
260
+ const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;
261
+ this.intervalMs = Math.min(this.maxIntervalMs, next);
262
+ }
263
+ /** Called after a success, so a single hiccup does not slow things down forever. */
264
+ relax() {
265
+ this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
266
+ }
267
+ async waitForSlot() {
268
+ if (this.intervalMs === 0 || this.lastStart === 0) return;
269
+ const elapsed = Date.now() - this.lastStart;
270
+ const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
271
+ if (remaining > 0) await sleep(remaining);
272
+ }
273
+ };
274
+ function sleep(ms) {
275
+ return new Promise((resolve) => setTimeout(resolve, ms));
276
+ }
277
+
278
+ // src/mc/http.ts
279
+ var BACKOFF_BASE_MS = 2e3;
280
+ var BACKOFF_FACTOR = 2;
281
+ var BACKOFF_MAX_MS = 2e4;
282
+ function backoffDelay(attempt, random = Math.random) {
283
+ const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
284
+ return Math.round(capped * (0.5 + random() * 0.5));
285
+ }
286
+ async function fetchText(url, deps) {
287
+ const { config, limiter, logger } = deps;
288
+ const doFetch = deps.fetchImpl ?? fetch;
289
+ return limiter.schedule(async () => {
290
+ let lastError;
291
+ let askedWaitMs = null;
292
+ for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
293
+ if (attempt > 0) {
294
+ const delay = askedWaitMs ?? backoffDelay(attempt - 1);
295
+ askedWaitMs = null;
296
+ logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
297
+ await sleep(Math.min(delay, BACKOFF_MAX_MS));
298
+ }
299
+ let status;
300
+ let body;
301
+ let retryAfterMs = null;
302
+ try {
303
+ await limiter.beforeRequest();
304
+ const response = await doFetch(url, {
305
+ headers: {
306
+ "User-Agent": config.userAgent,
307
+ Accept: "application/json,*/*;q=0.8"
308
+ },
309
+ redirect: "follow",
310
+ signal: AbortSignal.timeout(config.timeoutMs)
311
+ });
312
+ status = response.status;
313
+ retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
314
+ body = await response.text();
315
+ } catch (error) {
316
+ lastError = asTransportError(error, url);
317
+ logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
318
+ continue;
319
+ }
320
+ if (status === 429 || status === 503 || status === 403) {
321
+ limiter.penalize();
322
+ askedWaitMs = retryAfterMs;
323
+ lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));
324
+ logger.info(
325
+ `refused on ${url} with ${status}, interval now ${limiter.currentIntervalMs}ms`
326
+ );
327
+ continue;
328
+ }
329
+ if (status >= 500) {
330
+ lastError = upstreamError(url, status);
331
+ continue;
332
+ }
333
+ if (status === 404) throw notFound(url, "that request");
334
+ if (status >= 400) throw upstreamError(url, status);
335
+ const trimmed = body.trim();
336
+ if (trimmed === "") {
337
+ limiter.penalize();
338
+ lastError = rateLimited(url, backoffDelay(attempt));
339
+ logger.info(`empty body on ${url}, treating as a refusal`);
340
+ continue;
341
+ }
342
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
343
+ limiter.penalize();
344
+ lastError = parseFailure(url, "the body is not JSON");
345
+ askedWaitMs = retryAfterMs;
346
+ logger.info(`non-JSON body on ${url}, retrying`);
347
+ continue;
348
+ }
349
+ limiter.relax();
350
+ return body;
351
+ }
352
+ throw lastError ?? new McError("network_error", `Could not fetch ${url}.`, { url });
353
+ });
354
+ }
355
+ function parseRetryAfter(raw) {
356
+ if (!raw) return null;
357
+ const seconds = Number(raw.trim());
358
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
359
+ const when = Date.parse(raw);
360
+ if (Number.isNaN(when)) return null;
361
+ return Math.max(0, when - Date.now());
362
+ }
363
+ function asTransportError(error, url) {
364
+ if (error instanceof McError) return error;
365
+ const name = error instanceof Error ? error.name : "";
366
+ if (name === "TimeoutError" || name === "AbortError") {
367
+ return new McError("timeout", "Metacritic did not answer in time.", {
368
+ url,
369
+ hint: "Raise MC_TIMEOUT_MS if this happens often on a slow connection."
370
+ });
371
+ }
372
+ const message = error instanceof Error ? error.message : String(error);
373
+ return new McError("network_error", `Could not reach Metacritic: ${message}`, { url });
374
+ }
375
+
376
+ // src/mc/paths.ts
377
+ var API_BASE = "https://backend.metacritic.com";
378
+ var SITE_BASE = "https://www.metacritic.com";
379
+ var API_SEGMENT = {
380
+ movie: "movies",
381
+ show: "shows",
382
+ game: "games"
383
+ };
384
+ var SITE_SEGMENT = {
385
+ movie: "movie",
386
+ show: "tv",
387
+ game: "game"
388
+ };
389
+ var MCO_TYPE_ID = {
390
+ show: 1,
391
+ movie: 2,
392
+ game: 13
393
+ };
394
+ var SEARCH_TYPE = {
395
+ movie: "movie",
396
+ show: "show",
397
+ game: "game-title"
398
+ };
399
+ var SORT_BY = {
400
+ score: "-metaScore",
401
+ recent: "-releaseDate",
402
+ popular: "-popularityCount"
403
+ };
404
+ var COMPONENT = {
405
+ product: { componentName: "product", componentType: "Product" },
406
+ criticStats: { componentName: "critic-score-summary", componentType: "MetaScoreSummary" },
407
+ userStats: { componentName: "user-score-summary", componentType: "MetaScoreSummary" },
408
+ criticReviews: { componentName: "critic-reviews", componentType: "ProductReviewsSummary" },
409
+ userReviews: { componentName: "user-reviews", componentType: "ProductReviewsSummary" }
410
+ };
411
+ var FIELD = {
412
+ data: "data",
413
+ errors: "errors",
414
+ item: "item",
415
+ items: "items",
416
+ totalResults: "totalResults",
417
+ /** Reviews arrive pre-sorted into these buckets, which is the sentiment filter. */
418
+ reviewBuckets: { all: "default", positive: "positive", neutral: "neutral", negative: "negative" }
419
+ };
420
+
421
+ // src/mc/urls.ts
422
+ function build(path, params) {
423
+ const query = new URLSearchParams();
424
+ for (const [key, value] of Object.entries(params)) {
425
+ if (value !== void 0 && value !== "") query.set(key, String(value));
426
+ }
427
+ const suffix = query.toString();
428
+ return suffix ? `${API_BASE}${path}?${suffix}` : `${API_BASE}${path}`;
429
+ }
430
+ function checkSlug(slug) {
431
+ const trimmed = slug.trim();
432
+ if (trimmed === "") {
433
+ throw invalidInput("The slug is empty.", "Take it from a search_titles result.");
434
+ }
435
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) {
436
+ throw invalidInput(
437
+ `"${slug}" does not look like a Metacritic slug.`,
438
+ "Slugs are lowercase words joined by hyphens, such as the-matrix. Use search_titles to get one."
439
+ );
440
+ }
441
+ return trimmed;
442
+ }
443
+ function searchUrl(query, limit, offset) {
444
+ const trimmed = query.trim();
445
+ if (trimmed === "") {
446
+ throw invalidInput("The search query is empty.", "Pass a title or part of one.");
447
+ }
448
+ return build(`/finder/metacritic/search/${encodeURIComponent(trimmed)}/web`, { limit, offset });
449
+ }
450
+ function browseUrl(options) {
451
+ return build("/finder/metacritic/web", {
452
+ sortBy: SORT_BY[options.sort],
453
+ mcoTypeId: MCO_TYPE_ID[options.kind],
454
+ genres: options.genre,
455
+ limit: options.limit,
456
+ offset: options.offset
457
+ });
458
+ }
459
+ function detailUrl(kind, slug) {
460
+ return build(`/${API_SEGMENT[kind]}/metacritic/${checkSlug(slug)}/web`, COMPONENT.product);
461
+ }
462
+ function scoreUrl(kind, slug, source) {
463
+ const component = source === "critic" ? COMPONENT.criticStats : COMPONENT.userStats;
464
+ return build(
465
+ `/reviews/metacritic/${source}/${API_SEGMENT[kind]}/${checkSlug(slug)}/stats/web`,
466
+ component
467
+ );
468
+ }
469
+ function reviewsUrl(options) {
470
+ const component = options.source === "critic" ? COMPONENT.criticReviews : COMPONENT.userReviews;
471
+ return build(
472
+ `/reviews/metacritic/${options.source}/${API_SEGMENT[options.kind]}/${checkSlug(options.slug)}/summary/web`,
473
+ component
474
+ );
475
+ }
476
+ function watchUrl(imdbId, kind) {
477
+ const trimmed = imdbId.trim();
478
+ if (!/^tt\d+$/.test(trimmed)) {
479
+ throw invalidInput(
480
+ `"${imdbId}" is not an IMDb id.`,
481
+ "IMDb ids look like tt0133093. get_title returns one as imdb_id."
482
+ );
483
+ }
484
+ return build("/justwatch/metacritic/offersByIMDbId", {
485
+ id: trimmed,
486
+ type: kind === "show" ? "show" : "movie"
487
+ });
488
+ }
489
+ function titlePageUrl(kind, slug) {
490
+ return `${SITE_BASE}/${SITE_SEGMENT[kind]}/${slug}/`;
491
+ }
492
+ function bucketFor(sentiment) {
493
+ return sentiment === "all" ? "default" : sentiment;
494
+ }
495
+
496
+ // src/mc/parse.ts
497
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
498
+ function envelope(raw, url, what) {
499
+ let body;
500
+ try {
501
+ body = JSON.parse(raw);
502
+ } catch {
503
+ throw parseFailure(url, "the body is not valid JSON");
504
+ }
505
+ if (!isObject(body)) throw parseFailure(url, "the body is not an object");
506
+ const errors = body[FIELD.errors];
507
+ if (Array.isArray(errors) && errors.length > 0) {
508
+ const first = isObject(errors[0]) ? errors[0] : {};
509
+ if (first.code === 404) throw notFound(url, what);
510
+ throw parseFailure(
511
+ url,
512
+ `the site reported ${String(first.reason ?? first.code ?? "an error")}`
513
+ );
514
+ }
515
+ const data = body[FIELD.data];
516
+ if (!isObject(data)) throw parseFailure(url, "the response carries no data object");
517
+ return data;
518
+ }
519
+ var str = (value) => {
520
+ if (typeof value !== "string") return null;
521
+ const trimmed = value.trim();
522
+ return trimmed === "" ? null : trimmed;
523
+ };
524
+ var num = (value) => {
525
+ if (typeof value === "number" && Number.isFinite(value)) return value;
526
+ if (typeof value === "string") {
527
+ const parsed = Number(value);
528
+ if (Number.isFinite(parsed)) return parsed;
529
+ }
530
+ return null;
531
+ };
532
+ var intOf = (value) => {
533
+ const parsed = num(value);
534
+ return parsed === null ? null : Math.trunc(parsed);
535
+ };
536
+ function kindOf(value) {
537
+ const type = str(value);
538
+ if (type === SEARCH_TYPE.movie) return "movie";
539
+ if (type === SEARCH_TYPE.show) return "show";
540
+ if (type === SEARCH_TYPE.game) return "game";
541
+ return null;
542
+ }
543
+ function toSummary(node) {
544
+ if (!isObject(node)) return null;
545
+ const id = intOf(node.id);
546
+ const slug = str(node.slug);
547
+ const title = str(node.title);
548
+ const kind = kindOf(node.type);
549
+ if (id === null || !slug || !title || !kind) return null;
550
+ const critic = isObject(node.criticScoreSummary) ? node.criticScoreSummary : {};
551
+ const userSummary = isObject(node.userScoreSummary) ? node.userScoreSummary : {};
552
+ const userObject = isObject(node.userScore) ? node.userScore : {};
553
+ const userScore = num(userSummary.score) ?? num(userObject.score) ?? num(node.userScore);
554
+ return {
555
+ id,
556
+ kind,
557
+ title,
558
+ slug,
559
+ year: intOf(node.premiereYear),
560
+ releaseDate: str(node.releaseDate),
561
+ rating: str(node.rating),
562
+ metascore: num(critic.score),
563
+ userScore,
564
+ sourceUrl: titlePageUrl(kind, slug)
565
+ };
566
+ }
567
+ function parseTitlePage(raw, url, what) {
568
+ const data = envelope(raw, url, what);
569
+ const items = data[FIELD.items];
570
+ if (!Array.isArray(items)) throw parseFailure(url, "the response carries no items array");
571
+ const titles = [];
572
+ for (const node of items) {
573
+ const row = toSummary(node);
574
+ if (row) titles.push(row);
575
+ }
576
+ if (items.length > 0 && titles.length === 0) {
577
+ throw parseFailure(url, `${items.length} rows but none could be read`);
578
+ }
579
+ return {
580
+ titles,
581
+ totalResults: intOf(data[FIELD.totalResults]) ?? titles.length,
582
+ itemCount: items.length
583
+ };
584
+ }
585
+ function toCompanies(value) {
586
+ const source = isObject(value) ? value.companies : value;
587
+ if (!Array.isArray(source)) return [];
588
+ const seen = /* @__PURE__ */ new Set();
589
+ const out = [];
590
+ for (const node of source) {
591
+ const name = isObject(node) ? str(node.name) : str(node);
592
+ if (!name) continue;
593
+ const key = name.toLowerCase();
594
+ if (seen.has(key)) continue;
595
+ seen.add(key);
596
+ out.push({ name, id: isObject(node) ? intOf(node.id) : null });
597
+ }
598
+ return out;
599
+ }
600
+ function toStringList(value) {
601
+ if (!Array.isArray(value)) return [];
602
+ const out = [];
603
+ for (const node of value) {
604
+ const name = isObject(node) ? str(node.name) : str(node);
605
+ if (name) out.push(name);
606
+ }
607
+ return out;
608
+ }
609
+ function toAwards(value) {
610
+ if (!Array.isArray(value)) return [];
611
+ const out = [];
612
+ for (const node of value) {
613
+ if (!isObject(node)) continue;
614
+ const ceremony = str(node.awardEvent);
615
+ if (!ceremony) continue;
616
+ out.push({
617
+ ceremony,
618
+ wins: intOf(node.wins),
619
+ nominations: intOf(node.nominations)
620
+ });
621
+ }
622
+ return out;
623
+ }
624
+ function parseDetail(raw, url, kind, slug) {
625
+ const data = envelope(raw, url, `${kind} "${slug}"`);
626
+ const item = data[FIELD.item];
627
+ if (!isObject(item)) throw parseFailure(url, "the response carries no item object");
628
+ const id = intOf(item.id);
629
+ const title = str(item.title);
630
+ if (id === null || !title) throw parseFailure(url, "the entry has no id or no title");
631
+ const critic = isObject(item.criticScoreSummary) ? item.criticScoreSummary : {};
632
+ return {
633
+ id,
634
+ kind,
635
+ title,
636
+ slug,
637
+ year: intOf(item.premiereYear),
638
+ releaseDate: str(item.releaseDate),
639
+ rating: str(item.rating),
640
+ metascore: num(critic.score),
641
+ userScore: null,
642
+ sourceUrl: titlePageUrl(kind, slug),
643
+ description: str(item.description),
644
+ tagline: str(item.tagline),
645
+ genres: toStringList(item.genres),
646
+ duration: intOf(item.duration),
647
+ imdbId: str(item.imdbId),
648
+ networks: toStringList(item.networks),
649
+ production: toCompanies(item.production),
650
+ awards: toAwards(item.awards),
651
+ criticScoreDetail: null,
652
+ userScoreDetail: null
653
+ };
654
+ }
655
+ function parseScore(raw, url, what) {
656
+ const data = envelope(raw, url, what);
657
+ const item = data[FIELD.item];
658
+ if (!isObject(item)) throw parseFailure(url, "the response carries no score object");
659
+ const max = num(item.max);
660
+ if (max === null) throw parseFailure(url, "the score carries no scale");
661
+ return {
662
+ score: num(item.score),
663
+ max,
664
+ reviewCount: intOf(item.reviewCount),
665
+ positiveCount: intOf(item.positiveCount),
666
+ neutralCount: intOf(item.neutralCount),
667
+ negativeCount: intOf(item.negativeCount),
668
+ sentiment: str(item.sentiment)
669
+ };
670
+ }
671
+ function reviewBucket(data, url, sentiment) {
672
+ const item = data[FIELD.item];
673
+ if (!isObject(item)) throw parseFailure(url, "the response carries no reviews object");
674
+ const bucket = item[bucketFor(sentiment)];
675
+ if (!Array.isArray(bucket)) {
676
+ throw parseFailure(url, `there is no "${bucketFor(sentiment)}" group of reviews`);
677
+ }
678
+ return bucket;
679
+ }
680
+ function parseCriticReviews(raw, url, what, sentiment) {
681
+ const data = envelope(raw, url, what);
682
+ const bucket = reviewBucket(data, url, sentiment);
683
+ const reviews = [];
684
+ for (const node of bucket) {
685
+ if (!isObject(node)) continue;
686
+ const quote = str(node.quote);
687
+ const publication = str(node.publicationName);
688
+ if (!quote || !publication) continue;
689
+ reviews.push({
690
+ quote,
691
+ score: num(node.score),
692
+ publication,
693
+ author: str(node.author),
694
+ url: str(node.url),
695
+ date: str(node.date)
696
+ });
697
+ }
698
+ if (bucket.length > 0 && reviews.length === 0) {
699
+ throw parseFailure(url, `${bucket.length} reviews but none could be read`);
700
+ }
701
+ return {
702
+ reviews,
703
+ totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,
704
+ itemCount: bucket.length
705
+ };
706
+ }
707
+ function parseUserReviews(raw, url, what, sentiment) {
708
+ const data = envelope(raw, url, what);
709
+ const bucket = reviewBucket(data, url, sentiment);
710
+ const reviews = [];
711
+ for (const node of bucket) {
712
+ if (!isObject(node)) continue;
713
+ const quote = str(node.quote);
714
+ if (!quote) continue;
715
+ reviews.push({ quote, score: num(node.score), date: str(node.date) });
716
+ }
717
+ if (bucket.length > 0 && reviews.length === 0) {
718
+ throw parseFailure(url, `${bucket.length} reviews but none could be read`);
719
+ }
720
+ return {
721
+ reviews,
722
+ totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,
723
+ itemCount: bucket.length
724
+ };
725
+ }
726
+ function unwrapLink(link) {
727
+ if (!link) return null;
728
+ try {
729
+ const target = new URL(link).searchParams.get("r");
730
+ if (target && /^https?:\/\//i.test(target)) return target;
731
+ } catch {
732
+ }
733
+ return link;
734
+ }
735
+ function parseOffers(raw, url, what) {
736
+ const data = envelope(raw, url, what);
737
+ const item = data[FIELD.item];
738
+ if (!isObject(item)) throw parseFailure(url, "the response carries no offers object");
739
+ const offers = [];
740
+ for (const [group, value] of Object.entries(item)) {
741
+ if (!Array.isArray(value)) continue;
742
+ for (const node of value) {
743
+ if (!isObject(node)) continue;
744
+ const provider = str(node.providerName);
745
+ if (!provider) continue;
746
+ offers.push({ provider, kind: group, url: unwrapLink(str(node.link)) });
747
+ }
748
+ }
749
+ return offers;
750
+ }
751
+
752
+ // src/mc/client.ts
753
+ function withGuarantees(config) {
754
+ const userAgent = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
755
+ return {
756
+ ...config,
757
+ userAgent,
758
+ minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs)
759
+ };
760
+ }
761
+ var McClient = class {
762
+ config;
763
+ logger;
764
+ limiter;
765
+ /**
766
+ * Two caches, because the two kinds of data age at different speeds. A
767
+ * catalogue entry changes when someone edits it, which is rare. A score moves
768
+ * every time a review lands, which around a release is constantly.
769
+ */
770
+ catalogueCache;
771
+ scoresCache;
772
+ fetchImpl;
773
+ constructor(options = {}) {
774
+ this.config = withGuarantees(options.config ?? loadConfig());
775
+ this.logger = options.logger ?? createLogger(this.config.logLevel);
776
+ this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });
777
+ this.catalogueCache = new TtlLruCache(
778
+ this.config.cacheMaxEntries,
779
+ this.config.cacheTtlMs
780
+ );
781
+ this.scoresCache = new TtlLruCache(
782
+ this.config.cacheMaxEntries,
783
+ this.config.scoresCacheTtlMs
784
+ );
785
+ this.fetchImpl = options.fetchImpl;
786
+ }
787
+ /**
788
+ * Search across films, shows and games.
789
+ *
790
+ * The return type carries rows only. The upstream response embeds artwork
791
+ * variants on every match, and dropping them here rather than in a tool means
792
+ * they have no path out of this layer.
793
+ */
794
+ async search(query, limit, offset) {
795
+ const url = searchUrl(query, limit, offset);
796
+ return this.fetchParsed(
797
+ url,
798
+ this.catalogueCache,
799
+ (body) => parseTitlePage(body, url, `the query "${query}"`)
800
+ );
801
+ }
802
+ async browse(options) {
803
+ const url = browseUrl(options);
804
+ return this.fetchParsed(
805
+ url,
806
+ this.catalogueCache,
807
+ (body) => parseTitlePage(body, url, `that ${options.kind} listing`)
808
+ );
809
+ }
810
+ async getDetail(kind, slug) {
811
+ const url = detailUrl(kind, slug);
812
+ const clean = slug.trim();
813
+ return this.fetchParsed(
814
+ url,
815
+ this.catalogueCache,
816
+ (body) => parseDetail(body, url, kind, clean)
817
+ );
818
+ }
819
+ async getScore(kind, slug, source) {
820
+ const url = scoreUrl(kind, slug, source);
821
+ return this.fetchParsed(
822
+ url,
823
+ this.scoresCache,
824
+ (body) => parseScore(body, url, `the ${source} score of ${kind} "${slug}"`)
825
+ );
826
+ }
827
+ async getCriticReviews(options) {
828
+ const url = reviewsUrl({ ...options, source: "critic" });
829
+ return this.fetchParsed(
830
+ url,
831
+ this.scoresCache,
832
+ (body) => parseCriticReviews(
833
+ body,
834
+ url,
835
+ `reviews of ${options.kind} "${options.slug}"`,
836
+ options.sentiment
837
+ ),
838
+ options.sentiment
839
+ );
840
+ }
841
+ async getUserReviews(options) {
842
+ const url = reviewsUrl({ ...options, source: "user" });
843
+ return this.fetchParsed(
844
+ url,
845
+ this.scoresCache,
846
+ (body) => parseUserReviews(
847
+ body,
848
+ url,
849
+ `reviews of ${options.kind} "${options.slug}"`,
850
+ options.sentiment
851
+ ),
852
+ options.sentiment
853
+ );
854
+ }
855
+ async getWatchOffers(imdbId, kind) {
856
+ const url = watchUrl(imdbId, kind);
857
+ return this.fetchParsed(
858
+ url,
859
+ this.scoresCache,
860
+ (body) => parseOffers(body, url, `streaming offers for ${imdbId}`)
861
+ );
862
+ }
863
+ /**
864
+ * Fetch, parse, then cache. In that order: a response that could not be read
865
+ * is never stored, so a bad minute upstream cannot be replayed from memory
866
+ * for the rest of the cache lifetime.
867
+ */
868
+ async fetchParsed(url, cache, parse, variant = "") {
869
+ const key = variant ? `${url}#${variant}` : url;
870
+ const hit = cache.get(key);
871
+ if (hit !== void 0) {
872
+ this.logger.debug(`cache hit ${key}`);
873
+ return { data: hit, cached: true };
874
+ }
875
+ const body = await fetchText(url, {
876
+ config: this.config,
877
+ limiter: this.limiter,
878
+ logger: this.logger,
879
+ ...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
880
+ });
881
+ const data = parse(body);
882
+ cache.set(key, data);
883
+ return { data, cached: false };
884
+ }
885
+ };
886
+ export {
887
+ McClient
888
+ };
889
+ //# sourceMappingURL=client.js.map