diff --git a/src/controllers/streamResolver.controller.js b/src/controllers/streamResolver.controller.js new file mode 100644 index 0000000..fc02aba --- /dev/null +++ b/src/controllers/streamResolver.controller.js @@ -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 diff --git a/src/extractors/streamInfo.extractor.js b/src/extractors/streamInfo.extractor.js index 4b15a3f..b892da1 100644 --- a/src/extractors/streamInfo.extractor.js +++ b/src/extractors/streamInfo.extractor.js @@ -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 with linkId, url, and skipData (intro/outro timestamps) + * @param {string} [watchSlug] - Optional watch page slug to establish session + * @returns {Promise} 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 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) { diff --git a/src/extractors/streamResolver.extractor.js b/src/extractors/streamResolver.extractor.js new file mode 100644 index 0000000..7dfbacf --- /dev/null +++ b/src/extractors/streamResolver.extractor.js @@ -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 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} 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 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} 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 diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index e073964..7e22a6b 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -26,6 +26,7 @@ import { getSearchResults, getSearchSuggestions } from "../controllers/search.co import { getEpisodeList } from "../controllers/episodeList.controller.js"; import { getEpisodeListAjax } from "../controllers/episodeListAjax.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 { getSpotlight } from "../controllers/spotlight.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 // ══════════════════════════════════════════════════════════════ @@ -475,7 +494,7 @@ app.get("/api/stats", (req, res) => { ttl: "5 minutes", description: "Map-based cache with TTL expiration", }, - endpoints: 34, + endpoints: 36, timestamp: new Date().toISOString(), }, }); @@ -544,6 +563,8 @@ app.get("/api/openapi", (req, res) => { "/mirrors/reset": { post: { summary: "Reset mirror cache", tags: ["System"] } }, "/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } }, "/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"] } }, "/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } }, "/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } }, diff --git a/test.js b/test.js index ade875c..da07a70 100644 --- a/test.js +++ b/test.js @@ -22,7 +22,9 @@ const tests = [ { 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: "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 { name: "Suggestions", url: "/suggestions?keyword=naruto", check: (d) => d.results },