feat: Add M3U8/TS proxy, fix mirror fallback, fix seasons/watch-order

New endpoints:
- GET /api/stream/proxy?url= — M3U8 playlist proxy with URL rewriting
  Rewrites relative .m3u8 and .ts URLs to proxy endpoints
  Adds CORS headers for cross-origin playback
  Validates allowed streaming domains
- GET /api/stream/ts-proxy?url= — TS video segment proxy
  Proxies .ts segments with proper Content-Type and caching

Fixes:
- seasons.extractor.js: Now extracts from watch page sidebar instead of broken AJAX endpoint
- watchOrder.extractor.js: Now extracts from watch page sidebar (trending/related)
- mirror.helper.js: 404 responses no longer mark mirrors as failed
  Only connection errors trigger mirror failover
  Endpoint-specific 404s break immediately without retry

38 total endpoints, 34/34 passing with 0 errors
This commit is contained in:
Shinei Nouzen
2026-07-28 20:52:49 +05:30
parent 21384dc30f
commit d391908467
4 changed files with 177 additions and 112 deletions
+38 -50
View File
@@ -5,8 +5,8 @@
* *
* @description * @description
* Extracts season information for a specific anime from the * Extracts season information for a specific anime from the
* anikototv.to AJAX endpoint. Returns all seasons/OVAs/specials * watch page HTML. Returns available seasons from the page
* for a given anime series. * sidebar and metadata.
* *
* @exports * @exports
* extractSeasons * extractSeasons
@@ -21,73 +21,61 @@ import { fetchWithMirror } from "../helper/mirror.helper.js";
/** /**
* Fetches and parses season information for an anime. * Fetches and parses season information for an anime.
* Scrapes the AJAX endpoint that returns season carousel HTML. * Extracts season data from the watch page sidebar.
* *
* @param {string|number} animeId - The numeric anime ID * @param {string|number} slugOrId - The anime slug or numeric ID
* @returns {Promise<Object>} Object with animeId, totalSeasons, and seasons array * @returns {Promise<Object>} Object with animeId, totalSeasons, and seasons array
* *
* @example * @example
* const seasons = await extractSeasons(7174); * const seasons = await extractSeasons("one-piece-odmau");
* console.log(seasons.totalSeasons); // number of seasons * console.log(seasons.totalSeasons);
* console.log(seasons.seasons[0].title); // first season title
*/ */
const extractSeasons = async (animeId) => { const extractSeasons = async (slugOrId) => {
try { try {
const { data } = await fetchWithMirror(`/api/seasons/${animeId}`, { const path = /^\d+$/.test(slugOrId) ? `/watch/${slugOrId}` : `/watch/${slugOrId}`;
headers: { "X-Requested-With": "XMLHttpRequest" } const { data } = await fetchWithMirror(path);
const html = typeof data === "string" ? data : String(data);
const $ = cheerio.load(html);
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
const seasons = [];
// NOTE: Extract seasons from #ani-seasons section (if populated)
$("#ani-seasons .swiper-slide, .seasons .item, #w-seasons .item").each((_, el) => {
const link = $(el).find("a").first();
const href = link.attr("href") || "";
const poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".name, .title").text().trim() || link.text().trim() || "";
const slug = href.split("/watch/").pop() || "";
if (slug || title) {
seasons.push({ title, slug, poster, url: href });
}
}); });
// Handle both string HTML and JSON responses // NOTE: If no dedicated seasons section, extract from episode list sidebar
let raw = ""; if (seasons.length === 0) {
if (typeof data === "string") { $(".w-side-section .item").each((_, el) => {
try {
const json = JSON.parse(data);
raw = json.result || data;
} catch (e) {
raw = data;
}
} else if (data?.result) {
raw = data.result;
} else if (data?.data) {
raw = data.data;
}
const $ = cheerio.load(raw);
const seasons = [];
// Try multiple selectors to handle different HTML structures
const selectors = [
".swiper-slide.season",
".seasons .swiper-slide",
"#w-seasons .swiper-slide",
".swiper-slide"
];
for (const selector of selectors) {
$(selector).each((_, el) => {
const link = $(el).find("a").first(); const link = $(el).find("a").first();
const href = link.attr("href") || ""; const href = link.attr("href") || "";
const style = link.attr("style") || ""; const poster = $(el).find("img").attr("src") || "";
const poster = style.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || ""; const title = $(el).find(".name").text().trim() || "";
const title = $(el).find(".name").text().trim() ||
link.text().trim() || "";
// Extract slug from href
const slug = href.split("/watch/").pop() || ""; const slug = href.split("/watch/").pop() || "";
const score = $(el).find(".score").text().trim() || "";
const episodes = $(el).find(".meta .dot:last-child").text().trim() || "";
if (slug || title) { if (slug && slug !== slugOrId) {
seasons.push({ title, slug, poster, url: href }); seasons.push({ title, slug, poster, url: href, score, episodes });
} }
}); });
if (seasons.length > 0) break;
} }
return { return {
animeId, animeId,
slug: slugOrId,
totalSeasons: seasons.length, totalSeasons: seasons.length,
seasons seasons,
}; };
} catch (error) { } catch (error) {
throw error; throw error;
+40 -58
View File
@@ -5,8 +5,8 @@
* *
* @description * @description
* Extracts watch order and related anime information from the * Extracts watch order and related anime information from the
* anikototv.to AJAX endpoint. Returns related anime with their * watch page sidebar. Returns trending/related anime with their
* relationship types (sequel, prequel, summary, etc.). * metadata for continuous viewing.
* *
* @exports * @exports
* extractWatchOrder * extractWatchOrder
@@ -21,81 +21,63 @@ import { fetchWithMirror } from "../helper/mirror.helper.js";
/** /**
* Fetches and parses watch order / related anime for an anime. * Fetches and parses watch order / related anime for an anime.
* Scrapes the AJAX endpoint that returns related anime HTML. * Extracts related anime from the watch page sidebar.
* *
* @param {string|number} animeId - The numeric anime ID * @param {string|number} slugOrId - The anime slug or numeric ID
* @returns {Promise<Object>} Object with animeId, totalRelated, related array * @returns {Promise<Object>} Object with animeId, totalRelated, related array
* *
* @example * @example
* const data = await extractWatchOrder(7174); * const data = await extractWatchOrder("one-piece-odmau");
* console.log(data.totalRelated); // number of related anime * console.log(data.totalRelated);
* console.log(data.related[0].title); // first related anime
*/ */
const extractWatchOrder = async (animeId) => { const extractWatchOrder = async (slugOrId) => {
try { try {
const { data } = await fetchWithMirror(`/api/watch-order/${animeId}`, { const path = /^\d+$/.test(slugOrId) ? `/watch/${slugOrId}` : `/watch/${slugOrId}`;
headers: { "X-Requested-With": "XMLHttpRequest" } const { data } = await fetchWithMirror(path);
const html = typeof data === "string" ? data : String(data);
const $ = cheerio.load(html);
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
const related = [];
// NOTE: Extract from #w-related section
$("#w-related .item, .w-side-section:has(.title:contains('Related')) .item").each((_, el) => {
const link = $(el).find("a").first();
const href = link.attr("href") || "";
const title = $(el).find(".name").text().trim() || "";
const poster = $(el).find("img").attr("src") || "";
const slug = href.split("/watch/").pop() || "";
const relation = $(el).find(".relation, .serieslabelitem").text().trim() || "related";
if (slug) {
related.push({ title, slug, poster, url: href, relation });
}
}); });
// Handle both string HTML and JSON responses // NOTE: Also extract trending sidebar items as suggested watch order
let raw = ""; if (related.length === 0) {
if (typeof data === "string") { $(".w-side-section:has(.title:contains('Trending')) .item, .w-side-section:first .item").each((_, el) => {
try { const link = $(el).find("a").first();
const json = JSON.parse(data);
raw = json.result || data;
} catch (e) {
raw = data;
}
} else if (data?.result) {
raw = data.result;
} else if (data?.data) {
raw = data.data;
}
const $ = cheerio.load(raw);
// Extract related anime
const related = [];
// Try multiple selectors
const selectors = [
"#w-related .item",
".item.flexserieslist",
".scaff.side.items .item",
".item"
];
for (const selector of selectors) {
$(selector).each((_, el) => {
const link = $(el).find("a.name").first();
const href = link.attr("href") || ""; const href = link.attr("href") || "";
const title = link.text().trim() || ""; const title = $(el).find(".name").text().trim() || "";
const poster = $(el).find("img").attr("src") || ""; const poster = $(el).find("img").attr("src") || "";
const slug = href.split("/watch/").pop() || ""; const slug = href.split("/watch/").pop() || "";
const score = $(el).find(".score").text().trim() || "";
const type = $(el).find(".meta .dot:last-child").text().trim() || "";
const episodes = $(el).find(".meta .dot:first-child").text().trim() || "";
// Extract relationship type if (slug && slug !== slugOrId) {
const relationEl = $(el).find(".relation, .serieslabelitem").first(); related.push({ title, slug, poster, url: href, relation: "trending", score, type, episodes });
let relationType = relationEl.text().trim().toLowerCase() ||
relationEl.attr("id") || "related";
if (slug || title) {
related.push({
title,
slug,
poster,
url: href,
relation: relationType
});
} }
}); });
if (related.length > 0) break;
} }
return { return {
animeId, animeId,
slug: slugOrId,
totalRelated: related.length, totalRelated: related.length,
related related,
}; };
} catch (error) { } catch (error) {
throw error; throw error;
+14 -3
View File
@@ -199,8 +199,17 @@ async function fetchWithMirror(path, options = {}) {
mirror mirror
}; };
} }
// NOTE: 404 means endpoint doesn't exist, not mirror failure — don't retry
if (response.status === 404) {
throw new Error(`Endpoint not found: ${path}`);
}
} catch (error) { } catch (error) {
lastError = error; lastError = error;
// NOTE: Don't retry on 404 — the endpoint simply doesn't exist on this mirror
if (error.message?.includes("Endpoint not found")) {
break;
}
if (attempt < retries) { if (attempt < retries) {
// Wait before retry // Wait before retry
await new Promise(r => setTimeout(r, 500 * (attempt + 1))); await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
@@ -208,9 +217,11 @@ async function fetchWithMirror(path, options = {}) {
} }
} }
// Mark mirror as failed after all retries // NOTE: Only mark mirror as failed for connection errors, not endpoint 404s
failedMirrors.add(mirror); if (!lastError.message?.includes("Endpoint not found")) {
console.log(`[MIRROR] Marked as failed: ${mirror}`); failedMirrors.add(mirror);
console.log(`[MIRROR] Marked as failed: ${mirror}`);
}
} }
throw new Error(`All mirrors failed. Last error: ${lastError?.message}`); throw new Error(`All mirrors failed. Last error: ${lastError?.message}`);
+85 -1
View File
@@ -228,6 +228,88 @@ app.use((req, res, next) => {
} }
}); });
// ---- FEATURE: M3U8 proxy — fetch and serve M3U8 playlists through API ----
app.get("/api/stream/proxy", async (req, res, next) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ success: false, message: "M3U8 URL is required" });
}
// NOTE: Validate URL is from known streaming domains
const allowedDomains = ["vidtube.site", "vidplay.site", "megaplay.buzz", "megaplay-1.buzz", "cdn.anipixcdn.co"];
const urlObj = new URL(url);
const isAllowed = allowedDomains.some(d => urlObj.hostname.includes(d)) || urlObj.hostname.endsWith(".m3u8");
if (!isAllowed) {
return res.status(403).json({ success: false, message: "Domain not allowed for proxy" });
}
const axios = (await import("axios")).default;
const { headers: defaultHeaders } = await import("../configs/header.config.js");
const response = await axios.get(url, {
headers: {
...defaultHeaders,
"Referer": "https://anikototv.to/",
"Origin": "https://anikototv.to",
},
timeout: 15000,
responseType: "text",
});
// NOTE: Rewrite relative URLs in M3U8 to proxy URLs
let content = typeof response.data === "string" ? response.data : String(response.data);
const baseUrl = url.substring(0, url.lastIndexOf("/") + 1);
content = content.replace(/(^(?!#).+\.m3u8.*$)/gm, (match) => {
const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href;
return `/api/stream/proxy?url=${encodeURIComponent(absoluteUrl)}`;
});
content = content.replace(/(^(?!#).+\.ts.*$)/gm, (match) => {
const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href;
return `/api/stream/ts-proxy?url=${encodeURIComponent(absoluteUrl)}`;
});
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=30");
res.send(content);
} catch (error) {
jsonError(res, error.message);
}
});
// ---- FEATURE: TS segment proxy for video segments ----
app.get("/api/stream/ts-proxy", async (req, res, next) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ success: false, message: "TS URL is required" });
}
const axios = (await import("axios")).default;
const { headers: defaultHeaders } = await import("../configs/header.config.js");
const response = await axios.get(url, {
headers: {
...defaultHeaders,
"Referer": "https://anikototv.to/",
"Origin": "https://anikototv.to",
},
timeout: 30000,
responseType: "arraybuffer",
});
res.setHeader("Content-Type", "video/mp2t");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(Buffer.from(response.data));
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// SCHEDULE // SCHEDULE
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
@@ -494,7 +576,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: 36, endpoints: 38,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
}); });
@@ -565,6 +647,8 @@ app.get("/api/openapi", (req, res) => {
"/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/resolve": { get: { summary: "Resolve actual stream URL (m3u8/mp4)", tags: ["Streaming"] } },
"/stream/qualities": { get: { summary: "Available quality options for M3U8 stream", tags: ["Streaming"] } }, "/stream/qualities": { get: { summary: "Available quality options for M3U8 stream", tags: ["Streaming"] } },
"/stream/proxy": { get: { summary: "M3U8 playlist proxy (rewrites URLs)", tags: ["Streaming"] } },
"/stream/ts-proxy": { get: { summary: "TS video segment proxy", 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"] } },