Initial commit
This commit is contained in:
commit
6e86adb0e6
23 files changed
+6141
No files matched your search
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Resolve TMDB media to AniList, then retrieve Miruro episode sources.
|
||||
*/
|
||||
|
||||
import { BASE_URL, fetchJson, HEADERS, secureGet } from './http.js';
|
||||
|
||||
const ANIMAP_URL = 'https://animap.id/api/map';
|
||||
const ANILIST_URL = 'https://graphql.anilist.co';
|
||||
|
||||
// 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' }
|
||||
];
|
||||
|
||||
function asNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function parseIdentifier(value) {
|
||||
const id = String(value || '').trim();
|
||||
|
||||
if (/^tt\d+$/i.test(id)) {
|
||||
return { service: 'imdb', value: id.toLowerCase() };
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(id)) {
|
||||
return { service: 'tmdb', value: id };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function recordMatchesIdentifier(record, identifier, mediaType) {
|
||||
const expectedType = mediaType === 'movie' ? 'movie' : 'tv';
|
||||
|
||||
if (identifier.service === 'imdb') {
|
||||
const imdbMatches = (record.imdb_id || []).some((id) => (
|
||||
String(id).toLowerCase() === identifier.value
|
||||
));
|
||||
const typeMatches = (record.tmdb_id || []).length === 0 || (record.tmdb_id || []).some((mapping) => (
|
||||
mapping.type === expectedType
|
||||
));
|
||||
|
||||
return imdbMatches && typeMatches;
|
||||
}
|
||||
|
||||
return (record.tmdb_id || []).some((mapping) => (
|
||||
String(mapping.id) === identifier.value && mapping.type === expectedType
|
||||
));
|
||||
}
|
||||
|
||||
function getAniListIds(records, identifier, mediaType) {
|
||||
const ids = [];
|
||||
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
if (!recordMatchesIdentifier(record, identifier, mediaType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of record.anilist_id || []) {
|
||||
const numericId = asNumber(id);
|
||||
|
||||
if (numericId && !ids.includes(numericId)) {
|
||||
ids.push(numericId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getLocalEpisode(mappings, mediaType, season, episode) {
|
||||
if (!mappings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query ($id: Int!) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
relations {
|
||||
edges {
|
||||
relationType
|
||||
node {
|
||||
id
|
||||
type
|
||||
format
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id: anilistId }
|
||||
})
|
||||
});
|
||||
const edges = response && response.data && response.data.Media && response.data.Media.relations
|
||||
? response.data.Media.relations.edges
|
||||
: [];
|
||||
const sequel = (edges || []).find((edge) => (
|
||||
edge.relationType === 'SEQUEL' &&
|
||||
edge.node &&
|
||||
edge.node.type === 'ANIME' &&
|
||||
['TV', 'TV_SHORT', 'ONA'].includes(edge.node.format)
|
||||
));
|
||||
|
||||
return sequel ? asNumber(sequel.node.id) : null;
|
||||
}
|
||||
|
||||
async function getSeasonAniListId(anilistId, season) {
|
||||
const requestedSeason = asNumber(season) || 1;
|
||||
let currentId = anilistId;
|
||||
|
||||
for (let currentSeason = 1; currentSeason < requestedSeason; currentSeason++) {
|
||||
const sequelId = await getAniListSequel(currentId);
|
||||
|
||||
if (!sequelId || sequelId === currentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentId = 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);
|
||||
}
|
||||
|
||||
async function resolveEpisodePayload(mediaId, mediaType, season, episode) {
|
||||
const identifier = parseIdentifier(mediaId);
|
||||
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mappingRecords;
|
||||
|
||||
try {
|
||||
mappingRecords = await fetchJson(
|
||||
`${ANIMAP_URL}/${identifier.service}/${encodeURIComponent(identifier.value)}`,
|
||||
{ headers: { 'Accept': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[Miruro] No anime mapping for ${mediaId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const aniListIds = getAniListIds(mappingRecords, identifier, mediaType);
|
||||
|
||||
if (aniListIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A single mapping can be validated using the episode response itself.
|
||||
if (aniListIds.length === 1) {
|
||||
const anilistId = aniListIds[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) {
|
||||
try {
|
||||
const info = await secureGet(`info/${anilistId}`);
|
||||
const localEpisode = getLocalEpisode(info.mappings, mediaType, season, episode);
|
||||
|
||||
if (localEpisode) {
|
||||
const payload = await secureGet('episodes', { anilistId });
|
||||
return { anilistId, localEpisode, payload };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Miruro] Skipping AniList ${anilistId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEpisode(providerPayload, variant, episodeNumber) {
|
||||
const episodes = providerPayload && providerPayload.episodes
|
||||
? providerPayload.episodes[variant]
|
||||
: null;
|
||||
|
||||
if (!Array.isArray(episodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return episodes.find((item) => Number(item.number) === Number(episodeNumber)) || null;
|
||||
}
|
||||
|
||||
function getOrigin(url) {
|
||||
const match = typeof url === 'string' ? url.match(/^(https?:\/\/[^/]+)/i) : null;
|
||||
return match ? match[1] : BASE_URL;
|
||||
}
|
||||
|
||||
function getQuality(stream) {
|
||||
const value = String(stream.quality || stream.server || 'HD');
|
||||
const resolution = value.match(/(2160|1440|1080|720|480|360)p?/i);
|
||||
|
||||
if (resolution) {
|
||||
return `${resolution[1]}p`;
|
||||
}
|
||||
|
||||
return /4k/i.test(value) ? '4K' : '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'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStreams(sourcePayload, provider, variant) {
|
||||
const subtitleCount = Array.isArray(sourcePayload.subtitles) ? sourcePayload.subtitles.length : 0;
|
||||
const language = variant === 'dub' ? 'DUB' : 'SUB';
|
||||
|
||||
return (sourcePayload.streams || []).filter(isPlayableStream).map((stream) => {
|
||||
const referer = stream.referer || BASE_URL;
|
||||
const server = stream.server || getQuality(stream);
|
||||
const subtitleLabel = subtitleCount > 0 && variant !== 'dub' ? ' · CC' : '';
|
||||
|
||||
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: getQuality(stream),
|
||||
headers: {
|
||||
'Referer': referer,
|
||||
'Origin': getOrigin(referer),
|
||||
'User-Agent': HEADERS['User-Agent']
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export async function extractStreams(tmdbId, mediaType, season, episode) {
|
||||
if (!tmdbId || (mediaType !== 'movie' && mediaType !== 'tv')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolved = await resolveEpisodePayload(tmdbId, mediaType, season, episode);
|
||||
|
||||
if (!resolved || !resolved.payload || !resolved.payload.providers) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user