Initial commit

This commit is contained in:
janisslsm committed 2026-07-19 20:09:25 +03:00
commit 6e86adb0e6
23 files changed
+6141

No files matched your search

+379
View File
@@ -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;
});
}