@media-engine/core 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -255
- package/README.ru.md +80 -0
- package/dist/cache/memory.d.ts +5 -0
- package/dist/cache/memory.js +43 -3
- package/dist/cache/memory.js.map +1 -1
- package/dist/cache/types.d.ts +2 -0
- package/dist/details/types.d.ts +6 -0
- package/dist/engine/availability.d.ts +6 -0
- package/dist/engine/availability.js +137 -0
- package/dist/engine/availability.js.map +1 -0
- package/dist/engine/circuit-breaker.d.ts +37 -0
- package/dist/engine/circuit-breaker.js +174 -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 +15 -5
- package/dist/engine/engine.js +510 -848
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/in-flight.d.ts +15 -0
- package/dist/engine/in-flight.js +80 -0
- package/dist/engine/in-flight.js.map +1 -0
- 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 +52 -0
- package/dist/engine/provider-calls.js +262 -0
- package/dist/engine/provider-calls.js.map +1 -0
- package/dist/engine/query.d.ts +26 -0
- package/dist/engine/query.js +430 -0
- package/dist/engine/query.js.map +1 -0
- package/dist/engine/response-meta.d.ts +15 -0
- package/dist/engine/response-meta.js +33 -0
- package/dist/engine/response-meta.js.map +1 -0
- package/dist/engine/runtime.d.ts +5 -0
- package/dist/engine/runtime.js +48 -0
- package/dist/engine/runtime.js.map +1 -0
- 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 +42 -0
- package/dist/engine/search-enrichment.js +150 -0
- package/dist/engine/search-enrichment.js.map +1 -0
- 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/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/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 +35 -0
- 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/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/diversity.d.ts +15 -0
- package/dist/merge/diversity.js +52 -0
- package/dist/merge/diversity.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 +63 -0
- package/dist/merge/priority.js.map +1 -0
- package/dist/merge/scoring.d.ts +15 -0
- package/dist/merge/scoring.js +364 -0
- package/dist/merge/scoring.js.map +1 -0
- package/dist/merge/strategy.js +66 -955
- 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/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 +21 -0
- package/dist/search/types.d.ts +34 -0
- package/dist/testing/providers.js +2 -0
- package/dist/testing/providers.js.map +1 -1
- package/dist/torrent/index.d.ts +1 -0
- package/dist/torrent/index.js +2 -0
- package/dist/torrent/index.js.map +1 -0
- package/dist/torrent/types.d.ts +132 -0
- package/dist/torrent/types.js +2 -0
- package/dist/torrent/types.js.map +1 -0
- package/package.json +13 -4
package/dist/engine/engine.js
CHANGED
|
@@ -1,46 +1,55 @@
|
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
|
4
|
+
import { createAvailabilityCacheOptions, hasUnknownStreamValidation, 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 { createAvailabilityCacheKey, createDetailsCacheKey, createProviderSearchQuery, createSearchCacheKey, createSearchFallbackQuery, createSearchIdentitySnapshotCacheKey, inferTitleLanguage, normalizeDetailsQuery, normalizeSearchQuery, normalizeStreamQuery, normalizeTorrentQuery, validateDetailsQuery, validateSearchQuery, validateStreamQuery, validateTorrentQuery, } from "./query.js";
|
|
9
|
+
import { createResponseMeta, elapsedSince } from "./response-meta.js";
|
|
10
|
+
import { applySearchDetailsEnrichments, executeSearchEnrichmentPlan } from "./search-enrichment.js";
|
|
11
|
+
import { needsFallbackTitleDiscovery, needsPrimaryTitleBroadening } from "./search-discovery.js";
|
|
12
|
+
import { applySearchPosterEnrichments } from "./search-poster-enrichment.js";
|
|
13
|
+
import { applySearchIdEnrichments, createFrozenDiscoveryResults, createSearchEnrichmentCandidates, finalizeSearchRankingEvidence, filterFrozenSearchResults, } from "./search-result-freeze.js";
|
|
14
|
+
import { createSearchIdentitySnapshot, isUsableSearchIdentitySnapshot, recoverSearchIdentitySnapshot, SEARCH_IDENTITY_SNAPSHOT_CACHE_OPTIONS, } from "./search-identity-snapshot.js";
|
|
15
|
+
import { SearchOutcomeAccumulator } from "./search-outcomes.js";
|
|
16
|
+
import { InFlightRequestCoalescer } from "./in-flight.js";
|
|
17
|
+
import { throwIfAborted, waitForCaller } from "./operation.js";
|
|
18
|
+
import { resolveProviderTimeoutMs, validateStreamingProviders, validateTorrentProviders, } from "./runtime.js";
|
|
19
|
+
import { executeTorrentDiscovery } from "./torrents.js";
|
|
20
|
+
import { loadWithStaleFallback } from "./stale-fallback.js";
|
|
21
|
+
import { ProviderTimeoutBudget } from "./timeout-budget.js";
|
|
26
22
|
// Main entry point for using Media Engine core.
|
|
27
23
|
// Главная точка входа для использования Media Engine core.
|
|
28
24
|
export class MediaEngine {
|
|
29
25
|
registry;
|
|
30
26
|
streamingProviders;
|
|
27
|
+
torrentProviders;
|
|
31
28
|
cache;
|
|
32
29
|
mergeStrategy;
|
|
33
30
|
timeoutMs;
|
|
34
31
|
providerTimeouts;
|
|
35
32
|
debug;
|
|
33
|
+
circuitBreaker;
|
|
34
|
+
concurrencyLimiter;
|
|
35
|
+
inFlightRequests = new InFlightRequestCoalescer();
|
|
36
36
|
constructor(options = {}) {
|
|
37
37
|
this.registry = new ProviderRegistry(options.providers ?? []);
|
|
38
38
|
this.streamingProviders = validateStreamingProviders(options.streamingProviders ?? []);
|
|
39
|
+
this.torrentProviders = validateTorrentProviders(options.torrentProviders ?? []);
|
|
39
40
|
this.cache = options.cache;
|
|
40
41
|
this.mergeStrategy = options.mergeStrategy ?? new DefaultMergeStrategy();
|
|
41
42
|
this.timeoutMs = options.timeoutMs;
|
|
42
43
|
this.providerTimeouts = { ...options.providerTimeouts };
|
|
43
44
|
this.debug = options.debug ?? false;
|
|
45
|
+
this.circuitBreaker =
|
|
46
|
+
options.circuitBreaker === false
|
|
47
|
+
? undefined
|
|
48
|
+
: new ProviderCircuitBreaker(options.circuitBreaker);
|
|
49
|
+
this.concurrencyLimiter =
|
|
50
|
+
options.providerConcurrency === false
|
|
51
|
+
? undefined
|
|
52
|
+
: new ProviderConcurrencyLimiter(options.providerConcurrency);
|
|
44
53
|
}
|
|
45
54
|
// Returns safe registered provider metadata without provider internals.
|
|
46
55
|
// Возвращает безопасные метаданные зарегистрированных провайдеров без внутренних данных.
|
|
@@ -65,304 +74,491 @@ export class MediaEngine {
|
|
|
65
74
|
},
|
|
66
75
|
}));
|
|
67
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
|
+
}
|
|
102
|
+
// Returns process-local provider reliability counters without exposing provider internals.
|
|
103
|
+
// Возвращает локальные health-счетчики провайдеров без раскрытия их внутренностей.
|
|
104
|
+
getProviderHealth() {
|
|
105
|
+
const metadata = this.registry
|
|
106
|
+
.getProviders()
|
|
107
|
+
.map((provider) => this.createProviderHealthStatus(provider.name, "metadata"));
|
|
108
|
+
const streaming = this.streamingProviders.map((provider) => this.createProviderHealthStatus(provider.name, "streaming"));
|
|
109
|
+
const torrent = this.torrentProviders.map((provider) => this.createProviderHealthStatus(provider.name, "torrent"));
|
|
110
|
+
return [...metadata, ...streaming, ...torrent];
|
|
111
|
+
}
|
|
68
112
|
// Searches media through selected providers and merges normalized results.
|
|
69
113
|
// Ищет медиа через выбранных провайдеров и объединяет нормализованные результаты.
|
|
70
|
-
async search(query) {
|
|
114
|
+
async search(query, options = {}) {
|
|
115
|
+
throwIfAborted(options.signal);
|
|
71
116
|
const startedAt = Date.now();
|
|
72
117
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
73
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
|
+
}
|
|
74
135
|
const searchLanguage = normalizedQuery.language ?? inferTitleLanguage(normalizedQuery.title);
|
|
75
136
|
const cacheKey = createSearchCacheKey(normalizedQuery);
|
|
76
|
-
const
|
|
137
|
+
const identitySnapshotCacheKey = createSearchIdentitySnapshotCacheKey(normalizedQuery);
|
|
138
|
+
const cached = await waitForCaller(this.cache?.get(cacheKey), options.signal);
|
|
77
139
|
if (cached) {
|
|
140
|
+
const response = structuredClone(cached);
|
|
78
141
|
return {
|
|
79
|
-
...
|
|
142
|
+
...response,
|
|
80
143
|
query: normalizedQuery,
|
|
81
144
|
meta: {
|
|
82
|
-
...
|
|
145
|
+
...response.meta,
|
|
83
146
|
cached: true,
|
|
84
147
|
tookMs: elapsedSince(startedAt),
|
|
85
148
|
},
|
|
86
149
|
};
|
|
87
150
|
}
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const providerTimings = [];
|
|
95
|
-
let outcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(normalizedQuery), {
|
|
96
|
-
debug: this.debug,
|
|
97
|
-
language: searchLanguage,
|
|
98
|
-
timeoutMs: this.getProviderTimeoutMs(provider.name),
|
|
99
|
-
})));
|
|
100
|
-
if (outcomes.length > 0 && outcomes.every((outcome) => outcome.failure)) {
|
|
101
|
-
outcomes = await retryFailedSearchProviders(providers, outcomes, normalizedQuery, {
|
|
102
|
-
debug: this.debug,
|
|
103
|
-
language: searchLanguage,
|
|
104
|
-
getTimeoutMs: (providerName) => this.getProviderTimeoutMs(providerName),
|
|
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) => {
|
|
154
|
+
const timeoutBudget = this.createProviderTimeoutBudget();
|
|
155
|
+
const providers = this.registry.selectSearchProviders(normalizedQuery, {
|
|
156
|
+
titleDiscovery: "primary",
|
|
105
157
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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));
|
|
162
|
+
const requested = providers.map((provider) => provider.name);
|
|
163
|
+
const successful = [];
|
|
164
|
+
const failed = [];
|
|
165
|
+
const warnings = [];
|
|
166
|
+
const providerResults = [];
|
|
167
|
+
const providerTimings = [];
|
|
168
|
+
const searchOutcomes = new SearchOutcomeAccumulator({
|
|
169
|
+
successful,
|
|
170
|
+
failed,
|
|
171
|
+
results: providerResults,
|
|
172
|
+
timings: providerTimings,
|
|
173
|
+
warnings,
|
|
122
174
|
});
|
|
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), {
|
|
175
|
+
const outcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(normalizedQuery), {
|
|
141
176
|
debug: this.debug,
|
|
142
177
|
language: searchLanguage,
|
|
143
|
-
|
|
178
|
+
signal: operationSignal,
|
|
179
|
+
timeoutMs: timeoutBudget.getRemainingMs(provider.name),
|
|
180
|
+
circuitBreaker: this.circuitBreaker,
|
|
181
|
+
concurrencyLimiter: this.concurrencyLimiter,
|
|
144
182
|
})));
|
|
145
|
-
|
|
146
|
-
|
|
183
|
+
searchOutcomes.appendMandatory(outcomes, "primary");
|
|
184
|
+
if (outcomes.length > 0 && outcomes.every((outcome) => outcome.failure)) {
|
|
185
|
+
const retryOutcomes = await retryFailedSearchProviders(providers, outcomes, normalizedQuery, {
|
|
186
|
+
debug: this.debug,
|
|
187
|
+
language: searchLanguage,
|
|
188
|
+
signal: operationSignal,
|
|
189
|
+
circuitBreaker: this.circuitBreaker,
|
|
190
|
+
concurrencyLimiter: this.concurrencyLimiter,
|
|
191
|
+
getTimeoutMs: (providerName) => timeoutBudget.getRemainingMs(providerName),
|
|
192
|
+
});
|
|
193
|
+
searchOutcomes.appendMandatory(retryOutcomes, "retry");
|
|
194
|
+
}
|
|
195
|
+
let results = this.mergeStrategy.mergeSearchResults(providerResults, {
|
|
147
196
|
query: normalizedQuery,
|
|
148
197
|
language: searchLanguage,
|
|
149
198
|
debug: this.debug,
|
|
150
199
|
warnings,
|
|
151
200
|
includeIrrelevantSearchResults: true,
|
|
152
201
|
});
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
202
|
+
const fallbackQuery = createSearchFallbackQuery(normalizedQuery);
|
|
203
|
+
let relevantResults = fallbackQuery || fallbackProviders.length > 0
|
|
204
|
+
? this.mergeStrategy.mergeSearchResults(providerResults, {
|
|
205
|
+
query: normalizedQuery,
|
|
206
|
+
language: searchLanguage,
|
|
207
|
+
debug: this.debug,
|
|
208
|
+
})
|
|
209
|
+
: results;
|
|
210
|
+
let primaryTitleBroadened = false;
|
|
211
|
+
if (fallbackQuery && needsPrimaryTitleBroadening(normalizedQuery, relevantResults)) {
|
|
212
|
+
primaryTitleBroadened = true;
|
|
213
|
+
const fallbackOutcomes = await Promise.all(providers.map((provider) => callTimedProviderSearch(provider, createProviderSearchQuery(fallbackQuery), {
|
|
214
|
+
debug: this.debug,
|
|
215
|
+
language: searchLanguage,
|
|
216
|
+
signal: operationSignal,
|
|
217
|
+
timeoutMs: timeoutBudget.getRemainingMs(provider.name),
|
|
218
|
+
circuitBreaker: this.circuitBreaker,
|
|
219
|
+
concurrencyLimiter: this.concurrencyLimiter,
|
|
220
|
+
})));
|
|
221
|
+
searchOutcomes.appendMandatory(fallbackOutcomes, "fallback", {
|
|
222
|
+
deduplicateResults: true,
|
|
223
|
+
});
|
|
224
|
+
results = this.mergeStrategy.mergeSearchResults(providerResults, {
|
|
225
|
+
query: normalizedQuery,
|
|
226
|
+
language: searchLanguage,
|
|
227
|
+
debug: this.debug,
|
|
228
|
+
warnings,
|
|
229
|
+
includeIrrelevantSearchResults: true,
|
|
230
|
+
});
|
|
231
|
+
relevantResults = this.mergeStrategy.mergeSearchResults(providerResults, {
|
|
232
|
+
query: normalizedQuery,
|
|
233
|
+
language: searchLanguage,
|
|
234
|
+
debug: this.debug,
|
|
235
|
+
});
|
|
173
236
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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), {
|
|
243
|
+
debug: this.debug,
|
|
244
|
+
language: searchLanguage,
|
|
245
|
+
signal: operationSignal,
|
|
246
|
+
timeoutMs: timeoutBudget.getRemainingMs(provider.name),
|
|
247
|
+
circuitBreaker: this.circuitBreaker,
|
|
248
|
+
concurrencyLimiter: this.concurrencyLimiter,
|
|
249
|
+
})));
|
|
250
|
+
searchOutcomes.appendMandatory(providerFallbackOutcomes, "provider_fallback", {
|
|
251
|
+
deduplicateResults: true,
|
|
252
|
+
});
|
|
253
|
+
results = this.mergeStrategy.mergeSearchResults(providerResults, {
|
|
254
|
+
query: normalizedQuery,
|
|
255
|
+
language: searchLanguage,
|
|
256
|
+
debug: this.debug,
|
|
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 },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
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,
|
|
182
301
|
language: searchLanguage,
|
|
183
|
-
|
|
302
|
+
excludedProviders: excludedPosterProviders,
|
|
303
|
+
registry: this.registry,
|
|
304
|
+
mergeStrategy: this.mergeStrategy,
|
|
184
305
|
debug: this.debug,
|
|
185
|
-
|
|
186
|
-
|
|
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),
|
|
187
311
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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);
|
|
325
|
+
const limitedResults = normalizedQuery.limit === undefined
|
|
326
|
+
? visibleResults
|
|
327
|
+
: visibleResults.slice(0, normalizedQuery.limit);
|
|
328
|
+
const response = {
|
|
201
329
|
query: normalizedQuery,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
330
|
+
results: limitedResults,
|
|
331
|
+
meta: createResponseMeta({
|
|
332
|
+
requested,
|
|
333
|
+
successful,
|
|
334
|
+
failed,
|
|
335
|
+
warnings,
|
|
336
|
+
cached: false,
|
|
337
|
+
tookMs: elapsedSince(startedAt),
|
|
338
|
+
debug: this.debug,
|
|
339
|
+
timings: providerTimings,
|
|
340
|
+
enrichment: searchOutcomes.getEnrichmentDebugMeta(),
|
|
341
|
+
identitySnapshot: identitySnapshotDebug,
|
|
342
|
+
}),
|
|
343
|
+
};
|
|
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) {
|
|
357
|
+
await this.cache?.set(cacheKey, structuredClone(response));
|
|
358
|
+
}
|
|
359
|
+
return response;
|
|
360
|
+
});
|
|
361
|
+
return loadWithStaleFallback({
|
|
362
|
+
stale,
|
|
363
|
+
pending,
|
|
364
|
+
tookMs: () => elapsedSince(startedAt),
|
|
210
365
|
});
|
|
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
366
|
}
|
|
231
367
|
// Loads media details through selected providers and merges normalized results.
|
|
232
368
|
// Загружает детали медиа через выбранных провайдеров и объединяет нормализованные результаты.
|
|
233
|
-
async getDetails(query) {
|
|
369
|
+
async getDetails(query, options = {}) {
|
|
370
|
+
throwIfAborted(options.signal);
|
|
234
371
|
const startedAt = Date.now();
|
|
235
372
|
const normalizedQuery = normalizeDetailsQuery(query);
|
|
236
373
|
validateDetailsQuery(normalizedQuery);
|
|
237
374
|
const cacheKey = createDetailsCacheKey(normalizedQuery);
|
|
238
|
-
const cached = await this.cache?.get(cacheKey);
|
|
375
|
+
const cached = await waitForCaller(this.cache?.get(cacheKey), options.signal);
|
|
239
376
|
if (cached) {
|
|
377
|
+
const response = structuredClone(cached);
|
|
240
378
|
return {
|
|
241
|
-
...
|
|
379
|
+
...response,
|
|
242
380
|
query: normalizedQuery,
|
|
243
381
|
meta: {
|
|
244
|
-
...
|
|
382
|
+
...response.meta,
|
|
245
383
|
cached: true,
|
|
246
384
|
tookMs: elapsedSince(startedAt),
|
|
247
385
|
},
|
|
248
386
|
};
|
|
249
387
|
}
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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) => {
|
|
391
|
+
const timeoutBudget = this.createProviderTimeoutBudget();
|
|
392
|
+
const providers = this.registry.selectDetailsProviders(normalizedQuery);
|
|
393
|
+
const requested = providers.map((provider) => provider.name);
|
|
394
|
+
const successful = [];
|
|
395
|
+
const failed = [];
|
|
396
|
+
const warnings = [];
|
|
397
|
+
const providerResults = [];
|
|
398
|
+
const providerTimings = [];
|
|
399
|
+
const outcomes = await Promise.all(providers.map((provider) => callTimedProviderDetails(provider, normalizedQuery, {
|
|
400
|
+
debug: this.debug,
|
|
401
|
+
language: normalizedQuery.language,
|
|
402
|
+
signal: operationSignal,
|
|
403
|
+
timeoutMs: timeoutBudget.getRemainingMs(provider.name),
|
|
404
|
+
circuitBreaker: this.circuitBreaker,
|
|
405
|
+
concurrencyLimiter: this.concurrencyLimiter,
|
|
406
|
+
})));
|
|
407
|
+
for (const outcome of outcomes) {
|
|
408
|
+
providerTimings.push(outcome.timing);
|
|
409
|
+
if (outcome.failure) {
|
|
410
|
+
failed.push(outcome.failure);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
successful.push(outcome.provider);
|
|
414
|
+
if (outcome.result) {
|
|
415
|
+
providerResults.push(outcome.result);
|
|
416
|
+
}
|
|
271
417
|
}
|
|
272
418
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
419
|
+
if (providers.length > 0 && successful.length === 0 && failed.length > 0) {
|
|
420
|
+
throw new MediaEngineError({
|
|
421
|
+
code: "PROVIDER_ERROR",
|
|
422
|
+
message: "All details providers failed.",
|
|
423
|
+
cause: { failed },
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const details = this.mergeStrategy.mergeDetails(providerResults, {
|
|
427
|
+
query: normalizedQuery,
|
|
428
|
+
language: normalizedQuery.language,
|
|
429
|
+
debug: this.debug,
|
|
430
|
+
warnings,
|
|
279
431
|
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
432
|
+
const response = {
|
|
433
|
+
query: normalizedQuery,
|
|
434
|
+
details,
|
|
435
|
+
meta: createResponseMeta({
|
|
436
|
+
requested,
|
|
437
|
+
successful,
|
|
438
|
+
failed,
|
|
439
|
+
warnings,
|
|
440
|
+
cached: false,
|
|
441
|
+
tookMs: elapsedSince(startedAt),
|
|
442
|
+
debug: this.debug,
|
|
443
|
+
timings: providerTimings,
|
|
444
|
+
}),
|
|
445
|
+
};
|
|
446
|
+
throwIfAborted(operationSignal);
|
|
447
|
+
if (!hasRetryableProviderFailure(failed)) {
|
|
448
|
+
await this.cache?.set(cacheKey, structuredClone(response));
|
|
449
|
+
}
|
|
450
|
+
return response;
|
|
451
|
+
});
|
|
452
|
+
return loadWithStaleFallback({
|
|
453
|
+
stale,
|
|
454
|
+
pending,
|
|
455
|
+
tookMs: () => elapsedSince(startedAt),
|
|
286
456
|
});
|
|
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
457
|
}
|
|
304
458
|
// Loads normalized player and stream availability through streaming providers.
|
|
305
459
|
// Загружает нормализованную доступность player и stream через streaming-провайдеры.
|
|
306
|
-
async getAvailability(query) {
|
|
460
|
+
async getAvailability(query, options = {}) {
|
|
461
|
+
throwIfAborted(options.signal);
|
|
307
462
|
const startedAt = Date.now();
|
|
308
463
|
const normalizedQuery = normalizeStreamQuery(query);
|
|
309
464
|
validateStreamQuery(normalizedQuery);
|
|
310
465
|
const cacheKey = createAvailabilityCacheKey(normalizedQuery);
|
|
311
|
-
const cached = await this.cache?.get(cacheKey);
|
|
466
|
+
const cached = await waitForCaller(this.cache?.get(cacheKey), options.signal);
|
|
312
467
|
if (cached) {
|
|
468
|
+
const response = structuredClone(cached);
|
|
313
469
|
return {
|
|
314
|
-
...
|
|
470
|
+
...response,
|
|
315
471
|
query: normalizedQuery,
|
|
316
|
-
meta:
|
|
472
|
+
meta: response.meta
|
|
317
473
|
? {
|
|
318
|
-
...
|
|
474
|
+
...response.meta,
|
|
319
475
|
cached: true,
|
|
320
476
|
tookMs: elapsedSince(startedAt),
|
|
321
477
|
}
|
|
322
478
|
: undefined,
|
|
323
479
|
};
|
|
324
480
|
}
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
481
|
+
const inFlight = this.inFlightRequests.forCaller(options);
|
|
482
|
+
return inFlight.run(`availability:${cacheKey}`, async (operationSignal) => {
|
|
483
|
+
const timeoutBudget = this.createProviderTimeoutBudget();
|
|
484
|
+
const providers = selectStreamingProviders(this.streamingProviders, normalizedQuery);
|
|
485
|
+
const requested = providers.map((provider) => provider.name);
|
|
486
|
+
const successful = [];
|
|
487
|
+
const failed = [];
|
|
488
|
+
const providerResults = [];
|
|
489
|
+
const providerTimings = [];
|
|
490
|
+
const outcomes = await Promise.all(providers.map((provider) => callTimedProviderAvailability(provider, normalizedQuery, {
|
|
491
|
+
debug: this.debug,
|
|
492
|
+
language: normalizedQuery.language,
|
|
493
|
+
signal: operationSignal,
|
|
494
|
+
timeoutMs: timeoutBudget.getRemainingMs(provider.name),
|
|
495
|
+
circuitBreaker: this.circuitBreaker,
|
|
496
|
+
concurrencyLimiter: this.concurrencyLimiter,
|
|
497
|
+
})));
|
|
498
|
+
for (const outcome of outcomes) {
|
|
499
|
+
providerTimings.push(outcome.timing);
|
|
500
|
+
if (outcome.failure) {
|
|
501
|
+
failed.push(outcome.failure);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
successful.push(outcome.provider);
|
|
505
|
+
if (outcome.result) {
|
|
506
|
+
providerResults.push(outcome.result);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
340
509
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
510
|
+
if (providers.length > 0 && failed.length === providers.length) {
|
|
511
|
+
throw new MediaEngineError({
|
|
512
|
+
code: "PROVIDER_ERROR",
|
|
513
|
+
message: "All streaming providers failed.",
|
|
514
|
+
cause: { failed },
|
|
515
|
+
});
|
|
344
516
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
517
|
+
const availability = mergeAvailabilityResults(normalizedQuery, providerResults);
|
|
518
|
+
const hasUnknownValidation = hasUnknownStreamValidation(availability);
|
|
519
|
+
availability.meta = createResponseMeta({
|
|
520
|
+
requested,
|
|
521
|
+
successful,
|
|
522
|
+
failed,
|
|
523
|
+
warnings: hasUnknownValidation
|
|
524
|
+
? [
|
|
525
|
+
{
|
|
526
|
+
code: "STREAM_VALIDATION_DEGRADED",
|
|
527
|
+
message: "One or more discovered player options could not be validated reliably.",
|
|
528
|
+
},
|
|
529
|
+
]
|
|
530
|
+
: [],
|
|
531
|
+
cached: false,
|
|
532
|
+
tookMs: elapsedSince(startedAt),
|
|
533
|
+
debug: this.debug,
|
|
534
|
+
timings: providerTimings,
|
|
351
535
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
536
|
+
throwIfAborted(operationSignal);
|
|
537
|
+
if (!hasRetryableProviderFailure(failed) && !hasUnknownValidation) {
|
|
538
|
+
await this.cache?.set(cacheKey, structuredClone(availability), createAvailabilityCacheOptions(availability));
|
|
539
|
+
}
|
|
540
|
+
return availability;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
// Discovers normalized torrent handoff candidates through torrent providers.
|
|
544
|
+
// Находит нормализованные torrent handoff кандидаты через torrent-провайдеры.
|
|
545
|
+
async discoverTorrents(query, options = {}) {
|
|
546
|
+
throwIfAborted(options.signal);
|
|
547
|
+
const startedAt = Date.now();
|
|
548
|
+
const normalizedQuery = normalizeTorrentQuery(query);
|
|
549
|
+
validateTorrentQuery(normalizedQuery);
|
|
550
|
+
return executeTorrentDiscovery({
|
|
551
|
+
query: normalizedQuery,
|
|
552
|
+
options,
|
|
553
|
+
startedAt,
|
|
554
|
+
providers: this.torrentProviders,
|
|
555
|
+
cache: this.cache,
|
|
361
556
|
debug: this.debug,
|
|
362
|
-
|
|
557
|
+
circuitBreaker: this.circuitBreaker,
|
|
558
|
+
concurrencyLimiter: this.concurrencyLimiter,
|
|
559
|
+
inFlightRequests: this.inFlightRequests,
|
|
560
|
+
getProviderTimeoutMs: (providerName) => this.getProviderTimeoutMs(providerName),
|
|
363
561
|
});
|
|
364
|
-
await this.cache?.set(cacheKey, availability, createAvailabilityCacheOptions(availability));
|
|
365
|
-
return availability;
|
|
366
562
|
}
|
|
367
563
|
// Gives future engine methods access to the registered providers.
|
|
368
564
|
// Дает будущим методам движка доступ к зарегистрированным провайдерам.
|
|
@@ -387,621 +583,87 @@ export class MediaEngine {
|
|
|
387
583
|
// Resolves a provider override without allowing it to exceed the global boundary.
|
|
388
584
|
// Выбирает override провайдера, не позволяя ему превысить глобальную границу.
|
|
389
585
|
getProviderTimeoutMs(providerName) {
|
|
390
|
-
|
|
391
|
-
if (providerTimeoutMs === undefined) {
|
|
392
|
-
return this.timeoutMs;
|
|
393
|
-
}
|
|
394
|
-
return this.timeoutMs === undefined
|
|
395
|
-
? providerTimeoutMs
|
|
396
|
-
: Math.min(this.timeoutMs, providerTimeoutMs);
|
|
586
|
+
return resolveProviderTimeoutMs(providerName, this.timeoutMs, this.providerTimeouts);
|
|
397
587
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
588
|
+
createProviderTimeoutBudget() {
|
|
589
|
+
return new ProviderTimeoutBudget((providerName) => this.getProviderTimeoutMs(providerName));
|
|
590
|
+
}
|
|
591
|
+
async loadReusableDetails(query, signal, maxWaitMs) {
|
|
592
|
+
if (maxWaitMs <= 0) {
|
|
402
593
|
return undefined;
|
|
403
594
|
}
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
595
|
+
const normalizedQuery = normalizeDetailsQuery(query);
|
|
596
|
+
const cacheKey = createDetailsCacheKey(normalizedQuery);
|
|
597
|
+
const timeoutController = new AbortController();
|
|
598
|
+
const timeout = setTimeout(() => timeoutController.abort(), maxWaitMs);
|
|
599
|
+
const waitSignal = signal
|
|
600
|
+
? AbortSignal.any([signal, timeoutController.signal])
|
|
601
|
+
: timeoutController.signal;
|
|
602
|
+
try {
|
|
603
|
+
const cached = await waitForCaller(this.cache?.get(cacheKey), waitSignal);
|
|
604
|
+
if (cached) {
|
|
605
|
+
return cached.details ?? undefined;
|
|
606
|
+
}
|
|
607
|
+
const inFlight = this.inFlightRequests.joinExisting(`details:${cacheKey}`, {
|
|
608
|
+
signal: waitSignal,
|
|
416
609
|
});
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
language,
|
|
422
|
-
debug: this.debug,
|
|
423
|
-
warnings: [],
|
|
424
|
-
})?.poster;
|
|
425
|
-
}
|
|
426
|
-
// Gives future engine methods access to the debug flag.
|
|
427
|
-
// Дает будущим методам движка доступ к debug-флагу.
|
|
428
|
-
get engineDebug() {
|
|
429
|
-
return this.debug;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
// Enriches compact catalog hits when follow-up cards would otherwise choose different metadata.
|
|
433
|
-
// Обогащает compact catalog hits, чтобы search и details не выбирали разные metadata.
|
|
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;
|
|
610
|
+
if (!inFlight) {
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
return (await inFlight).details ?? undefined;
|
|
580
614
|
}
|
|
581
|
-
|
|
582
|
-
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (signal?.aborted) {
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
if (timeoutController.signal.aborted) {
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
throw error;
|
|
583
623
|
}
|
|
584
|
-
|
|
585
|
-
|
|
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;
|
|
624
|
+
finally {
|
|
625
|
+
clearTimeout(timeout);
|
|
606
626
|
}
|
|
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
627
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
existing.item.id === candidate.item.id);
|
|
655
|
-
if (!isDuplicate) {
|
|
656
|
-
target.push(candidate);
|
|
628
|
+
createProviderHealthStatus(provider, kind) {
|
|
629
|
+
if (!this.circuitBreaker) {
|
|
630
|
+
return {
|
|
631
|
+
provider,
|
|
632
|
+
kind,
|
|
633
|
+
circuitState: "disabled",
|
|
634
|
+
consecutiveFailures: 0,
|
|
635
|
+
totalRequests: 0,
|
|
636
|
+
totalSuccesses: 0,
|
|
637
|
+
totalFailures: 0,
|
|
638
|
+
};
|
|
657
639
|
}
|
|
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) {
|
|
640
|
+
const snapshot = this.circuitBreaker.getSnapshot(`${kind}:${provider}`);
|
|
694
641
|
return {
|
|
695
|
-
provider
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
642
|
+
provider,
|
|
643
|
+
kind,
|
|
644
|
+
circuitState: snapshot.state,
|
|
645
|
+
consecutiveFailures: snapshot.consecutiveFailures,
|
|
646
|
+
totalRequests: snapshot.totalRequests,
|
|
647
|
+
totalSuccesses: snapshot.totalSuccesses,
|
|
648
|
+
totalFailures: snapshot.totalFailures,
|
|
649
|
+
lastSuccessAt: snapshot.lastSuccessAt === undefined
|
|
650
|
+
? undefined
|
|
651
|
+
: new Date(snapshot.lastSuccessAt).toISOString(),
|
|
652
|
+
lastFailureAt: snapshot.lastFailureAt === undefined
|
|
653
|
+
? undefined
|
|
654
|
+
: new Date(snapshot.lastFailureAt).toISOString(),
|
|
655
|
+
lastFailureCode: snapshot.lastFailureCode,
|
|
656
|
+
failureCounts: snapshot.totalFailures > 0 ? snapshot.failureCounts : undefined,
|
|
657
|
+
retryAfterMs: snapshot.retryAfterMs,
|
|
703
658
|
};
|
|
704
659
|
}
|
|
705
|
-
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
}
|
|
660
|
+
// Gives future engine methods access to the debug flag.
|
|
661
|
+
// Дает будущим методам движка доступ к debug-флагу.
|
|
662
|
+
get engineDebug() {
|
|
663
|
+
return this.debug;
|
|
985
664
|
}
|
|
986
|
-
return unique;
|
|
987
|
-
}
|
|
988
|
-
// Returns elapsed milliseconds since a start timestamp.
|
|
989
|
-
// Возвращает количество миллисекунд, прошедших с начального timestamp.
|
|
990
|
-
function elapsedSince(startedAt) {
|
|
991
|
-
return Date.now() - startedAt;
|
|
992
665
|
}
|
|
993
|
-
|
|
994
|
-
|
|
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;
|
|
666
|
+
function hasRetryableProviderFailure(failures) {
|
|
667
|
+
return failures.some((failure) => failure.retryable);
|
|
1006
668
|
}
|
|
1007
669
|
//# sourceMappingURL=engine.js.map
|