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