]*class=["'][^"']*nv-server-grid[^"']*["'][^>]*data-id=["']([^"']+)["'][^>]*>([\s\S]*?)(?=
]*class=["'][^"']*nv-server-grid|$)/gi)) {
+ const rawAudio = panel[1].toLowerCase();
+ const panelAudio = rawAudio.includes("dub") ? "dub" : "sub";
+ for (const btn of panel[2].matchAll(/data-video=["']([^"']+)["']/gi)) byAudio[panelAudio].push(decodeEntities(btn[1]));
+ }
+ const audios = audio === "all" ? ["sub", "dub"] : [audio];
+ const streams = [];
+ await Promise.all(audios.map(async (aud) => {
+ const embeds = byAudio[aud] ?? [];
+ const resolved = await Promise.all(embeds.map(async (embed, i) => {
+ const hls = await extractHls(embed);
+ return {
+ url: hls ?? embed,
+ type: hls ? "hls" : "embed",
+ embed,
+ audio: aud,
+ server: "AniNeko",
+ priority: embeds.length - i,
+ referer: `${new URL(embed).origin}/`,
+ isActive: i === 0,
+ };
+ }));
+ streams.push(...resolved);
+ }));
+ return streams;
+}
+
+async function resolveSeries(anilistId, ctx = {}) {
+ const cacheKey = `np:anineko:${anilistId}`;
+ const cached = get(cacheKey);
+ if (isFresh(cached)) return cached.data;
+
+ const media = ctx.media ?? await getMedia(anilistId);
+ const titles = buildTitles(media, ctx.anizip);
+ const candidates = await findTopSlugs(titles, search);
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
+ const offset = await getPrequelOffset(anilistId).catch(() => 0);
+ const selected = await selectSeries(candidates, scrapeSeries, expected, media?.status, offset);
+ if (!selected) throw new Error(`AniNeko match not found for AniList ${anilistId}`);
+ const data = { slug: selected.slug, title: selected.title, mode: selected.mode, offset, score: selected.score };
+ set(cacheKey, data, SHOW_IDENTITY_TTL);
+ return data;
+}
+
+function buildEpisodeLists(anilistId, series, providerEpisodes, ctx, expected) {
+ const sub = [], dub = [];
+ for (const src of providerEpisodes) {
+ const number = series.mode === "offset" ? src.number - series.offset : src.number;
+ if (number < 1) continue;
+ if (expected && number > expected) continue;
+ const meta = episodeMeta(number, ctx);
+ const base = {
+ number,
+ title: meta.title ?? src.title ?? `Episode ${number}`,
+ duration: meta.duration,
+ filler: meta.filler,
+ uncensored: meta.uncensored,
+ description: meta.description,
+ image: meta.image,
+ airDate: meta.airDate,
+ sourceNumber: src.number,
+ };
+ if (src.hasSub) sub.push({ id: `watch/anineko/${anilistId}/sub/anineko-${number}`, ...base, audio: "sub" });
+ if (src.hasDub) dub.push({ id: `watch/anineko/${anilistId}/dub/anineko-${number}`, ...base, audio: "dub" });
+ }
+ return { sub, dub };
+}
+
+export async function getEpisodes(anilistId, ctx = {}) {
+ const media = ctx.media ?? await getMedia(anilistId);
+ const localCtx = { ...ctx, media };
+ const series = await resolveSeries(anilistId, localCtx);
+ const episodes = await scrapeSeries(series.slug);
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
+ return {
+ meta: {
+ id: series.slug,
+ title: series.title,
+ source: "anineko",
+ matchScore: Number(series.score.toFixed(3)),
+ numbering: series.mode,
+ episodeOffset: series.mode === "offset" ? series.offset : 0,
+ },
+ episodes: buildEpisodeLists(anilistId, series, episodes, localCtx, expected),
+ };
+}
+
+async function handleWatch(anilistId, audio, epNum, ctx = {}) {
+ const series = await resolveSeries(anilistId, ctx);
+ const providerEp = series.mode === "offset" ? Number(epNum) + series.offset : Number(epNum);
+ const streams = await scrapeEpisodeWatch(series.slug, `ep-${providerEp}`, audio);
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, streams });
+}
+
+export default {
+ async fetch(request) {
+ const url = new URL(request.url);
+ if (request.method === "OPTIONS") {
+ return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "*" } });
+ }
+ try {
+ const m = url.pathname.match(/^\/watch\/anineko\/(\d+)\/(sub|dub)\/anineko-(\d+)\/?$/);
+ if (m) return await handleWatch(m[1], m[2], m[3]);
+ return json({ error: "Not found" }, 404);
+ } catch (err) {
+ return json({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500);
+ }
+ },
+};
diff --git a/anivexa-api/providers/anizone.js b/anivexa-api/providers/anizone.js
new file mode 100644
index 0000000000000000000000000000000000000000..94d752c35d0c5e7aed21f7e4d7ff84287675ffe9
--- /dev/null
+++ b/anivexa-api/providers/anizone.js
@@ -0,0 +1,296 @@
+import { getMedia } from "../core/anilist.js";
+import {
+ buildTitles,
+ decodeEntities,
+ diceCoeff,
+ episodeMeta,
+ expectedCount,
+ fetchHtml,
+ getPrequelOffset,
+ json,
+ norm,
+ selectSeries,
+} from "../core/new-provider-utils.js";
+import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js";
+
+const BASE = "https://anizone.to";
+
+function scoreCandidate(query, candidate, slug) {
+ const base = Math.max(diceCoeff(query, candidate), diceCoeff(query, slug.replace(/-/g, " ")));
+ const isMovieQuery = /\b(movie|film|the movie)\b/i.test(query);
+ const isMovieMatch = /\b(movie|film)\b/i.test(candidate) || /movie|film/.test(slug);
+ if (isMovieQuery && !isMovieMatch) return base * 0.4;
+ const qLen = norm(query).length;
+ const sLen = norm(slug.replace(/-/g, " ")).length;
+ return sLen > qLen * 1.6 + 4 ? base * 0.8 : base;
+}
+
+function buildSearchQueries(title) {
+ const queries = new Set([title]);
+ const words = title.trim().split(/\s+/);
+ if (words.length > 4) queries.add(words.slice(0, 4).join(" "));
+ if (words.length > 3) queries.add(words.slice(0, 3).join(" "));
+ const stripped = title
+ .replace(/\bseason\s*\d+\b/gi, "")
+ .replace(/\bpart\s*\d+\b/gi, "")
+ .replace(/\b\d+rd\b|\b\d+th\b|\b\d+st\b|\b\d+nd\b/gi, "")
+ .replace(/\s+/g, " ")
+ .trim();
+ if (stripped && stripped !== title) queries.add(stripped);
+ return [...queries].filter((q) => q.length >= 3);
+}
+
+async function findCandidates(titles, searchFn, n = 6) {
+ const allCandidates = new Map();
+ const searchQueries = new Set();
+ for (const title of titles.slice(0, 4)) {
+ for (const q of buildSearchQueries(title)) searchQueries.add(q);
+ }
+ await Promise.all([...searchQueries].map(async (q) => {
+ try {
+ const results = await searchFn(q);
+ for (const r of results) if (!allCandidates.has(r.slug)) allCandidates.set(r.slug, r.text);
+ } catch {}
+ }));
+ const scored = [];
+ for (const [slug, text] of allCandidates) {
+ let best = 0;
+ for (const title of titles.slice(0, 2)) best = Math.max(best, scoreCandidate(title, text, slug));
+ if (best >= 0.5) scored.push({ slug, title: text, score: best });
+ }
+ return scored.sort((a, b) => b.score - a.score).slice(0, n);
+}
+
+function processJsonArg(raw) {
+ const PH = "\x01U\x01";
+ let s = raw.replace(/\\\\u([0-9a-fA-F]{4})/g, `${PH}$1`);
+ s = s.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
+ s = s.replace(/\x01U\x01([0-9a-fA-F]{4})/g, "\\u$1");
+ try { return JSON.parse(s); } catch { return {}; }
+}
+
+function pickTitle(titles) {
+ return titles["1"] || titles["5"] || titles["8"] || Object.values(titles)[0] || "";
+}
+
+function extractSlug(ctx) {
+ const m = ctx.match(/href="(?:https:\/\/anizone\.to)?\/anime\/([a-z0-9-]+)"/);
+ return m ? m[1] : null;
+}
+
+function extractJsonArg(xdata, key) {
+ const re = new RegExp(`${key}:\\s*JSON\\.parse\\('((?:[^'\\\\]|\\\\.)*)'\\)`);
+ const m = xdata.match(re);
+ return m ? m[1] : null;
+}
+
+async function search(query) {
+ const html = await fetchHtml(`${BASE}/anime?search=${encodeURIComponent(query)}`);
+ const results = [];
+ const xdataRe = /x-data="(\{[^"]*anmTitles[^"]*\})"/g;
+ let m;
+ while ((m = xdataRe.exec(html)) !== null) {
+ const ctxStart = Math.max(0, m.index - 300);
+ const ctxEnd = Math.min(html.length, m.index + m[0].length + 800);
+ const ctx = html.slice(ctxStart, ctxEnd);
+ const slug = extractSlug(ctx);
+ if (!slug) continue;
+ const xdata = decodeEntities(m[1]);
+ const raw = extractJsonArg(xdata, "anmTitles");
+ if (!raw) continue;
+ const titles = processJsonArg(raw);
+ const title = pickTitle(titles);
+ if (title) results.push({ slug, text: title });
+ }
+ return results;
+}
+
+async function scrapeSeries(slug) {
+ const html = await fetchHtml(`${BASE}/anime/${slug}`);
+ const episodes = [];
+ const xdataRe = /x-data="(\{[^"]*epsTitles[^"]*\})"/g;
+ let m;
+ while ((m = xdataRe.exec(html)) !== null) {
+ const ctxStart = Math.max(0, m.index - 400);
+ const ctxEnd = Math.min(html.length, m.index + m[0].length + 800);
+ const ctx = html.slice(ctxStart, ctxEnd);
+ const numMatch = ctx.match(/href="(?:https:\/\/anizone\.to)?\/anime\/[a-z0-9-]+\/(\d+)"/);
+ if (!numMatch) continue;
+ const num = Number(numMatch[1]);
+ if (!Number.isFinite(num) || num < 1) continue;
+ const xdata = decodeEntities(m[1]);
+ const raw = extractJsonArg(xdata, "epsTitles");
+ let title = `Episode ${num}`;
+ if (raw) {
+ const titles = processJsonArg(raw);
+ title = pickTitle(titles) || title;
+ }
+ episodes.push({ number: num, title, hasSub: true, hasDub: false });
+ }
+ const seen = new Set();
+ return episodes
+ .filter(e => seen.has(e.number) ? false : (seen.add(e.number), true))
+ .sort((a, b) => a.number - b.number);
+}
+
+async function scrapeWatch(slug, episodeNum) {
+ const html = await fetchHtml(`${BASE}/anime/${slug}/${episodeNum}`);
+
+ const hlsMatch = html.match(/
]+src="([^"]+\.m3u8[^"]*)"/i);
+ const hls = hlsMatch ? decodeEntities(hlsMatch[1]) : null;
+
+ const subtitles = [];
+ const trackRe = /