@media-engine/core 0.1.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/dist/cache/index.d.ts +2 -0
  4. package/dist/cache/index.js +2 -0
  5. package/dist/cache/index.js.map +1 -0
  6. package/dist/cache/memory.d.ts +19 -0
  7. package/dist/cache/memory.js +83 -0
  8. package/dist/cache/memory.js.map +1 -0
  9. package/dist/cache/types.d.ts +9 -0
  10. package/dist/cache/types.js +2 -0
  11. package/dist/cache/types.js.map +1 -0
  12. package/dist/details/index.d.ts +1 -0
  13. package/dist/details/index.js +2 -0
  14. package/dist/details/index.js.map +1 -0
  15. package/dist/details/types.d.ts +19 -0
  16. package/dist/details/types.js +2 -0
  17. package/dist/details/types.js.map +1 -0
  18. package/dist/engine/engine.d.ts +29 -0
  19. package/dist/engine/engine.js +1007 -0
  20. package/dist/engine/engine.js.map +1 -0
  21. package/dist/engine/index.d.ts +2 -0
  22. package/dist/engine/index.js +2 -0
  23. package/dist/engine/index.js.map +1 -0
  24. package/dist/engine/types.d.ts +13 -0
  25. package/dist/engine/types.js +2 -0
  26. package/dist/engine/types.js.map +1 -0
  27. package/dist/errors/index.d.ts +1 -0
  28. package/dist/errors/index.js +2 -0
  29. package/dist/errors/index.js.map +1 -0
  30. package/dist/errors/types.d.ts +27 -0
  31. package/dist/errors/types.js +77 -0
  32. package/dist/errors/types.js.map +1 -0
  33. package/dist/index.d.ts +12 -0
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/media/index.d.ts +1 -0
  37. package/dist/media/index.js +2 -0
  38. package/dist/media/index.js.map +1 -0
  39. package/dist/media/types.d.ts +136 -0
  40. package/dist/media/types.js +2 -0
  41. package/dist/media/types.js.map +1 -0
  42. package/dist/merge/index.d.ts +2 -0
  43. package/dist/merge/index.js +2 -0
  44. package/dist/merge/index.js.map +1 -0
  45. package/dist/merge/strategy.d.ts +8 -0
  46. package/dist/merge/strategy.js +1168 -0
  47. package/dist/merge/strategy.js.map +1 -0
  48. package/dist/merge/types.d.ts +18 -0
  49. package/dist/merge/types.js +2 -0
  50. package/dist/merge/types.js.map +1 -0
  51. package/dist/providers/index.d.ts +2 -0
  52. package/dist/providers/index.js +2 -0
  53. package/dist/providers/index.js.map +1 -0
  54. package/dist/providers/registry.d.ts +9 -0
  55. package/dist/providers/registry.js +92 -0
  56. package/dist/providers/registry.js.map +1 -0
  57. package/dist/providers/types.d.ts +63 -0
  58. package/dist/providers/types.js +2 -0
  59. package/dist/providers/types.js.map +1 -0
  60. package/dist/response/index.d.ts +1 -0
  61. package/dist/response/index.js +2 -0
  62. package/dist/response/index.js.map +1 -0
  63. package/dist/response/types.d.ts +32 -0
  64. package/dist/response/types.js +2 -0
  65. package/dist/response/types.js.map +1 -0
  66. package/dist/search/index.d.ts +1 -0
  67. package/dist/search/index.js +2 -0
  68. package/dist/search/index.js.map +1 -0
  69. package/dist/search/types.d.ts +26 -0
  70. package/dist/search/types.js +2 -0
  71. package/dist/search/types.js.map +1 -0
  72. package/dist/streaming/index.d.ts +1 -0
  73. package/dist/streaming/index.js +2 -0
  74. package/dist/streaming/index.js.map +1 -0
  75. package/dist/streaming/types.d.ts +126 -0
  76. package/dist/streaming/types.js +2 -0
  77. package/dist/streaming/types.js.map +1 -0
  78. package/dist/testing/fixtures.d.ts +4 -0
  79. package/dist/testing/fixtures.js +101 -0
  80. package/dist/testing/fixtures.js.map +1 -0
  81. package/dist/testing/index.d.ts +2 -0
  82. package/dist/testing/index.js +3 -0
  83. package/dist/testing/index.js.map +1 -0
  84. package/dist/testing/providers.d.ts +26 -0
  85. package/dist/testing/providers.js +147 -0
  86. package/dist/testing/providers.js.map +1 -0
  87. package/package.json +51 -0
@@ -0,0 +1,1007 @@
1
+ import { MediaEngineError, ProviderError, toProviderFailure } from "../errors/index.js";
2
+ import { DefaultMergeStrategy } from "../merge/index.js";
3
+ import { ProviderRegistry } from "../providers/index.js";
4
+ // Top-level public external ID shortcuts supported by engine queries.
5
+ // Верхнеуровневые публичные сокращения внешних ID, поддерживаемые query движка.
6
+ const EXTERNAL_ID_SHORTCUTS = [
7
+ "imdb",
8
+ "tmdb",
9
+ "kinopoisk",
10
+ "shikimori",
11
+ "myAnimeList",
12
+ "aniList",
13
+ ];
14
+ const SEARCH_ID_ENRICHMENT_LIMIT = 6;
15
+ const SEARCH_ID_ENRICHMENT_TIMEOUT_MS = 1_500;
16
+ const SEARCH_DETAILS_POSTER_ENRICHMENT_LIMIT = 3;
17
+ const SEARCH_DETAILS_POSTER_ENRICHMENT_TIMEOUT_MS = 1_500;
18
+ const SEARCH_FALLBACK_MIN_TOKENS = 3;
19
+ const SEARCH_FALLBACK_MIN_LAST_TOKEN_LENGTH = 4;
20
+ const SEARCH_JOINED_FALLBACK_MIN_LENGTH = 6;
21
+ const SEARCH_JOINED_FALLBACK_MAX_LENGTH = 8;
22
+ const SEARCH_JOINED_FALLBACK_MIN_PART_LENGTH = 3;
23
+ const MAX_SEARCH_LIMIT = 100;
24
+ const MAX_PROVIDER_SEARCH_LIMIT = 100;
25
+ const EXPIRING_AVAILABILITY_CACHE_SAFETY_MS = 1_000;
26
+ // Main entry point for using Media Engine core.
27
+ // Главная точка входа для использования Media Engine core.
28
+ export class MediaEngine {
29
+ registry;
30
+ streamingProviders;
31
+ cache;
32
+ mergeStrategy;
33
+ timeoutMs;
34
+ providerTimeouts;
35
+ debug;
36
+ constructor(options = {}) {
37
+ this.registry = new ProviderRegistry(options.providers ?? []);
38
+ this.streamingProviders = validateStreamingProviders(options.streamingProviders ?? []);
39
+ this.cache = options.cache;
40
+ this.mergeStrategy = options.mergeStrategy ?? new DefaultMergeStrategy();
41
+ this.timeoutMs = options.timeoutMs;
42
+ this.providerTimeouts = { ...options.providerTimeouts };
43
+ this.debug = options.debug ?? false;
44
+ }
45
+ // Returns safe registered provider metadata without provider internals.
46
+ // Возвращает безопасные метаданные зарегистрированных провайдеров без внутренних данных.
47
+ getProviders() {
48
+ return this.registry.getProviders();
49
+ }
50
+ // Returns safe registered streaming provider metadata without provider internals.
51
+ // Возвращает безопасные метаданные streaming-провайдеров без внутренних данных.
52
+ getStreamingProviders() {
53
+ return this.streamingProviders.map((provider) => ({
54
+ name: provider.name,
55
+ version: provider.version,
56
+ kind: provider.kind,
57
+ capabilities: {
58
+ mediaTypes: [...provider.capabilities.mediaTypes],
59
+ lookup: {
60
+ byTitle: provider.capabilities.lookup.byTitle,
61
+ byExternalIds: [...provider.capabilities.lookup.byExternalIds],
62
+ byEpisode: provider.capabilities.lookup.byEpisode,
63
+ },
64
+ features: provider.capabilities.features ? [...provider.capabilities.features] : undefined,
65
+ },
66
+ }));
67
+ }
68
+ // Searches media through selected providers and merges normalized results.
69
+ // Ищет медиа через выбранных провайдеров и объединяет нормализованные результаты.
70
+ async search(query) {
71
+ const startedAt = Date.now();
72
+ const normalizedQuery = normalizeSearchQuery(query);
73
+ validateSearchQuery(normalizedQuery);
74
+ const searchLanguage = normalizedQuery.language ?? inferTitleLanguage(normalizedQuery.title);
75
+ const cacheKey = createSearchCacheKey(normalizedQuery);
76
+ const cached = await this.cache?.get(cacheKey);
77
+ if (cached) {
78
+ return {
79
+ ...cached,
80
+ query: normalizedQuery,
81
+ meta: {
82
+ ...cached.meta,
83
+ cached: true,
84
+ tookMs: elapsedSince(startedAt),
85
+ },
86
+ };
87
+ }
88
+ const providers = this.registry.selectSearchProviders(normalizedQuery);
89
+ const requested = providers.map((provider) => provider.name);
90
+ const successful = [];
91
+ const failed = [];
92
+ const warnings = [];
93
+ const providerResults = [];
94
+ const providerTimings = [];
95
+ let outcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(normalizedQuery), {
96
+ debug: this.debug,
97
+ language: searchLanguage,
98
+ timeoutMs: this.getProviderTimeoutMs(provider.name),
99
+ })));
100
+ if (outcomes.length > 0 && outcomes.every((outcome) => outcome.failure)) {
101
+ outcomes = await retryFailedSearchProviders(providers, outcomes, normalizedQuery, {
102
+ debug: this.debug,
103
+ language: searchLanguage,
104
+ getTimeoutMs: (providerName) => this.getProviderTimeoutMs(providerName),
105
+ });
106
+ }
107
+ for (const outcome of outcomes) {
108
+ providerTimings.push(outcome.timing);
109
+ if (outcome.failure) {
110
+ failed.push(outcome.failure);
111
+ }
112
+ else {
113
+ successful.push(outcome.provider);
114
+ providerResults.push(...outcome.results);
115
+ }
116
+ }
117
+ if (providers.length > 0 && successful.length === 0 && failed.length > 0) {
118
+ throw new MediaEngineError({
119
+ code: "PROVIDER_ERROR",
120
+ message: "All search providers failed.",
121
+ cause: { failed },
122
+ });
123
+ }
124
+ let results = this.mergeStrategy.mergeSearchResults(providerResults, {
125
+ query: normalizedQuery,
126
+ language: searchLanguage,
127
+ debug: this.debug,
128
+ warnings,
129
+ includeIrrelevantSearchResults: true,
130
+ });
131
+ const fallbackQuery = createSearchFallbackQuery(normalizedQuery);
132
+ const hasRelevantResults = fallbackQuery
133
+ ? this.mergeStrategy.mergeSearchResults(providerResults, {
134
+ query: normalizedQuery,
135
+ language: searchLanguage,
136
+ debug: this.debug,
137
+ }).length > 0
138
+ : true;
139
+ if (fallbackQuery && !hasRelevantResults) {
140
+ const fallbackOutcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(fallbackQuery), {
141
+ debug: this.debug,
142
+ language: searchLanguage,
143
+ timeoutMs: this.getProviderTimeoutMs(provider.name),
144
+ })));
145
+ appendUniqueSearchResults(providerResults, fallbackOutcomes.flatMap((outcome) => (outcome.failure ? [] : outcome.results)));
146
+ results = this.mergeStrategy.mergeSearchResults(providerResults, {
147
+ query: normalizedQuery,
148
+ language: searchLanguage,
149
+ debug: this.debug,
150
+ warnings,
151
+ includeIrrelevantSearchResults: true,
152
+ });
153
+ }
154
+ const excludedPosterProviders = new Set(failed.map((failure) => failure.provider));
155
+ const posterEnrichmentPromise = Promise.all(results
156
+ .slice(0, SEARCH_DETAILS_POSTER_ENRICHMENT_LIMIT)
157
+ .filter((result) => hasExternalIds(result.item.ids))
158
+ .map(async (result) => ({
159
+ ids: result.item.ids,
160
+ poster: await this.loadSearchPoster(result.item.type, result.item.ids, searchLanguage, excludedPosterProviders).catch(() => undefined),
161
+ })));
162
+ const enrichmentResultsPromise = Promise.all(results
163
+ .slice(0, SEARCH_ID_ENRICHMENT_LIMIT)
164
+ .filter((result) => needsSearchEnrichment(result.item) && hasExternalIds(result.item.ids))
165
+ .map(async (result) => {
166
+ const existingProviders = new Set(result.sources.map((source) => source.provider));
167
+ const enrichmentType = result.item.type === "anime" ? undefined : result.item.type;
168
+ const enrichmentProvider = this.registry
169
+ .selectSearchProviders({ ids: result.item.ids, type: enrichmentType })
170
+ .find((provider) => !existingProviders.has(provider.name));
171
+ if (!enrichmentProvider) {
172
+ return [];
173
+ }
174
+ const providerTimeoutMs = this.getProviderTimeoutMs(enrichmentProvider.name);
175
+ const enrichmentTimeoutMs = providerTimeoutMs === undefined
176
+ ? SEARCH_ID_ENRICHMENT_TIMEOUT_MS
177
+ : Math.min(providerTimeoutMs, SEARCH_ID_ENRICHMENT_TIMEOUT_MS);
178
+ const outcome = await callTimedProviderSearch(enrichmentProvider, {
179
+ ids: result.item.ids,
180
+ type: enrichmentType,
181
+ limit: 1,
182
+ language: searchLanguage,
183
+ }, {
184
+ debug: this.debug,
185
+ language: searchLanguage,
186
+ timeoutMs: enrichmentTimeoutMs,
187
+ });
188
+ return outcome.failure ? [] : outcome.results;
189
+ }));
190
+ const [enrichmentResults, posterEnrichments] = await Promise.all([
191
+ enrichmentResultsPromise,
192
+ posterEnrichmentPromise,
193
+ ]);
194
+ const flattenedEnrichmentResults = enrichmentResults.flat();
195
+ if (flattenedEnrichmentResults.length > 0) {
196
+ providerResults.push(...flattenedEnrichmentResults);
197
+ }
198
+ if (this.mergeStrategy instanceof DefaultMergeStrategy ||
199
+ flattenedEnrichmentResults.length > 0) {
200
+ results = this.mergeStrategy.mergeSearchResults(providerResults, {
201
+ query: normalizedQuery,
202
+ language: searchLanguage,
203
+ debug: this.debug,
204
+ warnings,
205
+ });
206
+ }
207
+ const posterEnrichedResults = results.map((result) => {
208
+ const poster = posterEnrichments.find((enrichment) => enrichment.poster && hasSharedExternalId(result.item.ids, enrichment.ids))?.poster;
209
+ return poster ? { ...result, item: { ...result.item, poster } } : result;
210
+ });
211
+ const limitedResults = normalizedQuery.limit === undefined
212
+ ? posterEnrichedResults
213
+ : posterEnrichedResults.slice(0, normalizedQuery.limit);
214
+ const response = {
215
+ query: normalizedQuery,
216
+ results: limitedResults,
217
+ meta: createResponseMeta({
218
+ requested,
219
+ successful,
220
+ failed,
221
+ warnings,
222
+ cached: false,
223
+ tookMs: elapsedSince(startedAt),
224
+ debug: this.debug,
225
+ timings: providerTimings,
226
+ }),
227
+ };
228
+ await this.cache?.set(cacheKey, response);
229
+ return response;
230
+ }
231
+ // Loads media details through selected providers and merges normalized results.
232
+ // Загружает детали медиа через выбранных провайдеров и объединяет нормализованные результаты.
233
+ async getDetails(query) {
234
+ const startedAt = Date.now();
235
+ const normalizedQuery = normalizeDetailsQuery(query);
236
+ validateDetailsQuery(normalizedQuery);
237
+ const cacheKey = createDetailsCacheKey(normalizedQuery);
238
+ const cached = await this.cache?.get(cacheKey);
239
+ if (cached) {
240
+ return {
241
+ ...cached,
242
+ query: normalizedQuery,
243
+ meta: {
244
+ ...cached.meta,
245
+ cached: true,
246
+ tookMs: elapsedSince(startedAt),
247
+ },
248
+ };
249
+ }
250
+ const providers = this.registry.selectDetailsProviders(normalizedQuery);
251
+ const requested = providers.map((provider) => provider.name);
252
+ const successful = [];
253
+ const failed = [];
254
+ const warnings = [];
255
+ const providerResults = [];
256
+ const providerTimings = [];
257
+ const outcomes = await Promise.all(providers.map((provider) => callTimedProviderDetails(provider, normalizedQuery, {
258
+ debug: this.debug,
259
+ language: normalizedQuery.language,
260
+ timeoutMs: this.getProviderTimeoutMs(provider.name),
261
+ })));
262
+ for (const outcome of outcomes) {
263
+ providerTimings.push(outcome.timing);
264
+ if (outcome.failure) {
265
+ failed.push(outcome.failure);
266
+ }
267
+ else {
268
+ successful.push(outcome.provider);
269
+ if (outcome.result) {
270
+ providerResults.push(outcome.result);
271
+ }
272
+ }
273
+ }
274
+ if (providers.length > 0 && successful.length === 0 && failed.length > 0) {
275
+ throw new MediaEngineError({
276
+ code: "PROVIDER_ERROR",
277
+ message: "All details providers failed.",
278
+ cause: { failed },
279
+ });
280
+ }
281
+ const details = this.mergeStrategy.mergeDetails(providerResults, {
282
+ query: normalizedQuery,
283
+ language: normalizedQuery.language,
284
+ debug: this.debug,
285
+ warnings,
286
+ });
287
+ const response = {
288
+ query: normalizedQuery,
289
+ details,
290
+ meta: createResponseMeta({
291
+ requested,
292
+ successful,
293
+ failed,
294
+ warnings,
295
+ cached: false,
296
+ tookMs: elapsedSince(startedAt),
297
+ debug: this.debug,
298
+ timings: providerTimings,
299
+ }),
300
+ };
301
+ await this.cache?.set(cacheKey, response);
302
+ return response;
303
+ }
304
+ // Loads normalized player and stream availability through streaming providers.
305
+ // Загружает нормализованную доступность player и stream через streaming-провайдеры.
306
+ async getAvailability(query) {
307
+ const startedAt = Date.now();
308
+ const normalizedQuery = normalizeStreamQuery(query);
309
+ validateStreamQuery(normalizedQuery);
310
+ const cacheKey = createAvailabilityCacheKey(normalizedQuery);
311
+ const cached = await this.cache?.get(cacheKey);
312
+ if (cached) {
313
+ return {
314
+ ...cached,
315
+ query: normalizedQuery,
316
+ meta: cached.meta
317
+ ? {
318
+ ...cached.meta,
319
+ cached: true,
320
+ tookMs: elapsedSince(startedAt),
321
+ }
322
+ : undefined,
323
+ };
324
+ }
325
+ const providers = selectStreamingProviders(this.streamingProviders, normalizedQuery);
326
+ const requested = providers.map((provider) => provider.name);
327
+ const successful = [];
328
+ const failed = [];
329
+ const providerResults = [];
330
+ const providerTimings = [];
331
+ const outcomes = await Promise.all(providers.map((provider) => callTimedProviderAvailability(provider, normalizedQuery, {
332
+ debug: this.debug,
333
+ language: normalizedQuery.language,
334
+ timeoutMs: this.getProviderTimeoutMs(provider.name),
335
+ })));
336
+ for (const outcome of outcomes) {
337
+ providerTimings.push(outcome.timing);
338
+ if (outcome.failure) {
339
+ failed.push(outcome.failure);
340
+ }
341
+ else if (outcome.result) {
342
+ successful.push(outcome.provider);
343
+ providerResults.push(outcome.result);
344
+ }
345
+ }
346
+ if (providers.length > 0 && providerResults.length === 0 && failed.length > 0) {
347
+ throw new MediaEngineError({
348
+ code: "PROVIDER_ERROR",
349
+ message: "All streaming providers failed.",
350
+ cause: { failed },
351
+ });
352
+ }
353
+ const availability = mergeAvailabilityResults(normalizedQuery, providerResults);
354
+ availability.meta = createResponseMeta({
355
+ requested,
356
+ successful,
357
+ failed,
358
+ warnings: [],
359
+ cached: false,
360
+ tookMs: elapsedSince(startedAt),
361
+ debug: this.debug,
362
+ timings: providerTimings,
363
+ });
364
+ await this.cache?.set(cacheKey, availability, createAvailabilityCacheOptions(availability));
365
+ return availability;
366
+ }
367
+ // Gives future engine methods access to the registered providers.
368
+ // Дает будущим методам движка доступ к зарегистрированным провайдерам.
369
+ get providerRegistry() {
370
+ return this.registry;
371
+ }
372
+ // Gives future engine methods access to the optional cache.
373
+ // Дает будущим методам движка доступ к опциональному cache.
374
+ get engineCache() {
375
+ return this.cache;
376
+ }
377
+ // Gives future engine methods access to the configured merge strategy.
378
+ // Дает будущим методам движка доступ к настроенной стратегии объединения.
379
+ get engineMergeStrategy() {
380
+ return this.mergeStrategy;
381
+ }
382
+ // Gives future engine methods access to the configured timeout.
383
+ // Дает будущим методам движка доступ к настроенному timeout.
384
+ get engineTimeoutMs() {
385
+ return this.timeoutMs;
386
+ }
387
+ // Resolves a provider override without allowing it to exceed the global boundary.
388
+ // Выбирает override провайдера, не позволяя ему превысить глобальную границу.
389
+ getProviderTimeoutMs(providerName) {
390
+ const providerTimeoutMs = this.providerTimeouts[providerName];
391
+ if (providerTimeoutMs === undefined) {
392
+ return this.timeoutMs;
393
+ }
394
+ return this.timeoutMs === undefined
395
+ ? providerTimeoutMs
396
+ : Math.min(this.timeoutMs, providerTimeoutMs);
397
+ }
398
+ // Loads only the canonical poster needed by search without blocking on a full details request.
399
+ // Загружает только канонический постер для search, не блокируя полный details-запрос.
400
+ async loadSearchPoster(type, ids, language, excludedProviders) {
401
+ if (!hasExternalIds(ids)) {
402
+ return undefined;
403
+ }
404
+ const query = { type, ids, language };
405
+ const providers = this.registry
406
+ .selectDetailsProviders(query)
407
+ .filter((provider) => !excludedProviders.has(provider.name));
408
+ const outcomes = await Promise.all(providers.map((provider) => {
409
+ const providerTimeoutMs = this.getProviderTimeoutMs(provider.name);
410
+ return callTimedProviderDetails(provider, query, {
411
+ debug: this.debug,
412
+ language,
413
+ timeoutMs: providerTimeoutMs === undefined
414
+ ? SEARCH_DETAILS_POSTER_ENRICHMENT_TIMEOUT_MS
415
+ : Math.min(providerTimeoutMs, SEARCH_DETAILS_POSTER_ENRICHMENT_TIMEOUT_MS),
416
+ });
417
+ }));
418
+ const providerResults = outcomes.flatMap((outcome) => outcome.failure || !outcome.result ? [] : [outcome.result]);
419
+ return this.mergeStrategy.mergeDetails(providerResults, {
420
+ query,
421
+ language,
422
+ debug: this.debug,
423
+ warnings: [],
424
+ })?.poster;
425
+ }
426
+ // Gives future engine methods access to the debug flag.
427
+ // Дает будущим методам движка доступ к debug-флагу.
428
+ get engineDebug() {
429
+ return this.debug;
430
+ }
431
+ }
432
+ // Enriches compact catalog hits when follow-up cards would otherwise choose different metadata.
433
+ // Обогащает compact catalog hits, чтобы search и details не выбирали разные metadata.
434
+ function needsSearchEnrichment(item) {
435
+ return !item.ratings?.length || !item.description?.trim() || !item.poster;
436
+ }
437
+ // Validates streaming providers and rejects duplicate public names.
438
+ // Проверяет streaming-провайдеры и отклоняет дубли публичных имен.
439
+ function validateStreamingProviders(providers) {
440
+ const names = new Set();
441
+ for (const provider of providers) {
442
+ const name = provider.name.trim();
443
+ if (!name) {
444
+ throw new Error("Streaming provider name is required.");
445
+ }
446
+ if (name !== provider.name) {
447
+ throw new Error(`Streaming provider name "${provider.name}" must not include leading or trailing whitespace.`);
448
+ }
449
+ if (names.has(name)) {
450
+ throw new Error(`Streaming provider "${name}" is already registered.`);
451
+ }
452
+ names.add(name);
453
+ }
454
+ return [...providers];
455
+ }
456
+ // Normalizes top-level external ID shortcuts into the ids object.
457
+ // Нормализует верхнеуровневые сокращения внешних ID в объект ids.
458
+ function normalizeSearchQuery(query) {
459
+ const ids = { ...(query.ids ?? {}) };
460
+ for (const key of EXTERNAL_ID_SHORTCUTS) {
461
+ const value = query[key];
462
+ if (value) {
463
+ ids[key] = value;
464
+ }
465
+ }
466
+ return {
467
+ ...query,
468
+ title: query.title?.trim(),
469
+ ids: hasExternalIds(ids) ? ids : undefined,
470
+ };
471
+ }
472
+ // Infers a provider lookup language only when the caller did not specify one.
473
+ // Определяет язык provider lookup только если caller не передал его явно.
474
+ function inferTitleLanguage(title) {
475
+ if (!title)
476
+ return undefined;
477
+ if (/[а-яё]/iu.test(title))
478
+ return "ru";
479
+ if (/[\u3040-\u30ff\u3400-\u9fff]/u.test(title))
480
+ return "ja";
481
+ return /[a-z]/iu.test(title) ? "en" : undefined;
482
+ }
483
+ // Normalizes top-level external ID shortcuts into a details ids object.
484
+ // Нормализует верхнеуровневые сокращения внешних ID в объект ids для details.
485
+ function normalizeDetailsQuery(query) {
486
+ const ids = { ...(query.ids ?? {}) };
487
+ for (const key of EXTERNAL_ID_SHORTCUTS) {
488
+ const value = query[key];
489
+ if (value) {
490
+ ids[key] = value;
491
+ }
492
+ }
493
+ return {
494
+ ...query,
495
+ ids: hasExternalIds(ids) ? ids : undefined,
496
+ };
497
+ }
498
+ // Normalizes top-level external ID shortcuts into a streaming ids object.
499
+ // Нормализует верхнеуровневые сокращения внешних ID в объект ids для streaming.
500
+ function normalizeStreamQuery(query) {
501
+ const queryWithShortcuts = query;
502
+ const ids = { ...(query.ids ?? {}) };
503
+ const providers = query.providers?.map((provider) => provider.trim()).filter(Boolean);
504
+ const language = query.language?.trim();
505
+ for (const key of EXTERNAL_ID_SHORTCUTS) {
506
+ const value = queryWithShortcuts[key];
507
+ if (value) {
508
+ ids[key] = value;
509
+ }
510
+ }
511
+ return {
512
+ ...query,
513
+ title: query.title?.trim(),
514
+ ...(hasExternalIds(ids) ? { ids } : {}),
515
+ ...(providers && providers.length > 0 ? { providers } : {}),
516
+ ...(language ? { language } : {}),
517
+ };
518
+ }
519
+ // Validates that a search query has at least one supported lookup input.
520
+ // Проверяет, что search query содержит хотя бы один поддерживаемый вход для поиска.
521
+ function validateSearchQuery(query) {
522
+ if (query.limit !== undefined &&
523
+ (!Number.isInteger(query.limit) || query.limit < 0 || query.limit > MAX_SEARCH_LIMIT)) {
524
+ throw new MediaEngineError({
525
+ code: "INVALID_QUERY",
526
+ message: `Search query limit must be an integer between 0 and ${MAX_SEARCH_LIMIT}.`,
527
+ });
528
+ }
529
+ if (query.title || hasExternalIds(query.ids)) {
530
+ return;
531
+ }
532
+ throw new MediaEngineError({
533
+ code: "INVALID_QUERY",
534
+ message: "Search query must include title or external ids.",
535
+ });
536
+ }
537
+ // Validates that a details query has at least one supported lookup input.
538
+ // Проверяет, что details query содержит хотя бы один поддерживаемый вход для поиска.
539
+ function validateDetailsQuery(query) {
540
+ if (query.id?.trim() || hasExternalIds(query.ids)) {
541
+ return;
542
+ }
543
+ throw new MediaEngineError({
544
+ code: "INVALID_QUERY",
545
+ message: "Details query must include id or external ids.",
546
+ });
547
+ }
548
+ // Validates that a streaming query can identify a media item or episode.
549
+ // Проверяет, что streaming query может определить медиа или эпизод.
550
+ function validateStreamQuery(query) {
551
+ if (!query.type) {
552
+ throw new MediaEngineError({
553
+ code: "INVALID_QUERY",
554
+ message: "Stream query type is required.",
555
+ });
556
+ }
557
+ if ([query.year, query.seasonNumber, query.episodeNumber, query.absoluteEpisodeNumber].some((value) => value !== undefined && (!Number.isInteger(value) || value < 0))) {
558
+ throw new MediaEngineError({
559
+ code: "INVALID_QUERY",
560
+ message: "Stream query numeric fields must be non-negative integers.",
561
+ });
562
+ }
563
+ if (query.title || hasExternalIds(query.ids)) {
564
+ return;
565
+ }
566
+ throw new MediaEngineError({
567
+ code: "INVALID_QUERY",
568
+ message: "Stream query must include title or external ids.",
569
+ });
570
+ }
571
+ // Selects streaming providers that can answer the normalized stream query.
572
+ // Выбирает streaming-провайдеры, которые могут ответить на нормализованный stream query.
573
+ function selectStreamingProviders(providers, query) {
574
+ return providers.filter((provider) => {
575
+ if (query.providers && !query.providers.includes(provider.name)) {
576
+ return false;
577
+ }
578
+ if (!provider.capabilities.mediaTypes.includes(query.type)) {
579
+ return false;
580
+ }
581
+ if (hasEpisodeQuery(query) && !provider.capabilities.lookup.byEpisode) {
582
+ return false;
583
+ }
584
+ return (Boolean(query.title && provider.capabilities.lookup.byTitle) ||
585
+ hasSupportedExternalId(query.ids, provider.capabilities.lookup.byExternalIds));
586
+ });
587
+ }
588
+ // Gives providers enough candidates so the engine can rank before applying the public limit.
589
+ // Дает провайдерам достаточно кандидатов, чтобы движок ранжировал до применения публичного limit.
590
+ function createProviderSearchQuery(query) {
591
+ if (query.limit === undefined || query.limit === 0) {
592
+ return query;
593
+ }
594
+ return {
595
+ ...query,
596
+ limit: getProviderSearchLimit(query),
597
+ };
598
+ }
599
+ // Retries only transient failures when every selected search provider failed together.
600
+ // Повторяет только временные ошибки, когда одновременно упали все выбранные search-провайдеры.
601
+ async function retryFailedSearchProviders(providers, outcomes, query, context) {
602
+ return Promise.all(outcomes.map(async (outcome, index) => {
603
+ const provider = providers[index];
604
+ if (!provider || !outcome.failure?.retryable) {
605
+ return outcome;
606
+ }
607
+ return callTimedProviderSearch(provider, createProviderSearchQuery(query), {
608
+ debug: context.debug,
609
+ language: context.language,
610
+ timeoutMs: context.getTimeoutMs(provider.name),
611
+ });
612
+ }));
613
+ }
614
+ // Broadens an empty typo search or separates one likely joined compound title.
615
+ // Расширяет пустой поиск с опечаткой или разделяет вероятно слитное составное название.
616
+ function createSearchFallbackQuery(query) {
617
+ if (!query.title || hasExternalIds(query.ids)) {
618
+ return undefined;
619
+ }
620
+ const title = query.title.trim();
621
+ const tokens = title.split(/\s+/);
622
+ const lastToken = tokens.at(-1);
623
+ if (tokens.length >= SEARCH_FALLBACK_MIN_TOKENS &&
624
+ lastToken &&
625
+ lastToken.length >= SEARCH_FALLBACK_MIN_LAST_TOKEN_LENGTH) {
626
+ return {
627
+ ...query,
628
+ title: tokens.slice(0, -1).join(" "),
629
+ };
630
+ }
631
+ const characters = [...title];
632
+ if (tokens.length !== 1 ||
633
+ characters.length < SEARCH_JOINED_FALLBACK_MIN_LENGTH ||
634
+ characters.length > SEARCH_JOINED_FALLBACK_MAX_LENGTH ||
635
+ !/^\p{Script=Cyrillic}+$/u.test(title)) {
636
+ return undefined;
637
+ }
638
+ const splitIndex = Math.floor(characters.length / 2);
639
+ if (splitIndex < SEARCH_JOINED_FALLBACK_MIN_PART_LENGTH ||
640
+ characters.length - splitIndex < SEARCH_JOINED_FALLBACK_MIN_PART_LENGTH) {
641
+ return undefined;
642
+ }
643
+ return {
644
+ ...query,
645
+ title: `${characters.slice(0, splitIndex).join("")} ${characters.slice(splitIndex).join("")}`,
646
+ };
647
+ }
648
+ // Adds fallback discoveries without duplicating the same provider item and its attribution.
649
+ // Добавляет fallback-результаты без дублирования item и атрибуции одного провайдера.
650
+ function appendUniqueSearchResults(target, candidates) {
651
+ for (const candidate of candidates) {
652
+ const isDuplicate = target.some((existing) => existing.provider === candidate.provider &&
653
+ existing.item.type === candidate.item.type &&
654
+ existing.item.id === candidate.item.id);
655
+ if (!isDuplicate) {
656
+ target.push(candidate);
657
+ }
658
+ }
659
+ }
660
+ // Expands broad short queries more because final ranking needs enough cross-provider candidates.
661
+ // Расширяет короткие широкие запросы сильнее, потому что финальному ranking нужны кандидаты разных провайдеров.
662
+ function getProviderSearchLimit(query) {
663
+ if (isBroadShortTitleSearch(query)) {
664
+ return Math.min(MAX_PROVIDER_SEARCH_LIMIT, Math.max(query.limit * 10, 50));
665
+ }
666
+ return Math.min(MAX_PROVIDER_SEARCH_LIMIT, Math.max(query.limit * 5, 10));
667
+ }
668
+ // Detects searches like "one" or "game" where popular canonical results may be deeper.
669
+ // Определяет поиски вроде "one" или "game", где популярные канонические результаты могут быть глубже.
670
+ function isBroadShortTitleSearch(query) {
671
+ if (query.type || hasExternalIds(query.ids)) {
672
+ return false;
673
+ }
674
+ const normalizedTitle = query.title?.trim().replace(/\s+/g, " ") ?? "";
675
+ return (normalizedTitle.length > 0 && normalizedTitle.length <= 4 && !normalizedTitle.includes(" "));
676
+ }
677
+ // Calls one search provider and returns normalized timing/failure metadata.
678
+ // Вызывает один search-провайдер и возвращает нормализованные timing/failure метаданные.
679
+ async function callTimedProviderSearch(provider, query, context) {
680
+ const startedAt = Date.now();
681
+ try {
682
+ const results = await callProviderSearch(provider, query, context);
683
+ return {
684
+ provider: provider.name,
685
+ timing: {
686
+ provider: provider.name,
687
+ status: "success",
688
+ tookMs: elapsedSince(startedAt),
689
+ },
690
+ results,
691
+ };
692
+ }
693
+ catch (error) {
694
+ return {
695
+ provider: provider.name,
696
+ timing: {
697
+ provider: provider.name,
698
+ status: "failed",
699
+ tookMs: elapsedSince(startedAt),
700
+ },
701
+ results: [],
702
+ failure: toProviderFailure(provider.name, error),
703
+ };
704
+ }
705
+ }
706
+ // Calls one details provider and returns normalized timing/failure metadata.
707
+ // Вызывает один details-провайдер и возвращает нормализованные timing/failure метаданные.
708
+ async function callTimedProviderDetails(provider, query, context) {
709
+ const startedAt = Date.now();
710
+ try {
711
+ const result = await callProviderDetails(provider, query, context);
712
+ return {
713
+ provider: provider.name,
714
+ timing: {
715
+ provider: provider.name,
716
+ status: "success",
717
+ tookMs: elapsedSince(startedAt),
718
+ },
719
+ result,
720
+ };
721
+ }
722
+ catch (error) {
723
+ return {
724
+ provider: provider.name,
725
+ timing: {
726
+ provider: provider.name,
727
+ status: "failed",
728
+ tookMs: elapsedSince(startedAt),
729
+ },
730
+ result: null,
731
+ failure: toProviderFailure(provider.name, error),
732
+ };
733
+ }
734
+ }
735
+ // Calls one streaming provider and returns normalized timing/failure metadata.
736
+ // Вызывает один streaming-провайдер и возвращает нормализованные timing/failure метаданные.
737
+ async function callTimedProviderAvailability(provider, query, context) {
738
+ const startedAt = Date.now();
739
+ try {
740
+ const result = await callProviderAvailability(provider, query, context);
741
+ return {
742
+ provider: provider.name,
743
+ timing: {
744
+ provider: provider.name,
745
+ status: "success",
746
+ tookMs: elapsedSince(startedAt),
747
+ },
748
+ result,
749
+ };
750
+ }
751
+ catch (error) {
752
+ return {
753
+ provider: provider.name,
754
+ timing: {
755
+ provider: provider.name,
756
+ status: "failed",
757
+ tookMs: elapsedSince(startedAt),
758
+ },
759
+ result: null,
760
+ failure: toProviderFailure(provider.name, error),
761
+ };
762
+ }
763
+ }
764
+ // Calls one provider search method with timeout and abort signal support.
765
+ // Вызывает search одного провайдера с поддержкой timeout и abort signal.
766
+ async function callProviderSearch(provider, query, context) {
767
+ return withProviderTimeout(provider.name, context, (controller) => provider.search(query, {
768
+ signal: controller.signal,
769
+ timeoutMs: context.timeoutMs,
770
+ debug: context.debug,
771
+ language: context.language,
772
+ }));
773
+ }
774
+ // Calls one provider details method with timeout and abort signal support.
775
+ // Вызывает getDetails одного провайдера с поддержкой timeout и abort signal.
776
+ async function callProviderDetails(provider, query, context) {
777
+ if (!provider.getDetails) {
778
+ return null;
779
+ }
780
+ return withProviderTimeout(provider.name, context, (controller) => provider.getDetails(query, {
781
+ signal: controller.signal,
782
+ timeoutMs: context.timeoutMs,
783
+ debug: context.debug,
784
+ language: context.language,
785
+ }));
786
+ }
787
+ // Calls one streaming provider with timeout and abort signal support.
788
+ // Вызывает один streaming-провайдер с поддержкой timeout и abort signal.
789
+ async function callProviderAvailability(provider, query, context) {
790
+ return withProviderTimeout(provider.name, context, (controller) => provider.getAvailability(query, {
791
+ signal: controller.signal,
792
+ timeoutMs: context.timeoutMs,
793
+ debug: context.debug,
794
+ language: context.language,
795
+ }));
796
+ }
797
+ // Merges availability results without hiding provider attribution.
798
+ // Объединяет availability-результаты, не скрывая атрибуцию провайдеров.
799
+ function mergeAvailabilityResults(query, results) {
800
+ return {
801
+ query,
802
+ item: results.find((result) => result.item)?.item,
803
+ episodes: mergeEpisodeAvailability(results),
804
+ options: uniqueBy(results.flatMap((result) => result.options), (option) => `${option.provider}:${option.id}`),
805
+ sourceProviders: uniqueBy(results.flatMap((result) => result.sourceProviders), (source) => createStreamingSourceKey(source)),
806
+ checkedAt: new Date().toISOString(),
807
+ };
808
+ }
809
+ // Merges episode-level availability blocks by episode identity.
810
+ // Объединяет episode-level availability блоки по идентичности эпизода.
811
+ function mergeEpisodeAvailability(results) {
812
+ const episodesByKey = new Map();
813
+ for (const episode of results.flatMap((result) => [
814
+ ...(result.episodes ?? []),
815
+ ...createEpisodeAvailabilityFromOptions(result.options),
816
+ ])) {
817
+ const key = createEpisodeKey(episode);
818
+ const existing = episodesByKey.get(key);
819
+ if (!existing) {
820
+ episodesByKey.set(key, {
821
+ seasonNumber: episode.seasonNumber,
822
+ episodeNumber: episode.episodeNumber,
823
+ absoluteEpisodeNumber: episode.absoluteEpisodeNumber,
824
+ title: episode.title,
825
+ options: uniqueBy(episode.options, (option) => `${option.provider}:${option.id}`),
826
+ });
827
+ continue;
828
+ }
829
+ existing.options = uniqueBy([...existing.options, ...episode.options], (option) => `${option.provider}:${option.id}`);
830
+ existing.title ??= episode.title;
831
+ }
832
+ return episodesByKey.size > 0 ? [...episodesByKey.values()] : undefined;
833
+ }
834
+ // Creates episode blocks from top-level options that carry episode identity.
835
+ // Создает episode blocks из top-level options, которые содержат идентичность эпизода.
836
+ function createEpisodeAvailabilityFromOptions(options) {
837
+ return options
838
+ .filter((option) => option.episode)
839
+ .map((option) => ({
840
+ seasonNumber: option.episode?.seasonNumber,
841
+ episodeNumber: option.episode?.episodeNumber,
842
+ absoluteEpisodeNumber: option.episode?.absoluteEpisodeNumber,
843
+ options: [option],
844
+ }));
845
+ }
846
+ // Wraps a provider promise with configured timeout behavior.
847
+ // Оборачивает promise провайдера настроенным timeout-поведением.
848
+ async function withProviderTimeout(providerName, context, run) {
849
+ const controller = new AbortController();
850
+ let timeout;
851
+ try {
852
+ const providerPromise = run(controller);
853
+ if (context.timeoutMs === undefined) {
854
+ return await providerPromise;
855
+ }
856
+ const timeoutPromise = new Promise((_, reject) => {
857
+ const timeoutError = new ProviderError({
858
+ provider: providerName,
859
+ code: "PROVIDER_TIMEOUT",
860
+ message: `Provider "${providerName}" timed out.`,
861
+ retryable: true,
862
+ });
863
+ if (context.timeoutMs <= 0) {
864
+ controller.abort(timeoutError);
865
+ reject(timeoutError);
866
+ return;
867
+ }
868
+ timeout = setTimeout(() => {
869
+ controller.abort(timeoutError);
870
+ reject(timeoutError);
871
+ }, context.timeoutMs);
872
+ });
873
+ return await Promise.race([providerPromise, timeoutPromise]);
874
+ }
875
+ finally {
876
+ if (timeout) {
877
+ clearTimeout(timeout);
878
+ }
879
+ }
880
+ }
881
+ // Creates public response metadata for a search call.
882
+ // Создает публичные метаданные ответа для search-вызова.
883
+ function createResponseMeta(input) {
884
+ return {
885
+ providers: {
886
+ requested: input.requested,
887
+ successful: input.successful,
888
+ failed: input.failed,
889
+ },
890
+ cached: input.cached,
891
+ tookMs: input.tookMs,
892
+ warnings: input.warnings.length > 0 ? input.warnings : undefined,
893
+ debug: input.debug
894
+ ? {
895
+ providers: input.requested,
896
+ timings: input.timings ?? [],
897
+ }
898
+ : undefined,
899
+ };
900
+ }
901
+ // Creates a stable cache key for a normalized search query.
902
+ // Создает стабильный cache key для нормализованного search query.
903
+ function createSearchCacheKey(query) {
904
+ return `search:${JSON.stringify(sortObject(query))}`;
905
+ }
906
+ // Creates a stable cache key for a normalized details query.
907
+ // Создает стабильный cache key для нормализованного details query.
908
+ function createDetailsCacheKey(query) {
909
+ return `details:${JSON.stringify(sortObject(query))}`;
910
+ }
911
+ // Creates a stable cache key for a normalized streaming query.
912
+ // Создает стабильный cache key для нормализованного streaming query.
913
+ function createAvailabilityCacheKey(query) {
914
+ return `availability:${JSON.stringify(sortObject(query))}`;
915
+ }
916
+ // Keeps cached direct links from outliving the earliest advertised expiration.
917
+ // Не позволяет кешированным прямым ссылкам пережить ближайший заявленный срок действия.
918
+ function createAvailabilityCacheOptions(availability) {
919
+ const expiresAtValues = [
920
+ ...availability.options,
921
+ ...(availability.episodes?.flatMap((episode) => episode.options) ?? []),
922
+ ]
923
+ .map((option) => option.expiresAt)
924
+ .filter((value) => value !== undefined)
925
+ .map((value) => Date.parse(value))
926
+ .filter(Number.isFinite);
927
+ if (expiresAtValues.length === 0) {
928
+ return undefined;
929
+ }
930
+ const earliestExpiration = Math.min(...expiresAtValues);
931
+ return {
932
+ ttlMs: Math.max(0, earliestExpiration - Date.now() - EXPIRING_AVAILABILITY_CACHE_SAFETY_MS),
933
+ };
934
+ }
935
+ // Checks whether an external ID object contains at least one ID.
936
+ // Проверяет, содержит ли объект внешних ID хотя бы один ID.
937
+ function hasExternalIds(ids) {
938
+ return Boolean(ids && Object.values(ids).some((value) => Boolean(value)));
939
+ }
940
+ // Checks whether two normalized media identities share at least one exact external ID.
941
+ // Проверяет, совпадает ли у двух нормализованных media identity хотя бы один внешний ID.
942
+ function hasSharedExternalId(left, right) {
943
+ if (!left || !right) {
944
+ return false;
945
+ }
946
+ return EXTERNAL_ID_SHORTCUTS.some((key) => Boolean(left[key] && left[key] === right[key]));
947
+ }
948
+ // Checks whether query ids overlap provider-supported external ID sources.
949
+ // Проверяет, пересекаются ли query ids с поддерживаемыми провайдером источниками ID.
950
+ function hasSupportedExternalId(ids, supportedSources) {
951
+ return Boolean(ids && supportedSources.some((source) => Boolean(ids[source])));
952
+ }
953
+ // Checks whether query targets a concrete episode.
954
+ // Проверяет, нацелен ли query на конкретный эпизод.
955
+ function hasEpisodeQuery(query) {
956
+ return (query.seasonNumber !== undefined ||
957
+ query.episodeNumber !== undefined ||
958
+ query.absoluteEpisodeNumber !== undefined);
959
+ }
960
+ // Creates a stable identity for an episode availability block.
961
+ // Создает стабильную идентичность для блока доступности эпизода.
962
+ function createEpisodeKey(episode) {
963
+ return [
964
+ episode.seasonNumber ?? "",
965
+ episode.episodeNumber ?? "",
966
+ episode.absoluteEpisodeNumber ?? "",
967
+ ].join(":");
968
+ }
969
+ // Creates a stable identity for provider source attribution.
970
+ // Создает стабильную идентичность для атрибуции источника провайдера.
971
+ function createStreamingSourceKey(source) {
972
+ return `${source.provider}:${source.url ?? ""}:${JSON.stringify(sortObject(source.ids ?? {}))}`;
973
+ }
974
+ // Keeps the first value for each derived key.
975
+ // Оставляет первое значение для каждого вычисленного ключа.
976
+ function uniqueBy(values, getKey) {
977
+ const seen = new Set();
978
+ const unique = [];
979
+ for (const value of values) {
980
+ const key = getKey(value);
981
+ if (!seen.has(key)) {
982
+ seen.add(key);
983
+ unique.push(value);
984
+ }
985
+ }
986
+ return unique;
987
+ }
988
+ // Returns elapsed milliseconds since a start timestamp.
989
+ // Возвращает количество миллисекунд, прошедших с начального timestamp.
990
+ function elapsedSince(startedAt) {
991
+ return Date.now() - startedAt;
992
+ }
993
+ // Sorts object keys recursively for deterministic JSON cache keys.
994
+ // Рекурсивно сортирует ключи объекта для детерминированных JSON cache keys.
995
+ function sortObject(value) {
996
+ if (Array.isArray(value)) {
997
+ return value.map(sortObject);
998
+ }
999
+ if (value && typeof value === "object") {
1000
+ return Object.fromEntries(Object.entries(value)
1001
+ .filter(([, entryValue]) => entryValue !== undefined)
1002
+ .sort(([left], [right]) => left.localeCompare(right))
1003
+ .map(([key, entryValue]) => [key, sortObject(entryValue)]));
1004
+ }
1005
+ return value;
1006
+ }
1007
+ //# sourceMappingURL=engine.js.map