fix miruro

This commit is contained in:
janisslsm committed 2026-10-27 12:15:52 +02:00
1 parent 1b8cb7f6e9
commit 8c0fd70b7b
5 files changed
+955 -641

No files matched your search

+487 -272
View File
@@ -1,23 +1,51 @@
/**
* Resolve TMDB media to AniList, then retrieve Miruro episode sources.
* Resolve TMDB media to AniList, then retrieve Miruro playback sources.
*
* Miruro's current catalog is keyed by opaque catalog IDs and served from
* /api/v1. It no longer accepts AniList IDs as a filter, so this provider:
* 1. maps TMDB to AniList IDs through AniMap,
* 2. resolves TV seasons by walking AniList's SEQUEL relations,
* 3. finds the matching catalog entry by title search and verifies it via
* the entry's own external_ids.anilist list,
* 4. uses the show cluster's aired_season metadata to select the requested
* TMDB season, including seasons split across several entries,
* 5. requests episode playback data from the catalog.
*/
import { BASE_URL, fetchJson, fetchText, HEADERS, secureGet } from './http.js';
import { HEADERS, apiGet, fetchJson } from './http.js';
const ANIMAP_URL = 'https://animap.id/api/map';
const ANILIST_URL = 'https://graphql.anilist.co';
const HLS_QUALITY_TIMEOUT_MS = 4000;
const SEARCH_LIMIT = 15;
const MAX_SEARCH_QUERIES = 5;
// Miruro's visible native-player providers and their preferred subtitle mode.
const PROVIDERS = [
{ id: 'bonk', label: 'Bonk', subCategory: 'ssub' },
{ id: 'kiwi', label: 'Kiwi', subCategory: 'sub' },
{ id: 'hop', label: 'Hop', subCategory: 'ssub' },
{ id: 'ally', label: 'Ally', subCategory: 'sub' },
{ id: 'pewe', label: 'Pewe', subCategory: 'sub' },
{ id: 'bee', label: 'Bee', subCategory: 'ssub' },
{ id: 'moo', label: 'Moo', subCategory: 'sub' }
];
const PROVIDER_LABELS = {
animepahe: 'AnimePahe',
anikoto: 'Anikoto',
aniwaves: 'Aniwaves',
icarus: 'Icarus',
kickassanime: 'KickAssAnime'
};
const anilistCache = new Map();
const episodeCache = new Map();
const showCache = new Map();
function cacheGet(cache, key) {
return cache.has(key) ? cache.get(key) : undefined;
}
function cacheSet(cache, key, value) {
// Nuvio keeps a provider module alive between requests; bound the caches so
// browsing many shows cannot grow them without limit.
if (cache.size >= 40) {
const oldest = cache.keys().next().value;
cache.delete(oldest);
}
cache.set(key, value);
return value;
}
function asNumber(value) {
const number = Number(value);
@@ -66,8 +94,10 @@ function recordMatchesIdentifier(record, identifier, mediaType) {
));
}
function getAniListIds(records, identifier, mediaType) {
function getMapping(records, identifier, mediaType) {
const ids = [];
const titles = [];
const synonyms = [];
const normalizedRecords = Array.isArray(records)
? records
: records && typeof records === 'object'
@@ -86,50 +116,26 @@ function getAniListIds(records, identifier, mediaType) {
ids.push(numericId);
}
}
if (record.title) {
titles.push(String(record.title));
}
for (const synonym of asArray(record.synonyms)) {
if (synonym) {
synonyms.push(String(synonym));
}
}
}
return ids;
return { ids, titles, synonyms };
}
function getLocalEpisode(mappings, mediaType, season, episode) {
if (!mappings) {
return null;
async function getAniListMeta(anilistId) {
if (anilistCache.has(anilistId)) {
return anilistCache.get(anilistId);
}
if (mediaType === 'movie') {
return 1;
}
const requestedSeason = asNumber(season) || 1;
const requestedEpisode = asNumber(episode);
if (!requestedEpisode || requestedEpisode < 1) {
return null;
}
const mappedSeason = mappings.tmdbSeason || mappings.defaultTvdbSeason;
if (mappedSeason === 'absolute') {
// Absolute mappings cannot reliably translate later TMDB seasons without
// season lengths. Season one still maps directly.
return requestedSeason === 1 ? requestedEpisode : null;
}
if (mappedSeason != null && String(mappedSeason) !== String(requestedSeason)) {
return null;
}
const offset = asNumber(mappings.tmdbOffset) || asNumber(mappings.episodeOffset) || 0;
const episodeCount = asNumber(mappings.episodes);
if (requestedEpisode <= offset || (episodeCount && requestedEpisode > offset + episodeCount)) {
return null;
}
return requestedEpisode - offset;
}
async function getAniListSequel(anilistId) {
const response = await fetchJson(ANILIST_URL, {
method: 'POST',
headers: {
@@ -140,6 +146,12 @@ async function getAniListSequel(anilistId) {
query: `
query ($id: Int!) {
Media(id: $id, type: ANIME) {
title {
romaji
english
native
}
synonyms
relations {
edges {
relationType
@@ -156,17 +168,27 @@ async function getAniListSequel(anilistId) {
variables: { id: anilistId }
})
});
const edges = response && response.data && response.data.Media && response.data.Media.relations
? response.data.Media.relations.edges
const media = response && response.data && response.data.Media ? response.data.Media : null;
const edges = media && media.relations && Array.isArray(media.relations.edges)
? media.relations.edges
: [];
const sequel = (edges || []).find((edge) => (
// A sequel edge can point at a compilation movie as well as the TV cour.
// Nuvio requests a TV season here, so only follow episodic formats.
const sequel = edges.find((edge) => (
edge.relationType === 'SEQUEL' &&
edge.node &&
edge.node.type === 'ANIME' &&
['TV', 'TV_SHORT', 'ONA'].includes(edge.node.format)
));
const meta = {
title: media && media.title ? media.title : null,
synonyms: media && Array.isArray(media.synonyms) ? media.synonyms : [],
sequelId: sequel ? asNumber(sequel.node.id) : null
};
return sequel ? asNumber(sequel.node.id) : null;
anilistCache.set(anilistId, meta);
return meta;
}
async function getSeasonAniListId(anilistId, season) {
@@ -174,28 +196,382 @@ async function getSeasonAniListId(anilistId, season) {
let currentId = anilistId;
for (let currentSeason = 1; currentSeason < requestedSeason; currentSeason++) {
const sequelId = await getAniListSequel(currentId);
const meta = await getAniListMeta(currentId);
if (!sequelId || sequelId === currentId) {
if (!meta.sequelId || meta.sequelId === currentId) {
return null;
}
currentId = sequelId;
currentId = meta.sequelId;
}
return currentId;
}
function getSequelEpisode(payload, mediaType, season, episode) {
// A sequel has its own AniList episode list, so its first episode is local
// episode 1 even when the caller identifies it as TMDB/IMDb season 2+. Keep
// an explicit TMDB season mapping when Miruro provides one, then fall back
// to numbering relative to the selected AniList entry.
return getLocalEpisode(payload.mappings, mediaType, season, episode) ||
getLocalEpisode(payload.mappings, mediaType, 1, episode);
function responseItems(response) {
if (Array.isArray(response)) {
return response;
}
if (response && Array.isArray(response.data)) {
return response.data;
}
if (response && Array.isArray(response.items)) {
return response.items;
}
return [];
}
async function resolveEpisodePayload(mediaId, mediaType, season, episode) {
function getExternalIds(anime, key) {
return asArray(anime && anime.external_ids ? anime.external_ids[key] : null)
.map((id) => asNumber(id))
.filter((id) => id != null);
}
async function searchCatalogAnime(title) {
// Miruro rejects search limits above 15 with a generic 400.
const response = await apiGet('anime', {
q: title,
limit: SEARCH_LIMIT,
sort: '-popularity'
});
return responseItems(response);
}
function matchesAnyAniListId(anime, aniListIds) {
const targets = new Set(aniListIds.map((id) => Number(id)));
return getExternalIds(anime, 'anilist').some((id) => targets.has(Number(id)));
}
function buildSearchTitles(targetMeta, mapping) {
const titles = [
targetMeta && targetMeta.title ? targetMeta.title.romaji : null,
mapping.titles[0],
targetMeta && targetMeta.title ? targetMeta.title.english : null,
...mapping.synonyms.slice(0, 2),
...(targetMeta ? targetMeta.synonyms.slice(0, 2) : [])
];
const seen = new Set();
return titles
.filter((title) => typeof title === 'string' && title.trim().length >= 3)
.map((title) => title.trim())
.filter((title) => {
const key = title.toLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return title;
})
.slice(0, MAX_SEARCH_QUERIES);
}
async function findCatalogAnime(candidateIds, targetId, searchTitles) {
for (const title of searchTitles) {
let results;
try {
results = await searchCatalogAnime(title);
} catch (error) {
console.warn(`[Miruro] Catalog search "${title}" failed: ${error.message}`);
// A 403 means the catalog rejected the client itself (Cloudflare or
// an IP block); trying more title variants cannot help.
if (error.status === 403) {
break;
}
continue;
}
if (results.length === 0) {
continue;
}
const exact = results.find((anime) => getExternalIds(anime, 'anilist').includes(Number(targetId)));
if (exact) {
return exact;
}
const candidate = results.find((anime) => matchesAnyAniListId(anime, candidateIds));
if (candidate) {
return candidate;
}
}
return null;
}
async function listEpisodes(animeId) {
const cached = cacheGet(episodeCache, animeId);
if (cached) {
return cached;
}
const episodes = [];
let cursor;
for (let page = 0; page < 5; page++) {
const response = await apiGet(`anime/${encodeURIComponent(animeId)}/episodes`, {
limit: 10000,
cursor
});
const items = responseItems(response);
episodes.push(...items);
if (!response || !response.has_more || !response.next_cursor || items.length === 0) {
break;
}
cursor = response.next_cursor;
}
return cacheSet(episodeCache, animeId, episodes);
}
async function listShowEntries(animeId) {
const cached = cacheGet(showCache, animeId);
if (cached) {
return cached;
}
let entries = [];
try {
const response = await apiGet(`anime/${encodeURIComponent(animeId)}/show`);
entries = responseItems(response);
} catch (error) {
console.warn(`[Miruro] Show cluster for ${animeId} failed: ${error.message}`);
}
return cacheSet(showCache, animeId, entries);
}
function clusterPosition(anime) {
const position = asNumber(anime && anime.show_cluster_position);
return position == null ? Number.MAX_SAFE_INTEGER : position;
}
function seasonOf(anime) {
return asNumber(anime && anime.aired_season ? anime.aired_season.number : null);
}
function entryBelongsToRequest(anime, mapping, identifier) {
if (identifier.service === 'tmdb') {
const tmdbIds = asArray(anime && anime.external_ids ? anime.external_ids.tmdb_tv : null)
.map((id) => String(id));
if (tmdbIds.includes(identifier.value)) {
return true;
}
}
if (identifier.service === 'imdb') {
const imdbIds = asArray(anime && anime.external_ids ? anime.external_ids.imdb : null)
.map((id) => String(id).toLowerCase());
if (imdbIds.includes(identifier.value)) {
return true;
}
}
return matchesAnyAniListId(anime, mapping.ids);
}
function isMovieFormat(anime) {
return String(anime && anime.format || '').toUpperCase() === 'MOVIE';
}
async function resolveSeasonEpisode(seed, mapping, identifier, season, episode) {
const requestedSeason = asNumber(season) || 1;
const requestedEpisode = asNumber(episode);
if (!requestedEpisode || requestedEpisode < 1) {
return null;
}
const seedEpisodes = await listEpisodes(seed.id);
const showEntries = await listShowEntries(seed.id);
const related = showEntries.length > 0 ? showEntries : [seed];
const seasonCandidates = related
.filter((anime) => (
!isMovieFormat(anime) &&
seasonOf(anime) === requestedSeason &&
entryBelongsToRequest(anime, mapping, identifier)
))
.sort((first, second) => clusterPosition(first) - clusterPosition(second));
// Some catalog seasons are split across several entries (for example a TV
// season released in parts). Episode numbers restart in each entry, so
// concatenate the season's entries in cluster order and index into it.
const seasonEpisodes = [];
for (const anime of seasonCandidates) {
const episodes = anime.id === seed.id ? seedEpisodes : await listEpisodes(anime.id);
// A season candidate already belongs to the requested season. Some of
// its episodes have missing aired_season_number tags, so keep them all
// rather than trusting the per-episode tag here.
for (const item of episodes) {
seasonEpisodes.push({ anime, episode: item });
}
}
const fromCluster = seasonEpisodes[requestedEpisode - 1];
if (fromCluster) {
return fromCluster;
}
// Single-entry long runners (One Piece and similar) tag their episodes with
// the TMDB season even though the catalog has no per-season entry.
const tagged = seedEpisodes.filter((item) => (
item.aired_season_number != null &&
Number(item.aired_season_number) === requestedSeason
));
const fromTags = tagged[requestedEpisode - 1];
if (fromTags) {
return { anime: seed, episode: fromTags };
}
const local = seedEpisodes.find((item) => Number(item.episode_number) === requestedEpisode);
return local ? { anime: seed, episode: local } : null;
}
function selectMovieEpisode(episodes) {
if (episodes.length === 0) {
return null;
}
return episodes.find((item) => Number(item.episode_number) === 1) || episodes[0];
}
async function getPlayback(animeId, episodeNumber) {
const response = await apiGet(
`anime/${encodeURIComponent(animeId)}/episodes/${encodeURIComponent(episodeNumber)}/play`
);
if (response && response.data && Array.isArray(response.data.tracks)) {
return response.data;
}
return response;
}
function providerLabel(providerId) {
const id = String(providerId || 'miruro').toLowerCase();
if (PROVIDER_LABELS[id]) {
return PROVIDER_LABELS[id];
}
return id
.replace(/[-_]+/g, ' ')
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
function getQuality(stream) {
const raw = stream && (stream.quality ?? stream.resolution);
const height = raw && typeof raw === 'object' ? raw.height : raw;
const match = String(height ?? '').match(/(\d{3,4})/);
if (match) {
return `${match[1]}p`;
}
return /4k/i.test(String(height ?? '')) ? '4K' : 'HD';
}
function normalizeHeaders(headers, streamHeaders) {
const result = {};
for (const source of [streamHeaders, headers]) {
if (!source || typeof source !== 'object') {
continue;
}
for (const [key, value] of Object.entries(source)) {
if (value == null) {
continue;
}
result[key] = String(value);
}
}
if (!Object.keys(result).some((key) => key.toLowerCase() === 'user-agent')) {
result['User-Agent'] = HEADERS['User-Agent'];
}
return result;
}
function normalizePlayback(playback) {
const streams = [];
const seen = new Set();
const tracks = playback && Array.isArray(playback.tracks) ? playback.tracks : [];
for (const track of tracks) {
const trackKey = String(track && track.track || '').toLowerCase();
const language = trackKey === 'dub' ? 'DUB' : 'SUB';
const providers = track && Array.isArray(track.providers) ? track.providers : [];
for (const provider of providers) {
const label = providerLabel(provider && provider.provider);
const hasSubtitles = !!(provider && Array.isArray(provider.subtitles) && provider.subtitles.length > 0);
const subtitleLabel = hasSubtitles && language === 'SUB' ? ' · CC' : '';
const servers = provider && Array.isArray(provider.servers) ? provider.servers : [];
for (const server of servers) {
const serverName = String(server && server.server || 'default');
const serverStreams = server && Array.isArray(server.streams) ? server.streams : [];
for (const stream of serverStreams) {
const url = stream && stream.url;
// A stream can carry both a direct HLS URL and an embed URL.
// Only skip entries that have no direct URL at all.
if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
continue;
}
if (seen.has(url)) {
continue;
}
seen.add(url);
const quality = getQuality(stream);
streams.push({
name: `Miruro · ${label} · ${language}`,
title: `[${language}] ${label} · ${serverName} · ${quality}${subtitleLabel}`,
url,
quality,
headers: normalizeHeaders(server && server.headers, stream.headers)
});
}
}
}
}
return streams;
}
async function resolveAnime(mediaId, mediaType, season, episode) {
const identifier = parseIdentifier(mediaId);
if (!identifier) {
@@ -214,217 +590,72 @@ async function resolveEpisodePayload(mediaId, mediaType, season, episode) {
return null;
}
const aniListIds = getAniListIds(mappingRecords, identifier, mediaType);
const mapping = getMapping(mappingRecords, identifier, mediaType);
if (aniListIds.length === 0) {
if (mapping.ids.length === 0) {
return null;
}
// A single mapping can be validated using the episode response itself.
if (aniListIds.length === 1) {
const anilistId = aniListIds[0];
const requestedSeason = asNumber(season) || 1;
let targetId = mapping.ids[0];
if (mediaType === 'tv' && (asNumber(season) || 1) > 1) {
try {
const seasonAniListId = await getSeasonAniListId(anilistId, season);
if (seasonAniListId && seasonAniListId !== anilistId) {
const payload = await secureGet('episodes', { anilistId: seasonAniListId });
const localEpisode = getSequelEpisode(payload, mediaType, season, episode);
if (localEpisode) {
return { anilistId: seasonAniListId, localEpisode, payload };
}
}
} catch (error) {
// AniList is a fallback for mappings that only identify season
// one. If it is unavailable, retain Miruro's normal mapping.
console.warn(`[Miruro] Season ${season} mapping fallback failed: ${error.message}`);
}
}
const payload = await secureGet('episodes', { anilistId });
const localEpisode = getLocalEpisode(payload.mappings, mediaType, season, episode);
return localEpisode ? { anilistId, localEpisode, payload } : null;
}
// Franchises can map multiple AniList cours to one TMDB show. Miruro's info
// metadata identifies both the TMDB season and the cour's episode offset.
for (const anilistId of aniListIds) {
if (mediaType === 'tv' && requestedSeason > 1) {
try {
const info = await secureGet(`info/${anilistId}`);
const localEpisode = getLocalEpisode(info.mappings, mediaType, season, episode);
const seasonAniListId = await getSeasonAniListId(mapping.ids[0], requestedSeason);
if (localEpisode) {
const payload = await secureGet('episodes', { anilistId });
return { anilistId, localEpisode, payload };
if (seasonAniListId) {
targetId = seasonAniListId;
} else if (mapping.ids[requestedSeason - 1]) {
targetId = mapping.ids[requestedSeason - 1];
}
} catch (error) {
console.warn(`[Miruro] Skipping AniList ${anilistId}: ${error.message}`);
console.warn(`[Miruro] Season ${requestedSeason} mapping failed: ${error.message}`);
if (mapping.ids[requestedSeason - 1]) {
targetId = mapping.ids[requestedSeason - 1];
}
}
}
return null;
}
let targetMeta = null;
function findEpisode(providerPayload, variant, episodeNumber) {
const episodes = providerPayload && providerPayload.episodes
? providerPayload.episodes[variant]
: null;
try {
targetMeta = await getAniListMeta(targetId);
} catch (error) {
console.warn(`[Miruro] AniList metadata for ${targetId} failed: ${error.message}`);
}
if (!Array.isArray(episodes)) {
const searchTitles = buildSearchTitles(targetMeta, mapping);
const seed = await findCatalogAnime(mapping.ids, targetId, searchTitles);
if (!seed || !seed.id) {
console.warn(`[Miruro] No catalog entry for AniList ${targetId}`);
return null;
}
return episodes.find((item) => Number(item.number) === Number(episodeNumber)) || null;
}
let selection;
function getOrigin(url) {
const match = typeof url === 'string' ? url.match(/^(https?:\/\/[^/]+)/i) : null;
return match ? match[1] : BASE_URL;
}
if (mediaType === 'movie') {
const episodeItem = selectMovieEpisode(await listEpisodes(seed.id));
function getDeclaredQuality(stream) {
const value = `${stream.quality || ''} ${stream.resolution || ''} ${stream.server || ''}`;
const resolution = value.match(/(2160|1440|1080|720|480|360)p?/i);
if (!episodeItem) {
console.warn(`[Miruro] No playable episode for AniList ${targetId}`);
return null;
}
if (resolution) {
return `${resolution[1]}p`;
}
selection = { anime: seed, episode: episodeItem };
} else {
selection = await resolveSeasonEpisode(seed, mapping, identifier, requestedSeason, episode);
return /4k/i.test(value) ? '4K' : null;
}
function getHlsQuality(manifest) {
const heights = [];
const pattern = /\bRESOLUTION\s*=\s*\d+\s*x\s*(\d+)\b/gi;
let match;
while ((match = pattern.exec(manifest)) !== null) {
const height = Number(match[1]);
if (height > 0) {
heights.push(height);
if (!selection) {
console.warn(`[Miruro] Episode ${episode} not found for AniList ${targetId}`);
return null;
}
}
return heights.length > 0 ? `${Math.max(...heights)}p` : null;
}
const playback = await getPlayback(selection.anime.id, selection.episode.episode_number);
function isHlsStream(stream) {
const type = String(stream && stream.type || '').toLowerCase();
return type === 'hls' || type === 'm3u8' || type === 'application/x-mpegurl';
}
async function resolveQuality(stream, headers) {
const declaredQuality = getDeclaredQuality(stream);
if (declaredQuality) {
return declaredQuality;
}
if (isHlsStream(stream)) {
let timeoutId;
try {
const manifest = await Promise.race([
fetchText(stream.url, { headers }),
new Promise((resolve) => {
timeoutId = setTimeout(() => resolve(null), HLS_QUALITY_TIMEOUT_MS);
})
]);
const hlsQuality = typeof manifest === 'string' ? getHlsQuality(manifest) : null;
if (hlsQuality) {
return hlsQuality;
}
} catch (_) {
// A stream can still be playable when its manifest rejects this
// metadata request, so retain it with an unknown HD quality.
} finally {
if (timeoutId != null) {
clearTimeout(timeoutId);
}
}
}
return 'HD';
}
function isPlayableStream(stream) {
const type = String(stream && stream.type || '').toLowerCase();
return !!(stream && stream.url) && (
type === 'hls' ||
type === 'm3u8' ||
type === 'mp4' ||
type === 'application/x-mpegurl'
);
}
async function normalizeStreams(sourcePayload, provider, variant) {
const subtitleCount = Array.isArray(sourcePayload.subtitles) ? sourcePayload.subtitles.length : 0;
const language = variant === 'dub' ? 'DUB' : 'SUB';
return Promise.all((sourcePayload.streams || []).filter(isPlayableStream).map(async (stream) => {
const streamHeaders = stream.headers && typeof stream.headers === 'object'
? stream.headers
: {};
const referer = stream.referer || streamHeaders.Referer || streamHeaders.referer || BASE_URL;
const subtitleLabel = subtitleCount > 0 && variant !== 'dub' ? ' · CC' : '';
const headers = {
...streamHeaders,
'Referer': referer,
'Origin': getOrigin(referer),
'User-Agent': HEADERS['User-Agent']
};
const quality = await resolveQuality(stream, headers);
const server = !stream.server || /^(?:HD|auto)$/i.test(stream.server)
? quality
: stream.server;
return {
// Nuvio's source picker prominently displays `name`, so include the
// Miruro server and audio variant here instead of only in `title`.
name: `Miruro · ${provider.label} · ${language}`,
title: `[${language}] ${provider.label} · ${server}${subtitleLabel}`,
url: stream.url,
quality,
headers
};
}));
}
async function getProviderStreams(provider, providerPayload, anilistId, episodeNumber) {
const requests = [];
const subEpisode = findEpisode(providerPayload, 'sub', episodeNumber);
const dubEpisode = findEpisode(providerPayload, 'dub', episodeNumber);
if (subEpisode) {
requests.push({ episode: subEpisode, variant: 'sub', category: provider.subCategory });
}
if (dubEpisode) {
requests.push({ episode: dubEpisode, variant: 'dub', category: 'dub' });
}
const results = await Promise.all(requests.map(async (request) => {
try {
const sources = await secureGet('sources', {
episodeId: request.episode.id,
provider: provider.id,
category: request.category,
anilistId
});
return normalizeStreams(sources, provider, request.variant);
} catch (error) {
console.warn(`[Miruro] ${provider.label} ${request.variant} failed: ${error.message}`);
return [];
}
}));
return results.flat();
return { ...selection, playback };
}
export async function extractStreams(tmdbId, mediaType, season, episode) {
@@ -432,27 +663,11 @@ export async function extractStreams(tmdbId, mediaType, season, episode) {
return [];
}
const resolved = await resolveEpisodePayload(tmdbId, mediaType, season, episode);
const resolved = await resolveAnime(tmdbId, mediaType, season, episode);
if (!resolved || !resolved.payload || !resolved.payload.providers) {
if (!resolved || !resolved.playback) {
return [];
}
const providerResults = await Promise.all(PROVIDERS.map((provider) => {
const payload = resolved.payload.providers[provider.id];
return payload
? getProviderStreams(provider, payload, resolved.anilistId, resolved.localEpisode)
: [];
}));
const streams = providerResults.flat();
const seen = new Set();
return streams.filter((stream) => {
if (seen.has(stream.url)) {
return false;
}
seen.add(stream.url);
return true;
});
return normalizePlayback(resolved.playback);
}