@media-engine/core 0.1.1 → 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.
Files changed (90) hide show
  1. package/README.md +32 -6
  2. package/README.ru.md +32 -6
  3. package/dist/cache/memory.js +10 -8
  4. package/dist/cache/memory.js.map +1 -1
  5. package/dist/details/types.d.ts +6 -0
  6. package/dist/engine/availability.d.ts +1 -0
  7. package/dist/engine/availability.js +8 -0
  8. package/dist/engine/availability.js.map +1 -1
  9. package/dist/engine/circuit-breaker.d.ts +1 -0
  10. package/dist/engine/circuit-breaker.js +11 -0
  11. package/dist/engine/circuit-breaker.js.map +1 -1
  12. package/dist/engine/concurrency-limiter.js +2 -2
  13. package/dist/engine/concurrency-limiter.js.map +1 -1
  14. package/dist/engine/engine.d.ts +9 -4
  15. package/dist/engine/engine.js +277 -102
  16. package/dist/engine/engine.js.map +1 -1
  17. package/dist/engine/in-flight.d.ts +12 -1
  18. package/dist/engine/in-flight.js +69 -14
  19. package/dist/engine/in-flight.js.map +1 -1
  20. package/dist/engine/operation.d.ts +7 -0
  21. package/dist/engine/operation.js +49 -0
  22. package/dist/engine/operation.js.map +1 -0
  23. package/dist/engine/provider-calls.d.ts +10 -0
  24. package/dist/engine/provider-calls.js +62 -12
  25. package/dist/engine/provider-calls.js.map +1 -1
  26. package/dist/engine/query.d.ts +5 -0
  27. package/dist/engine/query.js +223 -34
  28. package/dist/engine/query.js.map +1 -1
  29. package/dist/engine/response-meta.d.ts +3 -1
  30. package/dist/engine/response-meta.js +8 -1
  31. package/dist/engine/response-meta.js.map +1 -1
  32. package/dist/engine/runtime.d.ts +2 -0
  33. package/dist/engine/runtime.js +19 -0
  34. package/dist/engine/runtime.js.map +1 -1
  35. package/dist/engine/search-discovery.d.ts +3 -0
  36. package/dist/engine/search-discovery.js +35 -0
  37. package/dist/engine/search-discovery.js.map +1 -0
  38. package/dist/engine/search-enrichment-shared.d.ts +23 -0
  39. package/dist/engine/search-enrichment-shared.js +68 -0
  40. package/dist/engine/search-enrichment-shared.js.map +1 -0
  41. package/dist/engine/search-enrichment.d.ts +21 -7
  42. package/dist/engine/search-enrichment.js +135 -75
  43. package/dist/engine/search-enrichment.js.map +1 -1
  44. package/dist/engine/search-identity-snapshot.d.ts +15 -0
  45. package/dist/engine/search-identity-snapshot.js +101 -0
  46. package/dist/engine/search-identity-snapshot.js.map +1 -0
  47. package/dist/engine/search-outcomes.d.ts +32 -0
  48. package/dist/engine/search-outcomes.js +118 -0
  49. package/dist/engine/search-outcomes.js.map +1 -0
  50. package/dist/engine/search-poster-enrichment.d.ts +31 -0
  51. package/dist/engine/search-poster-enrichment.js +93 -0
  52. package/dist/engine/search-poster-enrichment.js.map +1 -0
  53. package/dist/engine/search-result-freeze.d.ts +13 -0
  54. package/dist/engine/search-result-freeze.js +155 -0
  55. package/dist/engine/search-result-freeze.js.map +1 -0
  56. package/dist/engine/torrents.d.ts +22 -0
  57. package/dist/engine/torrents.js +188 -0
  58. package/dist/engine/torrents.js.map +1 -0
  59. package/dist/engine/types.d.ts +6 -1
  60. package/dist/errors/types.d.ts +1 -1
  61. package/dist/errors/types.js.map +1 -1
  62. package/dist/index.d.ts +2 -1
  63. package/dist/index.js +1 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/media/types.d.ts +1 -1
  66. package/dist/merge/diversity.d.ts +15 -0
  67. package/dist/merge/diversity.js +52 -0
  68. package/dist/merge/diversity.js.map +1 -0
  69. package/dist/merge/priority.js +3 -1
  70. package/dist/merge/priority.js.map +1 -1
  71. package/dist/merge/scoring.d.ts +10 -0
  72. package/dist/merge/scoring.js +132 -28
  73. package/dist/merge/scoring.js.map +1 -1
  74. package/dist/merge/strategy.js +53 -13
  75. package/dist/merge/strategy.js.map +1 -1
  76. package/dist/providers/registry.d.ts +4 -2
  77. package/dist/providers/registry.js +11 -2
  78. package/dist/providers/registry.js.map +1 -1
  79. package/dist/providers/types.d.ts +3 -0
  80. package/dist/response/types.d.ts +20 -0
  81. package/dist/search/types.d.ts +34 -0
  82. package/dist/testing/providers.js +2 -0
  83. package/dist/testing/providers.js.map +1 -1
  84. package/dist/torrent/index.d.ts +1 -0
  85. package/dist/torrent/index.js +2 -0
  86. package/dist/torrent/index.js.map +1 -0
  87. package/dist/torrent/types.d.ts +132 -0
  88. package/dist/torrent/types.js +2 -0
  89. package/dist/torrent/types.js.map +1 -0
  90. 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,13 @@ 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
465
  const cacheKey = createAvailabilityCacheKey(normalizedQuery);
361
- const cached = await this.cache?.get(cacheKey);
466
+ const cached = await waitForCaller(this.cache?.get(cacheKey), options.signal);
362
467
  if (cached) {
363
468
  const response = structuredClone(cached);
364
469
  return {
@@ -373,7 +478,8 @@ export class MediaEngine {
373
478
  : undefined,
374
479
  };
375
480
  }
376
- return this.inFlightRequests.run(`availability:${cacheKey}`, async () => {
481
+ const inFlight = this.inFlightRequests.forCaller(options);
482
+ return inFlight.run(`availability:${cacheKey}`, async (operationSignal) => {
377
483
  const timeoutBudget = this.createProviderTimeoutBudget();
378
484
  const providers = selectStreamingProviders(this.streamingProviders, normalizedQuery);
379
485
  const requested = providers.map((provider) => provider.name);
@@ -384,6 +490,7 @@ export class MediaEngine {
384
490
  const outcomes = await Promise.all(providers.map((provider) => callTimedProviderAvailability(provider, normalizedQuery, {
385
491
  debug: this.debug,
386
492
  language: normalizedQuery.language,
493
+ signal: operationSignal,
387
494
  timeoutMs: timeoutBudget.getRemainingMs(provider.name),
388
495
  circuitBreaker: this.circuitBreaker,
389
496
  concurrencyLimiter: this.concurrencyLimiter,
@@ -393,12 +500,14 @@ export class MediaEngine {
393
500
  if (outcome.failure) {
394
501
  failed.push(outcome.failure);
395
502
  }
396
- else if (outcome.result) {
503
+ else {
397
504
  successful.push(outcome.provider);
398
- providerResults.push(outcome.result);
505
+ if (outcome.result) {
506
+ providerResults.push(outcome.result);
507
+ }
399
508
  }
400
509
  }
401
- if (providers.length > 0 && providerResults.length === 0 && failed.length > 0) {
510
+ if (providers.length > 0 && failed.length === providers.length) {
402
511
  throw new MediaEngineError({
403
512
  code: "PROVIDER_ERROR",
404
513
  message: "All streaming providers failed.",
@@ -406,22 +515,51 @@ export class MediaEngine {
406
515
  });
407
516
  }
408
517
  const availability = mergeAvailabilityResults(normalizedQuery, providerResults);
518
+ const hasUnknownValidation = hasUnknownStreamValidation(availability);
409
519
  availability.meta = createResponseMeta({
410
520
  requested,
411
521
  successful,
412
522
  failed,
413
- warnings: [],
523
+ warnings: hasUnknownValidation
524
+ ? [
525
+ {
526
+ code: "STREAM_VALIDATION_DEGRADED",
527
+ message: "One or more discovered player options could not be validated reliably.",
528
+ },
529
+ ]
530
+ : [],
414
531
  cached: false,
415
532
  tookMs: elapsedSince(startedAt),
416
533
  debug: this.debug,
417
534
  timings: providerTimings,
418
535
  });
419
- if (!hasRetryableProviderFailure(failed)) {
536
+ throwIfAborted(operationSignal);
537
+ if (!hasRetryableProviderFailure(failed) && !hasUnknownValidation) {
420
538
  await this.cache?.set(cacheKey, structuredClone(availability), createAvailabilityCacheOptions(availability));
421
539
  }
422
540
  return availability;
423
541
  });
424
542
  }
543
+ // Discovers normalized torrent handoff candidates through torrent providers.
544
+ // Находит нормализованные torrent handoff кандидаты через torrent-провайдеры.
545
+ async discoverTorrents(query, options = {}) {
546
+ throwIfAborted(options.signal);
547
+ const startedAt = Date.now();
548
+ const normalizedQuery = normalizeTorrentQuery(query);
549
+ validateTorrentQuery(normalizedQuery);
550
+ return executeTorrentDiscovery({
551
+ query: normalizedQuery,
552
+ options,
553
+ startedAt,
554
+ providers: this.torrentProviders,
555
+ cache: this.cache,
556
+ debug: this.debug,
557
+ circuitBreaker: this.circuitBreaker,
558
+ concurrencyLimiter: this.concurrencyLimiter,
559
+ inFlightRequests: this.inFlightRequests,
560
+ getProviderTimeoutMs: (providerName) => this.getProviderTimeoutMs(providerName),
561
+ });
562
+ }
425
563
  // Gives future engine methods access to the registered providers.
426
564
  // Дает будущим методам движка доступ к зарегистрированным провайдерам.
427
565
  get providerRegistry() {
@@ -450,6 +588,43 @@ export class MediaEngine {
450
588
  createProviderTimeoutBudget() {
451
589
  return new ProviderTimeoutBudget((providerName) => this.getProviderTimeoutMs(providerName));
452
590
  }
591
+ async loadReusableDetails(query, signal, maxWaitMs) {
592
+ if (maxWaitMs <= 0) {
593
+ return undefined;
594
+ }
595
+ const normalizedQuery = normalizeDetailsQuery(query);
596
+ const cacheKey = createDetailsCacheKey(normalizedQuery);
597
+ const timeoutController = new AbortController();
598
+ const timeout = setTimeout(() => timeoutController.abort(), maxWaitMs);
599
+ const waitSignal = signal
600
+ ? AbortSignal.any([signal, timeoutController.signal])
601
+ : timeoutController.signal;
602
+ try {
603
+ const cached = await waitForCaller(this.cache?.get(cacheKey), waitSignal);
604
+ if (cached) {
605
+ return cached.details ?? undefined;
606
+ }
607
+ const inFlight = this.inFlightRequests.joinExisting(`details:${cacheKey}`, {
608
+ signal: waitSignal,
609
+ });
610
+ if (!inFlight) {
611
+ return undefined;
612
+ }
613
+ return (await inFlight).details ?? undefined;
614
+ }
615
+ catch (error) {
616
+ if (signal?.aborted) {
617
+ throw error;
618
+ }
619
+ if (timeoutController.signal.aborted) {
620
+ return undefined;
621
+ }
622
+ throw error;
623
+ }
624
+ finally {
625
+ clearTimeout(timeout);
626
+ }
627
+ }
453
628
  createProviderHealthStatus(provider, kind) {
454
629
  if (!this.circuitBreaker) {
455
630
  return {