mirror of
https://github.com/Shineii86/AniKotoAPI.git
synced 2026-10-22 10:55:10 +00:00
Added comprehensive documentation following project conventions: Box-style headers (added/fixed where missing): - All 17 files now have standard Project/Author/License headers Section headers (// == == SECTION == ==): - cache.helper.js: LRU CACHE CLASS, TTL CONFIGURATION, SINGLETON, EXPORTED HELPERS - download.controller.js: DOWNLOAD LINKS HANDLER - download.extractor.js: BASE64 DECODER, REDIRECT FOLLOWER, DOWNLOAD LINKS EXTRACTOR - seasons.extractor.js: SEASONS EXTRACTOR - watchOrder.extractor.js: WATCH ORDER EXTRACTOR - extractPages.helper.js: PAGE EXTRACTOR - pagination.helper.js: PAGINATION METADATA Feature markers (// --- FEATURE: XYZ ---): - cache.helper.js: LRU Cache, Per-Endpoint TTL, Cache Get/Set/Clear - download.controller.js: Download Links - download.extractor.js: Base64 Download Data Decoder, Redirect Chain Resolver, Download Links Extraction - seasons.extractor.js: Season Information Extraction - watchOrder.extractor.js: Watch Order / Related Anime Extraction - streamResolver.extractor.js: Server Name Normalization, Stream URL Resolution, Batch Stream URL Resolution, M3U8 Quality Parser, Subtitle Track Extractor - mirror.helper.js: Mirror Domain Configuration, Mirror Health Check, Working Mirror Discovery, Mirror Cache Reset, URL Builder, Resilient Fetch with Mirror Fallback, Mirror Status Reporter - pagination.helper.js: Pagination Metadata Generator - creatorInfo.js: Creator Attribution Object, Response Creator Injection Middleware Function-level JSDoc (added/fixed): - cache.helper.js: Full JSDoc on all 6 class methods (constructor, get, set, delete, clear, getStats, has) and 4 exported helpers Inline notes for non-obvious logic: - cache.helper.js: Lazy expiration, LRU re-insertion, singleton pattern - download.extractor.js: HTML/JSON hybrid response handling, multiple selectors for different mirrors, redirect fallback - random.extractor.js: maxRedirects, validateStatus, responseURL extraction - schedule.extractor.js: Dual selector pattern for different mirror layouts - animeInfo.extractor.js: Positional .bmeta selector fragility - homeInfo.extractor.js: Dual selector for different layouts - mirror.helper.js: 404 vs connection error handling - animeInfo.controller.js: Accept both id and slug params Footers (added where missing): - cache.helper.js, download.controller.js, download.extractor.js, seasons.extractor.js, watchOrder.extractor.js, extractPages.helper.js, mirror.helper.js All 68 source files now have complete documentation coverage (6/6 checks).
313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
/*
|
|
* ======= • ======= • ======= • ======= • =======• =======
|
|
* AniKotoAPI — streamResolver.extractor.js
|
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
|
*
|
|
* @description
|
|
* Resolves actual streaming video URLs (m3u8/mp4) from embed player URLs.
|
|
* Follows the chain: vidtube.site embed → megaplay API → m3u8 playlist.
|
|
* Supports quality selection and subtitle extraction.
|
|
*
|
|
* @exports
|
|
* resolveStreamUrl, resolveStreamUrls, parseM3u8Qualities
|
|
*
|
|
* @author Shinei Nouzen
|
|
* @license MIT
|
|
* ======= • ======= • ======= • ======= • =======• =======
|
|
*/
|
|
|
|
import axios from "axios";
|
|
import { headers } from "../configs/header.config.js";
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// STREAM RESOLVER
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// Server name normalization map
|
|
const SERVER_NAME_MAP = {
|
|
"VidPlay-1": "vidplay",
|
|
"VidPlay-2": "vidplay",
|
|
"HD-1": "hd",
|
|
"HD-2": "hd",
|
|
"Vidstream-1": "vidstream",
|
|
"Vidstream-2": "vidstream",
|
|
"VidCloud-1": "vidcloud",
|
|
"VidCloud-2": "vidcloud",
|
|
"StreamTape-1": "streamtape",
|
|
"StreamTape-2": "streamtape",
|
|
};
|
|
|
|
// Embed domain to API domain mapping
|
|
const EMBED_API_MAP = {
|
|
"vidtube.site": "megaplay-1.buzz",
|
|
"vidplay.site": "megaplay-1.buzz",
|
|
"megaplay.buzz": "megaplay-1.buzz",
|
|
"embed.bunkrerrer.com": "megaplay-1.buzz",
|
|
};
|
|
|
|
// ---- FEATURE: Server Name Normalization ----
|
|
/**
|
|
* Normalizes server display names to clean identifiers
|
|
* @param {string} name - Raw server name (e.g., "VidPlay-1")
|
|
* @returns {string} Normalized name (e.g., "vidplay")
|
|
*/
|
|
const normalizeServerName = (name) => {
|
|
if (!name) return "unknown";
|
|
return SERVER_NAME_MAP[name] || name.toLowerCase().replace(/[-\s]+\d+$/, "").trim();
|
|
};
|
|
|
|
// ---- FEATURE: Stream URL Resolution ----
|
|
/**
|
|
* Resolves the actual video stream URL from an embed player page.
|
|
* Extracts data-id from the embed HTML, then queries the megaplay API.
|
|
*
|
|
* @param {string} embedUrl - The embed player URL (e.g., vidtube.site/stream/...)
|
|
* @param {object} [options] - Resolution options
|
|
* @param {boolean} [options.followRedirects=true] - Follow HTTP redirects
|
|
* @param {number} [options.timeout=10000] - Request timeout in ms
|
|
* @returns {Promise<Object>} Object with url, qualities, skipData, type
|
|
*
|
|
* @example
|
|
* const resolved = await resolveStreamUrl("https://vidtube.site/stream/...");
|
|
* console.log(resolved.url); // m3u8 URL
|
|
* console.log(resolved.qualities); // [{ label: "720p", url: "..." }]
|
|
*/
|
|
const resolveStreamUrl = async (embedUrl, options = {}) => {
|
|
const { followRedirects = true, timeout = 10000 } = options;
|
|
|
|
try {
|
|
// NOTE: Extract the embed page to find data-id and API domain
|
|
const embedResponse = await axios.get(embedUrl, {
|
|
headers: {
|
|
...headers,
|
|
"Referer": "https://anikototv.to/",
|
|
"Origin": "https://anikototv.to",
|
|
},
|
|
timeout,
|
|
maxRedirects: followRedirects ? 5 : 0,
|
|
});
|
|
|
|
const embedHtml = typeof embedResponse.data === "string"
|
|
? embedResponse.data
|
|
: String(embedResponse.data);
|
|
|
|
// NOTE: Extract data attributes from the player div
|
|
const dataId = embedHtml.match(/data-id="(\d+)"/)?.[1];
|
|
const realId = embedHtml.match(/data-realid="([^"]+)"/)?.[1];
|
|
const mediaId = embedHtml.match(/data-mediaid="([^"]+)"/)?.[1];
|
|
|
|
if (!dataId) {
|
|
return {
|
|
url: null,
|
|
qualities: [],
|
|
skipData: null,
|
|
type: null,
|
|
error: "Could not extract player data-id from embed page",
|
|
};
|
|
}
|
|
|
|
// NOTE: Determine API domain from embed URL or extract from page
|
|
const embedDomain = new URL(embedUrl).hostname;
|
|
const apiDomain = EMBED_API_MAP[embedDomain] || embedDomain;
|
|
|
|
// NOTE: Query the megaplay API for the actual stream URL
|
|
const apiResponse = await axios.get(
|
|
`https://${apiDomain}/ajax/sources/${dataId}`,
|
|
{
|
|
headers: {
|
|
...headers,
|
|
"Referer": embedUrl,
|
|
"Origin": `https://${embedDomain}`,
|
|
},
|
|
timeout,
|
|
}
|
|
);
|
|
|
|
const apiData = typeof apiResponse.data === "string"
|
|
? (() => { try { return JSON.parse(apiResponse.data); } catch { return {}; } })()
|
|
: apiResponse.data;
|
|
|
|
// NOTE: Extract stream URL from API response
|
|
const streamUrl = apiData?.source || apiData?.url || apiData?.file || null;
|
|
const streamType = apiData?.type || (streamUrl?.includes(".m3u8") ? "hls" : "mp4");
|
|
|
|
// NOTE: Parse m3u8 qualities if it's an HLS stream
|
|
let qualities = [];
|
|
if (streamUrl && streamType === "hls") {
|
|
qualities = await parseM3u8Qualities(streamUrl, {
|
|
headers: {
|
|
...headers,
|
|
"Referer": embedUrl,
|
|
},
|
|
timeout,
|
|
});
|
|
}
|
|
|
|
// NOTE: Extract subtitles if available
|
|
const subtitles = extractSubtitles(apiData);
|
|
|
|
return {
|
|
url: streamUrl,
|
|
type: streamType,
|
|
qualities,
|
|
subtitles,
|
|
skipData: apiData?.skip_data || null,
|
|
dataId,
|
|
realId,
|
|
mediaId,
|
|
backup: apiData?.backup || null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
url: null,
|
|
qualities: [],
|
|
skipData: null,
|
|
type: null,
|
|
error: error.message,
|
|
};
|
|
}
|
|
};
|
|
|
|
// ---- FEATURE: Batch Stream URL Resolution ----
|
|
/**
|
|
* Resolves stream URLs for multiple episodes in batch
|
|
* @param {string} embedUrl - The embed player URL
|
|
* @param {object} [options] - Resolution options
|
|
* @returns {Promise<Object>} Batch resolution result
|
|
*/
|
|
const resolveStreamUrls = async (embedUrl, options = {}) => {
|
|
try {
|
|
const resolved = await resolveStreamUrl(embedUrl, options);
|
|
return resolved;
|
|
} catch (error) {
|
|
return { url: null, error: error.message };
|
|
}
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// M3U8 PARSER
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ---- FEATURE: M3U8 Quality Parser ----
|
|
/**
|
|
* Parses an M3U8 playlist to extract available quality options.
|
|
* Supports both master playlists (multiple qualities) and media playlists.
|
|
*
|
|
* @param {string} m3u8Url - URL of the M3U8 playlist
|
|
* @param {object} [options] - Fetch options
|
|
* @param {object} [options.headers] - Custom headers
|
|
* @param {number} [options.timeout=10000] - Request timeout
|
|
* @returns {Promise<Array<Object>>} Array of quality objects
|
|
*
|
|
* @example
|
|
* const qualities = await parseM3u8Qualities("https://example.com/master.m3u8");
|
|
* qualities.forEach(q => console.log(`${q.label}: ${q.url}`));
|
|
*/
|
|
const parseM3u8Qualities = async (m3u8Url, options = {}) => {
|
|
const { headers: customHeaders = {}, timeout = 10000 } = options;
|
|
|
|
try {
|
|
const response = await axios.get(m3u8Url, {
|
|
headers: { ...headers, ...customHeaders },
|
|
timeout,
|
|
});
|
|
|
|
const content = typeof response.data === "string"
|
|
? response.data
|
|
: String(response.data);
|
|
|
|
const qualities = [];
|
|
const lines = content.split("\n").map((l) => l.trim());
|
|
|
|
// NOTE: Parse master playlist (contains #EXT-X-STREAM-INF)
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].startsWith("#EXT-X-STREAM-INF:")) {
|
|
const attrs = lines[i].substring("#EXT-X-STREAM-INF:".length);
|
|
const bandwidth = parseInt(attrs.match(/BANDWIDTH=(\d+)/)?.[1] || "0");
|
|
const resolution = attrs.match(/RESOLUTION=(\d+x\d+)/)?.[1] || "";
|
|
const [width, height] = resolution.split("x").map(Number);
|
|
const nextLine = lines[i + 1];
|
|
|
|
if (nextLine && !nextLine.startsWith("#")) {
|
|
// NOTE: Resolve relative URLs against base M3U8 URL
|
|
const qualityUrl = nextLine.startsWith("http")
|
|
? nextLine
|
|
: new URL(nextLine, m3u8Url).href;
|
|
|
|
qualities.push({
|
|
label: height ? `${height}p` : `${bandwidth}bps`,
|
|
width: width || 0,
|
|
height: height || 0,
|
|
bandwidth,
|
|
url: qualityUrl,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// NOTE: If no qualities found, it's likely a single-quality stream
|
|
if (qualities.length === 0 && content.includes("#EXTINF")) {
|
|
qualities.push({
|
|
label: "default",
|
|
width: 0,
|
|
height: 0,
|
|
bandwidth: 0,
|
|
url: m3u8Url,
|
|
});
|
|
}
|
|
|
|
// NOTE: Sort by resolution/bandwidth descending (highest first)
|
|
qualities.sort((a, b) => (b.height || b.bandwidth) - (a.height || a.bandwidth));
|
|
|
|
return qualities;
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SUBTITLE EXTRACTOR
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ---- FEATURE: Subtitle Track Extractor ----
|
|
/**
|
|
* Extracts subtitle information from API response data
|
|
* @param {object} apiData - The megaplay API response
|
|
* @returns {Array<Object>} Array of subtitle objects
|
|
*/
|
|
const extractSubtitles = (apiData) => {
|
|
const subtitles = [];
|
|
|
|
// NOTE: Subtitles may be in different formats depending on the source
|
|
if (apiData?.subtitles && Array.isArray(apiData.subtitles)) {
|
|
for (const sub of apiData.subtitles) {
|
|
subtitles.push({
|
|
label: sub.label || sub.language || "Unknown",
|
|
language: sub.code || sub.language || "unknown",
|
|
url: sub.url || sub.file || null,
|
|
format: sub.format || "srt",
|
|
});
|
|
}
|
|
}
|
|
|
|
// NOTE: Some sources embed subtitles in tracks array
|
|
if (apiData?.tracks && Array.isArray(apiData.tracks)) {
|
|
for (const track of apiData.tracks) {
|
|
if (track.kind === "subtitles" || track.type === "subtitles") {
|
|
subtitles.push({
|
|
label: track.label || track.language || "Unknown",
|
|
language: track.srclang || track.language || "unknown",
|
|
url: track.file || track.src || null,
|
|
format: track.format || "srt",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return subtitles;
|
|
};
|
|
|
|
export { resolveStreamUrl, resolveStreamUrls, parseM3u8Qualities, normalizeServerName, extractSubtitles };
|
|
|
|
// ══════════════════════════════════════════════════════════════ END: streamResolver.extractor.js
|