mirror of
https://github.com/Shineii86/AniKotoAPI.git
synced 2026-10-22 10:55:10 +00:00
feat: Add streaming URL resolver, quality detection, and server normalization
New endpoints: - GET /api/stream/resolve — Resolves actual m3u8/mp4 URLs from embed player URLs Follows chain: vidtube.site → megaplay API → m3u8 playlist Returns url, type, qualities, subtitles, skipData - GET /api/stream/qualities — Parses M3U8 playlists for available quality options Returns sorted quality list with resolution, bandwidth, URLs New extractors: - streamResolver.extractor.js — Full stream resolution pipeline - resolveStreamUrl(): Resolves embed → actual video URL - parseM3u8Qualities(): Parses master playlists for qualities - normalizeServerName(): Maps VidPlay-1 → vidplay, HD-1 → hd - extractSubtitles(): Extracts subtitle tracks from API responses Enhancements: - streamInfo.extractor.js: Added session establishment before AJAX calls - Server list now includes normalizedName field for each server - Stream info now includes type (hls/mp4) and backup URL - Server list includes typeLabel (SUB/HSUB/DUB) - Session cookies properly maintained for AJAX stream endpoints Architecture: - Embed API domain mapping (vidtube.site → megaplay-1.buzz) - M3U8 parser supports master and media playlists - Subtitle extraction from multiple API response formats - 36 total endpoints (was 34)
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* AniKotoAPI — streamResolver.controller.js
|
||||||
|
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Controller for stream URL resolution and quality detection.
|
||||||
|
* Resolves actual video URLs (m3u8/mp4) from embed player URLs
|
||||||
|
* and parses available quality options.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getResolvedStream - Express route handler for GET /api/stream/resolve
|
||||||
|
* getStreamQualities - Express route handler for GET /api/stream/qualities
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolveStreamUrl, parseM3u8Qualities } from "../extractors/streamResolver.extractor.js";
|
||||||
|
import { extractStreamInfo } from "../extractors/streamInfo.extractor.js";
|
||||||
|
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: RESOLVE STREAM
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Resolve actual video stream URL from link ID ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/stream/resolve requests. Resolves the actual
|
||||||
|
* video stream URL (m3u8/mp4) from a server link ID.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {string} req.query.id - Server link ID
|
||||||
|
* @param {string} req.query.slug - Anime slug (used to establish session)
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with resolved stream URL
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/stream/resolve?id=abc123&slug=one-piece-odmau
|
||||||
|
* Response: { success: true, results: { url, qualities, type } }
|
||||||
|
*
|
||||||
|
* @throws {400} If 'id' query parameter is missing
|
||||||
|
*/
|
||||||
|
const getResolvedStream = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, slug } = req.query;
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ success: false, message: "Link ID is required" });
|
||||||
|
}
|
||||||
|
const cacheKey = `resolved_${id}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: First get the embed URL from the link ID (with session establishment)
|
||||||
|
const streamInfo = await extractStreamInfo(id, slug);
|
||||||
|
if (!streamInfo.url) {
|
||||||
|
return res.status(404).json({ success: false, message: "Stream URL not found for this link ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Resolve the actual video URL from the embed page
|
||||||
|
const resolved = await resolveStreamUrl(streamInfo.url);
|
||||||
|
const result = {
|
||||||
|
linkId: id,
|
||||||
|
embedUrl: streamInfo.url,
|
||||||
|
...resolved,
|
||||||
|
skipData: streamInfo.skipData || resolved.skipData,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCache(cacheKey, result, TTL.stream);
|
||||||
|
res.json({ success: true, results: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CONTROLLER: STREAM QUALITIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Available quality options for a stream URL ----
|
||||||
|
/**
|
||||||
|
* Handles GET /api/stream/qualities requests. Parses an M3U8
|
||||||
|
* playlist URL to return available quality options.
|
||||||
|
*
|
||||||
|
* @param {object} req - Express request object
|
||||||
|
* @param {string} req.query.url - M3U8 playlist URL
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {Function} next - Express next middleware function
|
||||||
|
* @returns {void} Sends JSON response with quality options
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* GET /api/stream/qualities?url=https://example.com/master.m3u8
|
||||||
|
* Response: { success: true, results: [{ label: "720p", url: "..." }] }
|
||||||
|
*
|
||||||
|
* @throws {400} If 'url' query parameter is missing
|
||||||
|
*/
|
||||||
|
const getStreamQualities = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ success: false, message: "M3U8 URL is required" });
|
||||||
|
}
|
||||||
|
const cacheKey = `qualities_${url}`;
|
||||||
|
const cached = getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return res.json({ success: true, results: cached });
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualities = await parseM3u8Qualities(url);
|
||||||
|
const result = {
|
||||||
|
url,
|
||||||
|
totalQualities: qualities.length,
|
||||||
|
qualities,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCache(cacheKey, result, TTL.stream);
|
||||||
|
res.json({ success: true, results: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getResolvedStream, getStreamQualities };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: streamResolver.controller.js
|
||||||
@@ -20,6 +20,7 @@ import * as cheerio from "cheerio";
|
|||||||
import { headers } from "../configs/header.config.js";
|
import { headers } from "../configs/header.config.js";
|
||||||
import { BASE_URL } from "../configs/dataUrl.js";
|
import { BASE_URL } from "../configs/dataUrl.js";
|
||||||
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
import { fetchWithMirror } from "../helper/mirror.helper.js";
|
||||||
|
import { normalizeServerName } from "./streamResolver.extractor.js";
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// STREAM INFO EXTRACTOR
|
// STREAM INFO EXTRACTOR
|
||||||
@@ -28,33 +29,51 @@ import { fetchWithMirror } from "../helper/mirror.helper.js";
|
|||||||
// ---- FEATURE: Resolve a single stream URL from a linkId ----
|
// ---- FEATURE: Resolve a single stream URL from a linkId ----
|
||||||
/**
|
/**
|
||||||
* Fetches the actual stream URL and skip data for a given server link ID.
|
* Fetches the actual stream URL and skip data for a given server link ID.
|
||||||
* This is the primary method for resolving playable video URLs.
|
* First establishes a session by fetching the watch page, then queries
|
||||||
|
* the AJAX stream endpoint with proper session context.
|
||||||
*
|
*
|
||||||
* @param {string} linkId - The server link ID to resolve
|
* @param {string} linkId - The server link ID to resolve
|
||||||
* @returns {Promise<Object>} Object with linkId, url, and skipData (intro/outro timestamps)
|
* @param {string} [watchSlug] - Optional watch page slug to establish session
|
||||||
|
* @returns {Promise<Object>} Object with linkId, url, type, skipData (intro/outro timestamps)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const stream = await extractStreamInfo("abc123");
|
* const stream = await extractStreamInfo("abc123", "one-piece-odmau");
|
||||||
* console.log(stream.url); // direct video URL
|
* console.log(stream.url); // direct video URL
|
||||||
* console.log(stream.skipData); // intro/outro skip ranges
|
* console.log(stream.skipData); // intro/outro skip ranges
|
||||||
*/
|
*/
|
||||||
const extractStreamInfo = async (linkId) => {
|
const extractStreamInfo = async (linkId, watchSlug = null) => {
|
||||||
try {
|
try {
|
||||||
|
// NOTE: Establish session by visiting watch page first (required for AJAX endpoints)
|
||||||
|
if (watchSlug) {
|
||||||
|
await fetchWithMirror(`/watch/${watchSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
const path = `/ajax/server?get=${linkId}`;
|
const path = `/ajax/server?get=${linkId}`;
|
||||||
const { data } = await fetchWithMirror(path, {
|
const { data: raw } = await fetchWithMirror(path, {
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// NOTE: Handle both string JSON and parsed object responses
|
||||||
|
let data = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try { data = JSON.parse(raw); } catch { data = {}; }
|
||||||
|
}
|
||||||
|
|
||||||
if (!data || !data.result) {
|
if (!data || !data.result) {
|
||||||
return { linkId, url: null, skipData: null };
|
return { linkId, url: null, type: null, skipData: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = typeof data.result === "string" ? (() => { try { return JSON.parse(data.result); } catch { return {}; } })() : data.result;
|
const result = typeof data.result === "string" ? (() => { try { return JSON.parse(data.result); } catch { return {}; } })() : data.result;
|
||||||
|
|
||||||
|
const streamUrl = result.url || null;
|
||||||
|
const streamType = streamUrl?.includes(".m3u8") ? "hls" : streamUrl ? "mp4" : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
linkId,
|
linkId,
|
||||||
url: result.url || null,
|
url: streamUrl,
|
||||||
skipData: result.skip_data || null
|
type: streamType,
|
||||||
|
skipData: result.skip_data || null,
|
||||||
|
backup: result.backup || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -68,17 +87,24 @@ const extractStreamInfo = async (linkId) => {
|
|||||||
// ---- FEATURE: Fetch available servers for a set of episode IDs ----
|
// ---- FEATURE: Fetch available servers for a set of episode IDs ----
|
||||||
/**
|
/**
|
||||||
* Retrieves the server list for the given episode IDs from the AJAX endpoint.
|
* Retrieves the server list for the given episode IDs from the AJAX endpoint.
|
||||||
* Returns raw server data as provided by the source site.
|
* First establishes a session by fetching the watch page, then queries
|
||||||
|
* the AJAX server list endpoint with proper session context.
|
||||||
*
|
*
|
||||||
* @param {string} episodeIds - Comma-separated or single episode ID string
|
* @param {string} episodeIds - Comma-separated or single episode ID string
|
||||||
|
* @param {string} [watchSlug] - Optional watch page slug to establish session
|
||||||
* @returns {Promise<Array>} Array of server objects with type, link_id, ep_id, etc.
|
* @returns {Promise<Array>} Array of server objects with type, link_id, ep_id, etc.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const servers = await extractServerList("12345");
|
* const servers = await extractServerList("12345", "one-piece-odmau");
|
||||||
* console.log(servers[0].link_id); // first server's link ID
|
* console.log(servers[0].link_id); // first server's link ID
|
||||||
*/
|
*/
|
||||||
const extractServerList = async (episodeIds) => {
|
const extractServerList = async (episodeIds, watchSlug = null) => {
|
||||||
try {
|
try {
|
||||||
|
// NOTE: Establish session by visiting watch page first (required for AJAX endpoints)
|
||||||
|
if (watchSlug) {
|
||||||
|
await fetchWithMirror(`/watch/${watchSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
const path = `/ajax/server/list?servers=${episodeIds}`;
|
const path = `/ajax/server/list?servers=${episodeIds}`;
|
||||||
const { data: raw } = await fetchWithMirror(path, {
|
const { data: raw } = await fetchWithMirror(path, {
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||||
@@ -88,19 +114,23 @@ const extractServerList = async (episodeIds) => {
|
|||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const servers = [];
|
const servers = [];
|
||||||
$(".servers .type").each((_, typeEl) => {
|
$(".servers .type").each((_, typeEl) => {
|
||||||
const type = $(typeEl).attr("data-type") || "sub";
|
const type = $(typeEl).attr("data-type") || "sub";
|
||||||
$(typeEl).find("li[data-link-id]").each((__, li) => {
|
const label = $(typeEl).find("label").text().trim() || type.toUpperCase();
|
||||||
servers.push({
|
$(typeEl).find("li[data-link-id]").each((__, li) => {
|
||||||
type,
|
const rawName = $(li).text().trim() || "";
|
||||||
ep_id: $(li).attr("data-ep-id") || "",
|
servers.push({
|
||||||
link_id: $(li).attr("data-link-id") || "",
|
type,
|
||||||
cmid: $(li).attr("data-cmid") || "",
|
typeLabel: label,
|
||||||
sv_id: $(li).attr("data-sv-id") || "",
|
ep_id: $(li).attr("data-ep-id") || "",
|
||||||
name: $(li).text().trim() || ""
|
link_id: $(li).attr("data-link-id") || "",
|
||||||
|
cmid: $(li).attr("data-cmid") || "",
|
||||||
|
sv_id: $(li).attr("data-sv-id") || "",
|
||||||
|
name: rawName,
|
||||||
|
normalizedName: normalizeServerName(rawName),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return servers;
|
return servers;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
+22
-1
@@ -26,6 +26,7 @@ import { getSearchResults, getSearchSuggestions } from "../controllers/search.co
|
|||||||
import { getEpisodeList } from "../controllers/episodeList.controller.js";
|
import { getEpisodeList } from "../controllers/episodeList.controller.js";
|
||||||
import { getEpisodeListAjax } from "../controllers/episodeListAjax.controller.js";
|
import { getEpisodeListAjax } from "../controllers/episodeListAjax.controller.js";
|
||||||
import { getStreamInfo, getServerList, getMapperServers } from "../controllers/streamInfo.controller.js";
|
import { getStreamInfo, getServerList, getMapperServers } from "../controllers/streamInfo.controller.js";
|
||||||
|
import { getResolvedStream, getStreamQualities } from "../controllers/streamResolver.controller.js";
|
||||||
import { getSchedule } from "../controllers/schedule.controller.js";
|
import { getSchedule } from "../controllers/schedule.controller.js";
|
||||||
import { getSpotlight } from "../controllers/spotlight.controller.js";
|
import { getSpotlight } from "../controllers/spotlight.controller.js";
|
||||||
import { getTrending } from "../controllers/trending.controller.js";
|
import { getTrending } from "../controllers/trending.controller.js";
|
||||||
@@ -209,6 +210,24 @@ app.use((req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Resolve actual stream URL (m3u8/mp4) from link ID ----
|
||||||
|
app.get("/api/stream/resolve", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getResolvedStream(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Available quality options for a stream URL ----
|
||||||
|
app.get("/api/stream/qualities", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await getStreamQualities(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
jsonError(res, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SCHEDULE
|
// SCHEDULE
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -475,7 +494,7 @@ app.get("/api/stats", (req, res) => {
|
|||||||
ttl: "5 minutes",
|
ttl: "5 minutes",
|
||||||
description: "Map-based cache with TTL expiration",
|
description: "Map-based cache with TTL expiration",
|
||||||
},
|
},
|
||||||
endpoints: 34,
|
endpoints: 36,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -544,6 +563,8 @@ app.get("/api/openapi", (req, res) => {
|
|||||||
"/mirrors/reset": { post: { summary: "Reset mirror cache", tags: ["System"] } },
|
"/mirrors/reset": { post: { summary: "Reset mirror cache", tags: ["System"] } },
|
||||||
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
||||||
"/watch": { get: { summary: "Watch page data", tags: ["Streaming"] } },
|
"/watch": { get: { summary: "Watch page data", tags: ["Streaming"] } },
|
||||||
|
"/stream/resolve": { get: { summary: "Resolve actual stream URL (m3u8/mp4)", tags: ["Streaming"] } },
|
||||||
|
"/stream/qualities": { get: { summary: "Available quality options for M3U8 stream", tags: ["Streaming"] } },
|
||||||
"/search/suggest": { get: { summary: "Search suggestions", tags: ["Search"] } },
|
"/search/suggest": { get: { summary: "Search suggestions", tags: ["Search"] } },
|
||||||
"/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } },
|
"/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } },
|
||||||
"/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } },
|
"/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } },
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ const tests = [
|
|||||||
{ name: "Info", url: "/info?id=one-piece-odmau", check: (d) => d.results?.title },
|
{ name: "Info", url: "/info?id=one-piece-odmau", check: (d) => d.results?.title },
|
||||||
{ name: "Episodes", url: "/episodes/one-piece-odmau", check: (d) => d.results?.episodes },
|
{ name: "Episodes", url: "/episodes/one-piece-odmau", check: (d) => d.results?.episodes },
|
||||||
{ name: "Servers", url: "/servers?ids=1", check: (d) => d.results },
|
{ name: "Servers", url: "/servers?ids=1", check: (d) => d.results },
|
||||||
{ name: "Watch", url: "/watch?slug=one-piece-odmau&ep=1", check: (d) => d.results?.servers || d.results?.episodeNumber },
|
// Streaming
|
||||||
|
{ name: "Stream", url: "/watch?slug=one-piece-odmau&ep=1", check: (d) => d.results?.servers || d.results?.episodeNumber },
|
||||||
|
{ name: "Stream Resolve", url: "/stream/resolve?id=MTF1dkFtaW9BRTZPbzJJRElFZUZrNHVJWFRVWWRWdU5JYnhuaU95bVRYSzk1Z0lVVjJha2FnSE95U3F4M1RsaXNCSnlnd05rTTlBUldTZGZxZjQzajVxcXVDRVY5bjBocnRCV2tkNnNzaXFnc3c5M1JJWjRnNmt5bDNPdm1TczlvVXdwWGplKzFmQmZJMlp0OTBNTXd3PT0&slug=one-piece-odmau", check: (d) => d.results?.url, optional: true },
|
||||||
|
|
||||||
// Discovery endpoints
|
// Discovery endpoints
|
||||||
{ name: "Suggestions", url: "/suggestions?keyword=naruto", check: (d) => d.results },
|
{ name: "Suggestions", url: "/suggestions?keyword=naruto", check: (d) => d.results },
|
||||||
|
|||||||
Reference in New Issue
Block a user