@media-engine/providers 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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/dist/anilist/index.d.ts +9 -0
  4. package/dist/anilist/index.js +279 -0
  5. package/dist/anilist/index.js.map +1 -0
  6. package/dist/cinemeta/index.d.ts +12 -0
  7. package/dist/cinemeta/index.js +379 -0
  8. package/dist/cinemeta/index.js.map +1 -0
  9. package/dist/experimental-streaming/index.d.ts +24 -0
  10. package/dist/experimental-streaming/index.js +199 -0
  11. package/dist/experimental-streaming/index.js.map +1 -0
  12. package/dist/flixhq-streaming/index.d.ts +30 -0
  13. package/dist/flixhq-streaming/index.js +625 -0
  14. package/dist/flixhq-streaming/index.js.map +1 -0
  15. package/dist/imdb-dataset/index.d.ts +9 -0
  16. package/dist/imdb-dataset/index.js +307 -0
  17. package/dist/imdb-dataset/index.js.map +1 -0
  18. package/dist/index.d.ts +10 -0
  19. package/dist/index.js +11 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/kinobd/index.d.ts +11 -0
  22. package/dist/kinobd/index.js +405 -0
  23. package/dist/kinobd/index.js.map +1 -0
  24. package/dist/kinobd-streaming/index.d.ts +31 -0
  25. package/dist/kinobd-streaming/index.js +919 -0
  26. package/dist/kinobd-streaming/index.js.map +1 -0
  27. package/dist/shared/http.d.ts +17 -0
  28. package/dist/shared/http.js +264 -0
  29. package/dist/shared/http.js.map +1 -0
  30. package/dist/shared/index.d.ts +1 -0
  31. package/dist/shared/index.js +2 -0
  32. package/dist/shared/index.js.map +1 -0
  33. package/dist/shikimori/index.d.ts +12 -0
  34. package/dist/shikimori/index.js +457 -0
  35. package/dist/shikimori/index.js.map +1 -0
  36. package/dist/wikidata/index.d.ts +12 -0
  37. package/dist/wikidata/index.js +356 -0
  38. package/dist/wikidata/index.js.map +1 -0
  39. package/package.json +62 -0
@@ -0,0 +1,919 @@
1
+ import { fetchJson, normalizePublicHttpUrl } from "../shared/index.js";
2
+ const PROVIDER_NAME = "kinobd-streaming";
3
+ const DEFAULT_BASE_URL = "https://kinobd.net";
4
+ const DEFAULT_SHIKIMORI_BASE_URL = "https://shikimori.io";
5
+ const DEFAULT_SEARCH_LIMIT = 10;
6
+ const DEFAULT_PLAYER_VALIDATION_LIMIT = 8;
7
+ const DEFAULT_SHIKIMORI_LOOKUP_TIMEOUT_MS = 2_500;
8
+ const PLAYER_VALIDATION_TIMEOUT_MS = 2_500;
9
+ const PLAYER_VALIDATION_MAX_DEPTH = 1;
10
+ const PLAYER_VALIDATION_MAX_BODY_BYTES = 256 * 1024;
11
+ const DEFAULT_PLAYER_PROVIDERS = [
12
+ "collaps",
13
+ "vibix",
14
+ "alloha",
15
+ "kodik",
16
+ "kinotochka",
17
+ "flixcdn",
18
+ "ashdi",
19
+ "turbo",
20
+ "videocdn",
21
+ "bazon",
22
+ "ustore",
23
+ "pleer",
24
+ "videospider",
25
+ "iframe",
26
+ "moonwalk",
27
+ "hdvb",
28
+ "cdnmovies",
29
+ "lookbase",
30
+ "kholobok",
31
+ "videoapi",
32
+ "voidboost",
33
+ "videoseed",
34
+ "vk",
35
+ ].join(",");
36
+ const BLOCKED_PLAYER_PROVIDERS = new Set([
37
+ "ext",
38
+ "ia",
39
+ "netflix",
40
+ "nf",
41
+ "torrent",
42
+ "trailer",
43
+ "trailer_local",
44
+ "youtube",
45
+ ]);
46
+ const KNOWN_RUSSIAN_VOICEOVER_TEAMS = [
47
+ "2x2",
48
+ "alexfilm",
49
+ "anidub",
50
+ "anilibria",
51
+ "coldfilm",
52
+ "cube",
53
+ "hdrezka studio",
54
+ "jaskier",
55
+ "kubik",
56
+ "le-production",
57
+ "lostfilm",
58
+ "newstudio",
59
+ "shachiburi",
60
+ ];
61
+ // Creates a no-token streaming provider that asks KinoBD-style endpoints for iframe players.
62
+ // Создает no-token streaming-провайдер, который запрашивает iframe-плееры через KinoBD-style endpoints.
63
+ export function kinobdStreamingProvider(options = {}) {
64
+ const config = createConfig(options);
65
+ return {
66
+ name: config.name,
67
+ version: options.version,
68
+ kind: "streaming",
69
+ capabilities: createCapabilities(),
70
+ async getAvailability(query, context) {
71
+ return getKinoBdAvailability(config, query, context);
72
+ },
73
+ };
74
+ }
75
+ // Builds provider config with ReYohoho-compatible defaults.
76
+ // Собирает provider config с ReYohoho-compatible defaults.
77
+ function createConfig(options) {
78
+ const searchLimit = options.searchLimit ?? DEFAULT_SEARCH_LIMIT;
79
+ const shikimoriLookupTimeoutMs = options.shikimoriLookupTimeoutMs ?? DEFAULT_SHIKIMORI_LOOKUP_TIMEOUT_MS;
80
+ const playerValidationLimit = options.playerValidationLimit ?? DEFAULT_PLAYER_VALIDATION_LIMIT;
81
+ const playerValidationTimeoutMs = options.playerValidationTimeoutMs ?? PLAYER_VALIDATION_TIMEOUT_MS;
82
+ const fast = options.fast ?? 1;
83
+ const allowedPlayerProviders = parsePlayerProviderKeys(options.playerProviders ?? DEFAULT_PLAYER_PROVIDERS);
84
+ if (!Number.isInteger(searchLimit) || searchLimit <= 0) {
85
+ throw new TypeError("KinoBD streaming searchLimit must be a positive integer.");
86
+ }
87
+ if (!Number.isInteger(shikimoriLookupTimeoutMs) || shikimoriLookupTimeoutMs <= 0) {
88
+ throw new TypeError("KinoBD streaming shikimoriLookupTimeoutMs must be a positive integer.");
89
+ }
90
+ if (!Number.isInteger(playerValidationLimit) || playerValidationLimit < 0) {
91
+ throw new TypeError("KinoBD streaming playerValidationLimit must be a non-negative integer.");
92
+ }
93
+ if (!Number.isInteger(playerValidationTimeoutMs) || playerValidationTimeoutMs <= 0) {
94
+ throw new TypeError("KinoBD streaming playerValidationTimeoutMs must be a positive integer.");
95
+ }
96
+ if (!Number.isInteger(fast) || fast < 0) {
97
+ throw new TypeError("KinoBD streaming fast must be a non-negative integer.");
98
+ }
99
+ return {
100
+ name: normalizeProviderName(options.name ?? PROVIDER_NAME),
101
+ baseUrl: trimTrailingSlash(options.baseUrl ?? DEFAULT_BASE_URL),
102
+ animeCacheBaseUrl: options.animeCacheBaseUrl === undefined
103
+ ? undefined
104
+ : trimTrailingSlash(options.animeCacheBaseUrl),
105
+ shikimoriBaseUrl: trimTrailingSlash(options.shikimoriBaseUrl ?? DEFAULT_SHIKIMORI_BASE_URL),
106
+ fetch: options.fetch,
107
+ searchLimit,
108
+ shikimoriLookupTimeoutMs,
109
+ playerValidationLimit,
110
+ playerValidationTimeoutMs,
111
+ playerProviders: [...allowedPlayerProviders].join(","),
112
+ allowedPlayerProviders,
113
+ fast,
114
+ userAgent: options.userAgent,
115
+ onPlayerAudit: options.onPlayerAudit,
116
+ };
117
+ }
118
+ // Builds safe capabilities for the public engine/API.
119
+ // Собирает безопасные capabilities для публичного engine/API.
120
+ function createCapabilities() {
121
+ return {
122
+ mediaTypes: ["movie", "series", "anime"],
123
+ lookup: {
124
+ byTitle: true,
125
+ byExternalIds: ["kinopoisk", "shikimori"],
126
+ byEpisode: true,
127
+ },
128
+ features: ["embed", "translations", "qualities", "episode_mapping"],
129
+ };
130
+ }
131
+ // Resolves availability through movie/series or anime player endpoints.
132
+ // Получает availability через movie/series или anime player endpoints.
133
+ async function getKinoBdAvailability(config, query, context) {
134
+ if (query.providers && !query.providers.includes(config.name)) {
135
+ return null;
136
+ }
137
+ if (query.type === "anime") {
138
+ return getAnimeAvailability(config, query, context);
139
+ }
140
+ return getMovieOrSeriesAvailability(config, query, context);
141
+ }
142
+ // Resolves anime availability through optional cache_shiki first, then KinoBD title fallback.
143
+ // Получает anime availability через опциональный cache_shiki, затем через KinoBD title fallback.
144
+ async function getAnimeAvailability(config, query, context) {
145
+ if (config.animeCacheBaseUrl && query.ids?.shikimori) {
146
+ const cached = await tryGetShikimoriCacheAvailability(config, query, context);
147
+ if (cached && cached.options.length > 0) {
148
+ return cached;
149
+ }
150
+ }
151
+ const fallbackQuery = await createAnimeTitleFallbackQuery(config, query, context);
152
+ if (!fallbackQuery.title && !fallbackQuery.ids?.kinopoisk) {
153
+ return createEmptyAvailability(query);
154
+ }
155
+ return getMovieOrSeriesAvailability(config, fallbackQuery, context);
156
+ }
157
+ // Resolves movie or series players through /api/player/search and /playerdata.
158
+ // Получает movie или series players через /api/player/search и /playerdata.
159
+ async function getMovieOrSeriesAvailability(config, query, context) {
160
+ const candidates = await searchPlayerCandidates(config, query, context);
161
+ const selected = selectBestPlayerCandidate(candidates, query);
162
+ if (!selected) {
163
+ return createEmptyAvailability(query);
164
+ }
165
+ const options = await loadPlayerOptions(config, selected, query, context);
166
+ return {
167
+ query,
168
+ item: {
169
+ type: query.type,
170
+ title: selected.title ?? selected.name_russian ?? query.title,
171
+ originalTitle: selected.name_original ?? undefined,
172
+ year: getCandidateStartYear(selected) ?? query.year,
173
+ ids: collectCandidateIds(selected) ?? query.ids,
174
+ },
175
+ options,
176
+ sourceProviders: [
177
+ {
178
+ provider: config.name,
179
+ ids: collectCandidateIds(selected) ?? query.ids,
180
+ },
181
+ ],
182
+ checkedAt: new Date().toISOString(),
183
+ };
184
+ }
185
+ // Loads playerdata first and falls back to candidate iframes when the player endpoint is unavailable.
186
+ // Сначала грузит playerdata и откатывается к iframe-кандидатам, если player endpoint недоступен.
187
+ async function loadPlayerOptions(config, selected, query, context) {
188
+ try {
189
+ const playerData = await loadPlayerData(config, selected, context);
190
+ const mapping = mapPlayerMapToOptions(config.name, playerData, selected, query, config.allowedPlayerProviders);
191
+ if (mapping.options.length > 0) {
192
+ const filtered = await filterBrokenPlayerOptions(config, mapping.options, context);
193
+ emitPlayerAudit(config, query, mapping.discovered, filtered.options, [
194
+ ...mapping.filtered,
195
+ ...filtered.filtered,
196
+ ]);
197
+ return filtered.options;
198
+ }
199
+ const fallbackOptions = mapCandidatesToFallbackOptions(config.name, [selected], query);
200
+ emitPlayerAudit(config, query, mapping.discovered, fallbackOptions, mapping.filtered);
201
+ return fallbackOptions;
202
+ }
203
+ catch {
204
+ // KinoBD/ReYohoho-style /playerdata can be rate-limited or temporarily unavailable.
205
+ // KinoBD/ReYohoho-style /playerdata может быть rate-limited или временно недоступен.
206
+ }
207
+ const fallbackOptions = mapCandidatesToFallbackOptions(config.name, [selected], query);
208
+ emitPlayerAudit(config, query, fallbackOptions.map((option) => option.player.label), fallbackOptions, []);
209
+ return fallbackOptions;
210
+ }
211
+ // Resolves anime players through /cache_shiki-compatible backend endpoint.
212
+ // Получает anime players через /cache_shiki-compatible backend endpoint.
213
+ async function tryGetShikimoriCacheAvailability(config, query, context) {
214
+ const url = new URL("/cache_shiki", `${config.animeCacheBaseUrl}/`);
215
+ const body = new URLSearchParams({
216
+ shikimori: query.ids.shikimori,
217
+ type: "anime",
218
+ });
219
+ try {
220
+ const playerData = await fetchJson({
221
+ provider: config.name,
222
+ url,
223
+ context,
224
+ fetch: config.fetch,
225
+ init: {
226
+ method: "POST",
227
+ headers: {
228
+ "content-type": "application/x-www-form-urlencoded",
229
+ },
230
+ body,
231
+ },
232
+ });
233
+ const mapping = mapPlayerMapToOptions(config.name, playerData, undefined, query, config.allowedPlayerProviders);
234
+ const options = mapping.options;
235
+ emitPlayerAudit(config, query, mapping.discovered, options, mapping.filtered);
236
+ return {
237
+ query,
238
+ item: {
239
+ type: "anime",
240
+ title: query.title,
241
+ year: query.year,
242
+ ids: query.ids,
243
+ },
244
+ episodes: hasEpisodeQuery(query)
245
+ ? [
246
+ {
247
+ seasonNumber: query.seasonNumber,
248
+ episodeNumber: query.episodeNumber,
249
+ absoluteEpisodeNumber: query.absoluteEpisodeNumber,
250
+ options,
251
+ },
252
+ ]
253
+ : undefined,
254
+ options,
255
+ sourceProviders: [
256
+ {
257
+ provider: config.name,
258
+ ids: query.ids,
259
+ },
260
+ ],
261
+ checkedAt: new Date().toISOString(),
262
+ };
263
+ }
264
+ catch {
265
+ return undefined;
266
+ }
267
+ }
268
+ // Builds an anime fallback query that KinoBD player search can understand.
269
+ // Собирает anime fallback query, который понимает KinoBD player search.
270
+ async function createAnimeTitleFallbackQuery(config, query, context) {
271
+ if (query.title) {
272
+ return query;
273
+ }
274
+ if (!query.ids?.shikimori) {
275
+ return query;
276
+ }
277
+ const lookup = await tryLookupShikimoriAnime(config, query.ids.shikimori, context);
278
+ const title = lookup?.russian?.trim() ||
279
+ lookup?.name?.trim() ||
280
+ lookup?.english?.find((value) => value.trim())?.trim();
281
+ return {
282
+ ...query,
283
+ title,
284
+ year: query.year ?? parseYear(lookup?.aired_on),
285
+ };
286
+ }
287
+ // Resolves Shikimori ID into title metadata without requiring user secrets.
288
+ // Резолвит Shikimori ID в title metadata без пользовательских секретов.
289
+ async function tryLookupShikimoriAnime(config, shikimoriId, context) {
290
+ const url = new URL(`/api/animes/${encodeURIComponent(shikimoriId)}`, `${config.shikimoriBaseUrl}/`);
291
+ const headers = {
292
+ accept: "application/json",
293
+ };
294
+ if (config.userAgent) {
295
+ headers["user-agent"] = config.userAgent;
296
+ }
297
+ try {
298
+ return await fetchJson({
299
+ provider: config.name,
300
+ url,
301
+ context: {
302
+ ...context,
303
+ timeoutMs: getBoundedTimeoutMs(context.timeoutMs, config.shikimoriLookupTimeoutMs),
304
+ },
305
+ fetch: config.fetch,
306
+ maxRetries: 0,
307
+ init: {
308
+ headers,
309
+ },
310
+ });
311
+ }
312
+ catch {
313
+ return undefined;
314
+ }
315
+ }
316
+ // Keeps helper lookups inside the remaining provider budget when one exists.
317
+ // Удерживает вспомогательные lookup-запросы внутри общего бюджета провайдера, если он задан.
318
+ function getBoundedTimeoutMs(contextTimeoutMs, fallbackTimeoutMs) {
319
+ return contextTimeoutMs === undefined
320
+ ? fallbackTimeoutMs
321
+ : Math.min(contextTimeoutMs, fallbackTimeoutMs);
322
+ }
323
+ // Searches KinoBD player candidates by Kinopoisk ID or title.
324
+ // Ищет KinoBD player candidates по Kinopoisk ID или title.
325
+ async function searchPlayerCandidates(config, query, context) {
326
+ const search = createCandidateSearch(query);
327
+ if (!search) {
328
+ return [];
329
+ }
330
+ const url = new URL("/api/player/search", `${config.baseUrl}/`);
331
+ url.searchParams.set("q", search.value);
332
+ url.searchParams.set("type", search.type);
333
+ url.searchParams.set("page", "1");
334
+ const response = await fetchJson({
335
+ provider: config.name,
336
+ url,
337
+ context,
338
+ fetch: config.fetch,
339
+ init: {
340
+ headers: {
341
+ accept: "application/json",
342
+ },
343
+ },
344
+ });
345
+ return (response.data ?? []).slice(0, config.searchLimit);
346
+ }
347
+ // Chooses the most likely KinoBD record instead of trusting upstream result order.
348
+ // Выбирает наиболее вероятную KinoBD-запись вместо доверия порядку upstream results.
349
+ function selectBestPlayerCandidate(candidates, query) {
350
+ if (candidates.length === 0) {
351
+ return undefined;
352
+ }
353
+ if (query.ids?.kinopoisk || query.ids?.imdb) {
354
+ const exact = candidates.find((candidate) => hasExactCandidateId(candidate, query.ids));
355
+ if (exact) {
356
+ return exact;
357
+ }
358
+ }
359
+ const normalizedTitle = normalizeSearchText(query.title ?? "");
360
+ return candidates
361
+ .map((candidate, index) => ({
362
+ candidate,
363
+ index,
364
+ score: scorePlayerCandidate(candidate, query, normalizedTitle),
365
+ }))
366
+ .filter((entry) => entry.score > Number.NEGATIVE_INFINITY)
367
+ .sort((left, right) => right.score - left.score || left.index - right.index)[0]?.candidate;
368
+ }
369
+ function scorePlayerCandidate(candidate, query, normalizedTitle) {
370
+ const candidateType = mapCandidateMediaType(candidate.type);
371
+ const queryType = query.type === "movie" || query.type === "series" ? query.type : undefined;
372
+ if (queryType && candidateType && candidateType !== queryType) {
373
+ return Number.NEGATIVE_INFINITY;
374
+ }
375
+ const startYear = getCandidateStartYear(candidate);
376
+ const endYear = parseOptionalInteger(candidate.year_end);
377
+ if (query.year !== undefined) {
378
+ if (startYear === undefined) {
379
+ return Number.NEGATIVE_INFINITY;
380
+ }
381
+ if (queryType === "series") {
382
+ const lastYear = endYear ?? startYear;
383
+ if (query.year < startYear || query.year > lastYear) {
384
+ return Number.NEGATIVE_INFINITY;
385
+ }
386
+ }
387
+ else if (startYear !== query.year) {
388
+ return Number.NEGATIVE_INFINITY;
389
+ }
390
+ }
391
+ const original = normalizeSearchText(candidate.name_original ?? "");
392
+ const russian = normalizeSearchText(candidate.name_russian ?? candidate.title ?? "");
393
+ const popularity = parseOptionalInteger(candidate.popular_rate ?? candidate.popularity?.popular_rate) ?? 0;
394
+ const votes = parseOptionalInteger(candidate.rating_kp_count) ??
395
+ parseOptionalInteger(candidate.rating_imdb_count) ??
396
+ 0;
397
+ const rating = parseOptionalInteger(candidate.rating_kp) ?? parseOptionalInteger(candidate.rating_imdb) ?? 0;
398
+ let score = 10;
399
+ if (queryType && candidateType === queryType) {
400
+ score += 40;
401
+ }
402
+ if (normalizedTitle && (original === normalizedTitle || russian === normalizedTitle)) {
403
+ score += 80;
404
+ }
405
+ else if (normalizedTitle &&
406
+ (original.includes(normalizedTitle) || russian.includes(normalizedTitle))) {
407
+ score += 25;
408
+ }
409
+ if (query.year !== undefined && startYear !== undefined) {
410
+ score += startYear === query.year ? 35 : 15;
411
+ }
412
+ if (candidate.imdb_id) {
413
+ score += 5;
414
+ }
415
+ if (candidate.kinopoisk_id ?? candidate.kp_id) {
416
+ score += 5;
417
+ }
418
+ score += Math.min(8, rating);
419
+ score += Math.min(10, Math.log10(votes + 1) * 2);
420
+ score += Math.min(12, Math.log10(popularity + 1) * 2);
421
+ return score;
422
+ }
423
+ function hasExactCandidateId(candidate, ids) {
424
+ const candidateIds = collectCandidateIds(candidate);
425
+ return Boolean((ids?.kinopoisk && candidateIds?.kinopoisk === ids.kinopoisk) ||
426
+ (ids?.imdb && candidateIds?.imdb === ids.imdb));
427
+ }
428
+ function mapCandidateMediaType(type) {
429
+ if (type === "film") {
430
+ return "movie";
431
+ }
432
+ if (type === "serial" || type === "series") {
433
+ return "series";
434
+ }
435
+ return undefined;
436
+ }
437
+ function getCandidateStartYear(candidate) {
438
+ return parseOptionalInteger(candidate.year ?? candidate.year_start);
439
+ }
440
+ // Creates the best supported player search input from a stream query.
441
+ // Создает лучший поддерживаемый input поиска player из stream query.
442
+ function createCandidateSearch(query) {
443
+ if (query.ids?.kinopoisk) {
444
+ return {
445
+ type: "kp_id",
446
+ value: query.ids.kinopoisk,
447
+ };
448
+ }
449
+ if (query.title) {
450
+ return {
451
+ type: "title",
452
+ value: query.title,
453
+ };
454
+ }
455
+ return undefined;
456
+ }
457
+ // Loads provider iframe data for a selected candidate.
458
+ // Загружает provider iframe data для выбранного candidate.
459
+ async function loadPlayerData(config, candidate, context) {
460
+ const inid = candidate.inid ?? candidate.id;
461
+ if (inid === undefined || inid === null) {
462
+ return {};
463
+ }
464
+ const url = new URL("/playerdata", `${config.baseUrl}/`);
465
+ url.search = `cache${String(inid)}`;
466
+ const body = new URLSearchParams({
467
+ fast: String(config.fast),
468
+ inid: String(inid),
469
+ player: config.playerProviders,
470
+ });
471
+ const headers = {
472
+ "content-type": "application/x-www-form-urlencoded",
473
+ };
474
+ const iframe = extractIframeUrl(candidate.iframe, config.baseUrl);
475
+ if (iframe) {
476
+ headers["x-re"] = iframe;
477
+ }
478
+ return fetchJson({
479
+ provider: config.name,
480
+ url,
481
+ context,
482
+ fetch: config.fetch,
483
+ init: {
484
+ method: "POST",
485
+ headers,
486
+ body,
487
+ },
488
+ });
489
+ }
490
+ // Maps a provider player map into normalized stream options.
491
+ // Мапит provider player map в нормализованные stream options.
492
+ function mapPlayerMapToOptions(providerName, playerMap, candidate, query, allowedPlayerProviders) {
493
+ const options = [];
494
+ const discovered = [];
495
+ const filtered = [];
496
+ for (const [providerKey, payload] of Object.entries(playerMap)) {
497
+ const player = normalizeProviderLabel(providerKey);
498
+ discovered.push(player);
499
+ if (!isAllowedPlayerProvider(providerKey, allowedPlayerProviders)) {
500
+ filtered.push({ player, reason: "provider_not_allowed" });
501
+ continue;
502
+ }
503
+ const option = mapPayloadToOption(providerName, providerKey, payload, candidate, query);
504
+ if (!option) {
505
+ filtered.push({ player, reason: "missing_iframe" });
506
+ continue;
507
+ }
508
+ options.push(option);
509
+ }
510
+ return { options, discovered, filtered };
511
+ }
512
+ async function filterBrokenPlayerOptions(config, options, context) {
513
+ const knownBrokenOptions = options.filter((option) => isKnownBrokenPlayerUrl(option.access.url));
514
+ const optionsWithoutKnownBrokenUrls = options.filter((option) => !knownBrokenOptions.includes(option));
515
+ const optionsToValidate = optionsWithoutKnownBrokenUrls.slice(0, config.playerValidationLimit);
516
+ const optionsSkippedByLimit = optionsWithoutKnownBrokenUrls.slice(config.playerValidationLimit);
517
+ const checks = await Promise.all(optionsToValidate.map(async (option) => ({
518
+ option,
519
+ broken: await isBrokenPlayerUrl(config, option.access.url, context),
520
+ })));
521
+ return {
522
+ options: [
523
+ ...checks.filter((check) => !check.broken).map((check) => check.option),
524
+ ...optionsSkippedByLimit,
525
+ ],
526
+ filtered: [
527
+ ...knownBrokenOptions.map((option) => ({
528
+ player: option.player.label,
529
+ reason: "known_broken_url",
530
+ url: option.access.url,
531
+ })),
532
+ ...checks
533
+ .filter((check) => check.broken)
534
+ .map((check) => ({
535
+ player: check.option.player.label,
536
+ reason: "player_validation_failed",
537
+ url: check.option.access.url,
538
+ })),
539
+ ],
540
+ };
541
+ }
542
+ function emitPlayerAudit(config, query, discovered, shown, filtered) {
543
+ try {
544
+ config.onPlayerAudit?.({
545
+ query: {
546
+ ...query,
547
+ ...(query.ids ? { ids: { ...query.ids } } : {}),
548
+ ...(query.providers ? { providers: [...query.providers] } : {}),
549
+ },
550
+ discovered: [...new Set(discovered)],
551
+ shown: [...new Set(shown.map((option) => option.player.label))],
552
+ filtered,
553
+ });
554
+ }
555
+ catch {
556
+ // Diagnostics must not change availability behavior.
557
+ }
558
+ }
559
+ async function isBrokenPlayerUrl(config, url, context, depth = 0) {
560
+ const fetchImpl = config.fetch ?? fetch;
561
+ const controller = new AbortController();
562
+ const timeout = setTimeout(() => controller.abort(), config.playerValidationTimeoutMs);
563
+ const signal = context.signal
564
+ ? AbortSignal.any([context.signal, controller.signal])
565
+ : controller.signal;
566
+ try {
567
+ if (context.signal?.aborted) {
568
+ return false;
569
+ }
570
+ const response = await fetchImpl(url, {
571
+ headers: {
572
+ accept: "text/html,application/xhtml+xml",
573
+ },
574
+ signal,
575
+ });
576
+ if (response.status === 404 || response.status === 410 || response.status >= 500) {
577
+ return true;
578
+ }
579
+ if (!response.ok) {
580
+ return false;
581
+ }
582
+ const html = await readBoundedResponseText(response, PLAYER_VALIDATION_MAX_BODY_BYTES);
583
+ if (hasBrokenPlayerMarker(html)) {
584
+ return true;
585
+ }
586
+ const nestedUrl = depth < PLAYER_VALIDATION_MAX_DEPTH ? extractIframeUrl(html, url) : undefined;
587
+ return nestedUrl ? isBrokenPlayerUrl(config, nestedUrl, context, depth + 1) : false;
588
+ }
589
+ catch {
590
+ return isKnownBrokenPlayerUrl(url);
591
+ }
592
+ finally {
593
+ clearTimeout(timeout);
594
+ }
595
+ }
596
+ async function readBoundedResponseText(response, maxBytes) {
597
+ if (!response.body) {
598
+ return "";
599
+ }
600
+ const reader = response.body.getReader();
601
+ const chunks = [];
602
+ let totalBytes = 0;
603
+ try {
604
+ while (totalBytes <= maxBytes) {
605
+ const { done, value } = await reader.read();
606
+ if (done) {
607
+ break;
608
+ }
609
+ totalBytes += value.byteLength;
610
+ if (totalBytes > maxBytes) {
611
+ await reader.cancel();
612
+ break;
613
+ }
614
+ chunks.push(value);
615
+ }
616
+ }
617
+ finally {
618
+ reader.releaseLock();
619
+ }
620
+ const body = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0));
621
+ let offset = 0;
622
+ for (const chunk of chunks) {
623
+ body.set(chunk, offset);
624
+ offset += chunk.byteLength;
625
+ }
626
+ return new TextDecoder().decode(body);
627
+ }
628
+ function isKnownBrokenPlayerUrl(url) {
629
+ try {
630
+ const parsed = new URL(url);
631
+ return (/(^|\.)sevstar\d*krop\.com$/i.test(parsed.hostname) && parsed.pathname.includes("/iframe"));
632
+ }
633
+ catch {
634
+ return false;
635
+ }
636
+ }
637
+ function hasBrokenPlayerMarker(html) {
638
+ const normalized = normalizeSearchText(html);
639
+ return (normalized.includes("video not found") ||
640
+ normalized.includes("404 not found") ||
641
+ normalized.includes("плеер недоступ") ||
642
+ normalized.includes("плеєр недоступ") ||
643
+ normalized.includes("недоступний для перегляду") ||
644
+ normalized.includes("змініть країну перегляду"));
645
+ }
646
+ // Maps search-result iframes into fallback player options when /playerdata cannot be used.
647
+ // Мапит iframe из search results в fallback player options, когда /playerdata недоступен.
648
+ function mapCandidatesToFallbackOptions(providerName, candidates, query) {
649
+ return candidates
650
+ .map((candidate) => {
651
+ const iframe = extractIframeUrl(candidate.iframe, undefined);
652
+ if (!iframe) {
653
+ return undefined;
654
+ }
655
+ const title = candidate.title?.trim() ||
656
+ candidate.name_russian?.trim() ||
657
+ candidate.name_original?.trim() ||
658
+ "KinoBD";
659
+ const fallbackKey = `KINOBD>${title}`;
660
+ return mapPayloadToOption(providerName, fallbackKey, {
661
+ translate: title,
662
+ iframe,
663
+ quality: "auto",
664
+ source: "kinobd",
665
+ }, candidate, query);
666
+ })
667
+ .filter((option) => Boolean(option));
668
+ }
669
+ // Maps one player payload into one stream option.
670
+ // Мапит один player payload в один stream option.
671
+ function mapPayloadToOption(providerName, providerKey, payload, candidate, query) {
672
+ const iframe = extractIframeUrl(payload.iframe, undefined);
673
+ if (!iframe) {
674
+ return undefined;
675
+ }
676
+ const label = normalizeProviderLabel(providerKey);
677
+ const translationTitle = payload.translate?.trim() || label;
678
+ const qualityLabel = payload.quality?.trim() || "auto";
679
+ return {
680
+ id: [
681
+ providerName,
682
+ label.toLowerCase(),
683
+ candidate?.id ?? candidate?.inid ?? query.ids?.shikimori ?? query.title ?? "item",
684
+ query.seasonNumber ?? "s",
685
+ query.episodeNumber ?? "e",
686
+ query.absoluteEpisodeNumber ?? "a",
687
+ ]
688
+ .join(":")
689
+ .replace(/\s+/g, "-"),
690
+ provider: providerName,
691
+ player: {
692
+ kind: "embed",
693
+ label,
694
+ providerPlayerId: providerKey,
695
+ },
696
+ translation: createTranslationInfo(translationTitle),
697
+ quality: {
698
+ label: qualityLabel,
699
+ height: parseQualityHeight(qualityLabel),
700
+ },
701
+ episode: hasEpisodeQuery(query)
702
+ ? {
703
+ seasonNumber: query.seasonNumber,
704
+ episodeNumber: query.episodeNumber,
705
+ absoluteEpisodeNumber: query.absoluteEpisodeNumber,
706
+ }
707
+ : undefined,
708
+ access: {
709
+ url: iframe,
710
+ },
711
+ availability: payload.warning ? "unknown" : "available",
712
+ sourceUrl: iframe,
713
+ };
714
+ }
715
+ // Creates an empty availability response without failing metadata flows.
716
+ // Создает пустой availability response без поломки metadata flows.
717
+ function createEmptyAvailability(query) {
718
+ return {
719
+ query,
720
+ options: [],
721
+ sourceProviders: [],
722
+ checkedAt: new Date().toISOString(),
723
+ };
724
+ }
725
+ // Collects known external IDs from a player candidate.
726
+ // Собирает известные external IDs из player candidate.
727
+ function collectCandidateIds(candidate) {
728
+ if (!candidate) {
729
+ return undefined;
730
+ }
731
+ const ids = {};
732
+ const kinopoiskId = candidate.kinopoisk_id ?? candidate.kp_id;
733
+ if (kinopoiskId !== undefined && kinopoiskId !== null) {
734
+ ids.kinopoisk = String(kinopoiskId);
735
+ }
736
+ if (candidate.imdb_id) {
737
+ ids.imdb = candidate.imdb_id;
738
+ }
739
+ return Object.keys(ids).length > 0 ? ids : undefined;
740
+ }
741
+ // Extracts either raw URL or iframe src/data-src value.
742
+ // Извлекает raw URL или iframe src/data-src значение.
743
+ function extractIframeUrl(value, baseUrl) {
744
+ if (!value) {
745
+ return undefined;
746
+ }
747
+ const trimmed = value.trim();
748
+ if (!trimmed) {
749
+ return undefined;
750
+ }
751
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("//")) {
752
+ return toAbsoluteUrl(trimmed, baseUrl);
753
+ }
754
+ const dataSrcMatch = /data-src="([^"]+)"/i.exec(trimmed);
755
+ if (dataSrcMatch?.[1]) {
756
+ return toAbsoluteUrl(dataSrcMatch[1], baseUrl);
757
+ }
758
+ const srcMatch = /src="([^"]+)"/i.exec(trimmed);
759
+ if (srcMatch?.[1]) {
760
+ return toAbsoluteUrl(srcMatch[1], baseUrl);
761
+ }
762
+ return undefined;
763
+ }
764
+ // Normalizes absolute, protocol-relative, or relative URLs.
765
+ // Нормализует absolute, protocol-relative или relative URLs.
766
+ function toAbsoluteUrl(value, baseUrl) {
767
+ if (value.startsWith("http://") || value.startsWith("https://")) {
768
+ return normalizePublicHttpUrl(value);
769
+ }
770
+ if (value.startsWith("//")) {
771
+ return normalizePublicHttpUrl(`https:${value}`);
772
+ }
773
+ if (baseUrl) {
774
+ return normalizePublicHttpUrl(new URL(value, `${baseUrl}/`).toString());
775
+ }
776
+ return undefined;
777
+ }
778
+ // Checks whether query contains episode targeting fields.
779
+ // Проверяет, содержит ли query поля выбора эпизода.
780
+ function hasEpisodeQuery(query) {
781
+ return (query.seasonNumber !== undefined ||
782
+ query.episodeNumber !== undefined ||
783
+ query.absoluteEpisodeNumber !== undefined);
784
+ }
785
+ // Converts provider map keys into display labels.
786
+ // Преобразует provider map keys в display labels.
787
+ function normalizeProviderLabel(providerKey) {
788
+ return providerKey.split(">")[0]?.trim().toUpperCase() || "PLAYER";
789
+ }
790
+ function createTranslationInfo(title) {
791
+ const team = inferTranslationTeam(title);
792
+ const type = inferTranslationType(title);
793
+ return {
794
+ title,
795
+ type,
796
+ language: inferTranslationLanguage(title, type),
797
+ ...(team ? { team } : {}),
798
+ };
799
+ }
800
+ function inferTranslationType(title) {
801
+ const normalized = normalizeSearchText(title);
802
+ if (/\b(sub|subs|subtitle|subtitles)\b/.test(normalized) || normalized.includes("субтит")) {
803
+ return "subtitles";
804
+ }
805
+ if (/\b(original|orig)\b/.test(normalized) || normalized.includes("оригинал")) {
806
+ return "original";
807
+ }
808
+ if (/\b(dub|dubbed|dubbing)\b/.test(normalized) || normalized.includes("дубл")) {
809
+ return "dub";
810
+ }
811
+ if (/\b(voice|voiceover)\b/.test(normalized) ||
812
+ normalized.includes("озвуч") ||
813
+ normalized.includes("закадр") ||
814
+ normalized.includes("одноголос") ||
815
+ normalized.includes("многоголос") ||
816
+ normalized.includes("любител") ||
817
+ normalized.includes("профессион") ||
818
+ hasKnownRussianVoiceoverTeam(normalized)) {
819
+ return "voiceover";
820
+ }
821
+ return "unknown";
822
+ }
823
+ function inferTranslationLanguage(title, type) {
824
+ const normalized = normalizeSearchText(title);
825
+ if (/[іїєґ]/i.test(title) ||
826
+ normalized.includes("украин") ||
827
+ normalized.includes("україн") ||
828
+ normalized.includes("професій") ||
829
+ normalized.includes("дубльований") ||
830
+ normalized.includes("багатоголос") ||
831
+ normalized.includes("закадровий") ||
832
+ /\b(uateam|dniprofilm)\b/.test(normalized)) {
833
+ return "uk";
834
+ }
835
+ if (/\b(eng|english)\b/.test(normalized) || normalized.includes("англ")) {
836
+ return "en";
837
+ }
838
+ if (/\b(ru|rus|russian)\b/.test(normalized) || normalized.includes("русск")) {
839
+ return "ru";
840
+ }
841
+ if (hasKnownRussianVoiceoverTeam(normalized)) {
842
+ return "ru";
843
+ }
844
+ // A Cyrillic UI label alone does not prove the language of original audio or subtitles.
845
+ // Кириллическая подпись сама по себе не доказывает язык оригинальной дорожки или субтитров.
846
+ if (type !== "original" && type !== "subtitles" && /[а-яё]/i.test(title)) {
847
+ return "ru";
848
+ }
849
+ return undefined;
850
+ }
851
+ function inferTranslationTeam(title) {
852
+ const normalized = normalizeSearchText(title);
853
+ return KNOWN_RUSSIAN_VOICEOVER_TEAMS.find((team) => normalized.includes(normalizeSearchText(team)));
854
+ }
855
+ function hasKnownRussianVoiceoverTeam(normalizedTitle) {
856
+ return KNOWN_RUSSIAN_VOICEOVER_TEAMS.some((team) => normalizedTitle.includes(normalizeSearchText(team)));
857
+ }
858
+ function parsePlayerProviderKeys(playerProviders) {
859
+ return new Set(playerProviders
860
+ .split(",")
861
+ .map((provider) => provider.trim().toLowerCase())
862
+ .filter((provider) => provider && !BLOCKED_PLAYER_PROVIDERS.has(provider)));
863
+ }
864
+ function isAllowedPlayerProvider(providerKey, allowedPlayerProviders) {
865
+ const key = providerKey.split(">")[0]?.trim().toLowerCase();
866
+ return Boolean(key && allowedPlayerProviders.has(key));
867
+ }
868
+ // Parses common quality labels like 720p or 1080p.
869
+ // Парсит распространенные quality labels вроде 720p или 1080p.
870
+ function parseQualityHeight(label) {
871
+ const match = /(\d{3,4})p?/i.exec(label);
872
+ return match ? Number.parseInt(match[1], 10) : undefined;
873
+ }
874
+ // Parses optional integer-like values.
875
+ // Парсит опциональные integer-like значения.
876
+ function parseOptionalInteger(value) {
877
+ if (value === undefined || value === null) {
878
+ return undefined;
879
+ }
880
+ const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
881
+ return Number.isInteger(parsed) ? parsed : undefined;
882
+ }
883
+ // Parses a year from date-like values such as 2002-10-03.
884
+ // Парсит год из date-like значений вроде 2002-10-03.
885
+ function parseYear(value) {
886
+ if (!value) {
887
+ return undefined;
888
+ }
889
+ const year = Number.parseInt(value.slice(0, 4), 10);
890
+ return Number.isInteger(year) ? year : undefined;
891
+ }
892
+ function normalizeSearchText(value) {
893
+ return value
894
+ .trim()
895
+ .toLowerCase()
896
+ .replace(/ё/g, "е")
897
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
898
+ .replace(/\s+/g, " ")
899
+ .trim();
900
+ }
901
+ // Validates and normalizes provider name.
902
+ // Проверяет и нормализует имя provider.
903
+ function normalizeProviderName(name) {
904
+ const normalized = name.trim();
905
+ if (!normalized) {
906
+ throw new TypeError("KinoBD streaming provider name is required.");
907
+ }
908
+ return normalized;
909
+ }
910
+ // Removes trailing slashes from base URLs.
911
+ // Убирает trailing slashes из base URLs.
912
+ function trimTrailingSlash(value) {
913
+ const trimmed = value.trim();
914
+ if (!trimmed) {
915
+ throw new TypeError("KinoBD streaming baseUrl is required.");
916
+ }
917
+ return trimmed.replace(/\/+$/, "");
918
+ }
919
+ //# sourceMappingURL=index.js.map