@media-engine/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/dist/cache/index.d.ts +2 -0
  4. package/dist/cache/index.js +2 -0
  5. package/dist/cache/index.js.map +1 -0
  6. package/dist/cache/memory.d.ts +19 -0
  7. package/dist/cache/memory.js +83 -0
  8. package/dist/cache/memory.js.map +1 -0
  9. package/dist/cache/types.d.ts +9 -0
  10. package/dist/cache/types.js +2 -0
  11. package/dist/cache/types.js.map +1 -0
  12. package/dist/details/index.d.ts +1 -0
  13. package/dist/details/index.js +2 -0
  14. package/dist/details/index.js.map +1 -0
  15. package/dist/details/types.d.ts +19 -0
  16. package/dist/details/types.js +2 -0
  17. package/dist/details/types.js.map +1 -0
  18. package/dist/engine/engine.d.ts +29 -0
  19. package/dist/engine/engine.js +1007 -0
  20. package/dist/engine/engine.js.map +1 -0
  21. package/dist/engine/index.d.ts +2 -0
  22. package/dist/engine/index.js +2 -0
  23. package/dist/engine/index.js.map +1 -0
  24. package/dist/engine/types.d.ts +13 -0
  25. package/dist/engine/types.js +2 -0
  26. package/dist/engine/types.js.map +1 -0
  27. package/dist/errors/index.d.ts +1 -0
  28. package/dist/errors/index.js +2 -0
  29. package/dist/errors/index.js.map +1 -0
  30. package/dist/errors/types.d.ts +27 -0
  31. package/dist/errors/types.js +77 -0
  32. package/dist/errors/types.js.map +1 -0
  33. package/dist/index.d.ts +12 -0
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/media/index.d.ts +1 -0
  37. package/dist/media/index.js +2 -0
  38. package/dist/media/index.js.map +1 -0
  39. package/dist/media/types.d.ts +136 -0
  40. package/dist/media/types.js +2 -0
  41. package/dist/media/types.js.map +1 -0
  42. package/dist/merge/index.d.ts +2 -0
  43. package/dist/merge/index.js +2 -0
  44. package/dist/merge/index.js.map +1 -0
  45. package/dist/merge/strategy.d.ts +8 -0
  46. package/dist/merge/strategy.js +1168 -0
  47. package/dist/merge/strategy.js.map +1 -0
  48. package/dist/merge/types.d.ts +18 -0
  49. package/dist/merge/types.js +2 -0
  50. package/dist/merge/types.js.map +1 -0
  51. package/dist/providers/index.d.ts +2 -0
  52. package/dist/providers/index.js +2 -0
  53. package/dist/providers/index.js.map +1 -0
  54. package/dist/providers/registry.d.ts +9 -0
  55. package/dist/providers/registry.js +92 -0
  56. package/dist/providers/registry.js.map +1 -0
  57. package/dist/providers/types.d.ts +63 -0
  58. package/dist/providers/types.js +2 -0
  59. package/dist/providers/types.js.map +1 -0
  60. package/dist/response/index.d.ts +1 -0
  61. package/dist/response/index.js +2 -0
  62. package/dist/response/index.js.map +1 -0
  63. package/dist/response/types.d.ts +32 -0
  64. package/dist/response/types.js +2 -0
  65. package/dist/response/types.js.map +1 -0
  66. package/dist/search/index.d.ts +1 -0
  67. package/dist/search/index.js +2 -0
  68. package/dist/search/index.js.map +1 -0
  69. package/dist/search/types.d.ts +26 -0
  70. package/dist/search/types.js +2 -0
  71. package/dist/search/types.js.map +1 -0
  72. package/dist/streaming/index.d.ts +1 -0
  73. package/dist/streaming/index.js +2 -0
  74. package/dist/streaming/index.js.map +1 -0
  75. package/dist/streaming/types.d.ts +126 -0
  76. package/dist/streaming/types.js +2 -0
  77. package/dist/streaming/types.js.map +1 -0
  78. package/dist/testing/fixtures.d.ts +4 -0
  79. package/dist/testing/fixtures.js +101 -0
  80. package/dist/testing/fixtures.js.map +1 -0
  81. package/dist/testing/index.d.ts +2 -0
  82. package/dist/testing/index.js +3 -0
  83. package/dist/testing/index.js.map +1 -0
  84. package/dist/testing/providers.d.ts +26 -0
  85. package/dist/testing/providers.js +147 -0
  86. package/dist/testing/providers.js.map +1 -0
  87. package/package.json +51 -0
@@ -0,0 +1,1168 @@
1
+ // Strong IDs that can safely prove two results describe the same media.
2
+ // Сильные ID, которые безопасно доказывают, что два результата описывают одно медиа.
3
+ const STRONG_ID_KEYS = ["imdb", "tmdb", "kinopoisk", "shikimori", "myAnimeList"];
4
+ // All external ID fields copied into a merged media item.
5
+ // Все поля внешних ID, которые копируются в объединенный media item.
6
+ const EXTERNAL_ID_KEYS = [
7
+ "imdb",
8
+ "tmdb",
9
+ "kinopoisk",
10
+ "shikimori",
11
+ "myAnimeList",
12
+ "aniList",
13
+ "worldArt",
14
+ ];
15
+ // Default provider priority for movies and series.
16
+ // Приоритет провайдеров по умолчанию для фильмов и сериалов.
17
+ const DEFAULT_PRIORITY = ["tmdb", "kinobd", "cinemeta", "imdb", "wikidata", "kinopoisk"];
18
+ // Default provider priority for anime results.
19
+ // Приоритет провайдеров по умолчанию для аниме.
20
+ const ANIME_PRIORITY = [
21
+ "shikimori",
22
+ "anilist",
23
+ "tmdb",
24
+ "kinobd",
25
+ "cinemeta",
26
+ "imdb",
27
+ "wikidata",
28
+ "kinopoisk",
29
+ ];
30
+ // Search-level tie-break priority used when results from different media types have equal scores.
31
+ // Search-level priority для tie-break, когда результаты разных media types имеют одинаковый score.
32
+ const SEARCH_RESULT_PRIORITY = [
33
+ "tmdb",
34
+ "kinobd",
35
+ "cinemeta",
36
+ "imdb",
37
+ "wikidata",
38
+ "kinopoisk",
39
+ "shikimori",
40
+ "anilist",
41
+ ];
42
+ // Built-in merge strategy used by core when no custom strategy is provided.
43
+ // Встроенная стратегия объединения, используемая core без пользовательской стратегии.
44
+ export class DefaultMergeStrategy {
45
+ // Merges provider search results into normalized scored search results.
46
+ // Объединяет результаты поиска провайдеров в нормализованные результаты с оценкой.
47
+ mergeSearchResults(results, context = {}) {
48
+ const groups = groupSearchResults(results);
49
+ return groups
50
+ .filter((group) => isSearchGroupRelevant(group, context))
51
+ .map((group, groupIndex) => ({
52
+ groupIndex,
53
+ exactPrimaryTitleMatch: hasExactPrimaryQueryTitle(group.entries, context),
54
+ result: mergeSearchGroup(group, context),
55
+ }))
56
+ .sort((left, right) => {
57
+ // The engine's preliminary broad merge feeds a bounded enrichment window. Keep exact
58
+ // canonical candidates in that window even when their initial provider card is sparse.
59
+ if (context.includeIrrelevantSearchResults &&
60
+ left.exactPrimaryTitleMatch !== right.exactPrimaryTitleMatch) {
61
+ return left.exactPrimaryTitleMatch ? -1 : 1;
62
+ }
63
+ const scoreDiff = right.result.score - left.result.score;
64
+ if (scoreDiff !== 0) {
65
+ return scoreDiff;
66
+ }
67
+ const providerDiff = searchResultProviderRank(left.result) - searchResultProviderRank(right.result);
68
+ if (providerDiff !== 0) {
69
+ return providerDiff;
70
+ }
71
+ const titleDiff = left.result.item.title.localeCompare(right.result.item.title);
72
+ if (titleDiff !== 0) {
73
+ return titleDiff;
74
+ }
75
+ return left.groupIndex - right.groupIndex;
76
+ })
77
+ .map(({ result }) => result);
78
+ }
79
+ // Merges provider details results around a primary provider result.
80
+ // Объединяет details-результаты провайдеров вокруг основного результата.
81
+ mergeDetails(results, context = {}) {
82
+ const priorityType = selectMetadataPriorityType(results.map((result) => result.details));
83
+ const sortedEntries = filterDetailsEntriesByIdentity(sortEntriesByPriority(results.map((result, index) => ({ result, index })), context, priorityType), context);
84
+ const mediaType = selectMergedMediaType(sortedEntries.map((entry) => entry.result.details));
85
+ const finalEntries = sortEntriesByPriority(sortedEntries, context, selectMetadataPriorityType(sortedEntries.map((entry) => entry.result.details)));
86
+ return mergeDetailsEntries(finalEntries, context, mediaType);
87
+ }
88
+ }
89
+ // Checks exact primary/original query intent for preliminary search enrichment ordering.
90
+ // Проверяет точное основное/original соответствие для предварительного enrichment-порядка.
91
+ function hasExactPrimaryQueryTitle(entries, context) {
92
+ const query = context.query;
93
+ const queryTitle = "title" in (query ?? {}) ? query?.title : undefined;
94
+ return Boolean(queryTitle?.trim() && hasExactPrimaryTitle(entries, queryTitle));
95
+ }
96
+ // Keeps details attached to one strong-ID identity before any fields are combined.
97
+ // Оставляет details одной strong-ID сущности до объединения любых полей.
98
+ function filterDetailsEntriesByIdentity(entries, context) {
99
+ const selectedIds = { ...readQueryExternalIds(context) };
100
+ const accepted = [];
101
+ for (const entry of entries) {
102
+ const ids = entry.result.details.ids;
103
+ const conflicts = strongIdConflicts(selectedIds, ids);
104
+ if (conflicts.length > 0 && !hasSharedStrongId(selectedIds, ids)) {
105
+ for (const key of conflicts) {
106
+ context.warnings?.push({
107
+ code: "EXTERNAL_ID_CONFLICT",
108
+ message: `Conflicting ${key} IDs while merging details; excluded ${ids?.[key]}.`,
109
+ provider: entry.result.provider,
110
+ });
111
+ }
112
+ continue;
113
+ }
114
+ accepted.push(entry);
115
+ for (const key of STRONG_ID_KEYS) {
116
+ if (!selectedIds[key] && ids?.[key]) {
117
+ selectedIds[key] = ids[key];
118
+ }
119
+ }
120
+ }
121
+ return accepted;
122
+ }
123
+ function readQueryExternalIds(context) {
124
+ const query = context.query;
125
+ if (!query) {
126
+ return undefined;
127
+ }
128
+ const ids = { ...query.ids };
129
+ for (const key of STRONG_ID_KEYS) {
130
+ const value = query[key];
131
+ if (typeof value === "string" && value.trim()) {
132
+ ids[key] = value.trim();
133
+ }
134
+ }
135
+ return Object.keys(ids).length > 0 ? ids : undefined;
136
+ }
137
+ // Drops provider noise that has no textual relationship to a title query.
138
+ // Убирает шум провайдеров, который никак текстово не связан с title-запросом.
139
+ function isSearchGroupRelevant(group, context) {
140
+ if (context.includeIrrelevantSearchResults) {
141
+ return true;
142
+ }
143
+ const query = context.query;
144
+ const queryTitle = "title" in (query ?? {}) ? query?.title : undefined;
145
+ if (!queryTitle?.trim()) {
146
+ return true;
147
+ }
148
+ return titleRelevanceScore(group.entries, queryTitle) > 0;
149
+ }
150
+ // Calculates rank for a merged search result using its primary source provider.
151
+ // Вычисляет rank для merged search result по его основному source provider.
152
+ function searchResultProviderRank(result) {
153
+ return providerRank(result.sources[0]?.provider ?? "", SEARCH_RESULT_PRIORITY);
154
+ }
155
+ // Groups search results by exact IDs first, then by normalized title/year/type.
156
+ // Группирует результаты сначала по точным ID, затем по нормализованным title/year/type.
157
+ function groupSearchResults(results) {
158
+ const groups = [];
159
+ const groupsByStrongId = new Map();
160
+ const groupsByTitleYearType = new Map();
161
+ results.forEach((result, index) => {
162
+ const entry = { result, index };
163
+ const exactIdGroupIndex = findIndexedGroup(strongIdIndexKeys(result.item), groupsByStrongId, groups, (group) => canJoinByExactId(entry, group));
164
+ if (exactIdGroupIndex !== undefined) {
165
+ const exactIdGroup = groups[exactIdGroupIndex];
166
+ exactIdGroup.entries.push(entry);
167
+ exactIdGroup.matchStrength = "exact_id";
168
+ indexSearchEntry(entry, exactIdGroupIndex, groupsByStrongId, groupsByTitleYearType);
169
+ return;
170
+ }
171
+ const titleGroupIndex = findIndexedGroup(titleYearTypeIndexKeys(result.item), groupsByTitleYearType, groups, (group) => canJoinByTitleYearType(entry, group));
172
+ if (titleGroupIndex !== undefined) {
173
+ const titleGroup = groups[titleGroupIndex];
174
+ const isExactTitleMatch = hasExactTitleYearTypeMatch(entry, titleGroup);
175
+ titleGroup.entries.push(entry);
176
+ titleGroup.matchStrength =
177
+ titleGroup.matchStrength === "exact_title_year_type" || isExactTitleMatch
178
+ ? "exact_title_year_type"
179
+ : "normalized_title_year_type";
180
+ indexSearchEntry(entry, titleGroupIndex, groupsByStrongId, groupsByTitleYearType);
181
+ return;
182
+ }
183
+ const groupIndex = groups.length;
184
+ groups.push({ entries: [entry], matchStrength: "none" });
185
+ indexSearchEntry(entry, groupIndex, groupsByStrongId, groupsByTitleYearType);
186
+ });
187
+ return groups;
188
+ }
189
+ function findIndexedGroup(keys, index, groups, matches) {
190
+ const candidates = new Set();
191
+ for (const key of keys) {
192
+ for (const groupIndex of index.get(key) ?? []) {
193
+ candidates.add(groupIndex);
194
+ }
195
+ }
196
+ return [...candidates]
197
+ .sort((left, right) => left - right)
198
+ .find((groupIndex) => {
199
+ const group = groups[groupIndex];
200
+ return group ? matches(group) : false;
201
+ });
202
+ }
203
+ function indexSearchEntry(entry, groupIndex, groupsByStrongId, groupsByTitleYearType) {
204
+ addGroupIndex(groupsByStrongId, strongIdIndexKeys(entry.result.item), groupIndex);
205
+ addGroupIndex(groupsByTitleYearType, titleYearTypeIndexKeys(entry.result.item), groupIndex);
206
+ }
207
+ function addGroupIndex(index, keys, groupIndex) {
208
+ for (const key of keys) {
209
+ const groupIndexes = index.get(key);
210
+ if (!groupIndexes) {
211
+ index.set(key, [groupIndex]);
212
+ }
213
+ else if (groupIndexes.at(-1) !== groupIndex && !groupIndexes.includes(groupIndex)) {
214
+ groupIndexes.push(groupIndex);
215
+ }
216
+ }
217
+ }
218
+ function strongIdIndexKeys(item) {
219
+ return STRONG_ID_KEYS.flatMap((key) => {
220
+ const value = item.ids?.[key];
221
+ return value ? [`${item.type}:${key}:${value}`] : [];
222
+ });
223
+ }
224
+ function titleYearTypeIndexKeys(item) {
225
+ if (item.year === undefined) {
226
+ return [];
227
+ }
228
+ const typeKey = item.type === "anime" || item.type === "series" ? "series-or-anime" : item.type;
229
+ return [...normalizedTitleCandidateSet(item)].map((title) => `${typeKey}:${item.year}:${title}`);
230
+ }
231
+ // Checks whether an entry can join a group through a shared strong external ID.
232
+ // Проверяет, может ли entry войти в группу по общему сильному внешнему ID.
233
+ function canJoinByExactId(entry, group) {
234
+ return group.entries.some((groupEntry) => {
235
+ return (groupEntry.result.item.type === entry.result.item.type &&
236
+ hasSharedStrongId(groupEntry.result.item.ids, entry.result.item.ids));
237
+ });
238
+ }
239
+ // Checks whether an entry can join a group by normalized title, year, and type.
240
+ // Проверяет, может ли entry войти в группу по нормализованным title, year и type.
241
+ function canJoinByTitleYearType(entry, group) {
242
+ const item = entry.result.item;
243
+ return group.entries.some((groupEntry) => {
244
+ const groupItem = groupEntry.result.item;
245
+ return (areSearchTypesCompatible(item.type, groupItem.type) &&
246
+ item.year !== undefined &&
247
+ item.year === groupItem.year &&
248
+ hasSharedNormalizedTitleCandidate(item, groupItem) &&
249
+ !hasStrongIdConflict(item.ids, groupItem.ids));
250
+ });
251
+ }
252
+ // Checks whether a title/year/type match is exact before normalization.
253
+ // Проверяет, является ли совпадение title/year/type точным до нормализации.
254
+ function hasExactTitleYearTypeMatch(entry, group) {
255
+ const item = entry.result.item;
256
+ return group.entries.some((groupEntry) => {
257
+ const groupItem = groupEntry.result.item;
258
+ return (areSearchTypesCompatible(item.type, groupItem.type) &&
259
+ item.year !== undefined &&
260
+ item.year === groupItem.year &&
261
+ hasSharedExactTitleCandidate(item, groupItem));
262
+ });
263
+ }
264
+ // Treats anime and series as compatible only for strong title/year grouping.
265
+ // Считает anime и series совместимыми только для сильной группировки по title/year.
266
+ function areSearchTypesCompatible(left, right) {
267
+ return (left === right ||
268
+ (left === "anime" && right === "series") ||
269
+ (left === "series" && right === "anime"));
270
+ }
271
+ // Checks title/original/alternative candidates after normalization.
272
+ // Проверяет title/original/alternative кандидаты после нормализации.
273
+ function hasSharedNormalizedTitleCandidate(left, right) {
274
+ const leftTitles = normalizedTitleCandidateSet(left);
275
+ if (leftTitles.size === 0) {
276
+ return false;
277
+ }
278
+ return titleCandidates(right).some((title) => leftTitles.has(normalizeTitle(title)));
279
+ }
280
+ // Checks title/original/alternative candidates without accent/case normalization.
281
+ // Проверяет title/original/alternative кандидаты без accent/case нормализации.
282
+ function hasSharedExactTitleCandidate(left, right) {
283
+ const leftTitles = new Set(titleCandidates(left).map(exactTitleKey).filter(Boolean));
284
+ if (leftTitles.size === 0) {
285
+ return false;
286
+ }
287
+ return titleCandidates(right).some((title) => leftTitles.has(exactTitleKey(title)));
288
+ }
289
+ // Builds normalized non-empty title candidate set for grouping.
290
+ // Собирает набор нормализованных непустых title candidates для группировки.
291
+ function normalizedTitleCandidateSet(item) {
292
+ return new Set(titleCandidates(item).map(normalizeTitle).filter(Boolean));
293
+ }
294
+ // Merges one internal search group into one public search result.
295
+ // Объединяет одну внутреннюю группу поиска в один публичный результат поиска.
296
+ function mergeSearchGroup(group, context) {
297
+ const mediaType = selectMergedSearchType(group.entries);
298
+ const priorityType = selectMetadataPriorityType(group.entries.map((entry) => entry.result.item));
299
+ const sortedEntries = sortEntriesByPriority(group.entries, context, priorityType);
300
+ const primary = sortedEntries[0]?.result.item;
301
+ if (!primary) {
302
+ throw new Error("Cannot merge an empty search group.");
303
+ }
304
+ const ids = mergeExternalIds(sortedEntries, context);
305
+ const title = selectTitle(sortedEntries, context) ?? primary.title;
306
+ const item = {
307
+ ...primary,
308
+ id: primary.id,
309
+ type: mediaType ?? primary.type,
310
+ title,
311
+ originalTitle: firstDefined(sortedEntries, (entry) => entry.result.item.originalTitle),
312
+ alternativeTitles: mergeAlternativeTitles(sortedEntries, title),
313
+ year: selectYear(sortedEntries, context),
314
+ releaseDate: selectReleaseDate(sortedEntries),
315
+ description: selectDescription(sortedEntries, context),
316
+ shortDescription: selectShortDescription(sortedEntries),
317
+ poster: selectBestImage(sortedEntries, "poster"),
318
+ backdrop: selectBestImage(sortedEntries, "backdrop"),
319
+ genres: mergeGenres(sortedEntries),
320
+ ratings: mergeRatings(sortedEntries),
321
+ ids,
322
+ };
323
+ return {
324
+ item,
325
+ score: scoreGroup(group, sortedEntries, context),
326
+ sources: mergeSources(sortedEntries),
327
+ };
328
+ }
329
+ // Preserves anime semantics when a generic series catalog describes the same animated title.
330
+ // Сохраняет anime-семантику, когда generic series-каталог описывает тот же анимационный тайтл.
331
+ function selectMergedSearchType(entries) {
332
+ return selectMergedMediaType(entries.map((entry) => entry.result.item));
333
+ }
334
+ // Uses general-series metadata priority for mixed anime/catalog groups without losing anime type.
335
+ // Использует приоритет metadata сериалов для смешанных anime/catalog групп без потери anime-типа.
336
+ function selectMetadataPriorityType(items) {
337
+ const types = new Set(items.map((item) => item.type));
338
+ return types.has("anime") && types.has("series") ? "series" : items[0]?.type;
339
+ }
340
+ function selectMergedMediaType(items) {
341
+ return items.some((item) => item.type === "anime") ? "anime" : items[0]?.type;
342
+ }
343
+ // Merges sorted details entries while keeping unsafe nested fields from primary.
344
+ // Объединяет отсортированные details entries, сохраняя небезопасные вложенные поля из primary.
345
+ function mergeDetailsEntries(entries, context, mediaType) {
346
+ const primary = entries[0]?.result.details;
347
+ if (!primary) {
348
+ return null;
349
+ }
350
+ warnDetailsTypeConflicts(entries, context, mediaType);
351
+ const ids = mergeDetailsExternalIds(entries, context);
352
+ const title = selectDetailsTitle(entries, context) ?? primary.title;
353
+ const common = {
354
+ ...primary,
355
+ id: primary.id,
356
+ type: primary.type,
357
+ title,
358
+ originalTitle: firstDefinedDetails(entries, (entry) => entry.result.details.originalTitle),
359
+ alternativeTitles: mergeDetailsAlternativeTitles(entries, title),
360
+ status: firstMeaningfulDetailsStatus(entries),
361
+ year: selectDetailsYear(entries, context),
362
+ releaseDate: selectDetailsReleaseDate(entries),
363
+ description: selectDetailsDescription(entries, context),
364
+ shortDescription: firstDefinedDetails(entries, (entry) => entry.result.details.shortDescription),
365
+ poster: selectBestDetailsImage(entries, "poster"),
366
+ backdrop: selectBestDetailsImage(entries, "backdrop"),
367
+ genres: mergeDetailsGenres(entries),
368
+ ratings: mergeDetailsRatings(entries),
369
+ ids,
370
+ images: mergeDetailsImages(entries),
371
+ sourceProviders: mergeDetailsSources(entries),
372
+ };
373
+ switch (mediaType ?? primary.type) {
374
+ case "movie":
375
+ return common;
376
+ case "series":
377
+ return {
378
+ ...common,
379
+ type: "series",
380
+ seasons: firstDefinedDetails(entries, (entry) => entry.result.details.type === "series" ? entry.result.details.seasons : undefined),
381
+ episodesCount: firstDefinedDetails(entries, (entry) => entry.result.details.type === "series" ? entry.result.details.episodesCount : undefined),
382
+ seasonsCount: firstDefinedDetails(entries, (entry) => entry.result.details.type === "series" ? entry.result.details.seasonsCount : undefined),
383
+ };
384
+ case "anime":
385
+ return {
386
+ ...common,
387
+ type: "anime",
388
+ episodes: firstDefinedDetails(entries, (entry) => entry.result.details.type === "anime" ? entry.result.details.episodes : undefined),
389
+ episodesCount: firstDefinedDetails(entries, (entry) => "episodesCount" in entry.result.details ? entry.result.details.episodesCount : undefined),
390
+ };
391
+ }
392
+ }
393
+ // Sorts entries by explicit provider priority and then original input order.
394
+ // Сортирует entries по заданному приоритету провайдеров и исходному порядку.
395
+ function sortEntriesByPriority(entries, context, mediaType) {
396
+ const priority = context.providerPriority ?? defaultPriority(mediaType);
397
+ return [...entries].sort((left, right) => {
398
+ const priorityDiff = providerRank(left.result.provider, priority) - providerRank(right.result.provider, priority);
399
+ if (priorityDiff !== 0) {
400
+ return priorityDiff;
401
+ }
402
+ return getEntryIndex(left) - getEntryIndex(right);
403
+ });
404
+ }
405
+ // Returns the default provider priority for a media type.
406
+ // Возвращает приоритет провайдеров по умолчанию для типа медиа.
407
+ function defaultPriority(mediaType) {
408
+ return mediaType === "anime" ? ANIME_PRIORITY : DEFAULT_PRIORITY;
409
+ }
410
+ // Calculates provider priority rank for deterministic sorting.
411
+ // Вычисляет ранг провайдера для детерминированной сортировки.
412
+ function providerRank(provider, priority) {
413
+ const index = priority.indexOf(provider);
414
+ return index === -1 ? priority.length : index;
415
+ }
416
+ // Reads an entry index from generic sorted objects.
417
+ // Читает index из обобщенных объектов для сортировки.
418
+ function getEntryIndex(entry) {
419
+ return typeof entry === "object" &&
420
+ entry !== null &&
421
+ "index" in entry &&
422
+ typeof entry.index === "number"
423
+ ? entry.index
424
+ : 0;
425
+ }
426
+ // Merges external IDs and records conflicts without overwriting priority values.
427
+ // Объединяет внешние ID и записывает конфликты без перезаписи приоритетных значений.
428
+ function mergeExternalIds(entries, context) {
429
+ const selected = new Map();
430
+ for (const entry of entries) {
431
+ const ids = entry.result.item.ids;
432
+ if (!ids) {
433
+ continue;
434
+ }
435
+ for (const key of EXTERNAL_ID_KEYS) {
436
+ const value = ids[key];
437
+ if (!value) {
438
+ continue;
439
+ }
440
+ const existing = selected.get(key);
441
+ if (!existing) {
442
+ selected.set(key, { value, provider: entry.result.provider });
443
+ continue;
444
+ }
445
+ if (existing.value !== value) {
446
+ context.warnings?.push({
447
+ code: "EXTERNAL_ID_CONFLICT",
448
+ message: `Conflicting ${key} IDs while merging search results; kept ${existing.value}.`,
449
+ provider: entry.result.provider,
450
+ });
451
+ }
452
+ }
453
+ }
454
+ if (selected.size === 0) {
455
+ return undefined;
456
+ }
457
+ const merged = {};
458
+ for (const [key, selectedId] of selected) {
459
+ merged[key] = selectedId.value;
460
+ }
461
+ return merged;
462
+ }
463
+ // Merges details external IDs and records conflicts without overwriting priority values.
464
+ // Объединяет внешние ID деталей и записывает конфликты без перезаписи приоритетных значений.
465
+ function mergeDetailsExternalIds(entries, context) {
466
+ const selected = new Map();
467
+ for (const entry of entries) {
468
+ const ids = entry.result.details.ids;
469
+ if (!ids) {
470
+ continue;
471
+ }
472
+ for (const key of EXTERNAL_ID_KEYS) {
473
+ const value = ids[key];
474
+ if (!value) {
475
+ continue;
476
+ }
477
+ const existing = selected.get(key);
478
+ if (!existing) {
479
+ selected.set(key, { value, provider: entry.result.provider });
480
+ continue;
481
+ }
482
+ if (existing.value !== value) {
483
+ context.warnings?.push({
484
+ code: "EXTERNAL_ID_CONFLICT",
485
+ message: `Conflicting ${key} IDs while merging details; kept ${existing.value}.`,
486
+ provider: entry.result.provider,
487
+ });
488
+ }
489
+ }
490
+ }
491
+ if (selected.size === 0) {
492
+ return undefined;
493
+ }
494
+ const merged = {};
495
+ for (const [key, selectedId] of selected) {
496
+ merged[key] = selectedId.value;
497
+ }
498
+ return merged;
499
+ }
500
+ // Selects a display title, preferring the queried title when it matches.
501
+ // Выбирает отображаемый title, предпочитая title из запроса при совпадении.
502
+ function selectTitle(entries, context) {
503
+ const queryTitle = "title" in (context.query ?? {}) ? context.query.title : undefined;
504
+ const titleValues = entries.flatMap((entry) => titleCandidates(entry.result.item));
505
+ const localizedTitleValues = filterLocalizedText(titleValues, context.language);
506
+ const exactQueryTitle = queryTitle
507
+ ? selectExactQueryTitle(localizedTitleValues.length ? localizedTitleValues : titleValues, queryTitle)
508
+ : undefined;
509
+ if (exactQueryTitle) {
510
+ return exactQueryTitle;
511
+ }
512
+ const localizedTitle = selectLocalizedText(entries.flatMap((entry) => [
513
+ entry.result.item.title,
514
+ entry.result.item.originalTitle,
515
+ ...(entry.result.item.alternativeTitles ?? []),
516
+ ]), context.language);
517
+ if (localizedTitle) {
518
+ return localizedTitle;
519
+ }
520
+ return firstDefined(entries, (entry) => entry.result.item.title);
521
+ }
522
+ function selectExactQueryTitle(values, queryTitle) {
523
+ const normalizedQueryTitle = normalizeTitle(queryTitle);
524
+ return values.find((value) => normalizeTitle(value) === normalizedQueryTitle);
525
+ }
526
+ // Selects a details display title, preferring the queried title when it matches.
527
+ // Выбирает отображаемый title деталей, предпочитая title из запроса при совпадении.
528
+ function selectDetailsTitle(entries, context) {
529
+ const localizedTitle = selectLocalizedText(entries.flatMap((entry) => [
530
+ entry.result.details.title,
531
+ entry.result.details.originalTitle,
532
+ ...(entry.result.details.alternativeTitles ?? []),
533
+ ]), context.language);
534
+ if (localizedTitle) {
535
+ return localizedTitle;
536
+ }
537
+ const queryTitle = "title" in (context.query ?? {}) ? context.query.title : undefined;
538
+ if (queryTitle) {
539
+ const normalizedQueryTitle = normalizeTitle(queryTitle);
540
+ const queryMatch = entries.find((entry) => normalizeTitle(entry.result.details.title) === normalizedQueryTitle);
541
+ if (queryMatch) {
542
+ return queryMatch.result.details.title;
543
+ }
544
+ }
545
+ return firstDefinedDetails(entries, (entry) => entry.result.details.title);
546
+ }
547
+ // Selects text matching an explicitly requested language while preserving fallback behavior.
548
+ // Выбирает текст на явно запрошенном языке, сохраняя fallback-поведение.
549
+ function selectLocalizedText(values, language) {
550
+ return filterLocalizedText(values.filter((value) => Boolean(value?.trim())), language)[0];
551
+ }
552
+ function filterLocalizedText(values, language) {
553
+ const baseLanguage = language?.split("-")[0]?.toLowerCase();
554
+ if (!baseLanguage) {
555
+ return [];
556
+ }
557
+ switch (baseLanguage) {
558
+ case "ru":
559
+ case "uk":
560
+ case "be":
561
+ return values.filter((value) => /[а-яёіїєґ]/iu.test(value));
562
+ case "ja":
563
+ return values.filter((value) => /[\u3040-\u30ff\u3400-\u9fff]/u.test(value));
564
+ case "en":
565
+ return values.filter((value) => /[a-z]/iu.test(value) && !/[а-яёіїєґ\u3040-\u30ff\u3400-\u9fff]/iu.test(value));
566
+ default:
567
+ return [];
568
+ }
569
+ }
570
+ // Selects the primary year and emits warnings for conflicting years.
571
+ // Выбирает основной год и добавляет warnings при конфликтующих годах.
572
+ function selectYear(entries, context) {
573
+ const primaryYear = firstDefined(entries, (entry) => entry.result.item.year);
574
+ for (const entry of entries) {
575
+ const year = entry.result.item.year;
576
+ if (year !== undefined && primaryYear !== undefined && year !== primaryYear) {
577
+ context.warnings?.push({
578
+ code: "YEAR_CONFLICT",
579
+ message: `Conflicting years while merging search results; kept ${primaryYear}.`,
580
+ provider: entry.result.provider,
581
+ });
582
+ }
583
+ }
584
+ return primaryYear;
585
+ }
586
+ // Selects the primary details year and emits warnings for conflicting years.
587
+ // Выбирает основной год деталей и добавляет warnings при конфликтующих годах.
588
+ function selectDetailsYear(entries, context) {
589
+ const primaryYear = firstDefinedDetails(entries, (entry) => entry.result.details.year);
590
+ for (const entry of entries) {
591
+ const year = entry.result.details.year;
592
+ if (year !== undefined && primaryYear !== undefined && year !== primaryYear) {
593
+ context.warnings?.push({
594
+ code: "YEAR_CONFLICT",
595
+ message: `Conflicting years while merging details; kept ${primaryYear}.`,
596
+ provider: entry.result.provider,
597
+ });
598
+ }
599
+ }
600
+ return primaryYear;
601
+ }
602
+ // Selects the most precise release date by string length.
603
+ // Выбирает наиболее точную дату релиза по длине строки.
604
+ function selectReleaseDate(entries) {
605
+ return maxBy(entries
606
+ .map((entry) => entry.result.item.releaseDate)
607
+ .filter((releaseDate) => Boolean(releaseDate)), (releaseDate) => releaseDate.length);
608
+ }
609
+ // Selects the most precise details release date by string length.
610
+ // Выбирает наиболее точную дату релиза деталей по длине строки.
611
+ function selectDetailsReleaseDate(entries) {
612
+ return maxBy(entries
613
+ .map((entry) => entry.result.details.releaseDate)
614
+ .filter((releaseDate) => Boolean(releaseDate)), (releaseDate) => releaseDate.length);
615
+ }
616
+ // Selects the longest useful description.
617
+ // Выбирает самое длинное полезное описание.
618
+ function selectDescription(entries, context) {
619
+ const descriptions = entries
620
+ .map((entry) => entry.result.item.description)
621
+ .filter((description) => Boolean(description?.trim()));
622
+ const localized = filterLocalizedText(descriptions, context?.language);
623
+ return maxBy(localized.length ? localized : descriptions, (description) => description.trim().length);
624
+ }
625
+ // Selects the longest useful details description.
626
+ // Выбирает самое длинное полезное описание деталей.
627
+ function selectDetailsDescription(entries, context) {
628
+ const descriptions = entries
629
+ .map((entry) => entry.result.details.description)
630
+ .filter((description) => Boolean(description?.trim()));
631
+ const localized = filterLocalizedText(descriptions, context?.language);
632
+ return maxBy(localized.length ? localized : descriptions, (description) => description.trim().length);
633
+ }
634
+ // Selects the first available short description by provider priority.
635
+ // Выбирает первое доступное короткое описание по приоритету провайдера.
636
+ function selectShortDescription(entries) {
637
+ return firstDefined(entries, (entry) => entry.result.item.shortDescription);
638
+ }
639
+ // Selects the best valid poster or backdrop image.
640
+ // Выбирает лучшее валидное изображение poster или backdrop.
641
+ function selectBestImage(entries, field) {
642
+ const images = entries
643
+ .map((entry, index) => ({ image: entry.result.item[field], index }))
644
+ .filter((candidate) => isValidImageUrl(candidate.image));
645
+ return [...images].sort((left, right) => {
646
+ const areaDiff = imageArea(right.image) - imageArea(left.image);
647
+ if (areaDiff !== 0) {
648
+ return areaDiff;
649
+ }
650
+ return left.index - right.index;
651
+ })[0]?.image;
652
+ }
653
+ // Selects the best valid details poster or backdrop image.
654
+ // Выбирает лучшее валидное изображение poster или backdrop из деталей.
655
+ function selectBestDetailsImage(entries, field) {
656
+ const images = entries
657
+ .map((entry, index) => ({ image: entry.result.details[field], index }))
658
+ .filter((candidate) => isValidImageUrl(candidate.image));
659
+ return [...images].sort((left, right) => {
660
+ const areaDiff = imageArea(right.image) - imageArea(left.image);
661
+ if (areaDiff !== 0) {
662
+ return areaDiff;
663
+ }
664
+ return left.index - right.index;
665
+ })[0]?.image;
666
+ }
667
+ // Merges unique genres by normalized genre name.
668
+ // Объединяет уникальные жанры по нормализованному названию.
669
+ function mergeGenres(entries) {
670
+ const genres = new Map();
671
+ for (const entry of entries) {
672
+ for (const genre of entry.result.item.genres ?? []) {
673
+ const key = normalizeTitle(genre.name);
674
+ if (key && !genres.has(key)) {
675
+ genres.set(key, { ...genre });
676
+ }
677
+ }
678
+ }
679
+ return genres.size > 0 ? [...genres.values()] : undefined;
680
+ }
681
+ // Merges unique details genres by normalized genre name.
682
+ // Объединяет уникальные жанры деталей по нормализованному названию.
683
+ function mergeDetailsGenres(entries) {
684
+ const genres = new Map();
685
+ for (const entry of entries) {
686
+ for (const genre of entry.result.details.genres ?? []) {
687
+ const key = normalizeTitle(genre.name);
688
+ if (key && !genres.has(key)) {
689
+ genres.set(key, { ...genre });
690
+ }
691
+ }
692
+ }
693
+ return genres.size > 0 ? [...genres.values()] : undefined;
694
+ }
695
+ // Merges ratings while keeping one rating per source.
696
+ // Объединяет рейтинги, сохраняя один рейтинг на источник.
697
+ function mergeRatings(entries) {
698
+ const ratings = new Map();
699
+ for (const entry of entries) {
700
+ for (const rating of entry.result.item.ratings ?? []) {
701
+ if (!ratings.has(rating.source)) {
702
+ ratings.set(rating.source, { ...rating });
703
+ }
704
+ }
705
+ }
706
+ return ratings.size > 0 ? [...ratings.values()] : undefined;
707
+ }
708
+ // Merges details ratings while keeping one rating per source.
709
+ // Объединяет рейтинги деталей, сохраняя один рейтинг на источник.
710
+ function mergeDetailsRatings(entries) {
711
+ const ratings = new Map();
712
+ for (const entry of entries) {
713
+ for (const rating of entry.result.details.ratings ?? []) {
714
+ if (!ratings.has(rating.source)) {
715
+ ratings.set(rating.source, { ...rating });
716
+ }
717
+ }
718
+ }
719
+ return ratings.size > 0 ? [...ratings.values()] : undefined;
720
+ }
721
+ // Merges alternative titles excluding the selected display title.
722
+ // Объединяет альтернативные названия, исключая выбранный отображаемый title.
723
+ function mergeAlternativeTitles(entries, selectedTitle) {
724
+ const selectedKey = normalizeTitle(selectedTitle);
725
+ const titles = new Map();
726
+ for (const entry of entries) {
727
+ addAlternativeTitle(titles, entry.result.item.title, selectedKey);
728
+ addAlternativeTitle(titles, entry.result.item.originalTitle, selectedKey);
729
+ for (const title of entry.result.item.alternativeTitles ?? []) {
730
+ addAlternativeTitle(titles, title, selectedKey);
731
+ }
732
+ }
733
+ return titles.size > 0 ? [...titles.values()] : undefined;
734
+ }
735
+ // Merges details alternative titles excluding the selected display title.
736
+ // Объединяет альтернативные названия деталей, исключая выбранный отображаемый title.
737
+ function mergeDetailsAlternativeTitles(entries, selectedTitle) {
738
+ const selectedKey = normalizeTitle(selectedTitle);
739
+ const titles = new Map();
740
+ for (const entry of entries) {
741
+ addAlternativeTitle(titles, entry.result.details.title, selectedKey);
742
+ addAlternativeTitle(titles, entry.result.details.originalTitle, selectedKey);
743
+ for (const title of entry.result.details.alternativeTitles ?? []) {
744
+ addAlternativeTitle(titles, title, selectedKey);
745
+ }
746
+ }
747
+ return titles.size > 0 ? [...titles.values()] : undefined;
748
+ }
749
+ // Adds one normalized alternative title if it is useful and unique.
750
+ // Добавляет одно нормализованное альтернативное название, если оно полезно и уникально.
751
+ function addAlternativeTitle(titles, title, selectedKey) {
752
+ if (!title?.trim()) {
753
+ return;
754
+ }
755
+ const key = normalizeTitle(title);
756
+ if (key && key !== selectedKey && !titles.has(key)) {
757
+ titles.set(key, title);
758
+ }
759
+ }
760
+ // Builds public source attribution for a merged search result.
761
+ // Создает публичную атрибуцию источников для объединенного результата поиска.
762
+ function mergeSources(entries) {
763
+ return entries.map((entry) => ({
764
+ provider: entry.result.source?.provider ?? entry.result.provider,
765
+ ids: entry.result.source?.ids ?? entry.result.item.ids,
766
+ url: entry.result.source?.url,
767
+ }));
768
+ }
769
+ // Builds public source attribution for merged details.
770
+ // Создает публичную атрибуцию источников для объединенных деталей.
771
+ function mergeDetailsSources(entries) {
772
+ return entries.map((entry) => ({
773
+ provider: entry.result.source?.provider ?? entry.result.provider,
774
+ ids: entry.result.source?.ids ?? entry.result.details.ids,
775
+ url: entry.result.source?.url,
776
+ }));
777
+ }
778
+ // Merges unique details images by URL.
779
+ // Объединяет уникальные изображения деталей по URL.
780
+ function mergeDetailsImages(entries) {
781
+ const images = new Map();
782
+ for (const entry of entries) {
783
+ const candidates = [
784
+ entry.result.details.poster,
785
+ entry.result.details.backdrop,
786
+ ...(entry.result.details.images ?? []),
787
+ ];
788
+ for (const image of candidates) {
789
+ if (isValidImageUrl(image) && !images.has(image.url)) {
790
+ images.set(image.url, { ...image });
791
+ }
792
+ }
793
+ }
794
+ return images.size > 0 ? [...images.values()] : undefined;
795
+ }
796
+ // Emits warnings when details results contain conflicting media types.
797
+ // Добавляет warnings, когда details-результаты содержат конфликтующие типы медиа.
798
+ function warnDetailsTypeConflicts(entries, context, selectedType) {
799
+ const primaryType = selectedType ?? entries[0]?.result.details.type;
800
+ if (!primaryType) {
801
+ return;
802
+ }
803
+ for (const entry of entries) {
804
+ const entryType = entry.result.details.type;
805
+ const isCompatibleAnimeCatalogType = primaryType === "anime" && entryType === "series";
806
+ if (entryType !== primaryType && !isCompatibleAnimeCatalogType) {
807
+ context.warnings?.push({
808
+ code: "MEDIA_TYPE_CONFLICT",
809
+ message: `Conflicting media types while merging details; kept ${primaryType}.`,
810
+ provider: entry.result.provider,
811
+ });
812
+ }
813
+ }
814
+ }
815
+ // Calculates a public score from match strength, query relevance, and popularity signals.
816
+ // Вычисляет публичную оценку по силе совпадения, релевантности запросу и популярности.
817
+ function scoreGroup(group, entries, context) {
818
+ const query = context.query;
819
+ const queryIds = "ids" in (query ?? {}) ? query?.ids : undefined;
820
+ const queryTitle = "title" in (query ?? {}) ? query?.title : undefined;
821
+ if (queryIds && entries.some((entry) => hasSharedStrongId(queryIds, entry.result.item.ids))) {
822
+ return 1;
823
+ }
824
+ if (!queryTitle?.trim()) {
825
+ return baseGroupScore(group, entries);
826
+ }
827
+ const baseScore = baseTextSearchScore(group, entries);
828
+ const titleScore = titleRelevanceScore(entries, queryTitle);
829
+ const exactPrimaryTitleScore = hasExactPrimaryTitle(entries, queryTitle) ? 1 : 0;
830
+ const popularityScore = ratingVotesScore(entries);
831
+ const ratingScore = normalizedRatingScore(entries);
832
+ const idScore = externalIdCompletenessScore(entries);
833
+ const sourceScore = sourceCoverageScore(entries);
834
+ const authorityScore = sourceAuthorityScore(entries);
835
+ return boundedTextScore(baseScore +
836
+ titleScore * 0.2 +
837
+ // Prefer exact primary/original intent over prefix and incidental-alias noise while
838
+ // still allowing overwhelmingly corroborated canonical results for broad short queries.
839
+ exactPrimaryTitleScore * 0.3 +
840
+ popularityScore * 0.15 +
841
+ ratingScore * 0.05 +
842
+ idScore * 0.01 +
843
+ sourceScore * 0.02 +
844
+ authorityScore * 0.15);
845
+ }
846
+ // Distinguishes exact primary/original titles from incidental alternative aliases.
847
+ // Отличает точные основные/оригинальные названия от случайных alternative aliases.
848
+ function hasExactPrimaryTitle(entries, queryTitle) {
849
+ const normalizedQuery = normalizeTitle(queryTitle);
850
+ return entries.some((entry) => [entry.result.item.title, entry.result.item.originalTitle]
851
+ .filter((title) => Boolean(title))
852
+ .some((title) => normalizeTitle(title) === normalizedQuery));
853
+ }
854
+ // Keeps legacy scores for non-title searches where no relevance ranking is possible.
855
+ // Сохраняет прежние оценки для поиска без title, где нельзя посчитать релевантность.
856
+ function baseGroupScore(group, entries) {
857
+ switch (group.matchStrength) {
858
+ case "exact_id":
859
+ return 1;
860
+ case "exact_title_year_type":
861
+ return 0.9;
862
+ case "normalized_title_year_type":
863
+ return 0.8;
864
+ case "weak":
865
+ return 0.4;
866
+ case "none":
867
+ return clampScore(entries[0]?.result.confidence ?? 0.5);
868
+ }
869
+ }
870
+ // Starts text-search scoring below 1 so popularity and relevance can break exact-ID ties.
871
+ // Начинает оценку текстового поиска ниже 1, чтобы популярность и релевантность разбивали tie по ID.
872
+ function baseTextSearchScore(group, entries) {
873
+ switch (group.matchStrength) {
874
+ case "exact_id":
875
+ return 0.44 + bestProviderConfidence(entries) * 0.18;
876
+ case "exact_title_year_type":
877
+ return 0.62;
878
+ case "normalized_title_year_type":
879
+ return 0.52;
880
+ case "weak":
881
+ return 0.25;
882
+ case "none":
883
+ return bestProviderConfidence(entries) * 0.45;
884
+ }
885
+ }
886
+ // Uses the strongest provider confidence inside a merged group.
887
+ // Использует самый сильный confidence провайдера внутри объединенной группы.
888
+ function bestProviderConfidence(entries) {
889
+ return Math.max(...entries.map((entry) => clampScore(entry.result.confidence ?? 0.5)));
890
+ }
891
+ // Scores how well result titles match the user's text query.
892
+ // Оценивает, насколько названия результатов совпадают с текстовым запросом пользователя.
893
+ function titleRelevanceScore(entries, queryTitle) {
894
+ const normalizedQuery = normalizeTitle(queryTitle);
895
+ if (!normalizedQuery) {
896
+ return 0;
897
+ }
898
+ return Math.max(...entries.flatMap((entry) => titleCandidates(entry.result.item).map((title) => scoreNormalizedTitle(normalizeTitle(title), normalizedQuery))));
899
+ }
900
+ // Returns all useful titles that may match the query.
901
+ // Возвращает все полезные названия, которые могут совпасть с запросом.
902
+ function titleCandidates(item) {
903
+ return [item.title, item.originalTitle, ...(item.alternativeTitles ?? [])].filter((title) => Boolean(title?.trim()));
904
+ }
905
+ // Scores one normalized title against one normalized query.
906
+ // Оценивает одно нормализованное название против одного нормализованного запроса.
907
+ function scoreNormalizedTitle(title, query) {
908
+ if (!title) {
909
+ return 0;
910
+ }
911
+ if (title === query) {
912
+ return 1;
913
+ }
914
+ if (title.replace(/\s+/g, "") === query.replace(/\s+/g, "")) {
915
+ return 0.98;
916
+ }
917
+ if (title.startsWith(`${query} `)) {
918
+ return 0.92;
919
+ }
920
+ if (title.includes(` ${query} `) || title.endsWith(` ${query}`)) {
921
+ return 0.75;
922
+ }
923
+ const queryTokens = query.split(" ").filter(Boolean);
924
+ if (queryTokens.length > 0 && queryTokens.every((token) => title.includes(token))) {
925
+ return 0.55;
926
+ }
927
+ const titleTokens = title.split(" ").filter(Boolean);
928
+ const fuzzyTokenScores = queryTokens.map((queryToken) => Math.max(...titleTokens.map((titleToken) => fuzzyTokenSimilarity(queryToken, titleToken)), 0));
929
+ const minimumFuzzyScore = queryTokens.length >= 3 ? 0.7 : 0.75;
930
+ if (fuzzyTokenScores.length > 0 &&
931
+ fuzzyTokenScores.every((score) => score >= minimumFuzzyScore)) {
932
+ return ((fuzzyTokenScores.reduce((sum, score) => sum + score, 0) / fuzzyTokenScores.length) * 0.7);
933
+ }
934
+ return 0;
935
+ }
936
+ // Allows one small typo in meaningful words while keeping short tokens exact.
937
+ // Допускает одну небольшую опечатку в значимых словах, сохраняя короткие токены точными.
938
+ function fuzzyTokenSimilarity(left, right) {
939
+ if (left === right) {
940
+ return 1;
941
+ }
942
+ if (Math.min(left.length, right.length) < 4) {
943
+ return 0;
944
+ }
945
+ if (isSingleAdjacentTransposition(left, right)) {
946
+ return 1 - 1 / Math.max(left.length, right.length);
947
+ }
948
+ const variants = [right, ...(right.endsWith("s") ? [right.slice(0, -1)] : [])];
949
+ const distance = Math.min(...variants
950
+ .filter((variant) => Math.abs(left.length - variant.length) <= 1)
951
+ .map((variant) => levenshteinDistance(left, variant, 1)));
952
+ if (distance <= 1) {
953
+ return 1 - distance / Math.max(left.length, right.length);
954
+ }
955
+ if (Math.min(left.length, right.length) >= 6 && isTranspositionPlusOneEdit(left, right)) {
956
+ return 1 - 2 / Math.max(left.length, right.length);
957
+ }
958
+ return 0;
959
+ }
960
+ // Allows one adjacent swap plus one insertion, deletion, or substitution in long words.
961
+ // Допускает перестановку соседних символов и еще одну правку в длинных словах.
962
+ function isTranspositionPlusOneEdit(left, right) {
963
+ for (let index = 0; index < right.length - 1; index += 1) {
964
+ const swapped = right.slice(0, index) + right[index + 1] + right[index] + right.slice(index + 2);
965
+ if (Math.abs(left.length - swapped.length) <= 1 && levenshteinDistance(left, swapped, 1) <= 1) {
966
+ return true;
967
+ }
968
+ }
969
+ return false;
970
+ }
971
+ // Recognizes a single swapped adjacent character, a common typing error Levenshtein counts as two.
972
+ // Распознает перестановку соседних символов, которую Levenshtein считает двумя ошибками.
973
+ function isSingleAdjacentTransposition(left, right) {
974
+ if (left.length !== right.length) {
975
+ return false;
976
+ }
977
+ let firstDifference = -1;
978
+ for (let index = 0; index < left.length; index += 1) {
979
+ if (left[index] === right[index]) {
980
+ continue;
981
+ }
982
+ if (firstDifference !== -1) {
983
+ return (index === firstDifference + 1 &&
984
+ left[firstDifference] === right[index] &&
985
+ left[index] === right[firstDifference] &&
986
+ left.slice(index + 1) === right.slice(index + 1));
987
+ }
988
+ firstDifference = index;
989
+ }
990
+ return false;
991
+ }
992
+ function levenshteinDistance(left, right, maxDistance) {
993
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
994
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
995
+ const current = [leftIndex];
996
+ let rowMinimum = leftIndex;
997
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
998
+ const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
999
+ const value = Math.min(current[rightIndex - 1] + 1, previous[rightIndex] + 1, previous[rightIndex - 1] + substitutionCost);
1000
+ current.push(value);
1001
+ rowMinimum = Math.min(rowMinimum, value);
1002
+ }
1003
+ if (rowMinimum > maxDistance) {
1004
+ return maxDistance + 1;
1005
+ }
1006
+ previous = current;
1007
+ }
1008
+ return previous[right.length] ?? maxDistance + 1;
1009
+ }
1010
+ // Scores popularity from the largest available vote count.
1011
+ // Оценивает популярность по самому большому доступному числу голосов.
1012
+ function ratingVotesScore(entries) {
1013
+ const maxVotes = Math.max(0, ...entries.flatMap((entry) => entry.result.item.ratings?.map((rating) => rating.votes ?? 0) ?? []));
1014
+ return Math.min(1, Math.log10(maxVotes + 1) / 7);
1015
+ }
1016
+ // Scores normalized rating values across providers.
1017
+ // Оценивает нормализованные значения рейтингов от провайдеров.
1018
+ function normalizedRatingScore(entries) {
1019
+ const values = entries.flatMap((entry) => entry.result.item.ratings
1020
+ ?.map((rating) => rating.value / rating.max)
1021
+ .filter((value) => Number.isFinite(value)) ?? []);
1022
+ return values.length === 0 ? 0 : Math.max(...values.map((value) => clampScore(value)));
1023
+ }
1024
+ // Rewards results that carry strong external IDs for better follow-up details lookup.
1025
+ // Поощряет результаты с сильными внешними ID для более надежной загрузки деталей.
1026
+ function externalIdCompletenessScore(entries) {
1027
+ const idCount = Math.max(0, ...entries.map((entry) => STRONG_ID_KEYS.filter((key) => Boolean(entry.result.item.ids?.[key])).length));
1028
+ return Math.min(1, idCount / 3);
1029
+ }
1030
+ // Rewards results confirmed by multiple providers.
1031
+ // Поощряет результаты, подтвержденные несколькими провайдерами.
1032
+ function sourceCoverageScore(entries) {
1033
+ return Math.min(1, new Set(entries.map((entry) => entry.result.provider)).size / 3);
1034
+ }
1035
+ // Adds a small authority signal for sources that usually imply broader popularity.
1036
+ // Добавляет небольшой сигнал авторитетности для источников, которые обычно отражают популярность.
1037
+ function sourceAuthorityScore(entries) {
1038
+ const providers = [...new Set(entries.map((entry) => entry.result.provider))];
1039
+ return (providers.reduce((total, provider) => total + providerAuthority(provider), 0) /
1040
+ Math.max(1, providers.length));
1041
+ }
1042
+ // Scores provider authority for broad text search ranking.
1043
+ // Оценивает авторитетность провайдера для ранжирования широкого текстового поиска.
1044
+ function providerAuthority(provider) {
1045
+ switch (provider) {
1046
+ case "wikidata":
1047
+ return 0.7;
1048
+ case "tmdb":
1049
+ return 0.95;
1050
+ case "cinemeta":
1051
+ return 0.75;
1052
+ case "kinobd":
1053
+ return 0.75;
1054
+ case "imdb":
1055
+ return 0.65;
1056
+ case "kinopoisk":
1057
+ return 0.6;
1058
+ case "shikimori":
1059
+ return 0.9;
1060
+ case "anilist":
1061
+ return 0.9;
1062
+ default:
1063
+ return 0.3;
1064
+ }
1065
+ }
1066
+ // Keeps text-search scores comparable without flattening many strong matches to exactly 1.
1067
+ // Сохраняет сравнимость text-search score без схлопывания сильных совпадений ровно в 1.
1068
+ function boundedTextScore(score) {
1069
+ return 0.5 + clampScore(score / (score + 1)) * 0.5;
1070
+ }
1071
+ // Restricts a score value to the public 0..1 range.
1072
+ // Ограничивает значение score публичным диапазоном 0..1.
1073
+ function clampScore(score) {
1074
+ return Math.max(0, Math.min(1, score));
1075
+ }
1076
+ // Returns the first non-empty selected value from sorted entries.
1077
+ // Возвращает первое непустое выбранное значение из отсортированных entries.
1078
+ function firstDefined(entries, pick) {
1079
+ for (const entry of entries) {
1080
+ const value = pick(entry);
1081
+ if (value !== undefined && value !== "") {
1082
+ return value;
1083
+ }
1084
+ }
1085
+ return undefined;
1086
+ }
1087
+ // Returns the first non-empty selected value from sorted details entries.
1088
+ // Возвращает первое непустое выбранное значение из отсортированных details entries.
1089
+ function firstDefinedDetails(entries, pick) {
1090
+ for (const entry of entries) {
1091
+ const value = pick(entry);
1092
+ if (value !== undefined && value !== "") {
1093
+ return value;
1094
+ }
1095
+ }
1096
+ return undefined;
1097
+ }
1098
+ // Returns the first lifecycle status that carries useful provider information.
1099
+ // Возвращает первый lifecycle status, который несет полезную информацию от provider.
1100
+ function firstMeaningfulDetailsStatus(entries) {
1101
+ return firstDefinedDetails(entries, (entry) => {
1102
+ const status = entry.result.details.status;
1103
+ return status === "unknown" ? undefined : status;
1104
+ });
1105
+ }
1106
+ // Returns the item with the highest computed score.
1107
+ // Возвращает элемент с максимальной вычисленной оценкой.
1108
+ function maxBy(items, score) {
1109
+ return [...items].sort((left, right) => score(right) - score(left))[0];
1110
+ }
1111
+ // Calculates comparable image size from optional width and height.
1112
+ // Вычисляет сравнимый размер изображения по опциональным width и height.
1113
+ function imageArea(image) {
1114
+ if (image.width && image.height) {
1115
+ return image.width * image.height;
1116
+ }
1117
+ return image.width ?? image.height ?? 0;
1118
+ }
1119
+ // Checks whether an image has a valid HTTP(S) URL.
1120
+ // Проверяет, есть ли у изображения валидный HTTP(S) URL.
1121
+ function isValidImageUrl(image) {
1122
+ if (!image?.url) {
1123
+ return false;
1124
+ }
1125
+ try {
1126
+ const url = new URL(image.url);
1127
+ return url.protocol === "http:" || url.protocol === "https:";
1128
+ }
1129
+ catch {
1130
+ return false;
1131
+ }
1132
+ }
1133
+ // Checks whether two ID maps share at least one strong external ID.
1134
+ // Проверяет, есть ли у двух карт ID хотя бы один общий сильный внешний ID.
1135
+ function hasSharedStrongId(left, right) {
1136
+ if (!left || !right) {
1137
+ return false;
1138
+ }
1139
+ return STRONG_ID_KEYS.some((key) => Boolean(left[key] && right[key] && left[key] === right[key]));
1140
+ }
1141
+ // Checks whether two ID maps contain conflicting strong external IDs.
1142
+ // Проверяет, содержат ли две карты ID конфликтующие сильные внешние ID.
1143
+ function hasStrongIdConflict(left, right) {
1144
+ return strongIdConflicts(left, right).length > 0;
1145
+ }
1146
+ function strongIdConflicts(left, right) {
1147
+ if (!left || !right) {
1148
+ return [];
1149
+ }
1150
+ return STRONG_ID_KEYS.filter((key) => Boolean(left[key] && right[key] && left[key] !== right[key]));
1151
+ }
1152
+ // Normalizes titles for safe exact automatic grouping.
1153
+ // Нормализует названия для безопасной автоматической группировки.
1154
+ function normalizeTitle(title) {
1155
+ return title
1156
+ .normalize("NFKD")
1157
+ .replace(/[\u0300-\u036f]/g, "")
1158
+ .toLowerCase()
1159
+ .replace(/[^a-z0-9а-яё]+/gi, " ")
1160
+ .trim()
1161
+ .replace(/\s+/g, " ");
1162
+ }
1163
+ // Builds a less aggressive key for exact title comparison.
1164
+ // Создает менее агрессивный ключ для точного сравнения title.
1165
+ function exactTitleKey(title) {
1166
+ return title.toLowerCase().trim().replace(/\s+/g, " ");
1167
+ }
1168
+ //# sourceMappingURL=strategy.js.map