@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.
- package/README.md +32 -6
- package/README.ru.md +32 -6
- package/dist/cache/memory.js +10 -8
- package/dist/cache/memory.js.map +1 -1
- package/dist/details/types.d.ts +6 -0
- package/dist/engine/availability.d.ts +1 -0
- package/dist/engine/availability.js +8 -0
- package/dist/engine/availability.js.map +1 -1
- package/dist/engine/circuit-breaker.d.ts +1 -0
- package/dist/engine/circuit-breaker.js +11 -0
- package/dist/engine/circuit-breaker.js.map +1 -1
- package/dist/engine/concurrency-limiter.js +2 -2
- package/dist/engine/concurrency-limiter.js.map +1 -1
- package/dist/engine/engine.d.ts +9 -4
- package/dist/engine/engine.js +277 -102
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/in-flight.d.ts +12 -1
- package/dist/engine/in-flight.js +69 -14
- package/dist/engine/in-flight.js.map +1 -1
- package/dist/engine/operation.d.ts +7 -0
- package/dist/engine/operation.js +49 -0
- package/dist/engine/operation.js.map +1 -0
- package/dist/engine/provider-calls.d.ts +10 -0
- package/dist/engine/provider-calls.js +62 -12
- package/dist/engine/provider-calls.js.map +1 -1
- package/dist/engine/query.d.ts +5 -0
- package/dist/engine/query.js +223 -34
- package/dist/engine/query.js.map +1 -1
- package/dist/engine/response-meta.d.ts +3 -1
- package/dist/engine/response-meta.js +8 -1
- package/dist/engine/response-meta.js.map +1 -1
- package/dist/engine/runtime.d.ts +2 -0
- package/dist/engine/runtime.js +19 -0
- package/dist/engine/runtime.js.map +1 -1
- package/dist/engine/search-discovery.d.ts +3 -0
- package/dist/engine/search-discovery.js +35 -0
- package/dist/engine/search-discovery.js.map +1 -0
- package/dist/engine/search-enrichment-shared.d.ts +23 -0
- package/dist/engine/search-enrichment-shared.js +68 -0
- package/dist/engine/search-enrichment-shared.js.map +1 -0
- package/dist/engine/search-enrichment.d.ts +21 -7
- package/dist/engine/search-enrichment.js +135 -75
- package/dist/engine/search-enrichment.js.map +1 -1
- package/dist/engine/search-identity-snapshot.d.ts +15 -0
- package/dist/engine/search-identity-snapshot.js +101 -0
- package/dist/engine/search-identity-snapshot.js.map +1 -0
- package/dist/engine/search-outcomes.d.ts +32 -0
- package/dist/engine/search-outcomes.js +118 -0
- package/dist/engine/search-outcomes.js.map +1 -0
- package/dist/engine/search-poster-enrichment.d.ts +31 -0
- package/dist/engine/search-poster-enrichment.js +93 -0
- package/dist/engine/search-poster-enrichment.js.map +1 -0
- package/dist/engine/search-result-freeze.d.ts +13 -0
- package/dist/engine/search-result-freeze.js +155 -0
- package/dist/engine/search-result-freeze.js.map +1 -0
- package/dist/engine/torrents.d.ts +22 -0
- package/dist/engine/torrents.js +188 -0
- package/dist/engine/torrents.js.map +1 -0
- package/dist/engine/types.d.ts +6 -1
- package/dist/errors/types.d.ts +1 -1
- package/dist/errors/types.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/media/types.d.ts +1 -1
- package/dist/merge/diversity.d.ts +15 -0
- package/dist/merge/diversity.js +52 -0
- package/dist/merge/diversity.js.map +1 -0
- package/dist/merge/priority.js +3 -1
- package/dist/merge/priority.js.map +1 -1
- package/dist/merge/scoring.d.ts +10 -0
- package/dist/merge/scoring.js +132 -28
- package/dist/merge/scoring.js.map +1 -1
- package/dist/merge/strategy.js +53 -13
- package/dist/merge/strategy.js.map +1 -1
- package/dist/providers/registry.d.ts +4 -2
- package/dist/providers/registry.js +11 -2
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/types.d.ts +3 -0
- package/dist/response/types.d.ts +20 -0
- package/dist/search/types.d.ts +34 -0
- package/dist/testing/providers.js +2 -0
- package/dist/testing/providers.js.map +1 -1
- package/dist/torrent/index.d.ts +1 -0
- package/dist/torrent/index.js +2 -0
- package/dist/torrent/index.js.map +1 -0
- package/dist/torrent/types.d.ts +132 -0
- package/dist/torrent/types.js +2 -0
- package/dist/torrent/types.js.map +1 -0
- package/package.json +8 -3
package/dist/engine/engine.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
158
|
-
:
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
.
|
|
196
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
241
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
503
|
+
else {
|
|
397
504
|
successful.push(outcome.provider);
|
|
398
|
-
|
|
505
|
+
if (outcome.result) {
|
|
506
|
+
providerResults.push(outcome.result);
|
|
507
|
+
}
|
|
399
508
|
}
|
|
400
509
|
}
|
|
401
|
-
if (providers.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
|
-
|
|
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 {
|