@media-engine/core 0.1.1 → 1.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 (92) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +48 -20
  3. package/README.ru.md +46 -19
  4. package/dist/cache/memory.js +10 -8
  5. package/dist/cache/memory.js.map +1 -1
  6. package/dist/details/types.d.ts +6 -0
  7. package/dist/engine/availability.d.ts +1 -0
  8. package/dist/engine/availability.js +8 -0
  9. package/dist/engine/availability.js.map +1 -1
  10. package/dist/engine/circuit-breaker.d.ts +1 -0
  11. package/dist/engine/circuit-breaker.js +11 -0
  12. package/dist/engine/circuit-breaker.js.map +1 -1
  13. package/dist/engine/concurrency-limiter.js +2 -2
  14. package/dist/engine/concurrency-limiter.js.map +1 -1
  15. package/dist/engine/engine.d.ts +9 -4
  16. package/dist/engine/engine.js +294 -104
  17. package/dist/engine/engine.js.map +1 -1
  18. package/dist/engine/in-flight.d.ts +12 -1
  19. package/dist/engine/in-flight.js +69 -14
  20. package/dist/engine/in-flight.js.map +1 -1
  21. package/dist/engine/operation.d.ts +7 -0
  22. package/dist/engine/operation.js +49 -0
  23. package/dist/engine/operation.js.map +1 -0
  24. package/dist/engine/provider-calls.d.ts +11 -0
  25. package/dist/engine/provider-calls.js +63 -12
  26. package/dist/engine/provider-calls.js.map +1 -1
  27. package/dist/engine/query.d.ts +6 -1
  28. package/dist/engine/query.js +228 -36
  29. package/dist/engine/query.js.map +1 -1
  30. package/dist/engine/response-meta.d.ts +3 -1
  31. package/dist/engine/response-meta.js +8 -1
  32. package/dist/engine/response-meta.js.map +1 -1
  33. package/dist/engine/runtime.d.ts +2 -0
  34. package/dist/engine/runtime.js +19 -0
  35. package/dist/engine/runtime.js.map +1 -1
  36. package/dist/engine/search-discovery.d.ts +3 -0
  37. package/dist/engine/search-discovery.js +35 -0
  38. package/dist/engine/search-discovery.js.map +1 -0
  39. package/dist/engine/search-enrichment-shared.d.ts +23 -0
  40. package/dist/engine/search-enrichment-shared.js +68 -0
  41. package/dist/engine/search-enrichment-shared.js.map +1 -0
  42. package/dist/engine/search-enrichment.d.ts +21 -7
  43. package/dist/engine/search-enrichment.js +135 -75
  44. package/dist/engine/search-enrichment.js.map +1 -1
  45. package/dist/engine/search-identity-snapshot.d.ts +15 -0
  46. package/dist/engine/search-identity-snapshot.js +101 -0
  47. package/dist/engine/search-identity-snapshot.js.map +1 -0
  48. package/dist/engine/search-outcomes.d.ts +32 -0
  49. package/dist/engine/search-outcomes.js +118 -0
  50. package/dist/engine/search-outcomes.js.map +1 -0
  51. package/dist/engine/search-poster-enrichment.d.ts +31 -0
  52. package/dist/engine/search-poster-enrichment.js +93 -0
  53. package/dist/engine/search-poster-enrichment.js.map +1 -0
  54. package/dist/engine/search-result-freeze.d.ts +13 -0
  55. package/dist/engine/search-result-freeze.js +155 -0
  56. package/dist/engine/search-result-freeze.js.map +1 -0
  57. package/dist/engine/torrents.d.ts +22 -0
  58. package/dist/engine/torrents.js +188 -0
  59. package/dist/engine/torrents.js.map +1 -0
  60. package/dist/engine/types.d.ts +7 -1
  61. package/dist/errors/types.d.ts +1 -1
  62. package/dist/errors/types.js.map +1 -1
  63. package/dist/index.d.ts +2 -1
  64. package/dist/index.js +1 -1
  65. package/dist/index.js.map +1 -1
  66. package/dist/media/types.d.ts +1 -1
  67. package/dist/merge/diversity.d.ts +15 -0
  68. package/dist/merge/diversity.js +52 -0
  69. package/dist/merge/diversity.js.map +1 -0
  70. package/dist/merge/priority.js +3 -1
  71. package/dist/merge/priority.js.map +1 -1
  72. package/dist/merge/scoring.d.ts +10 -0
  73. package/dist/merge/scoring.js +132 -28
  74. package/dist/merge/scoring.js.map +1 -1
  75. package/dist/merge/strategy.js +53 -13
  76. package/dist/merge/strategy.js.map +1 -1
  77. package/dist/providers/registry.d.ts +4 -2
  78. package/dist/providers/registry.js +11 -2
  79. package/dist/providers/registry.js.map +1 -1
  80. package/dist/providers/types.d.ts +4 -0
  81. package/dist/response/types.d.ts +20 -0
  82. package/dist/search/types.d.ts +34 -0
  83. package/dist/streaming/types.d.ts +1 -0
  84. package/dist/testing/providers.js +2 -0
  85. package/dist/testing/providers.js.map +1 -1
  86. package/dist/torrent/index.d.ts +1 -0
  87. package/dist/torrent/index.js +2 -0
  88. package/dist/torrent/index.js.map +1 -0
  89. package/dist/torrent/types.d.ts +132 -0
  90. package/dist/torrent/types.js +2 -0
  91. package/dist/torrent/types.js.map +1 -0
  92. package/package.json +8 -3
@@ -1,25 +1,30 @@
1
1
  import { MediaEngineError } from "../errors/index.js";
2
2
  import { DefaultMergeStrategy } from "../merge/index.js";
3
3
  import { ProviderRegistry } from "../providers/index.js";
4
- import { createAvailabilityCacheOptions, mergeAvailabilityResults, selectStreamingProviders, } from "./availability.js";
4
+ import { createAvailabilityCacheOptions, hasUnknownStreamValidation, mergeAvailabilityResults, selectStreamingProviders, } from "./availability.js";
5
5
  import { ProviderCircuitBreaker } from "./circuit-breaker.js";
6
6
  import { ProviderConcurrencyLimiter } from "./concurrency-limiter.js";
7
7
  import { callTimedProviderAvailability, callTimedProviderDetails, callTimedProviderSearch, retryFailedSearchProviders, } from "./provider-calls.js";
8
- import { appendUniqueSearchResults, createAvailabilityCacheKey, createDetailsCacheKey, createProviderSearchQuery, createSearchCacheKey, createSearchFallbackQuery, hasExternalIds, inferTitleLanguage, normalizeDetailsQuery, normalizeSearchQuery, normalizeStreamQuery, validateDetailsQuery, validateSearchQuery, validateStreamQuery, } from "./query.js";
8
+ import { createAvailabilityCacheKey, createDetailsCacheKey, createProviderSearchQuery, createSearchCacheKey, createSearchFallbackQuery, createSearchIdentitySnapshotCacheKey, inferTitleLanguage, normalizeDetailsQuery, normalizeSearchQuery, normalizeStreamQuery, normalizeTorrentQuery, validateDetailsQuery, validateSearchQuery, validateStreamQuery, validateTorrentQuery, } from "./query.js";
9
9
  import { createResponseMeta, elapsedSince } from "./response-meta.js";
10
- import { applySearchPosterEnrichments, loadSearchPoster, needsSearchEnrichment, } from "./search-enrichment.js";
10
+ import { applySearchDetailsEnrichments, executeSearchEnrichmentPlan } from "./search-enrichment.js";
11
+ import { needsFallbackTitleDiscovery, needsPrimaryTitleBroadening } from "./search-discovery.js";
12
+ import { applySearchPosterEnrichments } from "./search-poster-enrichment.js";
13
+ import { applySearchIdEnrichments, createFrozenDiscoveryResults, createSearchEnrichmentCandidates, finalizeSearchRankingEvidence, filterFrozenSearchResults, } from "./search-result-freeze.js";
14
+ import { createSearchIdentitySnapshot, isUsableSearchIdentitySnapshot, recoverSearchIdentitySnapshot, SEARCH_IDENTITY_SNAPSHOT_CACHE_OPTIONS, } from "./search-identity-snapshot.js";
15
+ import { SearchOutcomeAccumulator } from "./search-outcomes.js";
11
16
  import { InFlightRequestCoalescer } from "./in-flight.js";
12
- import { resolveProviderTimeoutMs, validateStreamingProviders } from "./runtime.js";
17
+ import { throwIfAborted, waitForCaller } from "./operation.js";
18
+ import { resolveProviderTimeoutMs, validateStreamingProviders, validateTorrentProviders, } from "./runtime.js";
19
+ import { executeTorrentDiscovery } from "./torrents.js";
13
20
  import { loadWithStaleFallback } from "./stale-fallback.js";
14
21
  import { ProviderTimeoutBudget } from "./timeout-budget.js";
15
- const SEARCH_ID_ENRICHMENT_LIMIT = 6;
16
- const SEARCH_ID_ENRICHMENT_TIMEOUT_MS = 1_500;
17
- const SEARCH_DETAILS_POSTER_ENRICHMENT_LIMIT = 3;
18
22
  // Main entry point for using Media Engine core.
19
23
  // Главная точка входа для использования Media Engine core.
20
24
  export class MediaEngine {
21
25
  registry;
22
26
  streamingProviders;
27
+ torrentProviders;
23
28
  cache;
24
29
  mergeStrategy;
25
30
  timeoutMs;
@@ -31,6 +36,7 @@ export class MediaEngine {
31
36
  constructor(options = {}) {
32
37
  this.registry = new ProviderRegistry(options.providers ?? []);
33
38
  this.streamingProviders = validateStreamingProviders(options.streamingProviders ?? []);
39
+ this.torrentProviders = validateTorrentProviders(options.torrentProviders ?? []);
34
40
  this.cache = options.cache;
35
41
  this.mergeStrategy = options.mergeStrategy ?? new DefaultMergeStrategy();
36
42
  this.timeoutMs = options.timeoutMs;
@@ -68,6 +74,31 @@ export class MediaEngine {
68
74
  },
69
75
  }));
70
76
  }
77
+ // Returns safe registered torrent provider metadata without provider internals.
78
+ // Возвращает безопасные метаданные torrent-провайдеров без внутренних данных.
79
+ getTorrentProviders() {
80
+ return this.torrentProviders.map((provider) => ({
81
+ name: provider.name,
82
+ version: provider.version,
83
+ kind: provider.kind,
84
+ catalog: provider.catalog
85
+ ? {
86
+ displayName: provider.catalog.displayName,
87
+ scope: provider.catalog.scope,
88
+ ...(provider.catalog.locale ? { locale: provider.catalog.locale } : {}),
89
+ }
90
+ : undefined,
91
+ capabilities: {
92
+ mediaTypes: [...provider.capabilities.mediaTypes],
93
+ lookup: {
94
+ byTitle: provider.capabilities.lookup.byTitle,
95
+ byExternalIds: [...provider.capabilities.lookup.byExternalIds],
96
+ byEpisode: provider.capabilities.lookup.byEpisode,
97
+ },
98
+ features: provider.capabilities.features ? [...provider.capabilities.features] : undefined,
99
+ },
100
+ }));
101
+ }
71
102
  // Returns process-local provider reliability counters without exposing provider internals.
72
103
  // Возвращает локальные health-счетчики провайдеров без раскрытия их внутренностей.
73
104
  getProviderHealth() {
@@ -75,17 +106,36 @@ export class MediaEngine {
75
106
  .getProviders()
76
107
  .map((provider) => this.createProviderHealthStatus(provider.name, "metadata"));
77
108
  const streaming = this.streamingProviders.map((provider) => this.createProviderHealthStatus(provider.name, "streaming"));
78
- return [...metadata, ...streaming];
109
+ const torrent = this.torrentProviders.map((provider) => this.createProviderHealthStatus(provider.name, "torrent"));
110
+ return [...metadata, ...streaming, ...torrent];
79
111
  }
80
112
  // Searches media through selected providers and merges normalized results.
81
113
  // Ищет медиа через выбранных провайдеров и объединяет нормализованные результаты.
82
- async search(query) {
114
+ async search(query, options = {}) {
115
+ throwIfAborted(options.signal);
83
116
  const startedAt = Date.now();
84
117
  const normalizedQuery = normalizeSearchQuery(query);
85
118
  validateSearchQuery(normalizedQuery);
119
+ if (normalizedQuery.limit === 0) {
120
+ return {
121
+ query: normalizedQuery,
122
+ results: [],
123
+ meta: createResponseMeta({
124
+ requested: [],
125
+ successful: [],
126
+ failed: [],
127
+ warnings: [],
128
+ cached: false,
129
+ tookMs: elapsedSince(startedAt),
130
+ debug: this.debug,
131
+ timings: [],
132
+ }),
133
+ };
134
+ }
86
135
  const searchLanguage = normalizedQuery.language ?? inferTitleLanguage(normalizedQuery.title);
87
136
  const cacheKey = createSearchCacheKey(normalizedQuery);
88
- const cached = await this.cache?.get(cacheKey);
137
+ const identitySnapshotCacheKey = createSearchIdentitySnapshotCacheKey(normalizedQuery);
138
+ const cached = await waitForCaller(this.cache?.get(cacheKey), options.signal);
89
139
  if (cached) {
90
140
  const response = structuredClone(cached);
91
141
  return {
@@ -98,48 +148,49 @@ export class MediaEngine {
98
148
  },
99
149
  };
100
150
  }
101
- const stale = await this.cache?.getStale?.(cacheKey);
102
- const pending = this.inFlightRequests.run(`search:${cacheKey}`, async () => {
151
+ const stale = await waitForCaller(this.cache?.getStale?.(cacheKey), options.signal);
152
+ const inFlight = this.inFlightRequests.forCaller(options);
153
+ const pending = inFlight.run(`search:${cacheKey}`, async (operationSignal) => {
103
154
  const timeoutBudget = this.createProviderTimeoutBudget();
104
- const providers = this.registry.selectSearchProviders(normalizedQuery);
155
+ const providers = this.registry.selectSearchProviders(normalizedQuery, {
156
+ titleDiscovery: "primary",
157
+ });
158
+ const primaryProviderNames = new Set(providers.map((provider) => provider.name));
159
+ const fallbackProviders = this.registry
160
+ .selectSearchProviders(normalizedQuery, { titleDiscovery: "fallback" })
161
+ .filter((provider) => !primaryProviderNames.has(provider.name));
105
162
  const requested = providers.map((provider) => provider.name);
106
163
  const successful = [];
107
164
  const failed = [];
108
165
  const warnings = [];
109
166
  const providerResults = [];
110
167
  const providerTimings = [];
111
- let outcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(normalizedQuery), {
168
+ const searchOutcomes = new SearchOutcomeAccumulator({
169
+ successful,
170
+ failed,
171
+ results: providerResults,
172
+ timings: providerTimings,
173
+ warnings,
174
+ });
175
+ const outcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(normalizedQuery), {
112
176
  debug: this.debug,
113
177
  language: searchLanguage,
178
+ signal: operationSignal,
114
179
  timeoutMs: timeoutBudget.getRemainingMs(provider.name),
115
180
  circuitBreaker: this.circuitBreaker,
116
181
  concurrencyLimiter: this.concurrencyLimiter,
117
182
  })));
183
+ searchOutcomes.appendMandatory(outcomes, "primary");
118
184
  if (outcomes.length > 0 && outcomes.every((outcome) => outcome.failure)) {
119
- outcomes = await retryFailedSearchProviders(providers, outcomes, normalizedQuery, {
185
+ const retryOutcomes = await retryFailedSearchProviders(providers, outcomes, normalizedQuery, {
120
186
  debug: this.debug,
121
187
  language: searchLanguage,
188
+ signal: operationSignal,
122
189
  circuitBreaker: this.circuitBreaker,
123
190
  concurrencyLimiter: this.concurrencyLimiter,
124
191
  getTimeoutMs: (providerName) => timeoutBudget.getRemainingMs(providerName),
125
192
  });
126
- }
127
- for (const outcome of outcomes) {
128
- providerTimings.push(outcome.timing);
129
- if (outcome.failure) {
130
- failed.push(outcome.failure);
131
- }
132
- else {
133
- successful.push(outcome.provider);
134
- providerResults.push(...outcome.results);
135
- }
136
- }
137
- if (providers.length > 0 && successful.length === 0 && failed.length > 0) {
138
- throw new MediaEngineError({
139
- code: "PROVIDER_ERROR",
140
- message: "All search providers failed.",
141
- cause: { failed },
142
- });
193
+ searchOutcomes.appendMandatory(retryOutcomes, "retry");
143
194
  }
144
195
  let results = this.mergeStrategy.mergeSearchResults(providerResults, {
145
196
  query: normalizedQuery,
@@ -149,22 +200,27 @@ export class MediaEngine {
149
200
  includeIrrelevantSearchResults: true,
150
201
  });
151
202
  const fallbackQuery = createSearchFallbackQuery(normalizedQuery);
152
- const hasRelevantResults = fallbackQuery
203
+ let relevantResults = fallbackQuery || fallbackProviders.length > 0
153
204
  ? this.mergeStrategy.mergeSearchResults(providerResults, {
154
205
  query: normalizedQuery,
155
206
  language: searchLanguage,
156
207
  debug: this.debug,
157
- }).length > 0
158
- : true;
159
- if (fallbackQuery && !hasRelevantResults) {
208
+ })
209
+ : results;
210
+ let primaryTitleBroadened = false;
211
+ if (fallbackQuery && needsPrimaryTitleBroadening(normalizedQuery, relevantResults)) {
212
+ primaryTitleBroadened = true;
160
213
  const fallbackOutcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(fallbackQuery), {
161
214
  debug: this.debug,
162
215
  language: searchLanguage,
216
+ signal: operationSignal,
163
217
  timeoutMs: timeoutBudget.getRemainingMs(provider.name),
164
218
  circuitBreaker: this.circuitBreaker,
165
219
  concurrencyLimiter: this.concurrencyLimiter,
166
220
  })));
167
- appendUniqueSearchResults(providerResults, fallbackOutcomes.flatMap((outcome) => (outcome.failure ? [] : outcome.results)));
221
+ searchOutcomes.appendMandatory(fallbackOutcomes, "fallback", {
222
+ deduplicateResults: true,
223
+ });
168
224
  results = this.mergeStrategy.mergeSearchResults(providerResults, {
169
225
  query: normalizedQuery,
170
226
  language: searchLanguage,
@@ -172,73 +228,103 @@ export class MediaEngine {
172
228
  warnings,
173
229
  includeIrrelevantSearchResults: true,
174
230
  });
175
- }
176
- const excludedPosterProviders = new Set(failed.map((failure) => failure.provider));
177
- const posterEnrichmentPromise = Promise.all(results
178
- .slice(0, SEARCH_DETAILS_POSTER_ENRICHMENT_LIMIT)
179
- .filter((result) => hasExternalIds(result.item.ids))
180
- .map(async (result) => ({
181
- ids: result.item.ids,
182
- poster: await loadSearchPoster({
183
- result,
231
+ relevantResults = this.mergeStrategy.mergeSearchResults(providerResults, {
232
+ query: normalizedQuery,
184
233
  language: searchLanguage,
185
- excludedProviders: excludedPosterProviders,
186
- registry: this.registry,
187
- mergeStrategy: this.mergeStrategy,
188
234
  debug: this.debug,
189
- circuitBreaker: this.circuitBreaker,
190
- concurrencyLimiter: this.concurrencyLimiter,
191
- getProviderTimeoutMs: (providerName) => timeoutBudget.getRemainingMs(providerName),
192
- }).catch(() => undefined),
193
- })));
194
- const enrichmentResultsPromise = Promise.all(results
195
- .slice(0, SEARCH_ID_ENRICHMENT_LIMIT)
196
- .filter((result) => needsSearchEnrichment(result.item) && hasExternalIds(result.item.ids))
197
- .map(async (result) => {
198
- const existingProviders = new Set(result.sources.map((source) => source.provider));
199
- const enrichmentType = result.item.type === "anime" ? undefined : result.item.type;
200
- const enrichmentProvider = this.registry
201
- .selectSearchProviders({ ids: result.item.ids, type: enrichmentType })
202
- .find((provider) => !existingProviders.has(provider.name));
203
- if (!enrichmentProvider) {
204
- return [];
205
- }
206
- const enrichmentTimeoutMs = timeoutBudget.getRemainingMs(enrichmentProvider.name, SEARCH_ID_ENRICHMENT_TIMEOUT_MS);
207
- const outcome = await callTimedProviderSearch(enrichmentProvider, {
208
- ids: result.item.ids,
209
- type: enrichmentType,
210
- limit: 1,
211
- language: searchLanguage,
212
- }, {
235
+ });
236
+ }
237
+ if (fallbackProviders.length > 0 &&
238
+ !(primaryTitleBroadened && relevantResults.length > 0) &&
239
+ needsFallbackTitleDiscovery(normalizedQuery, relevantResults)) {
240
+ requested.push(...fallbackProviders.map((provider) => provider.name));
241
+ const providerFallbackQuery = relevantResults.length === 0 && fallbackQuery ? fallbackQuery : normalizedQuery;
242
+ const providerFallbackOutcomes = await Promise.all(fallbackProviders.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(providerFallbackQuery), {
213
243
  debug: this.debug,
214
244
  language: searchLanguage,
215
- timeoutMs: enrichmentTimeoutMs,
245
+ signal: operationSignal,
246
+ timeoutMs: timeoutBudget.getRemainingMs(provider.name),
216
247
  circuitBreaker: this.circuitBreaker,
217
248
  concurrencyLimiter: this.concurrencyLimiter,
249
+ })));
250
+ searchOutcomes.appendMandatory(providerFallbackOutcomes, "provider_fallback", {
251
+ deduplicateResults: true,
218
252
  });
219
- return outcome.failure ? [] : outcome.results;
220
- }));
221
- const [enrichmentResults, posterEnrichments] = await Promise.all([
222
- enrichmentResultsPromise,
223
- posterEnrichmentPromise,
224
- ]);
225
- const flattenedEnrichmentResults = enrichmentResults.flat();
226
- if (flattenedEnrichmentResults.length > 0) {
227
- providerResults.push(...flattenedEnrichmentResults);
228
- }
229
- if (this.mergeStrategy instanceof DefaultMergeStrategy ||
230
- flattenedEnrichmentResults.length > 0) {
231
253
  results = this.mergeStrategy.mergeSearchResults(providerResults, {
232
254
  query: normalizedQuery,
233
255
  language: searchLanguage,
234
256
  debug: this.debug,
235
257
  warnings,
258
+ includeIrrelevantSearchResults: true,
259
+ });
260
+ }
261
+ if (requested.length > 0 && successful.length === 0 && failed.length > 0) {
262
+ throw new MediaEngineError({
263
+ code: "PROVIDER_ERROR",
264
+ message: "All search providers failed.",
265
+ cause: { failed },
236
266
  });
237
267
  }
238
- const posterEnrichedResults = applySearchPosterEnrichments(results, posterEnrichments);
268
+ const preliminaryDiscoveryResults = results;
269
+ if (this.mergeStrategy instanceof DefaultMergeStrategy) {
270
+ const rankedDiscoveryResults = this.mergeStrategy.mergeSearchResults(providerResults, {
271
+ query: normalizedQuery,
272
+ language: searchLanguage,
273
+ debug: this.debug,
274
+ });
275
+ results = createFrozenDiscoveryResults(rankedDiscoveryResults, results);
276
+ }
277
+ let identitySnapshotDebug;
278
+ const hasRetryableMandatoryFailure = searchOutcomes.hasRetryableMandatoryFailure();
279
+ const identitySnapshot = await waitForCaller(this.cache?.get(identitySnapshotCacheKey), operationSignal);
280
+ if (failed.length === 0 || hasRetryableMandatoryFailure) {
281
+ const recovery = recoverSearchIdentitySnapshot(results, identitySnapshot);
282
+ results = recovery.results;
283
+ identitySnapshotDebug = recovery.debug;
284
+ if (recovery.debug) {
285
+ warnings.push({
286
+ code: hasRetryableMandatoryFailure
287
+ ? "SEARCH_IDENTITY_SNAPSHOT_FALLBACK"
288
+ : "SEARCH_IDENTITY_SNAPSHOT_STABILIZED",
289
+ message: hasRetryableMandatoryFailure
290
+ ? "Restored previously confirmed search identities because mandatory discovery was retryably degraded."
291
+ : "Kept previously confirmed search identities stable across equivalent searches.",
292
+ });
293
+ }
294
+ }
295
+ const frozenDiscoveryResults = results;
296
+ const enrichmentCandidates = createSearchEnrichmentCandidates(frozenDiscoveryResults, preliminaryDiscoveryResults);
297
+ const excludedPosterProviders = new Set(failed.map((failure) => failure.provider));
298
+ const enrichment = await executeSearchEnrichmentPlan({
299
+ results: enrichmentCandidates,
300
+ publicLimit: normalizedQuery.limit,
301
+ language: searchLanguage,
302
+ excludedProviders: excludedPosterProviders,
303
+ registry: this.registry,
304
+ mergeStrategy: this.mergeStrategy,
305
+ debug: this.debug,
306
+ signal: operationSignal,
307
+ circuitBreaker: this.circuitBreaker,
308
+ concurrencyLimiter: this.concurrencyLimiter,
309
+ getProviderTimeoutMs: (providerName) => timeoutBudget.getRemainingMs(providerName),
310
+ loadReusableDetails: (query, signal, maxWaitMs) => this.loadReusableDetails(query, signal, maxWaitMs),
311
+ });
312
+ const idOutcomes = enrichment.idEnrichments.map((item) => item.outcome);
313
+ searchOutcomes.appendIdEnrichment(idOutcomes, enrichment.skippedId);
314
+ searchOutcomes.observePosterEnrichment(enrichment.posterEnrichments.flatMap((item) => item.outcomes), enrichment.skippedPoster);
315
+ searchOutcomes.appendEnrichmentWarnings();
316
+ const idEnrichedResults = applySearchIdEnrichments(frozenDiscoveryResults, enrichment.idEnrichments, warnings, searchLanguage);
317
+ const detailsEnrichedResults = applySearchDetailsEnrichments(idEnrichedResults, enrichment.detailsEnrichments);
318
+ const posterEnrichedResults = applySearchPosterEnrichments(detailsEnrichedResults, enrichment.posterEnrichments);
319
+ const filteredResults = this.mergeStrategy instanceof DefaultMergeStrategy
320
+ ? filterFrozenSearchResults(posterEnrichedResults, normalizedQuery)
321
+ : posterEnrichedResults;
322
+ const visibleResultSet = new Set(filteredResults);
323
+ const visibleDiscoveryResults = frozenDiscoveryResults.filter((_, index) => visibleResultSet.has(posterEnrichedResults[index]));
324
+ const visibleResults = finalizeSearchRankingEvidence(filteredResults);
239
325
  const limitedResults = normalizedQuery.limit === undefined
240
- ? posterEnrichedResults
241
- : posterEnrichedResults.slice(0, normalizedQuery.limit);
326
+ ? visibleResults
327
+ : visibleResults.slice(0, normalizedQuery.limit);
242
328
  const response = {
243
329
  query: normalizedQuery,
244
330
  results: limitedResults,
@@ -251,9 +337,23 @@ export class MediaEngine {
251
337
  tookMs: elapsedSince(startedAt),
252
338
  debug: this.debug,
253
339
  timings: providerTimings,
340
+ enrichment: searchOutcomes.getEnrichmentDebugMeta(),
341
+ identitySnapshot: identitySnapshotDebug,
254
342
  }),
255
343
  };
256
- if (!hasRetryableProviderFailure(failed)) {
344
+ throwIfAborted(operationSignal);
345
+ if (failed.length === 0 && !isUsableSearchIdentitySnapshot(identitySnapshot)) {
346
+ const newIdentitySnapshot = createSearchIdentitySnapshot(visibleDiscoveryResults);
347
+ if (newIdentitySnapshot) {
348
+ await this.cache?.set(identitySnapshotCacheKey, newIdentitySnapshot, SEARCH_IDENTITY_SNAPSHOT_CACHE_OPTIONS);
349
+ }
350
+ else {
351
+ await this.cache?.delete(identitySnapshotCacheKey);
352
+ }
353
+ }
354
+ // Keep the complete response most recent when a bounded cache can retain only one entry.
355
+ // Сохраняем полный ответ последним, если bounded cache вмещает только одну запись.
356
+ if (!hasRetryableMandatoryFailure) {
257
357
  await this.cache?.set(cacheKey, structuredClone(response));
258
358
  }
259
359
  return response;
@@ -266,12 +366,13 @@ export class MediaEngine {
266
366
  }
267
367
  // Loads media details through selected providers and merges normalized results.
268
368
  // Загружает детали медиа через выбранных провайдеров и объединяет нормализованные результаты.
269
- async getDetails(query) {
369
+ async getDetails(query, options = {}) {
370
+ throwIfAborted(options.signal);
270
371
  const startedAt = Date.now();
271
372
  const normalizedQuery = normalizeDetailsQuery(query);
272
373
  validateDetailsQuery(normalizedQuery);
273
374
  const cacheKey = createDetailsCacheKey(normalizedQuery);
274
- const cached = await this.cache?.get(cacheKey);
375
+ const cached = await waitForCaller(this.cache?.get(cacheKey), options.signal);
275
376
  if (cached) {
276
377
  const response = structuredClone(cached);
277
378
  return {
@@ -284,8 +385,9 @@ export class MediaEngine {
284
385
  },
285
386
  };
286
387
  }
287
- const stale = await this.cache?.getStale?.(cacheKey);
288
- const pending = this.inFlightRequests.run(`details:${cacheKey}`, async () => {
388
+ const stale = await waitForCaller(this.cache?.getStale?.(cacheKey), options.signal);
389
+ const inFlight = this.inFlightRequests.forCaller(options);
390
+ const pending = inFlight.run(`details:${cacheKey}`, async (operationSignal) => {
289
391
  const timeoutBudget = this.createProviderTimeoutBudget();
290
392
  const providers = this.registry.selectDetailsProviders(normalizedQuery);
291
393
  const requested = providers.map((provider) => provider.name);
@@ -297,6 +399,7 @@ export class MediaEngine {
297
399
  const outcomes = await Promise.all(providers.map((provider) => callTimedProviderDetails(provider, normalizedQuery, {
298
400
  debug: this.debug,
299
401
  language: normalizedQuery.language,
402
+ signal: operationSignal,
300
403
  timeoutMs: timeoutBudget.getRemainingMs(provider.name),
301
404
  circuitBreaker: this.circuitBreaker,
302
405
  concurrencyLimiter: this.concurrencyLimiter,
@@ -340,6 +443,7 @@ export class MediaEngine {
340
443
  timings: providerTimings,
341
444
  }),
342
445
  };
446
+ throwIfAborted(operationSignal);
343
447
  if (!hasRetryableProviderFailure(failed)) {
344
448
  await this.cache?.set(cacheKey, structuredClone(response));
345
449
  }
@@ -353,12 +457,18 @@ export class MediaEngine {
353
457
  }
354
458
  // Loads normalized player and stream availability through streaming providers.
355
459
  // Загружает нормализованную доступность player и stream через streaming-провайдеры.
356
- async getAvailability(query) {
460
+ async getAvailability(query, options = {}) {
461
+ throwIfAborted(options.signal);
357
462
  const startedAt = Date.now();
358
463
  const normalizedQuery = normalizeStreamQuery(query);
359
464
  validateStreamQuery(normalizedQuery);
360
- const cacheKey = createAvailabilityCacheKey(normalizedQuery);
361
- const cached = await this.cache?.get(cacheKey);
465
+ const playbackUserAgent = normalizePlaybackUserAgent(options.playbackUserAgent);
466
+ const providers = selectStreamingProviders(this.streamingProviders, normalizedQuery);
467
+ const cachePlaybackUserAgent = providers.some((provider) => provider.availabilityDependsOnPlaybackUserAgent)
468
+ ? playbackUserAgent
469
+ : undefined;
470
+ const cacheKey = createAvailabilityCacheKey(normalizedQuery, cachePlaybackUserAgent);
471
+ const cached = await waitForCaller(this.cache?.get(cacheKey), options.signal);
362
472
  if (cached) {
363
473
  const response = structuredClone(cached);
364
474
  return {
@@ -373,9 +483,9 @@ export class MediaEngine {
373
483
  : undefined,
374
484
  };
375
485
  }
376
- return this.inFlightRequests.run(`availability:${cacheKey}`, async () => {
486
+ const inFlight = this.inFlightRequests.forCaller(options);
487
+ return inFlight.run(`availability:${cacheKey}`, async (operationSignal) => {
377
488
  const timeoutBudget = this.createProviderTimeoutBudget();
378
- const providers = selectStreamingProviders(this.streamingProviders, normalizedQuery);
379
489
  const requested = providers.map((provider) => provider.name);
380
490
  const successful = [];
381
491
  const failed = [];
@@ -384,6 +494,8 @@ export class MediaEngine {
384
494
  const outcomes = await Promise.all(providers.map((provider) => callTimedProviderAvailability(provider, normalizedQuery, {
385
495
  debug: this.debug,
386
496
  language: normalizedQuery.language,
497
+ playbackUserAgent,
498
+ signal: operationSignal,
387
499
  timeoutMs: timeoutBudget.getRemainingMs(provider.name),
388
500
  circuitBreaker: this.circuitBreaker,
389
501
  concurrencyLimiter: this.concurrencyLimiter,
@@ -393,12 +505,14 @@ export class MediaEngine {
393
505
  if (outcome.failure) {
394
506
  failed.push(outcome.failure);
395
507
  }
396
- else if (outcome.result) {
508
+ else {
397
509
  successful.push(outcome.provider);
398
- providerResults.push(outcome.result);
510
+ if (outcome.result) {
511
+ providerResults.push(outcome.result);
512
+ }
399
513
  }
400
514
  }
401
- if (providers.length > 0 && providerResults.length === 0 && failed.length > 0) {
515
+ if (providers.length > 0 && failed.length === providers.length) {
402
516
  throw new MediaEngineError({
403
517
  code: "PROVIDER_ERROR",
404
518
  message: "All streaming providers failed.",
@@ -406,22 +520,51 @@ export class MediaEngine {
406
520
  });
407
521
  }
408
522
  const availability = mergeAvailabilityResults(normalizedQuery, providerResults);
523
+ const hasUnknownValidation = hasUnknownStreamValidation(availability);
409
524
  availability.meta = createResponseMeta({
410
525
  requested,
411
526
  successful,
412
527
  failed,
413
- warnings: [],
528
+ warnings: hasUnknownValidation
529
+ ? [
530
+ {
531
+ code: "STREAM_VALIDATION_DEGRADED",
532
+ message: "One or more discovered player options could not be validated reliably.",
533
+ },
534
+ ]
535
+ : [],
414
536
  cached: false,
415
537
  tookMs: elapsedSince(startedAt),
416
538
  debug: this.debug,
417
539
  timings: providerTimings,
418
540
  });
419
- if (!hasRetryableProviderFailure(failed)) {
541
+ throwIfAborted(operationSignal);
542
+ if (!hasRetryableProviderFailure(failed) && !hasUnknownValidation) {
420
543
  await this.cache?.set(cacheKey, structuredClone(availability), createAvailabilityCacheOptions(availability));
421
544
  }
422
545
  return availability;
423
546
  });
424
547
  }
548
+ // Discovers normalized torrent handoff candidates through torrent providers.
549
+ // Находит нормализованные torrent handoff кандидаты через torrent-провайдеры.
550
+ async discoverTorrents(query, options = {}) {
551
+ throwIfAborted(options.signal);
552
+ const startedAt = Date.now();
553
+ const normalizedQuery = normalizeTorrentQuery(query);
554
+ validateTorrentQuery(normalizedQuery);
555
+ return executeTorrentDiscovery({
556
+ query: normalizedQuery,
557
+ options,
558
+ startedAt,
559
+ providers: this.torrentProviders,
560
+ cache: this.cache,
561
+ debug: this.debug,
562
+ circuitBreaker: this.circuitBreaker,
563
+ concurrencyLimiter: this.concurrencyLimiter,
564
+ inFlightRequests: this.inFlightRequests,
565
+ getProviderTimeoutMs: (providerName) => this.getProviderTimeoutMs(providerName),
566
+ });
567
+ }
425
568
  // Gives future engine methods access to the registered providers.
426
569
  // Дает будущим методам движка доступ к зарегистрированным провайдерам.
427
570
  get providerRegistry() {
@@ -450,6 +593,43 @@ export class MediaEngine {
450
593
  createProviderTimeoutBudget() {
451
594
  return new ProviderTimeoutBudget((providerName) => this.getProviderTimeoutMs(providerName));
452
595
  }
596
+ async loadReusableDetails(query, signal, maxWaitMs) {
597
+ if (maxWaitMs <= 0) {
598
+ return undefined;
599
+ }
600
+ const normalizedQuery = normalizeDetailsQuery(query);
601
+ const cacheKey = createDetailsCacheKey(normalizedQuery);
602
+ const timeoutController = new AbortController();
603
+ const timeout = setTimeout(() => timeoutController.abort(), maxWaitMs);
604
+ const waitSignal = signal
605
+ ? AbortSignal.any([signal, timeoutController.signal])
606
+ : timeoutController.signal;
607
+ try {
608
+ const cached = await waitForCaller(this.cache?.get(cacheKey), waitSignal);
609
+ if (cached) {
610
+ return cached.details ?? undefined;
611
+ }
612
+ const inFlight = this.inFlightRequests.joinExisting(`details:${cacheKey}`, {
613
+ signal: waitSignal,
614
+ });
615
+ if (!inFlight) {
616
+ return undefined;
617
+ }
618
+ return (await inFlight).details ?? undefined;
619
+ }
620
+ catch (error) {
621
+ if (signal?.aborted) {
622
+ throw error;
623
+ }
624
+ if (timeoutController.signal.aborted) {
625
+ return undefined;
626
+ }
627
+ throw error;
628
+ }
629
+ finally {
630
+ clearTimeout(timeout);
631
+ }
632
+ }
453
633
  createProviderHealthStatus(provider, kind) {
454
634
  if (!this.circuitBreaker) {
455
635
  return {
@@ -491,4 +671,14 @@ export class MediaEngine {
491
671
  function hasRetryableProviderFailure(failures) {
492
672
  return failures.some((failure) => failure.retryable);
493
673
  }
674
+ function normalizePlaybackUserAgent(value) {
675
+ if (typeof value !== "string")
676
+ return undefined;
677
+ const normalized = value.trim();
678
+ return normalized.length > 0 &&
679
+ normalized.length <= 512 &&
680
+ !/[\u0000-\u001f\u007f]/u.test(normalized)
681
+ ? normalized
682
+ : undefined;
683
+ }
494
684
  //# sourceMappingURL=engine.js.map