@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.
- package/LICENSE +21 -21
- package/README.md +48 -20
- package/README.ru.md +46 -19
- 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 +294 -104
- 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 +11 -0
- package/dist/engine/provider-calls.js +63 -12
- package/dist/engine/provider-calls.js.map +1 -1
- package/dist/engine/query.d.ts +6 -1
- package/dist/engine/query.js +228 -36
- 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 +7 -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 +4 -0
- package/dist/response/types.d.ts +20 -0
- package/dist/search/types.d.ts +34 -0
- package/dist/streaming/types.d.ts +1 -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,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
|
|
361
|
-
const
|
|
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
|
-
|
|
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
|
|
508
|
+
else {
|
|
397
509
|
successful.push(outcome.provider);
|
|
398
|
-
|
|
510
|
+
if (outcome.result) {
|
|
511
|
+
providerResults.push(outcome.result);
|
|
512
|
+
}
|
|
399
513
|
}
|
|
400
514
|
}
|
|
401
|
-
if (providers.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
|
-
|
|
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
|