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:
Shinei Nouzen
2026-07-28 20:40:17 +05:30
parent e8c942a773
commit bdf7e96ce9
5 changed files with 513 additions and 24 deletions
+52 -22
View File
@@ -20,6 +20,7 @@ import * as cheerio from "cheerio";
import { headers } from "../configs/header.config.js";
import { BASE_URL } from "../configs/dataUrl.js";
import { fetchWithMirror } from "../helper/mirror.helper.js";
import { normalizeServerName } from "./streamResolver.extractor.js";
// ══════════════════════════════════════════════════════════════
// STREAM INFO EXTRACTOR
@@ -28,33 +29,51 @@ import { fetchWithMirror } from "../helper/mirror.helper.js";
// ---- FEATURE: Resolve a single stream URL from a linkId ----
/**
* 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
* @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
* const stream = await extractStreamInfo("abc123");
* const stream = await extractStreamInfo("abc123", "one-piece-odmau");
* console.log(stream.url); // direct video URL
* console.log(stream.skipData); // intro/outro skip ranges
*/
const extractStreamInfo = async (linkId) => {
const extractStreamInfo = async (linkId, watchSlug = null) => {
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 { data } = await fetchWithMirror(path, {
const { data: raw } = await fetchWithMirror(path, {
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) {
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 streamUrl = result.url || null;
const streamType = streamUrl?.includes(".m3u8") ? "hls" : streamUrl ? "mp4" : null;
return {
linkId,
url: result.url || null,
skipData: result.skip_data || null
url: streamUrl,
type: streamType,
skipData: result.skip_data || null,
backup: result.backup || null,
};
} catch (error) {
throw error;
@@ -68,17 +87,24 @@ const extractStreamInfo = async (linkId) => {
// ---- FEATURE: Fetch available servers for a set of episode IDs ----
/**
* 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} [watchSlug] - Optional watch page slug to establish session
* @returns {Promise<Array>} Array of server objects with type, link_id, ep_id, etc.
*
* @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
*/
const extractServerList = async (episodeIds) => {
const extractServerList = async (episodeIds, watchSlug = null) => {
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 { data: raw } = await fetchWithMirror(path, {
headers: { "X-Requested-With": "XMLHttpRequest" }
@@ -88,19 +114,23 @@ const extractServerList = async (episodeIds) => {
const $ = cheerio.load(html);
const servers = [];
$(".servers .type").each((_, typeEl) => {
const type = $(typeEl).attr("data-type") || "sub";
$(typeEl).find("li[data-link-id]").each((__, li) => {
servers.push({
type,
ep_id: $(li).attr("data-ep-id") || "",
link_id: $(li).attr("data-link-id") || "",
cmid: $(li).attr("data-cmid") || "",
sv_id: $(li).attr("data-sv-id") || "",
name: $(li).text().trim() || ""
$(".servers .type").each((_, typeEl) => {
const type = $(typeEl).attr("data-type") || "sub";
const label = $(typeEl).find("label").text().trim() || type.toUpperCase();
$(typeEl).find("li[data-link-id]").each((__, li) => {
const rawName = $(li).text().trim() || "";
servers.push({
type,
typeLabel: label,
ep_id: $(li).attr("data-ep-id") || "",
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;
} catch (error) {
+307
View File
@@ -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