diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..af12d6c442251147dd9872c9f4142e15074843bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +*.egg-info/ + +# Node +node_modules/ +dist/ +build/ +.wrangler/ +.dev.vars + +# Editor / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Logs / caches +*.log +.history/ +.cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d21fd53d8bfc1f2feeda933328b9f0b79bc6b87c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:20-alpine AS frontend-builder + +WORKDIR /frontend + +COPY frontend/package*.json ./ +RUN npm install + +COPY frontend/ ./ +RUN npm run build + +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ . + +COPY --from=frontend-builder /frontend/dist /app/static + +EXPOSE 7860 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e51517b41c0ad5125c72fa3d5c1846ee92ab0606 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# anidoom + +A from-scratch anime streaming platform. Browse titles with metadata from **AniList** and **MyAnimeList (via Jikan)**, and stream episodes using **m3u8 links aggregated from anivexa-api (13 providers)**, with an **Aniraku** hosted fallback. (Miruro is disabled by default — see below.) + +> ⚠️ **Educational / personal-use project.** anidoom aggregates metadata and stream links from third-party sources. Deploy responsibly and respect the upstream sites' terms. + +## Stack + +| Layer | Tech | +| --------- | ----------------------------------------------------------- | +| Backend | Python 3.11+ · FastAPI · httpx · curl_cffi | +| Frontend | React 18 · Vite · react-router · hls.js | +| Edge | Anivexa-Proxy (Cloudflare Worker — HLS/DASH/MP4 proxy) | +| Streaming | Anivexa-API sidecar (13 providers) · Aniraku fallback · Miruro pipe (disabled) | +| Metadata | AniList GraphQL · Jikan (MAL) | +| Manga | vendored MangaVault sidecar (Manganato/Atsumaru/Comix) | +| Movies/TV | vendored MovieBox-API sidecar (Node/Express, :8003) with CDN-bypass stream proxy | + +``` +┌────────────┐ ┌──────────────────┐ ┌────────────────────────┐ +│ React SPA │ ───▶ │ FastAPI backend │ ───▶ │ Anivexa-API sidecar │ m3u8 +│ (frontend)│ ◀─── │ (backend) │ │ (13 providers, :8002) │ links +└────────────┘ └──────────────────┘ └────────────────────────┘ + │ │ │ + │ hls.js │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────────┐ ┌──────────────────────┐ +│ Anivexa-Proxy│ │ provider CDNs │ │ Aniraku fallback │ +│ /hls (Worker)│ │ (animepahe…) │ │ (hosted backend) │ +└──────────────┘ └──────────────────┘ └──────────────────────┘ +``` + +## Repository layout + +``` +anidoom/ +├── backend/ # FastAPI REST API (metadata + stream aggregation + manga proxy) +├── anivexa-proxy/# vendored Anivexa-Proxy (MIT) — HLS/DASH/MP4 proxy, local :8787 / deploy to CF +├── frontend/ # React SPA (browse, search, watch, manga + reader) +├── manga-vault/# vendored MangaVault sidecar (MIT) — run on :8001 +├── anivexa-api/# vendored Anivexa-API sidecar (MIT) — run on :8002 +├── moviebox-api/ # vendored DavidCyril1/moviebox-api (MIT) — Movies & TV sidecar, run on :8003 +├── moviebox-worker/# reference copy of a MovieBox CF Worker (MIT) — ⚠️ not used; MovieBox 429s Cloudflare egress IPs +├── worker/ # ⚠️ RETIRED — old HLS proxy, kept for reference only +├── scripts/ # cf_clearance cookie minter +└── docs/ # architecture, setup, API + Cloudflare docs +``` + +## Quick start + +```bash +# 1. Backend (FastAPI) — see docs/SETUP.md for full details +cd backend +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env # streaming defaults: Anivexa + Aniraku (no CF needed) +uvicorn app.main:app --reload --port 8000 + +# 2. MangaVault sidecar (manga section) +cd manga-vault +./run.bat # Windows (creates venv, serves :8001) + +# 3. Anivexa-API sidecar (extra streaming providers) +cd anivexa-api +./run.bat # Windows (needs Node.js, serves :8002) + +# 4. Anivexa-Proxy (HLS/DASH/MP4 proxy — replaces the old worker/) +cd anivexa-proxy +npm install # wrangler dev dependency (for deploy) +npm run dev # local proxy on :8787 (Vite proxies /hls → :8787) +# Deploy to Cloudflare when ready (one-time, see docs/CLOUDFLARE.md): +# npx wrangler login +# npm run deploy # → https://anidoom-proxy.shawnmwask1234.workers.dev/proxy +# npm run secret:set # optional: STREAM_KEY auth + +# 4b. Movies & TV sidecar (vendored DavidCyril1/moviebox-api, Node/Express) +cd moviebox-api +./run.bat # Windows (needs Node.js, serves :8003) + +# 5. Frontend +cd frontend +npm install +cp .env.example .env # VITE_API_URL / VITE_STREAM_PROXY_URL / VITE_STREAM_KEY +npm run dev # http://localhost:5173 +``` + +## Why a proxy worker at all? + +Stream providers hand out m3u8 URLs pointing at **provider CDNs** (animepahe, anikoto, etc.). Those CDNs may require a `Referer`/`Origin`, and browsers hit CORS issues fetching segments cross-origin. **Anivexa-Proxy** (vendored from [`walterwhite-69/Anivexa-Proxy`](https://github.com/walterwhite-69/Anivexa-Proxy)) proxies the CDN streams — rewriting m3u8/DASH playlists, forwarding `Range` for seeking — so the browser never hits CORS or referer blocks. + +The proxy is **not** involved in scraping; the backend aggregates episode/source data from the Anivexa sidecar + Aniraku fallback. (Miruro's `api/secure/pipe` was our original source, but it now 403s even with a `cf_clearance` cookie, so it's disabled by default — set `MIRURO_ENABLED=true` to re-enable after re-minting, see [docs/CLOUDFLARE.md](docs/CLOUDFLARE.md).) + +See [docs/CLOUDFLARE.md](docs/CLOUDFLARE.md) for the full picture. + +## Documentation + +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — system design & data flow +- [docs/SETUP.md](docs/SETUP.md) — full local setup guide +- [docs/API.md](docs/API.md) — backend REST endpoints +- [docs/CLOUDFLARE.md](docs/CLOUDFLARE.md) — Cloudflare clearance + Worker deployment +- [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md) — AniList / Jikan / Miruro / Malkan notes + +## Roadmap + +- [x] Anime metadata (AniList + MAL via Jikan) +- [x] Miruro episode/source resolution (m3u8) +- [x] HLS proxy worker + watch page +- [x] Manga section — MangaVault sidecar (Manganato/Atsumaru/Comix) + reader +- [x] Multi-source streaming — Anivexa (13 providers) + Aniraku fallback (Miruro disabled pending clearance) +- [ ] Malkan provider (pluggable; see docs/DATA_SOURCES.md) +- [x] Continue watching (local progress tracking + resume row on Home) diff --git a/README_HF.md b/README_HF.md new file mode 100644 index 0000000000000000000000000000000000000000..d38130551a15e2796aed7669cc6eef4b10ab1e04 --- /dev/null +++ b/README_HF.md @@ -0,0 +1,50 @@ +# anidoom - Hugging Face Space + +Anime streaming platform deployed on Hugging Face Spaces. + +## What's Deployed + +This Space includes: +- **Backend**: FastAPI API with AniList + MAL metadata +- **Frontend**: React SPA (built and served as static files) +- **Streaming**: Aniraku fallback (no sidecar needed) + +## What's NOT Deployed + +Due to Hugging Face Space limitations, the following sidecar services are **not** included: +- `manga-vault` (manga section) +- `anivexa-api` (13 streaming providers) +- `moviebox-api` (movies & TV) + +These services need to be deployed separately (see below). + +## Deployment Options for Sidecars + +### Option 1: Deploy sidecars to other platforms +- **manga-vault**: Deploy to Railway, Render, or any Python hosting +- **anivexa-api**: Deploy to Railway, Render, or any Node.js hosting +- **moviebox-api**: Deploy to Railway, Render, or any Node.js hosting + +Then configure the backend `.env` to point to those external URLs: +``` +MANGA_VAULT_URL=https://your-manga-vault-url.com +ANIVEXA_API_URL=https://your-anivexa-api-url.com +MOVIEBOX_API_URL=https://your-moviebox-api-url.com +``` + +### Option 2: Use the existing Aniraku fallback +The backend already includes Aniraku as a fallback streaming source, so anime streaming will work without the sidecars (just fewer provider options). + +## Environment Variables + +Set these in the Space's Settings > Secrets: +- `MIRURO_ENABLED`: Set to `true` if you have cf_clearance cookies (default: false) +- `CORS_ORIGINS`: Comma-separated list of allowed origins (default: *) + +## Local Development + +See the main [README.md](README.md) for full local development setup with all sidecars. + +## License + +Educational / personal-use project. See individual component licenses. diff --git a/anivexa-api/.dockerignore b/anivexa-api/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..c2da4181a05b6155d6dd8c1f76cd41f96f24aba8 --- /dev/null +++ b/anivexa-api/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +node_modules +npm-debug.log +*.log +.env +.env.local +.env.*.local +sidecar-*.log +sidecar-*.err.log +Dockerfile +.dockerignore +README.md +docs diff --git a/anivexa-api/Dockerfile b/anivexa-api/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e9c8e48eaa0ad6ff574c94f782a3f4adfe38fee1 --- /dev/null +++ b/anivexa-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --production + +COPY . . + +ENV PORT=8002 +EXPOSE 8002 + +CMD ["npm", "start"] diff --git a/anivexa-api/README.md b/anivexa-api/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1a7080df41920c70885bd3737519663deb262886 --- /dev/null +++ b/anivexa-api/README.md @@ -0,0 +1,109 @@ +
+ + + + + +# Anivexa API 2.2 + +**Anime streaming aggregator API — one endpoint, all your sources.** + +![Views](https://visitor-badge.laobi.icu/badge?page_id=walterwhite-69.Anivexa-API) +[![Discord](https://img.shields.io/badge/Join%20Discord-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/MARQ9z9QSX) +[![GitHub stars](https://img.shields.io/github/stars/walterwhite-69/Anivexa-API?style=flat-square&color=yellow)](https://github.com/walterwhite-69/Anivexa-API/stargazers) + +
+ +--- + +## What is this? + +A single API that aggregates anime episode lists and streaming links from multiple providers. Give it an AniList ID, get back everything — episodes, sources, and stream URLs — all in one place. + +It's the backbone powering **[Anivexa](https://github.com/walterwhite-69/Anivexa)**, a full anime streaming client built on top of this. + +--- + +## Providers + +| Provider | Status | Notes | +|---|---|---| +| **AllManga** | ✅ Active | Large Library | +| **AnimePahe** | ❌ Removed | Cloudflare JS Challenge — no reliable bypass | +| **Reanime** | ✅ Active | Solid source for a wide range of titles | +| **AniKoto** | ✅ Active | Good library, consistent | +| **AnimeGG** | ✅ Active | Fuzzy title matching + compact-query fix for sequels (e.g. Re:Zero S4) | +| **AniNeko** | ✅ Active | Reliable slug-based matching | +| **AniDB App** | ✅ Active | Language-aware, AniDB ID backed | +| **AniZone** | ✅ Active | HLS + subtitles, sub-only; year-based re-scoring prevents wrong-season matches | +| **2dhive** | ✅ Active | Uses MAL ID internally; AniList ID used everywhere else | +| **Anibd** | ✅ Active | Uses Anilist ID internally; AniList ID used everywhere else | +| **Kickassanime** | ✅ Active | Fuzzy search, medium library | +| **AnimeDunya** | ✅ Active | HLS + subtitles, sub-only, MAL ID backed | + +--- + +## Routes + +``` +GET /map/:anilistId +``` +Returns cross-platform ID mappings — MAL, TVDB, TMDB, Kitsu, AniDB, and more. + +``` +GET /episodes/:anilistId +GET /episodes/:provider[/:provider...]/:anilistId +``` +Returns episode lists in a single response with smart background refresh. Pass one or more provider names in the path to filter results — e.g. `/episodes/anizone/allmanga/16498` returns only those two. Omit providers to get all of them. + +``` +GET /watch/:provider/:anilistId/sub|dub/:provider-:ep +``` +Returns stream URLs for a specific episode from a specific provider. + +``` +GET /stream/reanime/:id/sub|dub/:ep +``` +302 redirect directly to the HLS stream. + +--- + +## Self-hosted + +```bash +git clone https://github.com/walterwhite-69/Anivexa-API +cd Anivexa-API +node server.js +``` + +Runs on Node.js. No build step needed. + +--- + +## Deploying on Vercel + +> ⚠️ **Not recommended.** Vercel runs on shared datacenter IPs that are widely blocked by anime streaming sites. Most providers will fail silently or return errors — the API will technically run but you'll get little to no data back. Use a self-hosted VPS or use railway, render etc etc. The proxy file is for anidb app not for streams! + +--- + +## Contributing + +> **Only request providers that self-host their content. No scrapers of third-party sites.** + +Got a provider you'd like added? Open an issue or drop it in the Discord. + +This project is community-kept-alive — if it helps you, please: + +- ⭐ **Star the repo** so others can find it +- 💬 **[Join the Discord](https://discord.gg/MARQ9z9QSX)** to discuss, report issues, or suggest providers +- 🛠️ **Open a PR** if you want to add or fix something + +--- + +
+ +hope it helped :3 + +[![Discord](https://img.shields.io/badge/Join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/MARQ9z9QSX) + +
diff --git a/anivexa-api/api/handler.js b/anivexa-api/api/handler.js new file mode 100644 index 0000000000000000000000000000000000000000..9ed7fc43e0166ca0e50e3d1ef9b252258ed51306 --- /dev/null +++ b/anivexa-api/api/handler.js @@ -0,0 +1,5 @@ +import worker from "../index.js"; + +export const config = { runtime: "edge" }; + +export default (request) => worker.fetch(request, {}); diff --git a/anivexa-api/api/index.js b/anivexa-api/api/index.js new file mode 100644 index 0000000000000000000000000000000000000000..965fb4bf1f233cf79569e510c0a3e2516f0c1571 --- /dev/null +++ b/anivexa-api/api/index.js @@ -0,0 +1,25 @@ +import worker from "../index.js"; + +export default async function handler(req, res) { + const host = req.headers["host"] ?? "localhost"; + const url = `https://${host}${req.url}`; + + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = chunks.length ? Buffer.concat(chunks) : null; + + const request = new Request(url, { + method: req.method, + headers: req.headers, + body: body?.length ? body : undefined, + duplex: "half", + }); + + const response = await worker.fetch(request, {}); + + res.statusCode = response.status; + for (const [k, v] of response.headers) res.setHeader(k, v); + + const buf = await response.arrayBuffer(); + res.end(Buffer.from(buf)); +} diff --git a/anivexa-api/core/anilist.js b/anivexa-api/core/anilist.js new file mode 100644 index 0000000000000000000000000000000000000000..d691e68a9e30163282b9cdded85e95f4d1f1ecf3 --- /dev/null +++ b/anivexa-api/core/anilist.js @@ -0,0 +1,152 @@ +const __name = (fn, _) => fn; + +var resolved = new Map(); +var inflight = new Map(); +var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +var ARM = "https://arm.haglund.dev/api/v2/ids"; +var JIKAN = "https://api.jikan.moe/v4"; +var STATUS_MAP = { + "Currently Airing": "RELEASING", + "Finished Airing": "FINISHED", + "Not yet aired": "NOT_YET_RELEASED", + "On Hiatus": "HIATUS" +}; + +const AL_STATUS_MAP = { + RELEASING: "RELEASING", + FINISHED: "FINISHED", + NOT_YET_RELEASED: "NOT_YET_RELEASED", + CANCELLED: "FINISHED", + HIATUS: "HIATUS", +}; + +async function fetchFromAniList(id) { + const fullQuery = `query($id:Int){Media(id:$id,type:ANIME){id title{english romaji native} status format episodes seasonYear startDate{year} synonyms nextAiringEpisode{episode airingAt timeUntilAiring}}}`; + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": UA }, + body: JSON.stringify({ query: fullQuery, variables: { id } }), + }).catch(() => null); + if (!res || !res.ok) return null; + const json = await res.json(); + return json.data?.Media ?? null; +} + +async function getMedia(anilistId) { + const id = Number(anilistId); + if (resolved.has(id)) return resolved.get(id); + if (inflight.has(id)) return inflight.get(id); + const promise = (async () => { + const arm = await fetch(`${ARM}?source=anilist&id=${id}`, { + headers: { "User-Agent": UA, "Accept": "application/json" } + }).then((r) => { + if (!r.ok) return null; + return r.json(); + }).catch(() => null); + + const malId = arm?.myanimelist ?? null; + + if (!malId) { + const al = await fetchFromAniList(id); + if (!al) throw new Error(`No data found for AniList ID ${id}`); + const media = { + id, + idMal: null, + title: { + english: al.title?.english ?? null, + romaji: al.title?.romaji ?? null, + native: al.title?.native ?? null, + }, + status: AL_STATUS_MAP[al.status] ?? "RELEASING", + format: al.format ?? null, + episodes: al.episodes ?? null, + seasonYear: al.seasonYear ?? null, + startDate: al.startDate ?? null, + nextAiringEpisode: al.nextAiringEpisode ?? null, + synonyms: Array.isArray(al.synonyms) ? al.synonyms : [], + }; + resolved.set(id, media); + inflight.delete(id); + return media; + } + + const al = await fetchFromAniList(id).catch(() => null); + let jikan = null; + for (let attempt = 0; attempt <= 4; attempt++) { + const r = await fetch(`${JIKAN}/anime/${malId}`, { headers: { "User-Agent": UA, Accept: "application/json" } }); + if (r.status === 429) { + const wait = (parseInt(r.headers.get("Retry-After") ?? "1") || 1) * 1e3 + attempt * 500; + if (attempt < 4) { + await new Promise((res) => setTimeout(res, wait)); + continue; + } + throw new Error(`Jikan 429 for MAL ID ${malId} (exhausted retries)`); + } + // On 5xx / network errors, fall back to AniList-only data if available rather than hard-failing. + if (!r.ok) { + if (al) break; // exit loop, jikan stays null, fall through to AniList fallback below + throw new Error(`Jikan ${r.status}`); + } + jikan = await r.json(); + break; + } + const d = jikan?.data ?? null; + // If Jikan was unavailable but we have AniList data, build a partial media object from AniList only. + if (!d && al) { + const media = { + id, + idMal: malId, + title: { + english: al.title?.english ?? null, + romaji: al.title?.romaji ?? null, + native: al.title?.native ?? null, + }, + status: AL_STATUS_MAP[al.status] ?? "RELEASING", + format: al.format ?? null, + episodes: al.episodes ?? null, + seasonYear: al.seasonYear ?? null, + startDate: al.startDate ?? null, + nextAiringEpisode: al.nextAiringEpisode ?? null, + synonyms: Array.isArray(al.synonyms) ? al.synonyms : [], + }; + resolved.set(id, media); + inflight.delete(id); + return media; + } + if (!d) throw new Error(`Jikan returned no data for MAL ID ${malId}`); + const media = { + id, + idMal: malId, + title: { + english: al?.title?.english ?? d.title_english ?? null, + romaji: al?.title?.romaji ?? d.title ?? null, + native: al?.title?.native ?? d.title_japanese ?? null, + }, + status: AL_STATUS_MAP[al?.status] ?? STATUS_MAP[d.status] ?? "RELEASING", + format: al?.format ?? d.type ?? null, + episodes: al?.episodes ?? d.episodes ?? null, + seasonYear: al?.seasonYear ?? d.year ?? null, + startDate: al?.startDate ?? (d.aired?.from ? { year: new Date(d.aired.from).getFullYear() } : null), + nextAiringEpisode: al?.nextAiringEpisode ?? null, + synonyms: [ + ...(d.titles?.map((t) => t.title).filter(Boolean) ?? []), + ...(Array.isArray(al?.synonyms) ? al.synonyms : []), + ], + }; + resolved.set(id, media); + inflight.delete(id); + return media; + })().catch((e) => { + inflight.delete(id); + throw e; + }); + inflight.set(id, promise); + return promise; +} +__name(getMedia, "getMedia"); + +function forgetMedia(anilistId) { + resolved.delete(Number(anilistId)); +} + +export { getMedia, forgetMedia }; diff --git a/anivexa-api/core/episode-cache.js b/anivexa-api/core/episode-cache.js new file mode 100644 index 0000000000000000000000000000000000000000..d29edb83c7be3e0953f4c10a739dbf54d7917025 --- /dev/null +++ b/anivexa-api/core/episode-cache.js @@ -0,0 +1,212 @@ +import { forgetMedia, getMedia } from "./anilist.js"; +import { mapAnimeIds } from "./mapper.js"; +import { buildEpisodesWithCache, buildFilteredEpisodesWithCache } from "./episode-strategy.js"; +import { get, set, getAsync, setAsync, needsRefresh, delAsync, delByPrefixAsync } from "./smartcache.js"; + +const ANIZIP = "https://api.ani.zip/mappings"; +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; +const FULL_TTL = 30 * DAY; +const NORMAL_PROBE_INTERVAL = 15 * MIN; +const AIRING_PROBE_INTERVAL = 5 * MIN; +const AIRING_EARLY_WINDOW = 10 * MIN; +const AIRING_FAST_WINDOW = 6 * HOUR; + +const refreshing = new Set(); + +function runBackground(env, promise) { + const waitUntil = env?.context?.waitUntil ?? env?.waitUntil; + if (typeof waitUntil === "function") waitUntil.call(env.context ?? env, promise); + else promise.catch(() => {}); +} + +function latestEpisodeFromResponse(data) { + let max = 0; + for (const provider of Object.values(data ?? {})) { + const episodes = provider?.episodes; + if (!episodes || typeof episodes !== "object") continue; + for (const list of Object.values(episodes)) { + if (!Array.isArray(list)) continue; + for (const ep of list) { + const n = Number(ep?.number); + if (Number.isFinite(n) && n > max) max = n; + } + } + } + return max || null; +} + +function hasCurrentProviders(data) { + return data && + Object.prototype.hasOwnProperty.call(data, "anidbapp") && + Object.prototype.hasOwnProperty.call(data, "anizone"); +} + +function latestEpisodeFromAniZip(anizip) { + const nums = Object.keys(anizip?.episodes ?? {}).map(Number).filter(Number.isFinite); + return nums.length ? Math.max(...nums) : null; +} + +function resolveShared(anilistId, freshMedia = false) { + if (freshMedia) forgetMedia(anilistId); + return Promise.all([ + getMedia(anilistId).catch(() => null), + fetch(`${ANIZIP}?anilist_id=${anilistId}`).then((r) => r.json()).catch(() => null), + ]); +} + +async function clearProviderCache(anilistId, media) { + for (const p of ["pahe", "manga", "reanime", "anikoto", "animegg", "anineko", "anidbapp", "2dhive", "anizone"]) { + await delAsync(`epv:${p}:${anilistId}`); + } + if (media?.idMal) { + await delAsync(`jm:${media.idMal}`); + await delByPrefixAsync(`jp:${media.idMal}:`); + } +} + +async function buildResponse(anilistId, media, anizip, forceRefresh = false) { + if (forceRefresh) await clearProviderCache(anilistId, media); + + const [providerResult, mappingResult] = await Promise.all([ + buildEpisodesWithCache(anilistId, media, anizip), + mapAnimeIds(anilistId).catch(() => null), + ]); + + return { + page: 1, + type: "all", + mappings: mappingResult?.mappings ?? null, + ...providerResult, + }; +} + +function probeInterval(state) { + const airMs = state?.nextAiringAt ? state.nextAiringAt * 1000 : null; + if (!airMs) return NORMAL_PROBE_INTERVAL; + const now = Date.now(); + return now >= airMs - AIRING_EARLY_WINDOW && now <= airMs + AIRING_FAST_WINDOW + ? AIRING_PROBE_INTERVAL + : NORMAL_PROBE_INTERVAL; +} + +function shouldRebuild(entry, media, anizip) { + if ((media?.status ?? "RELEASING") === "FINISHED") return false; + + const cachedLatest = latestEpisodeFromResponse(entry?.data) ?? 0; + const knownLatest = Math.max( + latestEpisodeFromAniZip(anizip) ?? 0, + Number(media?.episodes) || 0 + ); + if (knownLatest > cachedLatest) return true; + + const next = media?.nextAiringEpisode; + if (next?.episode && cachedLatest >= Number(next.episode)) return false; + if (next?.airingAt) { + const airMs = Number(next.airingAt) * 1000; + const now = Date.now(); + if (now < airMs - AIRING_EARLY_WINDOW) return false; + if (now <= airMs + AIRING_FAST_WINDOW) return true; + } + + return needsRefresh(entry); +} + +function writeSyncState(anilistId, state, ttl = FULL_TTL) { + set(`sync:${anilistId}`, state, ttl, NORMAL_PROBE_INTERVAL); +} + +function scheduleRefresh(anilistId, entry, env) { + const key = `ep-bg:${anilistId}`; + if (refreshing.has(key)) return; + + const syncKey = `sync:${anilistId}`; + const oldState = get(syncKey)?.data; + const now = Date.now(); + if (oldState?.lastProbeAt && now - oldState.lastProbeAt < probeInterval(oldState)) return; + + refreshing.add(key); + writeSyncState(anilistId, { ...oldState, lastProbeAt: now, syncing: true }); + + const task = (async () => { + const [media, anizip] = await resolveShared(anilistId, true); + const cachedLatest = latestEpisodeFromResponse(entry?.data); + const next = media?.nextAiringEpisode ?? null; + + if (!shouldRebuild(entry, media, anizip)) { + writeSyncState(anilistId, { + lastProbeAt: Date.now(), + lastSyncAt: oldState?.lastSyncAt ?? null, + latestEpisode: cachedLatest, + nextEpisode: next?.episode ?? null, + nextAiringAt: next?.airingAt ?? null, + syncing: false, + }); + return; + } + + const result = await buildResponse(anilistId, media, anizip, true); + const latestEpisode = latestEpisodeFromResponse(result); + await setAsync(`episodes:${anilistId}`, result, FULL_TTL, NORMAL_PROBE_INTERVAL); + writeSyncState(anilistId, { + lastProbeAt: Date.now(), + lastSyncAt: Date.now(), + latestEpisode, + nextEpisode: next?.episode ?? null, + nextAiringAt: next?.airingAt ?? null, + syncing: false, + }); + })() + .catch((e) => { + console.error(`[ep-bg:${anilistId}]`, e.message); + writeSyncState(anilistId, { + ...oldState, + lastProbeAt: Date.now(), + syncing: false, + error: e.message, + }, HOUR); + }) + .finally(() => refreshing.delete(key)); + + runBackground(env, task); +} + +export async function getEpisodesResponse(anilistId, env) { + const cacheKey = `episodes:${anilistId}`; + const entry = await getAsync(cacheKey); + + if (entry && hasCurrentProviders(entry.data)) { + scheduleRefresh(anilistId, entry, env); + return entry.data; + } + + const [media, anizip] = await resolveShared(anilistId); + const result = await buildResponse(anilistId, media, anizip); + await setAsync(cacheKey, result, FULL_TTL, NORMAL_PROBE_INTERVAL); + writeSyncState(anilistId, { + lastProbeAt: Date.now(), + lastSyncAt: Date.now(), + latestEpisode: latestEpisodeFromResponse(result), + nextEpisode: media?.nextAiringEpisode?.episode ?? null, + nextAiringAt: media?.nextAiringEpisode?.airingAt ?? null, + syncing: false, + }); + return result; +} + +export async function getFilteredEpisodesResponse(anilistId, providers, includeMap) { + const [media, anizip] = await resolveShared(anilistId); + + const [providerResult, mappingResult] = await Promise.all([ + buildFilteredEpisodesWithCache(anilistId, providers, media, anizip), + includeMap ? mapAnimeIds(anilistId).catch(() => null) : Promise.resolve(null), + ]); + + return { + page: 1, + type: "filtered", + ...(includeMap ? { mappings: mappingResult?.mappings ?? null } : {}), + ...providerResult, + }; +} diff --git a/anivexa-api/core/episode-strategy.js b/anivexa-api/core/episode-strategy.js new file mode 100644 index 0000000000000000000000000000000000000000..dae271c60cb4b790b5ec261d13476bf025762fa0 --- /dev/null +++ b/anivexa-api/core/episode-strategy.js @@ -0,0 +1,271 @@ +import { + getAsync, setAsync, isFresh, needsRefresh, + episodeTTL, jikanPageTTL, +} from "./smartcache.js"; +import { getEpisodes as mangaEpisodes } from "../providers/allmanga.js"; +import { getEpisodes as reanimeEpisodes } from "../providers/reanime.js"; +import { getEpisodes as anikotoEpisodes } from "../providers/anikoto.js"; +import { getEpisodes as animeggEpisodes } from "../providers/animegg.js"; +import { getEpisodes as aninekoEpisodes } from "../providers/anineko.js"; +import { getEpisodes as anidbappEpisodes } from "../providers/anidbapp.js"; +import { getEpisodes as dhiveEpisodes } from "../providers/2dhive.js"; +import { getEpisodes as animenosubEpisodes } from "../providers/animenosub.js"; +import { getEpisodes as anizoneEpisodes } from "../providers/anizone.js"; +import { getEpisodes as anibdEpisodes } from "../providers/anibd.js"; +import { getEpisodes as senshiEpisodes } from "../providers/senshi.js"; +import { getEpisodes as kaaEpisodes } from "../providers/kickassanime.js"; +import { getEpisodes as animedunyaEpisodes } from "../providers/animedunya.js"; +const JIKAN = "https://api.jikan.moe/v4"; +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +const inflight = new Map(); +const bgRunning = new Set(); + +function dedupe(key, fn) { + if (inflight.has(key)) return inflight.get(key); + const p = Promise.resolve().then(fn).finally(() => inflight.delete(key)); + inflight.set(key, p); + return p; +} + +function bg(key, fn) { + if (bgRunning.has(key)) return; + bgRunning.add(key); + Promise.resolve() + .then(fn) + .catch(e => console.error(`[bg:${key}]`, e.message)) + .finally(() => bgRunning.delete(key)); +} + +async function jikanPage(malId, pageNum, retries = 3) { + for (let attempt = 0; attempt <= retries; attempt++) { + const res = await fetch( + `${JIKAN}/anime/${malId}/episodes?page=${pageNum}`, + { headers: { "User-Agent": UA, Accept: "application/json" } } + ).catch(() => null); + + if (!res) return null; + if (res.status === 429) { + const wait = (parseInt(res.headers.get("Retry-After") ?? "1") || 1) * 1000 + + attempt * 600; + if (attempt < retries) { await new Promise(r => setTimeout(r, wait)); continue; } + return null; + } + if (!res.ok) return null; + return res.json(); + } + return null; +} + +export function fetchAllJikanWithCache(malId, status) { + return dedupe(`jikan:${malId}`, () => _jikanAll(malId, status)); +} + +async function _jikanAll(malId, status) { + const metaKey = `jm:${malId}`; + const meta = await getAsync(metaKey); + + const isFinished = status === "FINISHED"; + const mustCheckTotal = !isFinished && (!meta || needsRefresh(meta)); + let lastPage = meta?.data?.lastPage ?? null; + + if (mustCheckTotal || !lastPage) { + const p1 = await jikanPage(malId, 1); + + if (!p1 && !lastPage) return []; + if (!p1 && lastPage) return _buildPages(malId, lastPage, status); + + const newLast = p1.pagination?.last_visible_page ?? 1; + const isP1Last = newLast === 1; + + const [p1ttl, p1ref] = jikanPageTTL(isP1Last, status); + await setAsync(`jp:${malId}:1`, p1.data ?? [], p1ttl, p1ref); + + if (lastPage && newLast > lastPage) { + const [stableTtl] = jikanPageTTL(false, "FINISHED"); + const oldLastEntry = await getAsync(`jp:${malId}:${lastPage}`); + if (oldLastEntry) await setAsync(`jp:${malId}:${lastPage}`, oldLastEntry.data, stableTtl, Infinity); + + await Promise.all( + Array.from({ length: newLast - lastPage }, (_, i) => { + const pn = lastPage + 1 + i; + const isLast = pn === newLast; + return jikanPage(malId, pn).then(pd => { + const [t, r] = jikanPageTTL(isLast, status); + return setAsync(`jp:${malId}:${pn}`, pd?.data ?? [], t, r); + }); + }) + ); + } + + const [mttl, mref] = episodeTTL(status); + await setAsync(metaKey, { lastPage: newLast }, mttl, mref); + lastPage = newLast; + } + + return _buildPages(malId, lastPage, status); +} + +async function _buildPages(malId, lastPage, status) { + const pages = await Promise.all( + Array.from({ length: lastPage }, (_, i) => i + 1).map(async pn => { + const key = `jp:${malId}:${pn}`; + const isLast = pn === lastPage; + const entry = await getAsync(key); + + if (isFresh(entry)) { + if (isLast && status === "RELEASING" && needsRefresh(entry)) { + bg(key, async () => { + const pd = await jikanPage(malId, pn); + if (pd) { + const [t, r] = jikanPageTTL(true, status); + await setAsync(key, pd.data ?? [], t, r); + } + }); + } + return entry.data; + } + + const pd = await jikanPage(malId, pn); + const data = pd?.data ?? []; + const [t, r] = jikanPageTTL(isLast, status); + await setAsync(key, data, t, r); + return data; + }) + ); + + return pages.flat(); +} + +async function withCache(key, status, fetchFn) { + const [ttl, refreshAfter] = episodeTTL(status); + const entry = await getAsync(key); + + if (isFresh(entry)) { + if (needsRefresh(entry)) { + bg(key, async () => { + const data = await fetchFn(); + await setAsync(key, data, ttl, refreshAfter); + }); + } + return entry.data; + } + + const data = await fetchFn(); + await setAsync(key, data, ttl, refreshAfter); + return data; +} + +async function safe(label, fn) { + try { return { ok: true, data: await fn() }; } + catch (e) { console.error(`[ep:${label}]`, e.message); return { ok: false, error: e.message, stack: e.stack }; } +} + +const PROVIDER_ALIASES = { + allmanga: "allmanga", + reanime: "reanime", + anikoto: "anikoto", + animegg: "animegg", + anineko: "anineko", + anidbapp: "anidbapp", + "2dhive": "2dhive", + animenosub: "animenosub", + anizone: "anizone", + anibd: "anibd", + senshi: "senshi", + kaa: "kaa", + animedunya: "animedunya", +}; + +export function resolveProviders(rawNames) { + const resolved = new Set(); + const unknown = []; + for (const raw of rawNames) { + const name = PROVIDER_ALIASES[raw.toLowerCase()]; + if (name) resolved.add(name); + else unknown.push(raw); + } + return { resolved, unknown }; +} + +function providerFns(anilistId, status, ctx) { + return { + allmanga: () => withCache(`epv:manga:${anilistId}`, status, () => mangaEpisodes(anilistId, ctx)), + reanime: () => withCache(`epv:reanime:${anilistId}`, status, () => reanimeEpisodes(anilistId, ctx)), + anikoto: () => withCache(`epv:anikoto:${anilistId}`, status, () => anikotoEpisodes(anilistId, ctx)), + animegg: () => withCache(`epv:animegg:${anilistId}`, status, () => animeggEpisodes(anilistId, ctx)), + anineko: () => withCache(`epv:anineko:${anilistId}`, status, () => aninekoEpisodes(anilistId, ctx)), + anidbapp: () => withCache(`epv:anidbapp:${anilistId}`, status, () => anidbappEpisodes(anilistId, ctx)), + "2dhive": () => withCache(`epv:2dhive:${anilistId}`, status, () => dhiveEpisodes(anilistId, ctx)), + animenosub: () => withCache(`epv:animenosub:${anilistId}`, status, () => animenosubEpisodes(anilistId, ctx)), + anizone: () => withCache(`epv:anizone:${anilistId}`, status, () => anizoneEpisodes(anilistId, ctx)), + anibd: () => withCache(`epv:anibd:${anilistId}`, status, () => anibdEpisodes(anilistId, ctx)), + senshi: () => withCache(`epv:senshi:${anilistId}`, status, () => senshiEpisodes(anilistId, ctx)), + kaa: () => withCache(`epv:kaa:${anilistId}`, status, () => kaaEpisodes(anilistId, ctx)), + animedunya: () => withCache(`epv:animedunya:${anilistId}`, status, () => animedunyaEpisodes(anilistId, ctx)), + }; +} + +export async function buildFilteredEpisodesWithCache(anilistId, providers, media, anizip) { + const status = media?.status ?? "RELEASING"; + const malId = media?.idMal ?? null; + + const jikanEps = malId + ? await fetchAllJikanWithCache(malId, status).catch(() => null) + : null; + + const ctx = { media, anizip, jikanEps, maxPages: undefined }; + const fns = providerFns(anilistId, status, ctx); + + const pairs = await Promise.all( + [...providers].map(async (name) => { + const result = await safe(name, fns[name]); + return [name, result.ok ? result.data : { error: result.error, stack: result.stack }]; + }) + ); + + return Object.fromEntries(pairs); +} + +export async function buildEpisodesWithCache(anilistId, media, anizip) { + const status = media?.status ?? "RELEASING"; + const malId = media?.idMal ?? null; + + const jikanEps = malId + ? await fetchAllJikanWithCache(malId, status).catch(() => null) + : null; + + const ctx = { media, anizip, jikanEps, maxPages: undefined }; + + const [manga, reanime, anikoto, animegg, anineko, anidbapp, dhive, animenosub, anizone, anibd, senshi, kaa, animedunya] = await Promise.all([ + safe("allmanga", () => withCache(`epv:manga:${anilistId}`, status, () => mangaEpisodes(anilistId, ctx))), + safe("reanime", () => withCache(`epv:reanime:${anilistId}`, status, () => reanimeEpisodes(anilistId, ctx))), + safe("anikoto", () => withCache(`epv:anikoto:${anilistId}`, status, () => anikotoEpisodes(anilistId, ctx))), + safe("animegg", () => withCache(`epv:animegg:${anilistId}`, status, () => animeggEpisodes(anilistId, ctx))), + safe("anineko", () => withCache(`epv:anineko:${anilistId}`, status, () => aninekoEpisodes(anilistId, ctx))), + safe("anidbapp", () => withCache(`epv:anidbapp:${anilistId}`, status, () => anidbappEpisodes(anilistId, ctx))), + safe("2dhive", () => withCache(`epv:2dhive:${anilistId}`, status, () => dhiveEpisodes(anilistId, ctx))), + safe("animenosub", () => withCache(`epv:animenosub:${anilistId}`, status, () => animenosubEpisodes(anilistId, ctx))), + safe("anizone", () => withCache(`epv:anizone:${anilistId}`, status, () => anizoneEpisodes(anilistId, ctx))), + safe("anibd", () => withCache(`epv:anibd:${anilistId}`, status, () => anibdEpisodes(anilistId, ctx))), + safe("senshi", () => withCache(`epv:senshi:${anilistId}`, status, () => senshiEpisodes(anilistId, ctx))), + safe("kaa", () => withCache(`epv:kaa:${anilistId}`, status, () => kaaEpisodes(anilistId, ctx))), + safe("animedunya", () => withCache(`epv:animedunya:${anilistId}`, status, () => animedunyaEpisodes(anilistId, ctx))), + ]); + + return { + allmanga: manga.ok ? manga.data : { error: manga.error, stack: manga.stack }, + reanime: reanime.ok ? reanime.data : { error: reanime.error, stack: reanime.stack }, + anikoto: anikoto.ok ? anikoto.data : { error: anikoto.error, stack: anikoto.stack }, + animegg: animegg.ok ? animegg.data : { error: animegg.error, stack: animegg.stack }, + anineko: anineko.ok ? anineko.data : { error: anineko.error, stack: anineko.stack }, + anidbapp: anidbapp.ok ? anidbapp.data : { error: anidbapp.error, stack: anidbapp.stack }, + "2dhive": dhive.ok ? dhive.data : { error: dhive.error, stack: dhive.stack }, + animenosub: animenosub.ok ? animenosub.data : { error: animenosub.error, stack: animenosub.stack }, + anizone: anizone.ok ? anizone.data : { error: anizone.error, stack: anizone.stack }, + anibd: anibd.ok ? anibd.data : { error: anibd.error, stack: anibd.stack }, + senshi: senshi.ok ? senshi.data : { error: senshi.error, stack: senshi.stack }, + kaa: kaa.ok ? kaa.data : { error: kaa.error, stack: kaa.stack }, + animedunya: animedunya.ok ? animedunya.data : { error: animedunya.error, stack: animedunya.stack }, + }; +} diff --git a/anivexa-api/core/mapper.js b/anivexa-api/core/mapper.js new file mode 100644 index 0000000000000000000000000000000000000000..680c743ef1fce1fcc4db9218555e2eff3a645a7f --- /dev/null +++ b/anivexa-api/core/mapper.js @@ -0,0 +1,144 @@ +const __name = (fn, _) => fn; +import { getMedia } from './anilist.js'; + +var ARM2 = "https://arm.haglund.dev/api/v2/ids"; +var UA2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"; +function hashFranchiseId(str) { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = (h << 5) - h + str.charCodeAt(i) | 0; + } + return h >>> 0; +} +__name(hashFranchiseId, "hashFranchiseId"); +async function fetchARM(anilistId) { + const res = await fetch(`${ARM2}?source=anilist&id=${anilistId}`, { + headers: { "User-Agent": UA2, "Accept": "application/json" } + }).catch(() => null); + if (!res || !res.ok) return null; + return res.json().catch(() => null); +} +__name(fetchARM, "fetchARM"); +async function fetchAniListRelations(anilistId) { + const q = ` + query ($id: Int) { + Media(id: $id, type: ANIME) { + id synonyms + relations { + edges { + relationType(version: 2) + node { + id type format title { romaji english native } + relations { + edges { + relationType(version: 2) + node { id type format title { romaji english native } } + } + } + } + } + } + } + }`; + try { + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ query: q, variables: { id: Number(anilistId) } }) + }); + if (!res.ok) return null; + const json6 = await res.json(); + return json6.data?.Media ?? null; + } catch { + return null; + } +} +__name(fetchAniListRelations, "fetchAniListRelations"); +async function mapAnimeIds(anilistId) { + const [arm, media, alRelations] = await Promise.all([ + fetchARM(anilistId), + getMedia(anilistId).catch(() => null), + fetchAniListRelations(anilistId) + ]); + const malId = arm?.myanimelist ?? null; + const format = media?.format ?? null; + const year = media?.seasonYear ?? null; + const titleEn = media?.title?.english || null; + const titleRom = media?.title?.romaji || null; + const synonyms = [...(media?.synonyms ?? [])]; + if (alRelations?.synonyms) { + for (const s of alRelations.synonyms) { + if (!synonyms.includes(s)) synonyms.push(s); + } + } + const franchiseMap = new Map(); + if (alRelations?.relations?.edges) { + for (const e1 of alRelations.relations.edges) { + if (!franchiseMap.has(e1.node.id)) { + franchiseMap.set(e1.node.id, { + relation: e1.relationType, + anilistId: e1.node.id, + title: e1.node.title.romaji || e1.node.title.english, + type: e1.node.type, + format: e1.node.format + }); + } + if (e1.node.relations?.edges) { + for (const e2 of e1.node.relations.edges) { + if (e2.node.id === Number(anilistId)) continue; + if (!franchiseMap.has(e2.node.id)) { + franchiseMap.set(e2.node.id, { + relation: e2.relationType, + anilistId: e2.node.id, + title: e2.node.title.romaji || e2.node.title.english, + type: e2.node.type, + format: e2.node.format + }); + } + } + } + } + } + const thetvdbId = arm?.thetvdb ?? null; + const themoviedbId = arm?.themoviedb ?? null; + const imdbId = arm?.imdb ?? null; + return { + mappings: { + id: Number(anilistId), + title: titleEn || titleRom, + type: arm?.media ?? null, + format, + episodes: media?.episodes ?? null, + malId, + aniId: Number(anilistId), + anidbId: arm?.anidb ?? null, + animePlanetId: arm?.["anime-planet"] ?? null, + kitsuId: arm?.kitsu ?? null, + animeCountdownId: arm?.animecountdown ?? null, + anisearchId: arm?.anisearch ?? null, + notifyMoeId: null, + simklId: arm?.simkl ?? null, + imdbId, + themoviedbId, + thetvdbId, + livechartId: arm?.livechart ?? null, + annId: arm?.animenewsnetwork ?? null, + animescheduleId: null, + animethemesId: null, + animefillerlistId: null, + franchiseAnchor: thetvdbId ? `tvdb:${thetvdbId}` : null, + franchiseId: thetvdbId ? hashFranchiseId(`tvdb:${thetvdbId}`) : null, + defaultTvdbSeason: arm?.["thetvdb-season"] != null ? String(arm["thetvdb-season"]) : null, + tmdbSeason: arm?.["themoviedb-season"] != null ? String(arm["themoviedb-season"]) : null, + episodeOffset: null, + tmdbOffset: null, + malIds: null, + aniskip: null, + animefillerlist: null, + synonyms, + franchise: Array.from(franchiseMap.values()) + } + }; +} +__name(mapAnimeIds, "mapAnimeIds"); +export { mapAnimeIds }; diff --git a/anivexa-api/core/new-provider-utils.js b/anivexa-api/core/new-provider-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..b2e78d2b1c566668792405c9e11fdd5e86a4c8d6 --- /dev/null +++ b/anivexa-api/core/new-provider-utils.js @@ -0,0 +1,227 @@ +import { get, set, isFresh, SHOW_IDENTITY_TTL } from "./smartcache.js"; + +export const UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +const RELATION_FRAGMENT = `edges{relationType(version:2) node{id type episodes relations{edges{relationType(version:2) node{id type episodes relations{edges{relationType(version:2) node{id type episodes relations{edges{relationType(version:2) node{id type episodes}}}}}}}}}}}`; + +export async function fetchHtml(url, headers = {}) { + const res = await fetch(url, { + headers: { + "User-Agent": UA, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + ...headers, + }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + return res.text(); +} + +export function decodeEntities(s = "") { + return s + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16))) + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .trim(); +} + +export function stripTags(html = "") { + return decodeEntities(html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ")); +} + +export function attr(tag, name) { + const m = tag.match(new RegExp(`${name}=["']([^"']*)["']`, "i")); + return m ? decodeEntities(m[1]) : ""; +} + +export function norm(s = "") { + return s.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +export function diceCoeff(a, b) { + const na = norm(a); + const nb = norm(b); + if (na === nb) return 1; + if (na.length < 2 || nb.length < 2) return 0; + const bigrams = new Map(); + for (let i = 0; i < na.length - 1; i++) { + const bg = na.slice(i, i + 2); + bigrams.set(bg, (bigrams.get(bg) ?? 0) + 1); + } + let hits = 0; + for (let i = 0; i < nb.length - 1; i++) { + const bg = nb.slice(i, i + 2); + const count = bigrams.get(bg) ?? 0; + if (count > 0) { + hits++; + bigrams.set(bg, count - 1); + } + } + return (2 * hits) / (na.length + nb.length - 2); +} + +export function titleScore(query, candidate, slug) { + const base = Math.max(diceCoeff(query, candidate), diceCoeff(query, slug.replace(/-/g, " "))); + const queryFirstNum = norm(query).match(/\d+/)?.[0] ?? ""; + const slugFirstNum = slug.match(/\d+/)?.[0] ?? ""; + if (queryFirstNum && slugFirstNum && queryFirstNum !== slugFirstNum) return base * 0.65; + if (queryFirstNum && !slugFirstNum) return base * 0.65; + if (!queryFirstNum && slugFirstNum) { + const n = parseInt(slugFirstNum); + if (n > 1 && n < 1900) return base * (1 - 0.06 * (n - 1)); + } + 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); +} + +export async function findTopSlugs(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, titleScore(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); +} + +async function anilistQuery(query, variables) { + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) throw new Error(`AniList HTTP ${res.status}`); + const json = await res.json(); + if (json.errors?.length) throw new Error(`AniList: ${json.errors[0].message}`); + return json.data; +} + +function computePrequelOffset(relations, depth = 0) { + if (!relations || depth > 5) return 0; + const prequelEdge = relations.edges?.find( + (e) => e.relationType === "PREQUEL" && e.node.type === "ANIME" && (e.node.episodes ?? 0) >= 5 + ); + if (!prequelEdge) return 0; + return (prequelEdge.node.episodes ?? 0) + computePrequelOffset(prequelEdge.node.relations, depth + 1); +} + +export async function getPrequelOffset(anilistId) { + const key = `np-offset:${anilistId}`; + const entry = get(key); + if (isFresh(entry)) return entry.data; + const data = await anilistQuery( + `query($id:Int){Media(id:$id,type:ANIME){relations{${RELATION_FRAGMENT}}}}`, + { id: Number(anilistId) } + ); + const offset = computePrequelOffset(data?.Media?.relations); + set(key, offset, SHOW_IDENTITY_TTL); + return offset; +} + +export function buildTitles(media, anizip) { + return [ + media?.title?.english, + media?.title?.romaji, + media?.title?.native, + ...(media?.synonyms ?? []), + anizip?.titles?.en, + anizip?.titles?.["x-jat"], + anizip?.titles?.ja, + ].filter(Boolean); +} + +export function expectedCount(media, anizip, jikanEps) { + const counts = [ + media?.episodes, + ...Object.keys(anizip?.episodes ?? {}).map(Number).filter(Number.isFinite), + ...(jikanEps ?? []).map((e) => e.mal_id).filter(Number.isFinite), + ].filter((n) => Number.isFinite(n) && n > 0); + return counts.length ? Math.max(...counts) : null; +} + +export function episodeMeta(n, ctx) { + const az = ctx.anizip?.episodes?.[String(n)] ?? {}; + const jk = (ctx.jikanEps ?? []).find((e) => Number(e.mal_id) === Number(n)); + const runtime = az.runtime ?? az.length ?? null; + return { + title: jk?.title ?? az.title?.en ?? az.title?.["x-jat"] ?? null, + duration: runtime ? runtime * 60 : null, + filler: jk?.filler ?? az.filler ?? false, + uncensored: false, + description: az.overview ?? az.summary ?? null, + image: az.image ?? ctx.anizip?.images?.cover ?? null, + airDate: jk?.aired ?? az.airdate ?? az.aired ?? null, + }; +} + +export function selectSeries(candidates, scrapeSeries, expected, status, offset, options = {}) { + return Promise.all(candidates.map(async (candidate) => { + const episodes = await scrapeSeries(candidate.slug); + const max = Math.max(0, ...episodes.map((e) => e.number)); + const localHits = expected ? episodes.filter((e) => e.number >= 1 && e.number <= expected).length : episodes.length; + const offsetHits = expected && offset + ? episodes.filter((e) => e.number > offset && e.number <= offset + expected).length + : 0; + const mode = offsetHits > localHits ? "offset" : "local"; + const hits = Math.max(localHits, offsetHits); + let countScore = 1; + if (expected && expected >= 6) { + const needed = status === "FINISHED" ? Math.ceil(expected * 0.9) : Math.max(1, expected - 3); + countScore = hits >= needed ? 1 : hits / needed; + } + return { ...candidate, episodes, max, mode, score: candidate.score * 0.7 + countScore * 0.3 }; + })).then((results) => { + const minScore = options.minScore ?? 0.65; + const viable = results + .filter((r) => r.episodes.length && r.score >= minScore) + .sort((a, b) => b.score - a.score); + if (!viable.length) return null; + return viable[0]; + }); +} + +export function json(data, status = 200) { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=300", + }, + }); +} diff --git a/anivexa-api/core/smartcache.js b/anivexa-api/core/smartcache.js new file mode 100644 index 0000000000000000000000000000000000000000..f40840e3fca65e1b220bb94031ad732f99e1e0f5 --- /dev/null +++ b/anivexa-api/core/smartcache.js @@ -0,0 +1,230 @@ +export const _CACHE_ENABLED = false; //change it to true and setup your upstash so you can cache your data + +const IS_LOCAL_NODE = (() => { + try { + return ( + typeof process !== "undefined" && + typeof process.versions?.node === "string" && + !process.env.VERCEL + ); + } catch { return false; } +})(); + +const UPSTASH_REDIS_REST_URL = "YOUR_UPSTASH_REDIS_REST_URL"; //get it from upstash.com +const UPSTASH_REDIS_REST_TOKEN = "YOUR_UPSTASH_REDIS_REST_TOKEN"; +const REDIS_ENABLED = Boolean(UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN); + +function encodeEntry(entry) { + return JSON.stringify(entry, (_, value) => value === Infinity ? "__Infinity__" : value); +} + +function decodeEntry(raw) { + return JSON.parse(raw, (_, value) => value === "__Infinity__" ? Infinity : value); +} + +async function redisCommand(command) { + if (!REDIS_ENABLED || typeof fetch !== "function") return null; + const res = await fetch(UPSTASH_REDIS_REST_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${UPSTASH_REDIS_REST_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(command), + }).catch(() => null); + if (!res?.ok) return null; + const json = await res.json().catch(() => null); + return json?.result ?? null; +} + +async function redisWrite(key, entry) { + if (!REDIS_ENABLED) return; + const value = encodeEntry(entry); + if (Number.isFinite(entry.ttl) && entry.ttl > 0) { + await redisCommand(["SET", key, value, "PX", Math.ceil(entry.ttl)]); + return; + } + await redisCommand(["SET", key, value]); +} + +let diskRead = () => null; +let diskWrite = () => {}; +let diskDel = () => {}; + +if (IS_LOCAL_NODE) { + const { readFileSync, mkdirSync, existsSync } = await import("node:fs"); + const { writeFile, unlink } = await import("node:fs/promises"); + const { join, dirname } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + + const __dir = dirname(fileURLToPath(import.meta.url)); + const CACHE_DIR = join(__dir, ".cache"); + try { mkdirSync(CACHE_DIR, { recursive: true }); } catch {} + + const keyToPath = (key) => + join(CACHE_DIR, key.replace(/[^a-zA-Z0-9_-]/g, "_") + ".json"); + + diskRead = (key) => { + try { + const p = keyToPath(key); + if (!existsSync(p)) return null; + return decodeEntry(readFileSync(p, "utf8")); + } catch { return null; } + }; + + diskWrite = (key, entry) => { + writeFile(keyToPath(key), encodeEntry(entry)).catch(() => {}); + }; + + diskDel = (key) => { + unlink(keyToPath(key)).catch(() => {}); + }; +} + +const MAX_MEM = 800; +const mem = new Map(); + +function evict() { + if (mem.size <= MAX_MEM) return; + const drop = mem.size - MAX_MEM; + let n = 0; + for (const k of mem.keys()) { + if (n++ >= drop) break; + mem.delete(k); + } +} + +export function get(key) { + if (!_CACHE_ENABLED) return null; + let e = mem.get(key); + if (e) return e; + + e = diskRead(key); + if (!e) return null; + + mem.set(key, e); + evict(); + return e; +} + +export async function getAsync(key) { + if (!_CACHE_ENABLED) return null; + let e = get(key); + if (e) return e; + + const raw = await redisCommand(["GET", key]); + if (!raw) return null; + + try { + e = typeof raw === "string" ? decodeEntry(raw) : raw; + if (!isFresh(e)) { + await delAsync(key); + return null; + } + mem.set(key, e); + evict(); + diskWrite(key, e); + return e; + } catch { + return null; + } +} + +function setLocal(key, data, ttlMs, refreshAfterMs) { + const now = Date.now(); + const entry = { + data, + cachedAt: now, + ttl: ttlMs, + refreshAfter: refreshAfterMs ?? ttlMs, + expiresAt: now + ttlMs, + }; + mem.delete(key); + mem.set(key, entry); + evict(); + diskWrite(key, entry); + return entry; +} + +export function set(key, data, ttlMs, refreshAfterMs) { + if (!_CACHE_ENABLED) return { data, cachedAt: Date.now(), ttl: ttlMs, refreshAfter: refreshAfterMs ?? ttlMs, expiresAt: Date.now() + ttlMs }; + const entry = setLocal(key, data, ttlMs, refreshAfterMs); + redisWrite(key, entry).catch(() => {}); + return entry; +} + +export async function setAsync(key, data, ttlMs, refreshAfterMs) { + if (!_CACHE_ENABLED) return { data, cachedAt: Date.now(), ttl: ttlMs, refreshAfter: refreshAfterMs ?? ttlMs, expiresAt: Date.now() + ttlMs }; + const entry = setLocal(key, data, ttlMs, refreshAfterMs); + await redisWrite(key, entry); + return entry; +} + +export function isFresh(entry) { + return entry !== null && entry !== undefined && Date.now() < entry.expiresAt; +} + +export function needsRefresh(entry) { + return !entry || Date.now() - entry.cachedAt > entry.refreshAfter; +} + +function delLocal(key) { + mem.delete(key); + diskDel(key); +} + +export function del(key) { + delLocal(key); + redisCommand(["DEL", key]).catch(() => {}); +} + +export async function delAsync(key) { + delLocal(key); + await redisCommand(["DEL", key]); +} + +export function delByPrefix(prefix) { + for (const k of [...mem.keys()]) { + if (k.startsWith(prefix)) mem.delete(k); + } +} + +export async function delByPrefixAsync(prefix) { + delByPrefix(prefix); + const keys = await redisCommand(["KEYS", `${prefix}*`]); + if (Array.isArray(keys) && keys.length) { + await redisCommand(["DEL", ...keys]); + } +} + +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +export function episodeTTL(status) { + switch (status) { + case "FINISHED": return [7 * DAY, Infinity]; + case "RELEASING": return [2 * HOUR, 15 * MIN]; + case "HIATUS": return [6 * HOUR, 60 * MIN]; + case "NOT_YET_RELEASED": return [30 * MIN, 15 * MIN]; + default: return [HOUR, 15 * MIN]; + } +} + +export function jikanPageTTL(isLastPage, status) { + if (!isLastPage || status === "FINISHED") return [7 * DAY, Infinity]; + switch (status) { + case "RELEASING": return [2 * HOUR, 15 * MIN]; + case "HIATUS": return [6 * HOUR, 60 * MIN]; + case "NOT_YET_RELEASED": return [30 * MIN, 15 * MIN]; + default: return [2 * HOUR, 15 * MIN]; + } +} + +export function mapTTL(status) { + return status === "FINISHED" ? 30 * DAY : 12 * HOUR; +} + +export const WATCH_TTL = 3 * HOUR; +export const SHOW_IDENTITY_TTL = 24 * HOUR; +export const THIRTY_DAYS = 30 * DAY; diff --git a/anivexa-api/docs/index.html b/anivexa-api/docs/index.html new file mode 100644 index 0000000000000000000000000000000000000000..b111fd1e11fabed3204901524d5a4a26875b9ad9 --- /dev/null +++ b/anivexa-api/docs/index.html @@ -0,0 +1,786 @@ + + + + + + Anivexa API — Docs + + + + + + + + + + + + + + +
+ +
+ Anivexa + Anivexa + +
+ + + +
+ +
+ +
+ +

Anivexa API

+

A unified anime streaming aggregator API. Resolve episode lists and stream sources across 13 providers using a single AniList ID — no scraping, no guessing, exact-match identity resolution.

+
+ 💡 + All endpoints are read-only. No authentication required. All responses are JSON with CORS headers included. +
+
+ +
+

Base URL

+

All requests are made to the root of the server. When running locally:

+
+
+ BASE + http://localhost:4000 +
+
+

Replace with your deployed URL when in production. Every response includes Access-Control-Allow-Origin: *.

+
+ +
+

Providers

+

Thirteen providers are available. Each uses exact-match identity resolution via AniList or MAL IDs — no blind fuzzy matching.

+
+
allmanga
AllManga
+
reanime
Reanime
+
anikoto
Anikoto
+
animegg
AnimeGG
+
anineko
AniNeko
+
anidbapp
AniDBApp
+
2dhive
2DHive
+
animenosub
AnimeNoSub
+
anizone
AniZone
+
anibd
AniBD
+
senshi
Senshi
+
kaa
KickAssAnime
+
animedunya
AnimeDunya
+
+
+ +
+ +
+

API Info

+
+
+ GET + / + Try it ↗ +
+
+

Returns API metadata: version, cache status, list of active providers, and all registered routes.

+
+
Response
+
{
+  "name": "Anivexa API 2.2",
+  "cache": false,
+  "providers": ["allmanga", "reanime", "..."],
+  "routes": ["/map/:anilistId", "..."]
+}
+
+
+ +
+

Map IDs

+
+
+ GET + /map/:anilistId + Try it ↗ +
+
+

Resolves an AniList ID to its equivalents across other databases (MAL, Kitsu, etc.).

+ + + + + +
ParameterTypeDescription
:anilistIdintegerAniList media ID
+
+
Example — /map/16498
+
{
+  "anilistId": 16498,
+  "malId": 16498,
+  "kitsuId": 7442
+}
+
+
+ +
+

Episodes

+
+
+ GET + /episodes/:anilistId + Try it ↗ +
+
+

Fetches episode lists from all providers in parallel for the given AniList ID. Each provider key contains either its episode data or an error object if unavailable.

+ + + + + +
ParameterTypeDescription
:anilistIdintegerAniList media ID
+
+
Response shape
+
{
+  "reanime": {
+    "meta": { "title": "Attack on Titan", "malId": 16498 },
+    "episodes": {
+      "sub": [
+        {
+          "id":     "watch/reanime/16498/sub/reanime-1",
+          "number": 1,
+          "title":  "To You, 2,000 Years in the Future",
+          "filler": false,
+          "audio":  "sub"
+        }
+      ],
+      "dub": [ "..." ]
+    }
+  },
+  "senshi": { "..." },
+  "anibd":  { "error": "..." }
+}
+
+
+ +
+

Episodes (Filtered)

+
+
+ GET + /episodes/:provider/:anilistId + Try it ↗ +
+
+

Fetch episode data from one or more specific providers. Chain multiple provider names in the path. Use ?map=false to skip the ID map lookup.

+ + + + + + + +
ParameterTypeDescription
:providerstringOne or more provider slugs separated by /
:anilistIdintegerAniList media ID
?mapbooleanInclude ID map in response (default: true)
+
+
Examples
+
GET /episodes/reanime/16498
+GET /episodes/reanime/senshi/16498
+GET /episodes/reanime/senshi/anibd/16498?map=false
+
+
+ +
+ +
+

AllManga

+
+
+ GET + /watch/allmanga/:id/sub|dub/allmanga-:ep + Try it ↗ +
+
+

Returns stream sources for the given AllManga episode. The episode ID comes directly from the id field in the episodes list response.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
Response
+
{
+  "anilistId": 16498,
+  "episode":   1,
+  "audio":     "sub",
+  "streams": [
+    {
+      "url":      "https://...",
+      "type":     "hls",
+      "server":   "Server Name",
+      "referer":  "https://...",
+      "priority": 5,
+      "isActive": true
+    }
+  ]
+}
+
+
+ +
+

Reanime

+
+
+ GET + /watch/reanime/:id/sub|dub/reanime-:ep + Try it ↗ +
+
+

Resolves stream sources from Reanime using AniList ID confirmation via cover image CDN URLs and detail endpoint matching. Returns decrypted HLS streams.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+
+ GET + /stream/reanime/:id/sub|dub/:ep + Try it ↗ +
+
+

Direct stream variant — returns the raw stream response without the watch wrapper. Useful for direct playback.

+
+
+
+ +
+

Anikoto

+
+
+ GET + /watch/anikoto/:id/sub|dub/anikoto-:ep + Try it ↗ +
+
+

Returns stream sources from Anikoto for the specified episode.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+ +
+

AnimeGG

+
+
+ GET + /watch/animegg/:id/sub|dub/animegg-:ep + Try it ↗ +
+
+

Returns stream sources from AnimeGG for the specified episode.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+ +
+

AniNeko

+
+
+ GET + /watch/anineko/:id/sub|dub/anineko-:ep + Try it ↗ +
+
+

Returns stream sources from AniNeko for the specified episode.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+ +
+

AniDBApp

+
+
+ GET + /watch/anidbapp/:id/sub|dub/anidbapp-:ep + Try it ↗ +
+
+

Returns stream sources from AniDBApp. Uses exact AniList ID confirmation after fuzzy title search before resolving streams.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+ +
+

2DHive

+
+
+ GET + /watch/2dhive/:id/sub|dub/2dhive-:ep + Try it ↗ +
+
+

Returns stream sources from 2DHive.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+
+ GET + /stream/2dhive/:id/sub|dub/:ep + Try it ↗ +
+
+

Direct stream variant for 2DHive.

+
+
+
+
+ GET + /stream/2dhive/download/:id/sub|dub/:ep + Try it ↗ +
+
+

Download variant — returns a direct downloadable stream URL from 2DHive.

+
+
+
+ +
+

AnimeNoSub

+
+
+ GET + /watch/animenosub/:id/sub|dub/animenosub-:ep + Try it ↗ +
+
+

Returns stream sources from AnimeNoSub for the specified episode.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+ +
+

AniZone

+
+
+ GET + /watch/anizone/:id/sub|dub/anizone-:ep + Try it ↗ +
+
+

Returns stream sources from AniZone for the specified episode.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+ +
+

AniBD

+
+
+ GET + /watch/anibd/:id/sub|dub/anibd-:ep + Try it ↗ +
+
+

Returns stream sources from AniBD. Resolves the player link, then extracts and returns the HLS URL directly.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
+
+ +
+

Senshi

+
+
+ GET + /watch/senshi/:id/sub|dub/senshi-:ep + Try it ↗ +
+
+

Returns stream sources from Senshi. Uses MAL ID directly — no slug resolution. Returns all available sources including HLS, alternate servers, FileMoon embeds, and download links when present.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
Response
+
{
+  "anilistId":  16498,
+  "malId":      16498,
+  "episode":    1,
+  "audio":      "sub",
+  "intro":      { "start": 123, "end": 215 },
+  "outro":      { "start": 1435, "end": 1525 },
+  "streams": [
+    { "url": "https://ninstream.com/.../playlist.m3u8", "type": "hls",   "server": "Senshi",   "priority": 5, "isActive": true  },
+    { "url": "https://streamnin.xyz/d/...",              "type": "embed", "server": "StreamNin", "priority": 3, "isActive": false },
+    { "url": "https://bysesayeveum.com/e/...",             "type": "embed", "server": "FileMoon",  "priority": 2, "isActive": false }
+  ],
+  "downloads": [
+    { "url": "https://bzzhr.to/...", "label": "Download" }
+  ]
+}
+
+
+ +
+

KickAssAnime

+
+
+ GET + /watch/kaa/:id/sub|dub/kaa-:ep + Try it ↗ +
+
+

Returns stream sources from KickAssAnime for the specified episode. Streams are served via CatStream (HLS) and require the included Referer header for playback.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
Response
+
{
+  "anilistId": 21,
+  "episode":   1,
+  "audio":     "sub",
+  "streams": [
+    {
+      "url":      "https://hls.krussdomi.com/manifest/.../master.m3u8",
+      "type":     "hls",
+      "server":   "CatStream",
+      "headers": { "Referer": "https://krussdomi.com/" },
+      "priority": 1,
+      "isActive": true
+    }
+  ]
+}
+
+
+ +
+

AnimeDunya

+
+
+ GET + /watch/animedunya/:id/sub|dub/animedunya-:ep + Try it ↗ +
+
+

Returns stream sources from AnimeDunya for the specified episode. Streams are served as direct HLS playlists with multiple subtitle tracks included.

+ + + + + + + +
ParameterTypeDescription
:idintegerAniList media ID
sub|dubstringAudio track preference
:epintegerEpisode number
+
+
Response
+
{
+  "anilistId": 16498,
+  "malId":     16498,
+  "episode":   1,
+  "audio":     "sub",
+  "streams": [
+    {
+      "url":       "https://fs2c.anime-dunya.com/files/.../master.m3u8",
+      "type":      "hls",
+      "server":    "AnimeDunya",
+      "referer":   "https://anime-dunya.com/",
+      "subtitles": [
+        { "url": "https://fs2c.anime-dunya.com/.../en.vtt", "label": "EN", "srclang": "en", "default": true }
+      ],
+      "priority":  5,
+      "isActive":  true
+    }
+  ]
+}
+
+
+ +
+
+ + + + + diff --git a/anivexa-api/docs/landing.html b/anivexa-api/docs/landing.html new file mode 100644 index 0000000000000000000000000000000000000000..05629fc4d2ef73df5676a02735639485c1edf2f5 --- /dev/null +++ b/anivexa-api/docs/landing.html @@ -0,0 +1,345 @@ + + + + + + Anivexa API + + + + + + + + + + + +
+ +
+ + +

Anivexa

+

Streaming Aggregator API

+ +

+ A unified API for anime stream sources. Resolve episodes and watch links across 13 providers using a single AniList ID — exact-match identity, no guessing. +

+ +
+ + + + + + + + + Get Started + + + +
+ +
+ 13 Providers + AniList ID + No Auth + CORS Enabled +
+
+ + + + + + diff --git a/anivexa-api/docs/logo.svg b/anivexa-api/docs/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..44833ecaf33abdbcccd2ee21230b2ce814da0bf3 --- /dev/null +++ b/anivexa-api/docs/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/anivexa-api/docs/style.css b/anivexa-api/docs/style.css new file mode 100644 index 0000000000000000000000000000000000000000..58d0f1acf48078557e9520d63a4ffda2f0d2ae3c --- /dev/null +++ b/anivexa-api/docs/style.css @@ -0,0 +1,757 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #09090d; + --sidebar: #0d0d12; + --border: #1c1c26; + --border-hi: #2a2a3a; + --surface: #111118; + --surface-hi: #16161f; + --text: #eaeaf4; + --muted: #63637a; + --faint: #1e1e2a; + --accent: #818cf8; + --accent-dim: rgba(129,140,248,.1); + --accent-glow: rgba(129,140,248,.18); + --accent-mid: rgba(129,140,248,.06); + --green: #4ade80; + --green-dim: rgba(74,222,128,.1); + --purple: #c084fc; + --purple-dim: rgba(192,132,252,.1); + --sidebar-w: 264px; + --header-h: 56px; + --radius: 10px; + --font: 'Inter', system-ui, sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', monospace; +} + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 15px; + line-height: 1.7; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.bg-orbs { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.bg-orb { + position: absolute; + border-radius: 50%; + filter: blur(90px); + will-change: transform; +} + +.bg-orb-1 { + width: 640px; height: 640px; + background: radial-gradient(circle, rgba(129,140,248,.11) 0%, transparent 70%); + top: -8%; left: 15%; + animation: orbFloat1 18s ease-in-out infinite; +} + +.bg-orb-2 { + width: 480px; height: 480px; + background: radial-gradient(circle, rgba(192,132,252,.08) 0%, transparent 70%); + top: 35%; right: -8%; + animation: orbFloat2 22s ease-in-out infinite; +} + +.bg-orb-3 { + width: 360px; height: 360px; + background: radial-gradient(circle, rgba(74,222,128,.06) 0%, transparent 70%); + bottom: 8%; left: 28%; + animation: orbFloat3 26s ease-in-out infinite; +} + +@keyframes orbFloat1 { + 0%,100% { transform: translate(0,0); } + 33% { transform: translate(30px,-40px); } + 66% { transform: translate(-20px,25px); } +} +@keyframes orbFloat2 { + 0%,100% { transform: translate(0,0); } + 40% { transform: translate(-35px,30px); } + 70% { transform: translate(20px,-20px); } +} +@keyframes orbFloat3 { + 0%,100% { transform: translate(0,0); } + 30% { transform: translate(25px,-30px); } + 65% { transform: translate(-15px,20px); } +} + +.layout { + display: flex; + min-height: 100vh; + position: relative; + z-index: 1; +} + +.sidebar { + position: fixed; + top: 0; left: 0; + width: var(--sidebar-w); + height: 100vh; + background: rgba(13,13,18,.92); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow-y: auto; + z-index: 100; + scrollbar-width: thin; + scrollbar-color: var(--faint) transparent; +} + +.sidebar::after { + content: ''; + position: absolute; + top: 0; right: 0; + width: 1px; + height: 100%; + background: linear-gradient(to bottom, transparent, rgba(129,140,248,.15) 30%, rgba(129,140,248,.15) 70%, transparent); + pointer-events: none; +} + +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-track { background: transparent; } +.sidebar::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; } + +.logo { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.logo::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(129,140,248,.05), transparent 60%); + pointer-events: none; +} + +.logo img { + width: 28px; height: 28px; + flex-shrink: 0; + filter: drop-shadow(0 0 10px rgba(129,140,248,.4)); + animation: logoPulse 4s ease-in-out infinite; +} + +@keyframes logoPulse { + 0%,100% { filter: drop-shadow(0 0 10px rgba(129,140,248,.4)); } + 50% { filter: drop-shadow(0 0 18px rgba(129,140,248,.65)); } +} + +.logo-text { + font-size: 15px; + font-weight: 700; + letter-spacing: -.3px; + color: var(--text); +} + +.logo-version { + font-size: 10px; + font-weight: 600; + color: var(--accent); + background: var(--accent-dim); + border: 1px solid rgba(129,140,248,.2); + padding: 2px 7px; + border-radius: 20px; + margin-left: auto; + flex-shrink: 0; + letter-spacing: .04em; +} + +.nav { padding: 12px 0 24px; flex: 1; } + +.nav-section { + padding: 0 12px; + margin-bottom: 4px; +} + +.nav-label { + font-size: 10px; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--muted); + padding: 16px 8px 6px; +} + +.nav-item { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 7px; + font-size: 13.5px; + color: var(--muted); + cursor: pointer; + transition: color .15s, background .15s, transform .15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; +} + +.nav-item:hover { + color: var(--text); + background: var(--faint); + text-decoration: none; + transform: translateX(2px); +} + +.nav-item.active { + color: var(--accent); + background: var(--accent-dim); +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; top: 20%; bottom: 20%; + width: 2px; + background: var(--accent); + border-radius: 2px; + box-shadow: 0 0 8px var(--accent); +} + +.nav-item .method { + font-size: 10px; + font-family: var(--mono); + font-weight: 600; + color: var(--green); + background: var(--green-dim); + border: 1px solid rgba(74,222,128,.15); + padding: 1px 5px; + border-radius: 4px; + margin-right: 7px; + flex-shrink: 0; +} + +.main { + margin-left: var(--sidebar-w); + min-width: 0; + flex: 1; + padding: 64px max(40px, calc((100% - 820px) / 2)); +} + +.topbar { + display: none; + position: fixed; + top: 0; left: 0; right: 0; + height: var(--header-h); + background: rgba(9,9,13,.95); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-bottom: 1px solid var(--border); + align-items: center; + padding: 0 16px; + gap: 12px; + z-index: 200; +} + +.topbar img { width: 24px; height: 24px; } +.topbar-title { font-size: 15px; font-weight: 700; color: var(--text); } + +.menu-btn { + margin-left: auto; + background: none; + border: 1px solid var(--border); + border-radius: 7px; + padding: 6px 8px; + color: var(--text); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + transition: border-color .2s, background .2s; +} + +.menu-btn:hover { + border-color: var(--accent); + background: var(--accent-dim); +} + +.menu-btn span { + display: block; + width: 16px; height: 2px; + background: var(--text); + border-radius: 2px; + transition: transform .3s cubic-bezier(.22,1,.36,1), opacity .2s; +} + +.menu-btn.active span:nth-child(1) { transform: rotate(45deg) translate(4px, 4px); } +.menu-btn.active span:nth-child(2) { opacity: 0; } +.menu-btn.active span:nth-child(3) { transform: rotate(-45deg) translate(4px, -4px); } + +.section { + margin-bottom: 80px; + scroll-margin-top: 32px; + opacity: 0; + transform: translateY(28px); + transition: opacity .55s cubic-bezier(.22,1,.36,1), transform .55s cubic-bezier(.22,1,.36,1); +} + +.section.visible { + opacity: 1; + transform: translateY(0); +} + +.section-hero { margin-bottom: 72px; } + +.hero-logo { + width: 56px; height: 56px; + margin-bottom: 24px; + filter: drop-shadow(0 0 24px rgba(129,140,248,.5)); + animation: heroFloat 5s ease-in-out infinite; + transform-style: preserve-3d; +} + +@keyframes heroFloat { + 0%,100% { transform: translateY(0) rotateY(0deg); } + 50% { transform: translateY(-6px) rotateY(8deg); } +} + +h1 { + font-size: 36px; + font-weight: 800; + letter-spacing: -.8px; + line-height: 1.15; + margin-bottom: 14px; + background: linear-gradient(135deg, #eaeaf4 0%, #818cf8 55%, #c084fc 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +h2 { + font-size: 20px; + font-weight: 700; + letter-spacing: -.3px; + color: var(--text); + margin-bottom: 18px; + padding-bottom: 13px; + border-bottom: 1px solid var(--border); + position: relative; +} + +h2::after { + content: ''; + position: absolute; + bottom: -1px; left: 0; + width: 40px; height: 1px; + background: var(--accent); + box-shadow: 0 0 8px var(--accent); + border-radius: 2px; +} + +h3 { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin: 24px 0 8px; +} + +p { color: var(--muted); margin-bottom: 16px; } +p:last-child { margin-bottom: 0; } + +.endpoint { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 20px; + overflow: hidden; + transition: border-color .2s, box-shadow .2s, transform .2s; + transform: perspective(1000px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg)) translateZ(0); +} + +.endpoint:hover { + border-color: var(--border-hi); + box-shadow: 0 8px 40px rgba(0,0,0,.35), 0 0 0 1px rgba(129,140,248,.06); +} + +.endpoint-head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + background: linear-gradient(135deg, var(--sidebar) 0%, rgba(22,22,32,1) 100%); + position: relative; +} + +.endpoint-head::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, rgba(129,140,248,.03) 0%, transparent 100%); + pointer-events: none; +} + +.method-pill { + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + padding: 3px 9px; + border-radius: 5px; + flex-shrink: 0; + letter-spacing: .04em; +} + +.method-pill.get { + color: var(--green); + background: var(--green-dim); + border: 1px solid rgba(74,222,128,.2); +} + +.endpoint-path { + font-family: var(--mono); + font-size: 13.5px; + color: var(--text); + font-weight: 500; + word-break: break-all; +} + +.endpoint-path .param { color: var(--accent); } + +.try-btn { + margin-left: auto; + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + color: var(--accent); + background: var(--accent-dim); + border: 1px solid rgba(129,140,248,.18); + border-radius: 6px; + padding: 4px 11px; + white-space: nowrap; + transition: background .15s, border-color .15s, box-shadow .15s; + text-decoration: none; +} + +.try-btn:hover { + background: rgba(129,140,248,.18); + border-color: rgba(129,140,248,.4); + box-shadow: 0 0 12px rgba(129,140,248,.2); + text-decoration: none; +} + +.endpoint-body { padding: 16px 18px; } + +.endpoint-desc { + color: var(--muted); + font-size: 14px; + margin-bottom: 14px; +} + +.params-table { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; + margin-bottom: 16px; +} + +.params-table th { + text-align: left; + padding: 8px 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + +.params-table td { + padding: 9px 12px; + border-bottom: 1px solid var(--faint); + vertical-align: top; +} + +.params-table tr:last-child td { border-bottom: none; } + +.param-name { + font-family: var(--mono); + font-size: 12.5px; + color: var(--accent); + white-space: nowrap; +} + +.param-type { + font-family: var(--mono); + font-size: 11.5px; + color: var(--purple); + white-space: nowrap; +} + +.param-desc { color: var(--muted); } + +.code-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .07em; + color: var(--muted); + padding: 10px 18px 6px; + background: var(--surface); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +pre { + background: var(--surface); + padding: 14px 18px 18px; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: var(--faint) transparent; + position: relative; + transition: background .2s; +} + +pre:hover { background: var(--surface-hi); } + +pre::-webkit-scrollbar { height: 4px; } +pre::-webkit-scrollbar-track { background: transparent; } +pre::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; } + +code { + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.7; + color: var(--text); +} + +.k { color: #c084fc; } +.s { color: #86efac; } +.n { color: #818cf8; } +.p { color: #555568; } +.b { color: #fb923c; } + +.copy-btn { + position: absolute; + top: 10px; right: 12px; + font-size: 11px; + font-weight: 600; + font-family: var(--font); + color: var(--muted); + background: var(--faint); + border: 1px solid var(--border); + border-radius: 5px; + padding: 3px 9px; + cursor: pointer; + opacity: 0; + transition: opacity .2s, color .15s, background .15s; + z-index: 2; +} + +pre:hover .copy-btn { opacity: 1; } + +.copy-btn:hover { + color: var(--text); + background: var(--border-hi); + border-color: var(--border-hi); +} + +.copy-btn.copied { + color: var(--green); + border-color: rgba(74,222,128,.3); + background: var(--green-dim); + opacity: 1; +} + +.provider-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 12px; + margin-top: 18px; +} + +.provider-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; + transition: border-color .2s, box-shadow .2s; + transform: perspective(600px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg)); + will-change: transform; + cursor: default; +} + +.provider-card:hover { + border-color: rgba(129,140,248,.35); + box-shadow: 0 6px 28px rgba(0,0,0,.3), 0 0 0 1px rgba(129,140,248,.08), inset 0 1px 0 rgba(129,140,248,.06); +} + +.provider-name { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 5px; +} + +.provider-meta { + font-size: 12px; + color: var(--muted); +} + +.callout { + display: flex; + gap: 12px; + background: linear-gradient(135deg, rgba(129,140,248,.08) 0%, rgba(129,140,248,.04) 100%); + border: 1px solid rgba(129,140,248,.18); + border-radius: var(--radius); + padding: 14px 18px; + margin-bottom: 24px; + font-size: 13.5px; + color: var(--text); + position: relative; + overflow: hidden; +} + +.callout::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 3px; height: 100%; + background: linear-gradient(to bottom, var(--accent), var(--purple)); + border-radius: 2px 0 0 2px; +} + +.callout-icon { flex-shrink: 0; font-size: 15px; } + +.divider { + border: none; + height: 1px; + background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent); + margin: 56px 0; +} + +.overlay { + display: none; + position: fixed; + inset: 0; + background: transparent; + z-index: 90; +} + +.overlay.open { display: block; } + +.provider-grid .provider-card { transition-delay: calc(var(--i, 0) * 40ms); } + +@media (max-width: 768px) { + :root { --sidebar-w: 280px; } + + .topbar { display: flex; } + + .sidebar { + transform: translateX(-100%); + transition: transform .28s cubic-bezier(.4,0,.2,1); + background: var(--sidebar); + backdrop-filter: none; + -webkit-backdrop-filter: none; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + } + + .sidebar.open { + transform: translateX(0); + box-shadow: 4px 0 24px rgba(0,0,0,.5); + } + + .main { + margin-left: 0; + padding: calc(var(--header-h) + 32px) 20px 48px; + } + + h1 { font-size: 26px; } + h2 { font-size: 17px; } + .bg-orb-2, .bg-orb-3 { display: none; } + + .endpoint { transform: none !important; } + .provider-card { transform: none !important; } +} + +@media (min-width: 769px) and (max-width: 1100px) { + .main { padding: 52px 36px; } +} + +body, a, button, label, [role="button"] { cursor: none; } + +.cursor-dot, +.cursor-ring { + position: fixed; + top: 0; left: 0; + pointer-events: none; + z-index: 99999; + will-change: transform; + border-radius: 50%; +} + +.cursor-dot { + width: 6px; height: 6px; + background: var(--accent); + margin: -3px 0 0 -3px; + box-shadow: 0 0 10px rgba(129,140,248,.8); + transition: opacity .2s, transform .1s; +} + +.cursor-ring { + width: 36px; height: 36px; + border: 1.5px solid rgba(129,140,248,.45); + margin: -18px 0 0 -18px; + transition: width .25s cubic-bezier(.22,1,.36,1), + height .25s cubic-bezier(.22,1,.36,1), + margin .25s cubic-bezier(.22,1,.36,1), + border-color .25s, + background .25s, + opacity .2s; +} + +.cursor-ring.is-hovering { + width: 52px; height: 52px; + margin: -26px 0 0 -26px; + border-color: rgba(129,140,248,.7); + background: rgba(129,140,248,.06); +} + +.cursor-dot.is-hovering { opacity: 0; } + +.cursor-dot.is-hidden, +.cursor-ring.is-hidden { opacity: 0; } + +.cursor-ring.is-clicking { + width: 28px; height: 28px; + margin: -14px 0 0 -14px; + border-color: rgba(129,140,248,.9); + background: rgba(129,140,248,.12); +} + +input, textarea, select { cursor: text; } + +@media (prefers-reduced-motion: reduce) { + .section { opacity: 1; transform: none; transition: none; } + .hero-logo, .logo img { animation: none; } + .bg-orb { animation: none; } + .provider-card, .endpoint { transform: none !important; } +} diff --git a/anivexa-api/index.js b/anivexa-api/index.js new file mode 100644 index 0000000000000000000000000000000000000000..5dac963cb617194a2a9464594266f4ddb510d632 --- /dev/null +++ b/anivexa-api/index.js @@ -0,0 +1,302 @@ +import { getMedia } from "./core/anilist.js"; +import { mapAnimeIds } from "./core/mapper.js"; +import mangaHandler from "./providers/allmanga.js"; +import reanimeHandler from "./providers/reanime.js"; +import anikotoHandler from "./providers/anikoto.js"; +import animeggHandler from "./providers/animegg.js"; +import aninekoHandler from "./providers/anineko.js"; +import anidbappHandler from "./providers/anidbapp.js"; +import dhiveHandler from "./providers/2dhive.js"; +import animenosubHandler from "./providers/animenosub.js"; +import anizoneHandler from "./providers/anizone.js"; +import anibdHandler from "./providers/anibd.js"; +import senshiHandler from "./providers/senshi.js"; +import kaaHandler from "./providers/kickassanime.js"; +import animedunyaHandler from "./providers/animedunya.js"; +import { getEpisodesResponse, getFilteredEpisodesResponse } from "./core/episode-cache.js"; +import { resolveProviders } from "./core/episode-strategy.js"; +import { getAsync, setAsync, isFresh, mapTTL, WATCH_TTL, _CACHE_ENABLED } from "./core/smartcache.js"; + +function json(data, status = 200) { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=300", + }, + }); +} + +function rewriteRequest(request, newPath) { + const u = new URL(request.url); + u.pathname = newPath; + return new Request(u.toString(), { method: request.method, headers: request.headers }); +} + +const watchInflight = new Map(); + +async function cachedWatch(cacheKey, handlerFn) { + const entry = await getAsync(cacheKey); + if (entry && isFresh(entry)) return json(entry.data); + + if (watchInflight.has(cacheKey)) { + await watchInflight.get(cacheKey).catch(() => {}); + const warm = await getAsync(cacheKey); + if (warm && isFresh(warm)) return json(warm.data); + return handlerFn(); + } + + const promise = (async () => { + const response = await handlerFn(); + if (response.status === 200) { + try { + const data = await response.clone().json(); + await setAsync(cacheKey, data, WATCH_TTL); + } catch {} + } + return response; + })(); + + watchInflight.set(cacheKey, promise); + try { return await promise; } + finally { watchInflight.delete(cacheKey); } +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const path = url.pathname; + + 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": "*", + }, + }); + } + + let m = path.match(/^\/map\/(\d+)\/?$/); + if (m) { + const anilistId = m[1]; + const cacheKey = `map:${anilistId}`; + const entry = await getAsync(cacheKey); + if (entry && isFresh(entry)) return json(entry.data); + + try { + const [data, media] = await Promise.all([ + mapAnimeIds(anilistId), + getMedia(anilistId).catch(() => null), + ]); + await setAsync(cacheKey, data, mapTTL(media?.status ?? "RELEASING")); + return json(data); + } catch (e) { + if (entry) return json(entry.data); + return json({ error: e.message }, 500); + } + } + + m = path.match(/^\/episodes\/((?:[\w-]+\/)+)(\d+)\/?$/i); + if (m) { + const rawNames = m[1].replace(/\/$/, "").split("/"); + const anilistId = m[2]; + const includeMap = url.searchParams.get("map") !== "false"; + const { resolved, unknown } = resolveProviders(rawNames); + + if (resolved.size === 0) { + return json({ error: "No valid providers specified", unknown }, 400); + } + + try { + const data = await getFilteredEpisodesResponse(anilistId, resolved, includeMap); + if (unknown.length) data._unknownProviders = unknown; + return json(data); + } catch (e) { + return json({ error: e.message }, 500); + } + } + + m = path.match(/^\/episodes\/(\d+)\/?$/); + if (m) { + const anilistId = m[1]; + try { + return json(await getEpisodesResponse(anilistId, env)); + } catch (e) { + return json({ error: e.message }, 500); + } + } + + m = path.match(/^\/watch\/allmanga\/(\d+)\/(sub|dub)\/allmanga-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:manga:${id}:${audio}:${ep}`, + () => mangaHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/reanime\/(\d+)\/(sub|dub)\/reanime-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:reanime:${id}:${audio}:${ep}`, + () => reanimeHandler.fetch(rewriteRequest(request, `/watch/${id}/${audio}/${ep}`)) + ); + } + + m = path.match(/^\/stream\/reanime\/(\d+)\/(sub|dub)\/(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return reanimeHandler.fetch(rewriteRequest(request, `/stream/${id}/${audio}/${ep}`)); + } + + m = path.match(/^\/watch\/anikoto\/(\d+)\/(sub|dub)\/anikoto-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:anikoto:${id}:${audio}:${ep}`, + () => anikotoHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/animegg\/(\d+)\/(sub|dub)\/animegg-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:animegg:${id}:${audio}:${ep}`, + () => animeggHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/anineko\/(\d+)\/(sub|dub)\/anineko-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:anineko:${id}:${audio}:${ep}`, + () => aninekoHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/anidbapp\/(\d+)\/(sub|dub)\/anidbapp-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:anidbapp:${id}:${audio}:${ep}`, + () => anidbappHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/2dhive\/(\d+)\/(sub|dub)\/2dhive-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:2dhive:${id}:${audio}:${ep}`, + () => dhiveHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/animenosub\/(\d+)\/(sub|dub)\/animenosub-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:animenosub:${id}:${audio}:${ep}`, + () => animenosubHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/anizone\/(\d+)\/(sub|dub)\/anizone-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:anizone:${id}:${audio}:${ep}`, + () => anizoneHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/anibd\/(\d+)\/(sub|dub)\/anibd-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:anibd:${id}:${audio}:${ep}`, + () => anibdHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/senshi\/(\d+)\/(sub|dub)\/senshi-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:senshi:${id}:${audio}:${ep}`, + () => senshiHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/kaa\/(\d+)\/(sub|dub)\/kaa-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:kaa:${id}:${audio}:${ep}`, + () => kaaHandler.fetch(request) + ); + } + + m = path.match(/^\/watch\/animedunya\/(\d+)\/(sub|dub)\/animedunya-(\d+)\/?$/); + if (m) { + const [, id, audio, ep] = m; + return cachedWatch( + `watch:animedunya:${id}:${audio}:${ep}`, + () => animedunyaHandler.fetch(request) + ); + } + + m = path.match(/^\/stream\/2dhive\/(\d+)\/(sub|dub)\/(\d+)\/?$/); + if (m) return dhiveHandler.fetch(request); + + m = path.match(/^\/stream\/2dhive\/download\/(\d+)\/(sub|dub)\/(\d+)\/?$/); + if (m) return dhiveHandler.fetch(request); + + return json({ + name: "Anivexa API 2.1", //actually i will goon to you if you change this ok? so erm..maybe i wont..or maybe i will idk + cache: _CACHE_ENABLED, + providers: [ + "allmanga", + "reanime", + "anikoto", + "animegg", + "anineko", + "anidbapp", + "2dhive", + "animenosub", + "anizone", + "anibd", + "senshi", + "kaa", + "animedunya", + ], + routes: [ + "/map/:anilistId", + "/episodes/:anilistId", + "/episodes/:provider[/:provider...]/:anilistId?map=true|false", + "/watch/allmanga/:id/sub|dub/allmanga-:ep", + "/watch/reanime/:id/sub|dub/reanime-:ep", + "/stream/reanime/:id/sub|dub/:ep", + "/watch/anikoto/:id/sub|dub/anikoto-:ep", + "/watch/animegg/:id/sub|dub/animegg-:ep", + "/watch/anineko/:id/sub|dub/anineko-:ep", + "/watch/anidbapp/:id/sub|dub/anidbapp-:ep", + "/watch/2dhive/:id/sub|dub/2dhive-:ep", + "/stream/2dhive/:id/sub|dub/:ep", + "/stream/2dhive/download/:id/sub|dub/:ep", + "/watch/animenosub/:id/sub|dub/animenosub-:ep", + "/watch/anizone/:id/sub|dub/anizone-:ep", + "/watch/anibd/:id/sub|dub/anibd-:ep", + "/watch/senshi/:id/sub|dub/senshi-:ep", + "/watch/kaa/:id/sub|dub/kaa-:ep", + "/watch/animedunya/:id/sub|dub/animedunya-:ep", + ], + }); + }, +}; diff --git a/anivexa-api/package.json b/anivexa-api/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d54b13e4f7d870d50fac950e015e31d572233d77 --- /dev/null +++ b/anivexa-api/package.json @@ -0,0 +1,9 @@ +{ + "name": "all-api", + "version": "1.0.0", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js" + } +} diff --git a/anivexa-api/providers/2dhive.js b/anivexa-api/providers/2dhive.js new file mode 100644 index 0000000000000000000000000000000000000000..872509a33e9fc0761131fd27c2d312f47bfc5450 --- /dev/null +++ b/anivexa-api/providers/2dhive.js @@ -0,0 +1,270 @@ +import { getMedia } from "../core/anilist.js"; +import { episodeMeta, expectedCount, json } from "../core/new-provider-utils.js"; + +async function getMalId(anilistId, ctx) { + const idMal = ctx?.media?.idMal ?? (await getMedia(anilistId)).idMal; + if (!idMal) throw new Error(`2dhive: no MAL ID found for AniList ${anilistId}`); + return idMal; +} + +const BASE = "https://2dhive.com"; +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +async function fetchPage(url) { + const res = await fetch(url, { headers: { "User-Agent": UA } }); + if (!res.ok) throw new Error(`2dhive ${res.status}: ${url}`); + return res.text(); +} + +function extractPlayerProps(html) { + const idx = html.indexOf("prefetchedHls"); + if (idx === -1) return null; + const propsIdx = html.lastIndexOf('props="', idx); + if (propsIdx === -1) return null; + const valueIdx = propsIdx + 7; + const endIdx = html.indexOf('"', valueIdx); + if (endIdx === -1) return null; + const raw = html.slice(valueIdx, endIdx) + .replace(/"/g, '"') + .replace(/&/g, "&") + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">"); + try { return JSON.parse(raw); } catch { return null; } +} + +function astroDecode(v) { + if (!Array.isArray(v)) return v; + const [type, data] = v; + if (type === 0) { + if (data === null || typeof data !== "object" || Array.isArray(data)) return data; + return Object.fromEntries(Object.entries(data).map(([k, val]) => [k, astroDecode(val)])); + } + if (type === 1) return Array.isArray(data) ? data.map(astroDecode) : data; + return data; +} + +function decodeProps(raw) { + return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, astroDecode(v)])); +} + +function parseEpisodeNums(html, malId) { + const re = new RegExp(`/episode\\?anime=${malId}&(?:amp;)?ep_num=(\\d+)`, "gi"); + const nums = new Set(); + for (const m of html.matchAll(re)) nums.add(Number(m[1])); + return [...nums].sort((a, b) => a - b); +} + +async function fetchEpisodePage(malId, epNum) { + const html = await fetchPage(`${BASE}/episode?anime=${malId}&ep_num=${epNum}`); + const rawProps = extractPlayerProps(html); + if (!rawProps) throw new Error(`2dhive: no player props for mal ${malId} ep${epNum}`); + return decodeProps(rawProps); +} + +export async function getEpisodes(anilistId, ctx = {}) { + const malId = await getMalId(anilistId, ctx); + const animeHtml = await fetchPage(`${BASE}/anime?anime=${malId}`); + const epNums = parseEpisodeNums(animeHtml, malId); + if (!epNums.length) throw new Error(`2dhive: no episodes found for AniList ${anilistId} (MAL ${malId})`); + + const props = await fetchEpisodePage(malId, epNums[0]); + const hasDub = Boolean(props.prefetchedHls?.dub?.content); + const expected = expectedCount(ctx.media, ctx.anizip, ctx.jikanEps); + + const sub = [], dub = []; + for (const num of epNums) { + if (expected && num > expected) continue; + const meta = episodeMeta(num, ctx); + const base = { + number: num, + title: meta.title ?? `Episode ${num}`, + duration: meta.duration ?? null, + filler: meta.filler ?? false, + uncensored: meta.uncensored ?? false, + description: meta.description ?? null, + image: meta.image ?? null, + airDate: meta.airDate ?? null, + }; + sub.push({ id: `watch/2dhive/${anilistId}/sub/2dhive-${num}`, ...base, audio: "sub" }); + if (hasDub) dub.push({ id: `watch/2dhive/${anilistId}/dub/2dhive-${num}`, ...base, audio: "dub" }); + } + + return { + meta: { + id: String(anilistId), + source: "2dhive", + matchScore: 1, + numbering: "standard", + episodeOffset: 0, + }, + episodes: { sub, dub }, + }; +} + +async function handleWatch(anilistId, audio, epNum) { + const malId = await getMalId(anilistId); + const referer = `${BASE}/episode?anime=${malId}&ep_num=${epNum}`; + const fileKey = `${malId}_${epNum}_${audio}`; + + const [propsResult, hiAnimeResult, dlContent] = await Promise.allSettled([ + fetchEpisodePage(malId, epNum), + audio !== "dub" + ? fetch(`${BASE}/api/hianime?mal_id=${malId}&ep_num=${epNum}`, { + headers: { "User-Agent": UA, "Referer": referer }, + }).then(r => r.ok ? r.json() : null).catch(() => null) + : Promise.resolve(null), + fetchDownloadHls(malId, audio, epNum), + ]); + + const streams = []; + const props = propsResult.status === "fulfilled" ? propsResult.value : null; + + if (props) { + const hlsContent = audio === "dub" + ? props.prefetchedHls?.dub?.content + : props.prefetchedHls?.sub?.content; + + if (hlsContent) { + streams.push({ + server: audio === "dub" ? "HLS DUB" : "HLS SUB", + url: `/stream/2dhive/${anilistId}/${audio}/${epNum}`, + }); + } + + const rawServers = Array.isArray(props.servers) ? props.servers : []; + const hadfreeEntries = rawServers.filter(s => + s.server_name === "HAdfree" && Boolean(s.dub) === (audio === "dub") && s.slug + ); + + const hadfreeResults = await Promise.allSettled( + hadfreeEntries.map(entry => + fetch(`${BASE}/api/hadfree?slug=${encodeURIComponent(entry.slug)}`, { + headers: { "User-Agent": UA, "Referer": referer }, + }).then(r => r.ok ? r.json() : null).catch(() => null) + ) + ); + + for (const r of hadfreeResults) { + if (r.status === "fulfilled" && r.value?.streamUrl) { + streams.push({ server: "HAdfree", url: r.value.streamUrl }); + } + } + } + + streams.push({ + server: audio === "dub" ? "MegaPlay Dub" : "MegaPlay Sub", + url: `https://megaplay.buzz/stream/mal/${malId}/${epNum}/${audio === "dub" ? "dub" : "sub"}`, + type: "embed", + }); + + const hiAnime = hiAnimeResult.status === "fulfilled" ? hiAnimeResult.value : null; + if (hiAnime?.m3u8) { + const entry = { server: "hiAnime", url: hiAnime.m3u8 }; + if (hiAnime.subtitle) entry.subtitle = hiAnime.subtitle; + streams.push(entry); + } + + if (dlContent.status === "fulfilled" && dlContent.value) { + streams.push({ + server: "Download", + url: `/stream/2dhive/download/${anilistId}/${audio}/${epNum}`, + }); + } + + return json({ anilistId: Number(anilistId), episode: Number(epNum), audio, streams }); +} + +async function fetchDownloadHls(malId, audio, epNum) { + const fileKey = `${malId}_${epNum}_${audio}`; + try { + const res = await fetch(`${BASE}/download?file=${encodeURIComponent(fileKey)}`, { + headers: { + "User-Agent": UA, + "Referer": `${BASE}/episode?anime=${malId}&ep_num=${epNum}`, + }, + }); + if (!res.ok) return null; + const html = await res.text(); + const m = html.match(/downloadPayload\s*=\s*(\{.*?\});/s); + if (!m) return null; + const payload = JSON.parse(m[1]); + return payload.hlsContent || null; + } catch { + return null; + } +} + +async function handleDownloadStream(anilistId, audio, epNum) { + const malId = await getMalId(anilistId); + const content = await fetchDownloadHls(malId, audio, epNum); + if (!content) { + return new Response(JSON.stringify({ error: "No download stream found" }), { + status: 404, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + return new Response(content, { + status: 200, + headers: { + "Content-Type": "application/vnd.apple.mpegurl", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + }, + }); +} + +async function handleStream(anilistId, audio, epNum) { + const malId = await getMalId(anilistId); + const props = await fetchEpisodePage(malId, epNum); + const content = audio === "dub" + ? props.prefetchedHls?.dub?.content + : props.prefetchedHls?.sub?.content; + + if (!content) { + return new Response(JSON.stringify({ error: "No HLS stream found" }), { + status: 404, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + return new Response(content, { + status: 200, + headers: { + "Content-Type": "application/vnd.apple.mpegurl", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + }, + }); +} + +export default { + async fetch(request) { + 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": "*", + }, + }); + } + const url = new URL(request.url); + const path = url.pathname; + try { + let m = path.match(/^\/watch\/2dhive\/(\d+)\/(sub|dub)\/2dhive-(\d+)\/?$/); + if (m) return await handleWatch(m[1], m[2], m[3]); + + m = path.match(/^\/stream\/2dhive\/(\d+)\/(sub|dub)\/(\d+)\/?$/); + if (m) return await handleStream(m[1], m[2], m[3]); + + m = path.match(/^\/stream\/2dhive\/download\/(\d+)\/(sub|dub)\/(\d+)\/?$/); + if (m) return await handleDownloadStream(m[1], m[2], m[3]); + + return json({ error: "Not found" }, 404); + } catch (err) { + return json({ error: err.message, stack: err.stack }, 500); + } + }, +}; diff --git a/anivexa-api/providers/allmanga.js b/anivexa-api/providers/allmanga.js new file mode 100644 index 0000000000000000000000000000000000000000..c50ff859bf7e9e2042e3803fe4cd7ce0132b90d6 --- /dev/null +++ b/anivexa-api/providers/allmanga.js @@ -0,0 +1,757 @@ +const __name = (fn, _) => fn; + +var UA4 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"; +var API = "https://api.allanime.day"; +var REFERER = "https://allmanga.to"; +var ANIZIP = "https://api.ani.zip/mappings"; +var PASSPHRASE = "Xot36i3lK3:v1"; +var TMDB_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJlYjdkMWM0ZTgwMGUzM2FiMmE3Y2I3NDA5YmM4NjQ2YSIsIm5iZiI6MTc3OTUzMDcxOS40MzIsInN1YiI6IjZhMTE3YmRmYTlhNjNlYmFiOWUzYjc4YyIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.Z9pa96oJEyicf6wAoaKGKJd9ldapeiOdktoJd4xcgLo"; //i honestly forgot why i added it here, anyway it was created using tempmail so idc if its leaked or whatever +var HASHES = { + episode: "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec" +}; +var HEX_TABLE = { + "79": "A", + "7a": "B", + "7b": "C", + "7c": "D", + "7d": "E", + "7e": "F", + "7f": "G", + "70": "H", + "71": "I", + "72": "J", + "73": "K", + "74": "L", + "75": "M", + "76": "N", + "77": "O", + "68": "P", + "69": "Q", + "6a": "R", + "6b": "S", + "6c": "T", + "6d": "U", + "6e": "V", + "6f": "W", + "60": "X", + "61": "Y", + "62": "Z", + "59": "a", + "5a": "b", + "5b": "c", + "5c": "d", + "5d": "e", + "5e": "f", + "5f": "g", + "50": "h", + "51": "i", + "52": "j", + "53": "k", + "54": "l", + "55": "m", + "56": "n", + "57": "o", + "48": "p", + "49": "q", + "4a": "r", + "4b": "s", + "4c": "t", + "4d": "u", + "4e": "v", + "4f": "w", + "40": "x", + "41": "y", + "42": "z", + "08": "0", + "09": "1", + "0a": "2", + "0b": "3", + "0c": "4", + "0d": "5", + "0e": "6", + "0f": "7", + "00": "8", + "01": "9", + "15": "-", + "16": ".", + "67": "_", + "46": "~", + "02": ":", + "17": "/", + "07": "?", + "1b": "#", + "63": "[", + "65": "]", + "78": "@", + "19": "!", + "1c": "$", + "1e": "&", + "10": "(", + "11": ")", + "12": "*", + "13": "+", + "14": ",", + "03": ";", + "05": "=", + "1d": "%" +}; +var _aesKey = null; +async function getAESKey() { + if (_aesKey) return _aesKey; + const raw = new TextEncoder().encode(PASSPHRASE); + const hash = await crypto.subtle.digest("SHA-256", raw); + _aesKey = await crypto.subtle.importKey("raw", hash, { name: "AES-CTR" }, false, ["decrypt"]); + return _aesKey; +} +__name(getAESKey, "getAESKey"); +async function decryptTobeparsed(b64) { + const buf = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const iv12 = buf.slice(1, 13); + const counter = new Uint8Array(16); + counter.set(iv12, 0); + counter[12] = 0; + counter[13] = 0; + counter[14] = 0; + counter[15] = 2; + const ctLen = buf.length - 13 - 16; + const ciphertext = buf.slice(13, 13 + ctLen); + const key = await getAESKey(); + const plain = await crypto.subtle.decrypt( + { name: "AES-CTR", counter, length: 32 }, + key, + ciphertext + ); + return new TextDecoder().decode(plain); +} +__name(decryptTobeparsed, "decryptTobeparsed"); +function decodeHexUrl(hex) { + let out = ""; + for (let i = 0; i < hex.length; i += 2) { + const pair = hex.substring(i, i + 2).toLowerCase(); + out += HEX_TABLE[pair] ?? pair; + } + return out; +} +__name(decodeHexUrl, "decodeHexUrl"); +function hexToBytes(hex) { + const c = hex.replace(/[^0-9a-f]/gi, ""); + const b = new Uint8Array(c.length / 2); + for (let i = 0; i < b.length; i++) b[i] = parseInt(c.slice(i * 2, i * 2 + 2), 16); + return b; +} +__name(hexToBytes, "hexToBytes"); +async function aesDecrypt(hex) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode("kiemtienmua911ca"), + { name: "AES-CBC" }, + false, + ["decrypt"] + ); + const plain = await crypto.subtle.decrypt( + { name: "AES-CBC", iv: new TextEncoder().encode("1234567890oiuytr") }, + key, + hexToBytes(hex) + ); + return new TextDecoder().decode(plain); +} +__name(aesDecrypt, "aesDecrypt"); +async function extractMp4(id) { + try { + const r = await fetch(`https://www.mp4upload.com/embed-${id}.html`, { + headers: { "User-Agent": UA4, Referer: "https://allanime.to/" } + }); + if (!r.ok) return null; + const h = await r.text(); + const m = h.match(/player\.src\s*\(\s*\{[^}]*\bsrc\s*:\s*"([^"]+)"/) || h.match(/"file"\s*:\s*"(https?:[^"]+\.mp4[^"]*)"/) || h.match(/\bsrc\s*:\s*"(https?:[^"]+\.mp4[^"]*)"/); + return m?.[1]?.replace(/\\/g, "") || null; + } catch { + return null; + } +} +__name(extractMp4, "extractMp4"); +async function extractUns(id) { + try { + const base = "https://allanime.uns.bio"; + const r = await fetch(`${base}/api/v1/video?id=${id}&w=1280&h=720&r=`, { + headers: { "User-Agent": UA4, Referer: `${base}/#${id}`, Origin: base } + }); + if (!r.ok) return null; + const hex = (await r.text()).trim(); + if (!hex || !/^[0-9a-f]+$/i.test(hex)) return null; + const p = JSON.parse(await aesDecrypt(hex)); + return p.source || p.cf || null; + } catch { + return null; + } +} +__name(extractUns, "extractUns"); +async function extractOk(id) { + try { + const r = await fetch(`https://ok.ru/videoembed/${id}`, { + headers: { "User-Agent": UA4, Referer: "https://ok.ru/" } + }); + if (!r.ok) return null; + const h = await r.text(); + const m = h.match(/ondemandHls\\":\\"(https?:\/\/.*?)\\"/); + if (!m) return null; + return m[1].replace(/\\u0026/g, "&"); + } catch { + return null; + } +} +__name(extractOk, "extractOk"); +async function extractStreamSB(id) { + try { + const baseHeaders = { + "User-Agent": UA4, + "Referer": "https://allmanga.to/", + "watchsb": "streamsb", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9" + }; + const r1 = await fetch(`https://streamsb.net/api/v1/video?id=${id}`, { headers: baseHeaders }); + const sid = (r1.headers.get("set-cookie") || "").match(/sid=([^;]+)/)?.[1] ?? ""; + const html1 = await r1.text(); + const m = html1.match(/window\.location\.replace\('([^']+)'\)/); + if (!m) return null; + const r2 = await fetch(m[1], { + headers: { ...baseHeaders, "Cookie": `sid=${sid}`, "Referer": `https://streamsb.net/e/${id}.html` } + }); + if (!r2.ok) return null; + const ct = r2.headers.get("content-type") ?? ""; + if (!ct.includes("json")) return null; + const data = await r2.json(); + return data?.stream_data?.file ?? data?.data?.file ?? null; + } catch { + return null; + } +} +__name(extractStreamSB, "extractStreamSB"); +async function extractStreamlare(id) { + try { + const r = await fetch("https://streamlare.com/api/video/stream/get", { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": UA4, + "Referer": "https://streamlare.com/", + "Origin": "https://streamlare.com", + "Accept": "application/json, */*" + }, + body: JSON.stringify({ id }) + }); + if (!r.ok) return null; + const data = await r.json(); + return data?.data?.file ?? null; + } catch { + return null; + } +} +__name(extractStreamlare, "extractStreamlare"); +function embedMediaType(url) { + if (!url) return null; + if (url.includes(".m3u8")) return "hls"; + if (url.includes(".mp4")) return "mp4"; + return "direct"; +} +__name(embedMediaType, "embedMediaType"); +async function apiFetch(url) { + const res = await fetch(url, { + headers: { "User-Agent": UA4, "Referer": REFERER, "Origin": REFERER } + }); + if (!res.ok) { const _raw = await res.text().catch(() => null); const _e = new Error(`API ${res.status}`); _e.rawBody = _raw; throw _e; } + const json6 = await res.json(); + if (json6?.data?.tobeparsed) { + const decrypted = await decryptTobeparsed(json6.data.tobeparsed); + json6.data = JSON.parse(decrypted); + } + return json6.data; +} +__name(apiFetch, "apiFetch"); +function buildApiUrl(variables, hash) { + const v = encodeURIComponent(JSON.stringify(variables)); + const e = encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: hash } })); + return `${API}/api?variables=${v}&extensions=${e}`; +} +__name(buildApiUrl, "buildApiUrl"); +async function apiPost(query, variables) { + const res = await fetch(`${API}/api`, { + method: "POST", + headers: { + "User-Agent": UA4, + "Referer": REFERER, + "Origin": REFERER, + "Content-Type": "application/json" + }, + body: JSON.stringify({ variables, query }) + }); + if (!res.ok) { const _raw = await res.text().catch(() => null); const _e = new Error(`API POST ${res.status}`); _e.rawBody = _raw; throw _e; } + const json6 = await res.json(); + if (json6?.data?.tobeparsed) { + const decrypted = await decryptTobeparsed(json6.data.tobeparsed); + json6.data = JSON.parse(decrypted); + } + return json6.data; +} +__name(apiPost, "apiPost"); +async function searchAllAnime(query, mode = "sub") { + const gql = `query($search:SearchInput $limit:Int $page:Int $translationType:VaildTranslationTypeEnumType $countryOrigin:VaildCountryOriginEnumType){shows(search:$search limit:$limit page:$page translationType:$translationType countryOrigin:$countryOrigin){edges{_id name englishName nativeName availableEpisodes availableEpisodesDetail aniListId __typename}}}`; + const data = await apiPost(gql, { + search: { allowAdult: false, allowUnknown: false, query }, + limit: 40, + page: 1, + translationType: mode, + countryOrigin: "ALL" + }); + return data?.shows?.edges ?? []; +} +__name(searchAllAnime, "searchAllAnime"); +async function getEpisodeSources(showId, epNum, audio = "sub") { + const url = buildApiUrl( + { showId, translationType: audio, episodeString: String(epNum) }, + HASHES.episode + ); + const data = await apiFetch(url); + return data?.episode ?? null; +} +__name(getEpisodeSources, "getEpisodeSources"); +async function fetchAniZip(anilistId) { + const res = await fetch(`${ANIZIP}?anilist_id=${anilistId}`); + if (!res.ok) return null; + return res.json(); +} +__name(fetchAniZip, "fetchAniZip"); +function normalize(s) { + return (s || "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""); +} +__name(normalize, "normalize"); +function extractYear(title2) { + if (!title2) return null; + const m = title2.match(/\b(19\d{2}|20\d{2})\b/); + return m ? parseInt(m[1]) : null; +} +__name(extractYear, "extractYear"); +function findBestMatch(results, titles, targetYear, targetId) { + const normalizedTitles = titles.map(normalize).filter(Boolean); + let bestShow = null; + let maxScore = -Infinity; + for (const r of results) { + if (targetId && r.aniListId && String(r.aniListId) === String(targetId)) { + return r; + } + const names = [r.name, r.englishName, r.nativeName].map(normalize).filter(Boolean); + let nameScore = 0; + let isExact = false; + for (const n of names) { + if (normalizedTitles.includes(n)) { + nameScore = 100; + isExact = true; + break; + } + } + if (!isExact) { + let maxFuzzy = 0; + for (const rName of names) { + for (const t of normalizedTitles) { + if (t.includes(rName) || rName.includes(t)) { + const score = Math.min(rName.length, t.length); + const lengthPenalty = Math.abs(rName.length - t.length) * 0.1; + const finalFuzzy = score - lengthPenalty; + if (finalFuzzy > maxFuzzy) maxFuzzy = finalFuzzy; + } + } + } + nameScore = maxFuzzy; + } + let yearScore = 0; + const rYear = extractYear(r.name) || extractYear(r.englishName) || extractYear(r.nativeName); + if (targetYear && rYear) { + yearScore = rYear === targetYear ? 50 : -200; + } + const totalScore = nameScore + yearScore; + if (totalScore > maxScore) { + maxScore = totalScore; + bestShow = r; + } + } + return bestShow || results[0]; +} +__name(findBestMatch, "findBestMatch"); +async function fetchAniListMedia(anilistId) { + try { + const q = "query ($id: Int) { Media (id: $id, type: ANIME) { seasonYear startDate { year } title { romaji english native } } }"; + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": UA4, + "Origin": "https://anilist.co" + }, + body: JSON.stringify({ query: q, variables: { id: Number(anilistId) } }) + }); + if (!res.ok) return null; + const json6 = await res.json(); + return json6.data?.Media ?? null; + } catch (e) { + console.error("AniList titles fetch failed:", e); + return null; + } +} +__name(fetchAniListMedia, "fetchAniListMedia"); +async function resolveAllAnimeId(anilistId, ctx = {}) { + const [anizipRes, alMedia] = await Promise.all([ + ctx.anizip ? Promise.resolve(ctx.anizip) : fetchAniZip(anilistId).catch(() => ({})), + ctx.media ? Promise.resolve({ + title: ctx.media.title, + seasonYear: ctx.media.seasonYear, + startDate: ctx.media.startDate + }) : fetchAniListMedia(anilistId).catch(() => null) + ]); + const anizip = anizipRes || {}; + let titlesToTry = []; + if (anizip.titles) { + titlesToTry = [ + anizip.titles.en, + anizip.titles.ja, + anizip.titles["x-jat"], + ...Object.values(anizip.titles) + ].filter(Boolean); + } + if (alMedia?.title) { + const alTitles = [alMedia.title.english, alMedia.title.romaji, alMedia.title.native].filter(Boolean); + titlesToTry = [...new Set([...alTitles, ...titlesToTry])]; + } + if (!titlesToTry.length && anizip.mappings) { + const apId = anizip.mappings.animeplanet_id; + if (apId) { + const cleanApTitle = apId.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); + titlesToTry = [cleanApTitle]; + } + } + if (!titlesToTry.length) { + throw new Error(`Could not resolve titles for AniList ID: ${anilistId}`); + } + const targetYear = alMedia?.seasonYear || alMedia?.startDate?.year || null; + let allResults = []; + for (const title2 of titlesToTry.slice(0, 3)) { + const results = await searchAllAnime(title2, "sub"); + allResults.push(...results); + } + const seen = new Set(); + allResults = allResults.filter((r) => { + if (seen.has(r._id)) return false; + seen.add(r._id); + return true; + }); + if (!allResults.length) { + throw new Error(`No AllAnime match for "${titlesToTry[0]}"`); + } + const match = findBestMatch(allResults, titlesToTry, targetYear, anilistId); + return { showId: match._id, show: match, anizip }; +} +__name(resolveAllAnimeId, "resolveAllAnimeId"); +async function fetchAniListFull(anilistId) { + const q = ` + query ($id: Int) { + Media(id: $id, type: ANIME) { + id + idMal + title { romaji english native } + synonyms + format + episodes + seasonYear + startDate { year } + type + relations { + edges { relationType(version: 2) node { id type format title { romaji english native } } } + } + } + }`; + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": UA4, "Origin": "https://anilist.co" }, + body: JSON.stringify({ query: q, variables: { id: Number(anilistId) } }) + }); + if (!res.ok) throw new Error("AniList fetch failed"); + const json6 = await res.json(); + return json6.data?.Media; +} +__name(fetchAniListFull, "fetchAniListFull"); +async function fetchKitsuId(malId) { + if (!malId) return null; + try { + const res = await fetch(`https://kitsu.io/api/edge/mappings?filter[externalSite]=myanimelist/anime&filter[externalId]=${malId}`); + const json6 = await res.json(); + const mapping = json6.data?.[0]; + if (mapping && mapping.relationships?.item?.links?.related) { + const itemRes = await fetch(mapping.relationships.item.links.related); + const itemJson = await itemRes.json(); + return itemJson.data?.id ? Number(itemJson.data.id) : null; + } + } catch (e) { + console.error("Kitsu Error:", e); + } + return null; +} +__name(fetchKitsuId, "fetchKitsuId"); +async function fetchTMDB(titles, year, format) { + let tmdbType = format === "MOVIE" || format === "OVA" || format === "SPECIAL" ? "movie" : "tv"; + let result = null; + for (const title2 of titles) { + if (!title2) continue; + try { + const searchUrl = `https://api.themoviedb.org/3/search/${tmdbType}?query=${encodeURIComponent(title2)}&first_air_date_year=${year}&year=${year}`; + const res = await fetch(searchUrl, { + headers: { "Authorization": `Bearer ${TMDB_TOKEN}`, "Accept": "application/json" } + }); + const json6 = await res.json(); + if (json6.results && json6.results.length > 0) { + result = json6.results[0]; + break; + } + } catch (e) { + console.error("TMDB Search Error:", e); + } + } + if (!result) return { themoviedbId: null, imdbId: null, thetvdbId: null }; + let externalIds = {}; + try { + const extUrl = `https://api.themoviedb.org/3/${tmdbType}/${result.id}/external_ids`; + const extRes = await fetch(extUrl, { + headers: { "Authorization": `Bearer ${TMDB_TOKEN}`, "Accept": "application/json" } + }); + externalIds = await extRes.json(); + } catch (e) { + console.error("TMDB External IDs Error:", e); + } + return { + themoviedbId: result.id, + imdbId: externalIds.imdb_id || null, + thetvdbId: externalIds.tvdb_id || null + }; +} +__name(fetchTMDB, "fetchTMDB"); +async function handleMap(anilistId) { + const al = await fetchAniListFull(anilistId); + if (!al) throw new Error("AniList entry not found"); + const year = al.seasonYear || al.startDate?.year; + const titlesToSearch = [al.title.english, al.title.romaji, al.title.native].filter(Boolean); + const [kitsuId, tmdbData] = await Promise.all([ + fetchKitsuId(al.idMal), + fetchTMDB(titlesToSearch, year, al.format) + ]); + return { + mappings: { + id: Number(anilistId), + title: al.title.english || al.title.romaji, + type: al.type, + format: al.format, + episodes: al.episodes, + malId: al.idMal, + aniId: Number(anilistId), + anidbId: null, + animePlanetId: null, + kitsuId, + imdbId: tmdbData.imdbId, + themoviedbId: tmdbData.themoviedbId, + thetvdbId: tmdbData.thetvdbId, + livechartId: null, + annId: null, + synonyms: al.synonyms || [], + franchise: al.relations?.edges?.map((e) => ({ + relation: e.relationType, + id: e.node.id, + title: e.node.title.romaji || e.node.title.english, + type: e.node.type, + format: e.node.format + })) || [] + } + }; +} +__name(handleMap, "handleMap"); +async function handleEpisodes2(anilistId) { + const { showId, show, anizip } = await resolveAllAnimeId(anilistId); + const epDetail = show.availableEpisodesDetail || {}; + const subEps = (epDetail.sub || []).map(Number).sort((a, b) => a - b); + const dubEps = (epDetail.dub || []).map(Number).sort((a, b) => a - b); + const buildEpList = __name((nums, audio) => nums.map((n) => { + const meta = anizip.episodes?.[String(n)] ?? {}; + return { + id: `watch/allmanga/${anilistId}/${audio}/allmanga-${n}`, + number: n, + title: meta.title?.en || meta.title?.["x-jat"] || `Episode ${n}`, + duration: meta.runtime ?? meta.length ?? 0, + audio, + filler: meta.filler ?? false, + uncensored: false, + description: meta.overview || meta.summary || "", + image: meta.image || anizip.images?.cover || "", + airDate: meta.airdate || meta.aired || "" + }; + }), "buildEpList"); + return { + anilistId: Number(anilistId), + allAnimeId: showId, + title: show.englishName || show.name, + sub: buildEpList(subEps, "sub"), + dub: buildEpList(dubEps, "dub") + }; +} +__name(handleEpisodes2, "handleEpisodes"); +async function handleWatch2(anilistId, audio, epNum) { + const { showId, anizip } = await resolveAllAnimeId(anilistId); + const episode = await getEpisodeSources(showId, epNum, audio); + if (!episode) throw new Error("Episode not found"); + const sources = await Promise.all((episode.sourceUrls || []).map(async (src) => { + let url = src.sourceUrl; + if (url && url.startsWith("--")) url = decodeHexUrl(url.slice(2)); + if (url && url.startsWith("/apivtwo/clock")) { + url = "https://allanime.day" + url.replace("/clock", "/clock.json"); + } + let extractedUrl = null; + const name = src.sourceName || ""; + if (url?.includes("mp4upload.com")) { + const m = url.match(/embed-([a-zA-Z0-9]+)\.html/); + if (m?.[1]) extractedUrl = await extractMp4(m[1]); + } else if (url?.includes("allanime.uns.bio")) { + const id = url.split("#").pop(); + if (id && id.length > 2) extractedUrl = await extractUns(id); + } else if (url?.includes("ok.ru")) { + const id = url.split("/").pop(); + if (id) extractedUrl = await extractOk(id); + } else if (url?.includes("streamsb.net")) { + const m = url.match(/\/(?:e\/|embed-)([a-zA-Z0-9]+)(?:\.html)?/); + if (m?.[1]) extractedUrl = await extractStreamSB(m[1]); + } else if (url?.includes("streamlare.com")) { + const m = url.match(/\/e\/([a-zA-Z0-9]+)/); + if (m?.[1]) extractedUrl = await extractStreamlare(m[1]); + } + return { + name, + url, + extractedUrl, + extractedType: embedMediaType(extractedUrl), + type: src.type, + priority: src.priority, + headers: { + "Referer": "https://allmanga.to", + "User-Agent": UA4 + }, + downloads: src.downloads || null + }; + })); + sources.sort((a, b) => b.priority - a.priority); + const epMeta = anizip?.episodes?.[String(epNum)] ?? {}; + const intro = epMeta.intro ?? null; + const outro = epMeta.outro ?? null; + return { + anilistId: Number(anilistId), + allAnimeId: showId, + episode: Number(epNum), + audio, + intro, + outro, + sources + }; +} +__name(handleWatch2, "handleWatch"); +function json2(data, status = 200) { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=300" + } + }); +} +__name(json2, "json"); +function matchRoute(pathname) { + let m = pathname.match(/^\/episodes\/(\d+)\/?$/); + if (m) return { handler: "episodes", anilistId: m[1] }; + m = pathname.match(/^\/watch\/allmanga\/(\d+)\/(sub|dub)\/allmanga-(\d+)\/?$/); + if (m) return { handler: "watch", anilistId: m[1], audio: m[2], ep: m[3] }; + m = pathname.match(/^\/map\/(\d+)\/?$/); + if (m) return { handler: "map", anilistId: m[1] }; + return null; +} +__name(matchRoute, "matchRoute"); +var allmanga_default = { + async fetch(request) { + const url = new URL(request.url); + if (request.method === "OPTIONS") { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); + } + const route = matchRoute(url.pathname); + if (!route) { + return json2({ + error: "Not found", + routes: [ + "GET /episodes/:anilistId", + "GET /watch/allmanga/:anilistId/:audio/allmanga-:ep", + "GET /map/:anilistId" + ] + }, 404); + } + try { + if (route.handler === "map") { + const data = await handleMap(route.anilistId); + return json2(data); + } + if (route.handler === "episodes") { + const data = await handleEpisodes2(route.anilistId); + return json2(data); + } + if (route.handler === "watch") { + const data = await handleWatch2(route.anilistId, route.audio, route.ep); + return json2(data); + } + } catch (err) { + return json2({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500); + } + } +}; +async function getEpisodes2(anilistId, ctx = {}) { + const { showId, show, anizip } = await resolveAllAnimeId(anilistId, ctx); + const epDetail = show.availableEpisodesDetail || {}; + const subEps = (epDetail.sub || []).map(Number).sort((a, b) => a - b); + const dubEps = (epDetail.dub || []).map(Number).sort((a, b) => a - b); + const buildList = __name((nums, audio) => nums.map((n) => { + const meta = anizip.episodes?.[String(n)] ?? {}; + return { + id: `watch/allmanga/${anilistId}/${audio}/allmanga-${n}`, + number: n, + title: meta.title?.en || meta.title?.["x-jat"] || null, + duration: meta.runtime ?? meta.length ?? 0, + audio, + filler: meta.filler ?? false, + uncensored: false, + description: meta.overview || meta.summary || null, + image: meta.image || anizip.images?.cover || null, + airDate: meta.airdate || meta.aired || null + }; + }), "buildList"); + return { + meta: { + id: showId, + title: show.englishName || show.name + }, + episodes: { + sub: buildList(subEps, "sub"), + dub: buildList(dubEps, "dub"), + raw: [] + } + }; +} +__name(getEpisodes2, "getEpisodes"); +export default allmanga_default; +export { getEpisodes2 as getEpisodes }; \ No newline at end of file diff --git a/anivexa-api/providers/anibd.js b/anivexa-api/providers/anibd.js new file mode 100644 index 0000000000000000000000000000000000000000..8475db1a1d7ac3a078a2c3f4839710b50efa5741 --- /dev/null +++ b/anivexa-api/providers/anibd.js @@ -0,0 +1,175 @@ +import { episodeMeta, expectedCount, json } from "../core/new-provider-utils.js"; + +const BASE = "https://epeng.animeapps.top"; +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +async function fetchJson(url) { + const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json" } }); + if (!res.ok) throw new Error(`anibd ${res.status}: ${url}`); + return res.json(); +} + +async function fetchHtml(url, referer) { + const res = await fetch(url, { + headers: { + "User-Agent": UA, + Accept: "text/html,application/xhtml+xml", + ...(referer ? { Referer: referer } : {}), + }, + }); + if (!res.ok) throw new Error(`anibd ${res.status}: ${url}`); + return res.text(); +} + +async function fetchServers(anilistId) { + const data = await fetchJson(`${BASE}/api2.php?epid=${anilistId}`); + return Array.isArray(data) ? data : []; +} + +async function fetchPlayerLinks(providerLink) { + const data = await fetchJson(`${BASE}/apilink.php?data=${encodeURIComponent(providerLink)}`); + return Array.isArray(data) ? data : []; +} + +function extractVideoUrl(html, origin) { + const m = html.match(/videoUrl\s*:\s*"([^"]+)"/); + if (!m) return null; + const raw = m[1]; + if (/^https?:\/\//i.test(raw)) return raw; + return `${origin}${raw.startsWith("/") ? "" : "/"}${raw}`; +} + +async function resolvePlayerStream(playerLink) { + const origin = new URL(playerLink).origin; + const referer = `${origin}/`; + const html = await fetchHtml(playerLink, referer); + const hls = extractVideoUrl(html, origin); + if (!hls) throw new Error(`anibd: no videoUrl found at ${playerLink}`); + return { hls, referer }; +} + +function audioFromServerName(name = "") { + return /dub/i.test(name) ? "dub" : "sub"; +} + +function buildEpisodeLists(anilistId, groups, ctx, expected) { + const sub = []; + const dub = []; + const seenSub = new Set(); + const seenDub = new Set(); + for (const group of groups) { + const audio = audioFromServerName(group.server_name); + for (const ep of group.server_data ?? []) { + const number = Number(ep.name ?? ep.slug); + if (!Number.isFinite(number) || number < 1) continue; + if (expected && number > expected) continue; + const bucket = audio === "dub" ? dub : sub; + const seen = audio === "dub" ? seenDub : seenSub; + if (seen.has(number)) continue; + seen.add(number); + const meta = episodeMeta(number, ctx); + bucket.push({ + id: `watch/anibd/${anilistId}/${audio}/anibd-${number}`, + number, + title: meta.title ?? `Episode ${number}`, + duration: meta.duration, + filler: meta.filler, + uncensored: meta.uncensored, + description: meta.description, + image: meta.image, + airDate: meta.airDate, + sourceLink: ep.link, + audio, + }); + } + } + sub.sort((a, b) => a.number - b.number); + dub.sort((a, b) => a.number - b.number); + return { sub, dub }; +} + +export async function getEpisodes(anilistId, ctx = {}) { + const groups = await fetchServers(anilistId); + if (!groups.length) throw new Error(`anibd: no episodes found for AniList ${anilistId}`); + const expected = expectedCount(ctx.media, ctx.anizip, ctx.jikanEps); + return { + meta: { + id: String(anilistId), + source: "anibd", + matchScore: 1, + numbering: "standard", + episodeOffset: 0, + }, + episodes: buildEpisodeLists(anilistId, groups, ctx, expected), + }; +} + +async function findEpisodeLink(anilistId, audio, epNum) { + const groups = await fetchServers(anilistId); + for (const group of groups) { + if (audioFromServerName(group.server_name) !== audio) continue; + for (const ep of group.server_data ?? []) { + if (Number(ep.name ?? ep.slug) === Number(epNum)) return ep.link; + } + } + return null; +} + +async function handleWatch(anilistId, audio, epNum) { + const providerLink = await findEpisodeLink(anilistId, audio, epNum); + if (!providerLink) return json({ error: `anibd episode ${epNum} not found` }, 404); + + const servers = await fetchPlayerLinks(providerLink); + const streams = []; + let activeAssigned = false; + + for (const entry of servers) { + if (!entry?.link) continue; + try { + const { hls, referer } = await resolvePlayerStream(entry.link); + streams.push({ + url: hls, + type: "hls", + server: entry.server ?? "AniBD", + referer, + priority: activeAssigned ? 4 : 5, + isActive: !activeAssigned, + }); + activeAssigned = true; + } catch { + streams.push({ + url: entry.link, + type: "embed", + server: entry.server ?? "AniBD", + referer: `${new URL(entry.link).origin}/`, + priority: 1, + isActive: false, + }); + } + } + + return json({ anilistId: Number(anilistId), episode: Number(epNum), audio, streams }); +} + +export default { + async fetch(request) { + 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": "*", + }, + }); + } + const url = new URL(request.url); + try { + const m = url.pathname.match(/^\/watch\/anibd\/(\d+)\/(sub|dub)\/anibd-(\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, stack: err.stack }, 500); + } + }, +}; diff --git a/anivexa-api/providers/anidbapp.js b/anivexa-api/providers/anidbapp.js new file mode 100644 index 0000000000000000000000000000000000000000..f7a48da496bb9046afdcefcfa8bf99779438a0b0 --- /dev/null +++ b/anivexa-api/providers/anidbapp.js @@ -0,0 +1,358 @@ +import { getMedia } from "../core/anilist.js"; +import { + attr, + buildTitles, + decodeEntities, + episodeMeta, + expectedCount, + json, + stripTags, +} from "../core/new-provider-utils.js"; +import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +const BASE = "https://anidb.app"; +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"; +const COOKIE_JAR = "/tmp/anidbapp_cookies.txt"; + +const NAV_HEADERS = [ + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language: en-US,en;q=0.9", + "sec-ch-ua: \"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"", + "sec-ch-ua-mobile: ?0", + "sec-ch-ua-platform: \"Windows\"", + "sec-fetch-dest: document", + "sec-fetch-mode: navigate", + "sec-fetch-site: none", + "sec-fetch-user: ?1", + "upgrade-insecure-requests: 1", +]; + +const XHR_HEADERS = [ + "Accept: application/json, text/html, */*;q=0.8", + "Accept-Language: en-US,en;q=0.9", + "sec-ch-ua: \"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"", + "sec-ch-ua-mobile: ?0", + "sec-ch-ua-platform: \"Windows\"", + "sec-fetch-dest: empty", + "sec-fetch-mode: cors", + "sec-fetch-site: same-origin", + "X-Requested-With: XMLHttpRequest", +]; + +async function curlFetch(url, headers, extraArgs = []) { + const args = [ + "-s", + "--compressed", + "-A", UA, + "-c", COOKIE_JAR, + "-b", COOKIE_JAR, + "-w", "\n__STATUS:%{http_code}", + ...headers.flatMap(h => ["-H", h]), + ...extraArgs, + url, + ]; + const { stdout } = await execFileAsync("curl", args, { maxBuffer: 8 * 1024 * 1024 }); + const sep = stdout.lastIndexOf("\n__STATUS:"); + const status = sep >= 0 ? Number(stdout.slice(sep + 10)) : 0; + const body = sep >= 0 ? stdout.slice(0, sep) : stdout; + if (status < 200 || status >= 300) { + const err = new Error(`HTTP ${status} fetching ${url}`); + err.rawBody = body; + throw err; + } + return body; +} + +async function fetchAnidbHtml(url, referer) { + const headers = referer ? [...NAV_HEADERS, `Referer: ${referer}`] : NAV_HEADERS; + return curlFetch(url, headers); +} + +async function fetchXhr(url, referer) { + const headers = referer ? [...XHR_HEADERS, `Referer: ${referer}`] : XHR_HEADERS; + return curlFetch(url, headers); +} + +async function fetchJson(url, referer) { + const text = await fetchXhr(url, referer); + return JSON.parse(text); +} + +async function search(query) { + const html = await fetchXhr(`${BASE}/search/suggestions?q=${encodeURIComponent(query)}`, `${BASE}/home`).catch(() => ""); + const results = []; + for (const m of html.matchAll(/]*data-search-item\b[^>]*>[\s\S]*?<\/a>/gi)) { + const tag = m[0].match(/]*>/i)?.[0] ?? ""; + const href = attr(tag, "href"); + const path = href.startsWith("http") ? new URL(href).pathname : href; + const slug = path.match(/^\/anime\/([^/?#]+)/)?.[1]; + if (!slug) continue; + const title = stripTags(m[0].match(/]*class=["'][^"']*text-sm[^"']*["'][^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? ""); + const meta = stripTags(m[0].match(/]*class=["'][^"']*text-xs[^"']*["'][^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? ""); + const siteId = Number(slug.match(/-(\d+)$/)?.[1]); + results.push({ slug, title: title || slug.replace(/-/g, " "), meta, siteId }); + } + if (results.length) return results; + + const browseHtml = await fetchAnidbHtml(`${BASE}/browse?q=${encodeURIComponent(query)}`, `${BASE}/home`).catch(() => ""); + const seen = new Set(); + for (const m of browseHtml.matchAll(/]*href=["'](?:https:\/\/anidb\.app)?\/anime\/([^"']+)["'][^>]*class=["'][^"']*\banime-card\b[^"']*["'][^>]*>[\s\S]*?<\/a>/gi)) { + const slug = m[1]; + if (seen.has(slug)) continue; + seen.add(slug); + const title = stripTags(m[0].match(/title=["']([^"']+)["']/i)?.[1] ?? "") + || stripTags(m[0].match(/alt=["']([^"']+)["']/i)?.[1] ?? "") + || slug.replace(/-/g, " "); + const siteId = Number(slug.match(/-(\d+)$/)?.[1]); + results.push({ slug, title, meta: "", siteId }); + } + return results; +} + +function parseExternalIds(html) { + return { + anilistId: Number(html.match(/https:\/\/anilist\.co\/anime\/(\d+)/i)?.[1]) || null, + malId: Number(html.match(/https:\/\/myanimelist\.net\/anime\/(\d+)/i)?.[1]) || null, + anidbId: Number(html.match(/https:\/\/anidb\.net\/anime\/(\d+)/i)?.[1]) || null, + kitsuId: Number(html.match(/https:\/\/kitsu\.app\/anime\/(\d+)/i)?.[1]) || null, + }; +} + +function parsePageTitle(html) { + return stripTags(html.match(/]*>([\s\S]*?)<\/h1>/i)?.[1] ?? ""); +} + +function searchQueries(media, anizip) { + const titles = buildTitles(media, anizip); + const out = new Set(); + for (const title of titles.slice(0, 5)) { + out.add(title); + const words = title.trim().split(/\s+/); + if (words.length > 4) out.add(words.slice(0, 4).join(" ")); + } + return [...out].filter((q) => q.length >= 2); +} + +async function resolveSeries(anilistId, ctx = {}) { + const cacheKey = `np:anidbapp:${anilistId}`; + const cached = get(cacheKey); + if (isFresh(cached)) return cached.data; + + const media = ctx.media ?? await getMedia(anilistId); + const queries = searchQueries(media, ctx.anizip); + const candidates = new Map(); + await Promise.all(queries.map(async (q) => { + for (const r of await search(q).catch(() => [])) { + if (!candidates.has(r.slug)) candidates.set(r.slug, r); + } + })); + + for (const candidate of candidates.values()) { + const html = await fetchAnidbHtml(`${BASE}/anime/${candidate.slug}`, `${BASE}/home`).catch(() => ""); + if (!html) continue; + const ids = parseExternalIds(html); + if (ids.anilistId !== Number(anilistId)) continue; + const data = { + slug: candidate.slug, + siteId: candidate.siteId || Number(candidate.slug.match(/-(\d+)$/)?.[1]), + title: parsePageTitle(html) || candidate.title, + matchType: "anilist", + matchScore: 1, + ...ids, + }; + set(cacheKey, data, SHOW_IDENTITY_TTL); + return data; + } + + const malId = media?.idMal ?? null; + if (malId) { + for (const candidate of candidates.values()) { + const html = await fetchAnidbHtml(`${BASE}/anime/${candidate.slug}`, `${BASE}/home`).catch(() => ""); + if (!html) continue; + const ids = parseExternalIds(html); + if (ids.anilistId || ids.malId !== Number(malId)) continue; + const data = { + slug: candidate.slug, + siteId: candidate.siteId || Number(candidate.slug.match(/-(\d+)$/)?.[1]), + title: parsePageTitle(html) || candidate.title, + matchType: "mal", + matchScore: 0.9, + ...ids, + }; + set(cacheKey, data, SHOW_IDENTITY_TTL); + return data; + } + } + + throw new Error(`AniDB.app match not found for AniList ${anilistId}`); +} + +async function fetchProviderEpisodes(siteId) { + const data = await fetchJson(`${BASE}/api/frontend/anime/${siteId}/episodes`, `${BASE}/anime/${siteId}`); + return Array.isArray(data.episodes) ? data.episodes : []; +} + +function inferOffset(providerEpisodes, expected) { + const nums = providerEpisodes.map((e) => Number(e.number)).filter((n) => Number.isFinite(n) && n > 0); + if (!nums.length || !expected) return 0; + const min = Math.min(...nums); + const max = Math.max(...nums); + if (min > expected) return min - 1; + if (min > 1 && max - min + 1 >= expected) return min - 1; + return 0; +} + +async function fetchLanguages(episodeId, seriesSlug) { + const data = await fetchJson(`${BASE}/api/frontend/episode/${episodeId}/languages`, `${BASE}/anime/${seriesSlug}`).catch(() => null); + return Array.isArray(data?.languages) ? data.languages : []; +} + +function hasLanguage(languages, audio) { + return Boolean(languageForAudio(languages, audio)?.embed_url); +} + +function buildEpisodeLists(anilistId, providerEpisodes, ctx, expected, offset, availability) { + const sub = []; + const dub = []; + for (const src of providerEpisodes) { + const sourceNumber = Number(src.number); + const number = sourceNumber - offset; + if (!Number.isFinite(number) || number < 1) continue; + if (expected && number > expected) continue; + const meta = episodeMeta(number, ctx); + const base = { + number, + title: meta.title ?? `Episode ${number}`, + duration: meta.duration, + filler: src.filler ?? meta.filler, + uncensored: meta.uncensored, + description: meta.description, + image: meta.image, + airDate: meta.airDate, + sourceNumber, + sourceId: src.id, + }; + if (availability.hasSub) sub.push({ ...base, id: `watch/anidbapp/${anilistId}/sub/anidbapp-${number}`, audio: "sub" }); + if (availability.hasDub) dub.push({ ...base, id: `watch/anidbapp/${anilistId}/dub/anidbapp-${number}`, audio: "dub" }); + } + return { sub, dub }; +} + +function languageForAudio(languages, audio) { + const preferred = audio === "sub" ? ["jpn", "ja", "japanese"] : ["eng", "en", "english"]; + return languages.find((l) => preferred.includes(String(l.code ?? "").toLowerCase())) + ?? languages.find((l) => preferred.includes(String(l.name ?? "").toLowerCase())) + ?? null; +} + +function extractHls(html) { + const patterns = [ + /file\s*:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i, + /sources\s*:\s*\[\s*\{[^}]*file\s*:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i, + /["'](https?:\/\/[^"']+\/master\.m3u8[^"']*)["']/i, + /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i, + ]; + for (const pattern of patterns) { + const m = html.match(pattern); + if (m?.[1]) return decodeEntities(m[1]); + } + return null; +} + +async function streamsForEmbed(embedUrl, audio, language) { + const html = await fetchAnidbHtml(embedUrl, { Referer: `${BASE}/` }).catch(() => ""); + const hls = html ? extractHls(html) : null; + const streams = []; + if (hls) { + streams.push({ + url: hls, + type: "hls", + audio, + language: language.code, + server: "AniDB.app", + embed: embedUrl, + referer: `${new URL(embedUrl).origin}/`, + priority: 5, + isActive: true, + }); + } + streams.push({ + url: embedUrl, + type: "embed", + audio, + language: language.code, + server: "AniDB.app-embed", + referer: `${BASE}/`, + priority: 4, + isActive: !hls, + }); + return streams; +} + +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 fetchProviderEpisodes(series.siteId); + const expected = expectedCount(media, ctx.anizip, ctx.jikanEps); + const offset = inferOffset(episodes, expected); + const sampleLanguages = episodes[0]?.id ? await fetchLanguages(episodes[0].id, series.slug) : []; + const availability = { + hasSub: hasLanguage(sampleLanguages, "sub") || !sampleLanguages.length, + hasDub: hasLanguage(sampleLanguages, "dub"), + }; + return { + meta: { + id: series.slug, + siteId: series.siteId, + title: series.title, + source: "anidbapp", + matchScore: series.matchScore, + matchType: series.matchType, + anilistId: series.anilistId, + malId: series.malId, + numbering: offset ? "offset" : "local", + episodeOffset: offset, + }, + episodes: buildEpisodeLists(anilistId, episodes, localCtx, expected, offset, availability), + }; +} + +async function handleWatch(anilistId, audio, epNum, ctx = {}) { + const series = await resolveSeries(anilistId, ctx); + const episodes = await fetchProviderEpisodes(series.siteId); + const media = ctx.media ?? await getMedia(anilistId).catch(() => null); + const expected = expectedCount(media, ctx.anizip, ctx.jikanEps); + const offset = inferOffset(episodes, expected); + const providerEp = Number(epNum) + offset; + const episode = episodes.find((e) => Number(e.number) === providerEp); + if (!episode) return json({ error: `AniDB.app episode ${epNum} not found` }, 404); + const languages = await fetchLanguages(episode.id, series.slug); + const language = languageForAudio(languages, audio); + if (!language?.embed_url) { + return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, streams: [] }); + } + const embedUrl = decodeEntities(language.embed_url); + const streams = await streamsForEmbed(embedUrl, audio, language); + return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, language: language.code, 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\/anidbapp\/(\d+)\/(sub|dub)\/anidbapp-(\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/anikoto.js b/anivexa-api/providers/anikoto.js new file mode 100644 index 0000000000000000000000000000000000000000..6e592cb6b45969f8bf772e3269c3865bbe10cbf2 --- /dev/null +++ b/anivexa-api/providers/anikoto.js @@ -0,0 +1,523 @@ +import { getMedia } from '../core/anilist.js'; + +const ANIKOTO = "https://anikototv.to"; +const MAPPER = "https://mapper.nekostream.site/api/mal"; +const ANIZIP = "https://api.ani.zip/mappings"; +const SPOOF_REF = "https://hianimes.re/"; +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +const LANG_MAP = { + en: "en", english: "en", ja: "ja", japanese: "ja", + fr: "fr", french: "fr", de: "de", german: "de", + es: "es", spanish: "es", pt: "pt", portuguese: "pt" +}; + +function normalize(s) { + return (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +async function httpGet(url, headers = {}) { + const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "text/html,*/*", ...headers } }); + if (!res.ok) { + const _raw = await res.text().catch(() => null); + const _e = new Error(`HTTP ${res.status} fetching ${url}`); + _e.rawBody = _raw; + throw _e; + } + return res.text(); +} + +async function getJSON(url, headers = {}) { + const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json,*/*", ...headers } }); + if (!res.ok) { + const _raw = await res.text().catch(() => null); + const _e = new Error(`HTTP ${res.status} fetching ${url}`); + _e.rawBody = _raw; + throw _e; + } + return res.json(); +} + +const MODIFIERS = [ + "ova", "movie", "special", "specials", "tales", "journal", "part", "season", "kanwa", "spin-off", "theatre" +]; + +function scoreCandidate(cand, primaryEn, primaryRom, synonyms) { + let score = 0; + const candNameNorm = normalize(cand.name); + const candJpNorm = normalize(cand.jp); + const candSlugNorm = normalize(cand.slug); + + const normEn = normalize(primaryEn); + const normRom = normalize(primaryRom); + + if (normEn && candNameNorm === normEn) score += 1000; + if (normRom && candNameNorm === normRom) score += 900; + if (normRom && candJpNorm === normRom) score += 800; + + const targetText = `${primaryEn || ""} ${primaryRom || ""} ${(synonyms || []).join(" ")}`.toLowerCase(); + + for (const mod of MODIFIERS) { + const candHasMod = candNameNorm.includes(mod) || candSlugNorm.includes(mod); + const targetHasMod = targetText.includes(mod); + if (candHasMod && !targetHasMod) { + score -= 300; + } + } + + for (const t of [primaryEn, primaryRom, ...(synonyms || [])]) { + const normT = normalize(t); + if (!normT || normT.length < 3) continue; + + if (candNameNorm === normT) score += 200; + else if (candNameNorm.startsWith(normT) || normT.startsWith(candNameNorm)) score += 80; + else if (candNameNorm.includes(normT) || normT.includes(candNameNorm)) score += 40; + + if (candJpNorm && candJpNorm === normT) score += 100; + } + + const lengthDiff = Math.abs(candNameNorm.length - (normEn || normRom || "").length); + score -= lengthDiff * 2; + + return score; +} + +async function searchAnikoto(query) { + const searchHtml = await httpGet(`${ANIKOTO}/filter?keyword=${encodeURIComponent(query)}`, { Referer: `${ANIKOTO}/` }); + const candidates = []; + + const re = /]*data-jp="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g; + let m; + while ((m = re.exec(searchHtml)) !== null) { + const slug = m[1]; + const jp = m[2].trim(); + const name = m[3].replace(/<[^>]*>/g, "").trim(); + candidates.push({ slug, name, jp }); + } + + if (!candidates.length) { + const reFallback = /]*>([\s\S]*?)<\/a>/g; + while ((m = reFallback.exec(searchHtml)) !== null) { + candidates.push({ slug: m[1], name: m[1], jp: "" }); + } + } + + const seen = new Set(); + return candidates.filter(c => { + if (seen.has(c.slug)) return false; + seen.add(c.slug); + return true; + }); +} + +async function findAnikotoShow(media) { + const primaryEn = media.title?.english; + const primaryRom = media.title?.romaji; + const synonyms = media.synonyms || []; + + const keywords = [...new Set([primaryEn, primaryRom, ...synonyms].filter(Boolean))]; + const allCandidatesMap = new Map(); + + for (const k of keywords.slice(0, 5)) { + const res = await searchAnikoto(k).catch(() => []); + for (const c of res) { + allCandidatesMap.set(c.slug, c); + } + } + + const candidates = Array.from(allCandidatesMap.values()); + if (!candidates.length) { + throw new Error(`No results found on Anikoto for: ${primaryEn || primaryRom}`); + } + + const scored = candidates.map(c => ({ + ...c, + score: scoreCandidate(c, primaryEn, primaryRom, synonyms) + })).sort((a, b) => b.score - a.score); + + const chosen = scored[0]; + const watchHtml = await httpGet(`${ANIKOTO}/watch/${chosen.slug}`, { Referer: `${ANIKOTO}/` }); + const showIdMatch = watchHtml.match(/data-id="(\d+)"/); + if (!showIdMatch) throw new Error(`Could not find show ID for slug: ${chosen.slug}`); + + return { slug: chosen.slug, showId: showIdMatch[1], title: chosen.name }; +} + +function mapTrack(t, source) { + const label = t.label ?? ""; + const langKey = label.toLowerCase().split(" ")[0]; + return { + url: t.file, + label: label || "English", + srclang: LANG_MAP[langKey] ?? "en", + default: t.default ?? false, + source + }; +} + +async function extractEmbedSource(embedUrl) { + try { + const pageHtml = await httpGet(embedUrl, { Referer: SPOOF_REF, "Accept-Language": "en-US,en;q=0.9" }); + const m = pageHtml.match(/data-id="([^"]*)"/); + if (!m?.[1]) return null; + const fileId = m[1]; + const origin = new URL(embedUrl).origin; + const data = await getJSON(`${origin}/stream/getSources?id=${fileId}&id=${fileId}`, { Referer: `${origin}/`, "X-Requested-With": "XMLHttpRequest" }); + return { fileId, data, origin }; + } catch (e) { + return null; + } +} + +export async function getEpisodes(anilistId, ctx = {}) { + const media = ctx.media || await getMedia(anilistId); + if (!media) throw new Error(`Could not resolve media for AniList ID: ${anilistId}`); + + const [show, anizipRes] = await Promise.all([ + findAnikotoShow(media), + ctx.anizip + ? Promise.resolve(ctx.anizip) + : getJSON(`${ANIZIP}?anilist_id=${anilistId}`).catch(() => null) + ]); + + const listJson = await getJSON(`${ANIKOTO}/ajax/episode/list/${show.showId}`, { + "X-Requested-With": "XMLHttpRequest", + Referer: `${ANIKOTO}/watch/${show.slug}` + }); + + const html = listJson.result || ""; + const sub = []; + const dub = []; + + let firstMal = media.idMal || null; + + const re = /]*data-id="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g; + let m; + while ((m = re.exec(html)) !== null) { + const tag = m[0]; + const inner = m[2]; + const getAttr = (attr) => { + const x = tag.match(new RegExp(`data-${attr}="([^"]*)"`)); + return x ? x[1] : ""; + }; + + const numStr = getAttr("num"); + if (!numStr) continue; + const num = parseInt(numStr); + const hasSub = getAttr("sub") === "1"; + const hasDub = getAttr("dub") === "1"; + const malAttr = getAttr("mal"); + if (!firstMal && malAttr) firstMal = parseInt(malAttr); + + const titleMatch = inner.match(/]*>([\s\S]*?)<\/span>/); + const parsedTitle = titleMatch ? titleMatch[1].replace(/<[^>]*>/g, "").trim() : ""; + const epTitle = parsedTitle || `Episode ${num}`; + + const azEp = anizipRes?.episodes?.[String(num)] ?? {}; + const img = azEp.image || null; + const desc = azEp.overview || azEp.summary || null; + const airDate = azEp.airDate || azEp.airdate || null; + + const base = { + number: num, + title: epTitle, + duration: null, + filler: false, + uncensored: false, + description: desc, + image: img, + airDate: airDate + }; + + if (hasSub) { + sub.push({ + id: `watch/anikoto/${anilistId}/sub/anikoto-${num}`, + ...base, + audio: "sub" + }); + } + if (hasDub) { + dub.push({ + id: `watch/anikoto/${anilistId}/dub/anikoto-${num}`, + ...base, + audio: "dub" + }); + } + } + + sub.sort((a, b) => a.number - b.number); + dub.sort((a, b) => a.number - b.number); + + return { + meta: { + title: show.title, + slug: show.slug, + malId: firstMal, + source: "anikoto" + }, + episodes: { sub, dub } + }; +} + +async function handleWatch(anilistId, audio, epNum, ctx = {}) { + if (audio !== "sub" && audio !== "dub") { + return jsonResponse({ error: "audio must be sub or dub" }, 400); + } + + const media = ctx.media || await getMedia(anilistId); + if (!media) { + return jsonResponse({ error: `Could not resolve media for AniList ID: ${anilistId}` }, 400); + } + + const show = await findAnikotoShow(media); + const listJson = await getJSON(`${ANIKOTO}/ajax/episode/list/${show.showId}`, { + "X-Requested-With": "XMLHttpRequest", + Referer: `${ANIKOTO}/watch/${show.slug}` + }); + + const html = listJson.result || ""; + let targetEp = null; + const re = /]*data-id="([^"]*)"[^>]*>/g; + let m; + while ((m = re.exec(html)) !== null) { + const tag = m[0]; + const getAttr = (attr) => { + const x = tag.match(new RegExp(`data-${attr}="([^"]*)"`)); + return x ? x[1] : ""; + }; + if (parseInt(getAttr("num")) === epNum) { + targetEp = { + ids: getAttr("ids"), + mal: getAttr("mal"), + slug: getAttr("slug"), + timestamp: getAttr("timestamp") + }; + break; + } + } + + if (!targetEp?.ids) { + return jsonResponse({ error: `Episode ${epNum} not found for show: ${show.title}` }, 404); + } + + const malIdNum = media.idMal || (targetEp.mal ? parseInt(targetEp.mal) : null); + + const [serverDataRes, mapperRes] = await Promise.allSettled([ + getJSON(`${ANIKOTO}/ajax/server/list?servers=${encodeURIComponent(targetEp.ids)}`, { + "X-Requested-With": "XMLHttpRequest", + Referer: `${ANIKOTO}/` + }), + (targetEp.mal && targetEp.slug && targetEp.timestamp) + ? getJSON(`${MAPPER}/${targetEp.mal}/${targetEp.slug}/${targetEp.timestamp}`, { Referer: `${ANIKOTO}/` }) + : Promise.resolve(null) + ]); + + const serverData = serverDataRes.status === "fulfilled" ? serverDataRes.value : null; + const mapperData = mapperRes.status === "fulfilled" ? mapperRes.value : null; + + const serverHtml = serverData?.result || ""; + const serverItems = []; + const downloadItems = []; + + const typeRe = /
([\s\S]*?)<\/ul>\s*<\/div>/g; + let typeM; + while ((typeM = typeRe.exec(serverHtml)) !== null) { + const typeName = typeM[1]; + for (const li of typeM[2].matchAll(/]*data-link-id[^>]*)>([\s\S]*?)<\/li>/g)) { + const linkId = li[1].match(/data-link-id="([^"]+)"/)?.[1]; + const name = li[2].replace(/<[^>]+>/g, "").trim(); + if (!linkId) continue; + + if (typeName === "dl" || name.toLowerCase().includes("download") || name.toLowerCase().includes("kiwi")) { + downloadItems.push({ linkId, name }); + } else if (typeName === audio) { + serverItems.push({ linkId, name }); + } + } + } + + if (mapperData) { + for (const [sKey, sObj] of Object.entries(mapperData)) { + if (sKey === "status") continue; + const cleanName = sKey.replace(/[-_]+$/, "").trim(); + if (sObj?.[audio]?.url) { + serverItems.push({ linkId: sObj[audio].url, name: cleanName }); + } + if (sObj?.[audio]?.download) { + for (const [dLabel, dUrl] of Object.entries(sObj[audio].download)) { + if (dUrl && typeof dUrl === "string") { + downloadItems.push({ url: dUrl, name: cleanName }); + } + } + } + } + } + + const streams = []; + const subtitles = []; + const downloads = []; + + const serverSeen = new Set(); + const subSeen = new Set(); + const dlSeen = new Set(); + + for (const item of serverItems) { + if (serverSeen.has(item.name)) continue; + serverSeen.add(item.name); + + const resolved = item.linkId.startsWith("http") + ? { result: { url: item.linkId } } + : await getJSON(`${ANIKOTO}/ajax/server?get=${encodeURIComponent(item.linkId)}`, { + "X-Requested-With": "XMLHttpRequest", + Referer: `${ANIKOTO}/` + }).catch(() => null); + + const embedUrl = resolved?.result?.url; + if (!embedUrl) continue; + + let serverIntro = { start: 0, end: 0 }; + let serverOutro = { start: 0, end: 0 }; + + if (resolved?.result?.skip_data?.intro?.length === 2) { + const [s, e] = resolved.result.skip_data.intro; + if (s || e) serverIntro = { start: Number(s) || 0, end: Number(e) || 0 }; + } + if (resolved?.result?.skip_data?.outro?.length === 2) { + const [s, e] = resolved.result.skip_data.outro; + if (s || e) serverOutro = { start: Number(s) || 0, end: Number(e) || 0 }; + } + + let hlsUrl = null; + + if (embedUrl.includes("#aHR0c")) { + const b64 = embedUrl.split("#")[1]; + try { + const decodedUrl = atob(b64); + if (decodedUrl.includes(".m3u8")) { + hlsUrl = decodedUrl; + } + } catch (e) {} + } + + const extracted = await extractEmbedSource(embedUrl); + const itemSubs = []; + + if (extracted?.data?.sources?.file) { + hlsUrl = extracted.data.sources.file; + + for (const t of extracted.data.tracks ?? []) { + const mapped = mapTrack(t, item.name); + itemSubs.push(mapped); + if (!subSeen.has(mapped.url)) { + subSeen.add(mapped.url); + subtitles.push(mapped); + } + } + + if (extracted.data.intro?.start || extracted.data.intro?.end) { + serverIntro = { start: Number(extracted.data.intro.start) || 0, end: Number(extracted.data.intro.end) || 0 }; + } + if (extracted.data.outro?.start || extracted.data.outro?.end) { + serverOutro = { start: Number(extracted.data.outro.start) || 0, end: Number(extracted.data.outro.end) || 0 }; + } + } + + if (hlsUrl) { + const streamObj = { + url: hlsUrl, + type: "hls", + server: item.name, + embedUrl, + referer: extracted?.origin ? `${extracted.origin}/` : `${new URL(embedUrl).origin}/`, + subtitles: itemSubs, + priority: 5, + isActive: streams.length === 0 + }; + if (serverIntro.start || serverIntro.end) streamObj.intro = serverIntro; + if (serverOutro.start || serverOutro.end) streamObj.outro = serverOutro; + streams.push(streamObj); + } else { + const streamObj = { + url: embedUrl, + type: "embed", + server: item.name, + referer: `${new URL(embedUrl).origin}/`, + priority: 4, + isActive: streams.length === 0 + }; + if (serverIntro.start || serverIntro.end) streamObj.intro = serverIntro; + if (serverOutro.start || serverOutro.end) streamObj.outro = serverOutro; + streams.push(streamObj); + } + } + + for (const dl of downloadItems) { + let dlUrl = dl.url; + if (!dlUrl && dl.linkId) { + const resolved = await getJSON(`${ANIKOTO}/ajax/server?get=${encodeURIComponent(dl.linkId)}`, { + "X-Requested-With": "XMLHttpRequest", + Referer: `${ANIKOTO}/` + }).catch(() => null); + dlUrl = resolved?.result?.url; + } + + if (dlUrl && !dlSeen.has(dlUrl)) { + dlSeen.add(dlUrl); + downloads.push({ + url: dlUrl, + label: dl.name + }); + } + } + + return jsonResponse({ + anilistId: parseInt(anilistId), + malId: malIdNum, + episode: epNum, + audio, + streams, + subtitles, + downloads, + headers: { + "User-Agent": UA, + "Referer": streams[0]?.referer || "https://anikototv.to/" + } + }); +} + +function jsonResponse(data, status = 200) { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + }); +} + +export default { + async fetch(request) { + const url = new URL(request.url); + const path = url.pathname; + 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 { + let m = path.match(/^\/watch\/anikoto\/(\d+)\/(sub|dub)\/anikoto-(\d+)\/?$/); + if (m) return await handleWatch(m[1], m[2], parseInt(m[3])); + + m = path.match(/^\/episodes\/anikoto\/(\d+)\/?$/); + if (m) { + const data = await getEpisodes(parseInt(m[1])); + return jsonResponse(data); + } + return jsonResponse({ error: "Not found" }, 404); + } catch (err) { + return jsonResponse({ error: err.message, stack: err.stack }, 500); + } + } +}; diff --git a/anivexa-api/providers/animedunya.js b/anivexa-api/providers/animedunya.js new file mode 100644 index 0000000000000000000000000000000000000000..4fdc159259addfc49136ecd8569192e526abe24c --- /dev/null +++ b/anivexa-api/providers/animedunya.js @@ -0,0 +1,187 @@ +import child_process from "node:child_process"; +import { json, episodeMeta } from "../core/new-provider-utils.js"; +import { getMedia } from "../core/anilist.js"; +import { get as cacheGet, set as cacheSet, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js"; + +const BASE = "https://anime-dunya.com"; + +async function resolveMalId(anilistId) { + const cacheKey = `np:animedunya:${anilistId}`; + const cached = cacheGet(cacheKey); + if (isFresh(cached)) return cached.data; + + const media = await getMedia(anilistId); + if (!media?.idMal) throw new Error("AnimeDunya: no MAL ID found"); + + cacheSet(cacheKey, media.idMal, SHOW_IDENTITY_TTL); + return media.idMal; +} + +function fetchHtml(url) { + const cmd = `curl -s -L -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" -H "Accept-Language: en-US,en;q=0.9" "${url}"`; + return child_process.execSync(cmd, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }); +} + +function extractEpisodesList(html) { + const match = html.match(/\\?"episodes\\?":\s*\[/); + if (!match) return []; + const idx = match.index; + const matchLen = match[0].length; + let braceCount = 1; + let result = "["; + for (let i = idx + matchLen; i < html.length; i++) { + const char = html[i]; + if (char === "[") braceCount++; + else if (char === "]") braceCount--; + result += char; + if (braceCount === 0) break; + } + try { + const cleanStr = result.replace(/\\u0026/g, "&").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + return JSON.parse(cleanStr); + } catch (e) { + return []; + } +} + +function extractStream(html) { + const match = html.match(/\\?"stream\\?":\s*/); + if (!match) return null; + const idx = match.index; + const matchLen = match[0].length; + let braceCount = 0; + let started = false; + let result = ""; + for (let i = idx + matchLen; i < html.length; i++) { + const char = html[i]; + if (char === "{") { + braceCount++; + started = true; + } else if (char === "}") { + braceCount--; + } + if (started) { + result += char; + if (braceCount === 0) break; + } + } + try { + const cleanStr = result.replace(/\\u0026/g, "&").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + return JSON.parse(cleanStr); + } catch (e) { + const sourceMatch = html.match(/"source"\s*:\s*"([^"]+)"/); + if (sourceMatch) { + return { source: sourceMatch[1].replace(/\\/g, "") }; + } + return null; + } +} + +export async function getEpisodes(anilistId, ctx = {}) { + const malId = await resolveMalId(anilistId); + const html = fetchHtml(`${BASE}/en/anime/${malId}`); + if (!html) throw new Error("AnimeDunya: episodes fetch failed"); + + let cdnBase = "https://cdn.anime-dunya.com/thumbnail/"; + let cdnExt = "small.jpg"; + + const thumbMatch = html.match(/(https?:\/\/[^\s"'`<>]+?\/thumbnail\/)([a-zA-Z0-9]+?)\/((?:small|large)\.jpg)/); + if (thumbMatch) { + cdnBase = thumbMatch[1]; + cdnExt = thumbMatch[3]; + } + + const episodes = extractEpisodesList(html); + const watchable = episodes.filter(ep => ep.streamId !== null && ep.streamId !== undefined); + const sub = []; + + for (const ep of watchable) { + const epNum = ep.episodeNumber; + const meta = episodeMeta(epNum, ctx); + const customTitle = Array.isArray(ep.translations) + ? ep.translations.find(t => t.language === "en")?.title + : ep.translations?.title; + sub.push({ + id: `watch/animedunya/${anilistId}/sub/animedunya-${epNum}`, + number: epNum, + title: customTitle || meta.title || `Episode ${epNum}`, + duration: meta.duration, + audio: "sub", + filler: ep.filler || meta.filler || false, + uncensored: false, + description: meta.description, + image: ep.streamId ? `${cdnBase}${ep.streamId}/${cdnExt}` : meta.image, + airDate: meta.airDate + }); + } + + sub.sort((a, b) => a.number - b.number); + + return { + meta: { + title: ctx.media?.title?.english ?? ctx.media?.title?.romaji ?? null, + malId, + source: "animedunya" + }, + episodes: { sub, dub: [] } + }; +} + +async function handleWatch(anilistId, audio, epNum) { + const malId = await resolveMalId(anilistId); + const html = fetchHtml(`${BASE}/en/play/${malId}/${epNum}`); + if (!html) return json({ error: "AnimeDunya watch fetch failed" }, 500); + + const streamData = extractStream(html); + if (!streamData || !streamData.source) { + return json({ error: "AnimeDunya: stream source not found" }, 404); + } + + const subtitles = (streamData.subtitles || []).map(s => ({ + url: s.src, + label: s.label, + srclang: s.srclang, + default: s.default || false + })); + + const streams = [{ + url: streamData.source, + type: "hls", + server: "AnimeDunya", + referer: `${BASE}/`, + subtitles, + priority: 5, + isActive: true + }]; + + return json({ + anilistId: Number(anilistId), + malId, + episode: Number(epNum), + audio, + streams + }); +} + +export default { + async fetch(request) { + 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": "*" + } + }); + } + const url = new URL(request.url); + try { + const m = url.pathname.match(/^\/watch\/animedunya\/(\d+)\/(sub|dub)\/animedunya-(\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, stack: err.stack }, 500); + } + } +}; diff --git a/anivexa-api/providers/animegg.js b/anivexa-api/providers/animegg.js new file mode 100644 index 0000000000000000000000000000000000000000..e452275dc1b9736ad4f4369b81561ae1ebce6ad8 --- /dev/null +++ b/anivexa-api/providers/animegg.js @@ -0,0 +1,229 @@ +import { getMedia } from "../core/anilist.js"; +import { + attr, + buildTitles, + decodeEntities, + episodeMeta, + expectedCount, + fetchHtml, + findTopSlugs, + getPrequelOffset, + json, + selectSeries, + stripTags, +} from "../core/new-provider-utils.js"; +import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js"; + +const BASE = "https://www.animegg.org"; + +async function search(query) { + const html = await fetchHtml(`${BASE}/search/?q=${encodeURIComponent(query)}`); + const results = []; + for (const m of html.matchAll(/]*class=["'][^"']*\bmse\b[^"']*["'][^>]*>[\s\S]*?<\/a>/gi)) { + const tag = m[0].match(/]*>/i)?.[0] ?? ""; + const href = attr(tag, "href"); + const slug = href.match(/^\/series\/([^/?#]+)/)?.[1]; + if (!slug) continue; + const strong = m[0].match(/]*>([\s\S]*?)<\/strong>/i)?.[1]; + results.push({ slug, text: strong ? stripTags(strong) : slug.replace(/-/g, " ") }); + } + return results; +} + +async function scrapeSeries(slug) { + const html = await fetchHtml(`${BASE}/series/${slug}`); + const episodes = []; + for (const m of html.matchAll(/]*>([\s\S]*?)<\/li>/gi)) { + const block = m[1]; + if (!/\banm_det_pop\b/.test(block)) continue; + const link = block.match(/]*class=["'][^"']*anm_det_pop[^"']*["'][^>]*>/i)?.[0] ?? ""; + const href = attr(link, "href").replace(/#.*$/, "").replace(/^\//, ""); + const strong = stripTags(block.match(/]*>([\s\S]*?)<\/strong>/i)?.[1] ?? ""); + const rangeMatch = strong.match(/(\d+)-(\d+)\s*$/); + const numMatch = rangeMatch || strong.match(/(\d+)\s*$/); + if (!numMatch || !href) continue; + const number = parseInt(numMatch[1]); + const title = stripTags(block.match(/]*class=["'][^"']*anititle[^"']*["'][^>]*>([\s\S]*?)<\/i>/i)?.[1] ?? "") || strong; + const audio = []; + if (/\bbtn-subbed\b/.test(block)) audio.push("sub"); + if (/\bbtn-dubbed\b/.test(block)) audio.push("dub"); + episodes.push({ number, title, epSlug: href, hasSub: audio.includes("sub"), hasDub: audio.includes("dub") }); + } + episodes.sort((a, b) => a.number - b.number); + const seen = new Set(); + return episodes.filter((e) => seen.has(e.number) ? false : (seen.add(e.number), true)); +} + +async function scrapeEmbed(embedId) { + const html = await fetchHtml(`${BASE}/embed/${embedId}`, { Referer: BASE }); + const m = html.match(/var\s+videoSources\s*=\s*(\[[\s\S]*?\]);/); + if (!m) return []; + let parsed = []; + try { + const asJson = m[1] + .replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":') + .replace(/:\s*'([^']*)'/g, ': "$1"'); + parsed = JSON.parse(asJson); + } catch { + return []; + } + return parsed.map((s) => { + let backup = null; + if (s.bk) { + try { backup = decodeURIComponent(atob(s.bk)); } + catch { backup = null; } + } + return { + quality: s.label || "unknown", + url: s.file ? (s.file.startsWith("http") ? s.file : `${BASE}${s.file}`) : "", + backup, + }; + }).filter((s) => s.url); +} + +async function scrapeEpisodeWatch(epSlug, audio) { + const html = await fetchHtml(`${BASE}/${epSlug}`, { Referer: BASE }); + const title = stripTags(html.match(/]*class=["'][^"']*info[^"']*["'][^>]*>[\s\S]*?]*>([\s\S]*?)<\/a>/i)?.[1] ?? ""); + const tabs = []; + for (const m of html.matchAll(/]*data-toggle=["']tab["'][^>]*>/gi)) { + const tag = m[0]; + const embedId = attr(tag, "data-id"); + const server = attr(tag, "data-mirror") || "AnimeGG"; + const version = attr(tag, "data-version") || "subbed"; + if (!embedId) continue; + const normalized = version.startsWith("dub") ? "dub" : "sub"; + if (audio === "all" || normalized === audio) { + tabs.push({ embedId, embedUrl: `${BASE}/embed/${embedId}`, server, normalized }); + } + } + const results = await Promise.allSettled(tabs.map(async (tab, i) => { + const sources = await scrapeEmbed(tab.embedId); + const streams = sources.map((s, j) => ({ + url: s.url, + type: s.url.includes(".m3u8") ? "hls" : "mp4", + quality: s.quality, + backup: s.backup, + audio: tab.normalized, + server: tab.server, + embed: tab.embedUrl, + referer: `${new URL(tab.embedUrl).origin}/`, + priority: tabs.length - i, + isActive: i === 0 && j === 0, + })); + streams.push({ + url: tab.embedUrl, + type: "embed", + audio: tab.normalized, + server: `${tab.server}-embed`, + referer: `${new URL(tab.embedUrl).origin}/`, + priority: 1, + isActive: false, + }); + return streams; + })); + return { title, streams: results.flatMap((r) => r.status === "fulfilled" ? r.value : []) }; +} + +async function searchFn(query) { + const r1 = await search(query); + // AnimeGG needs a plain alphanumeric token to surface all season variants. + // "Re:Zero" → 0 results; "ReZero" → all slugs including season-4. + const compact = query.split(/\s+/)[0].replace(/[^a-zA-Z0-9]/g, ""); + if (compact.length >= 4 && compact.toLowerCase() !== query.toLowerCase()) { + try { + const r2 = await search(compact); + const seen = new Set(r1.map(r => r.slug)); + r2.forEach(r => { if (!seen.has(r.slug)) r1.push(r); }); + } catch {} + } + return r1; +} + +async function resolveSeries(anilistId, ctx = {}) { + const cacheKey = `np:animegg:${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, searchFn); + const expected = expectedCount(media, ctx.anizip, ctx.jikanEps); + const offset = await getPrequelOffset(anilistId).catch(() => 0); + const isSingleMovie = String(media?.format ?? "").toUpperCase() === "MOVIE" || expected === 1; + const selected = await selectSeries(candidates, scrapeSeries, expected, media?.status, offset, { + minScore: isSingleMovie ? 0.9 : 0.65, + }); + if (!selected) throw new Error(`AnimeGG 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({ ...base, id: `watch/animegg/${anilistId}/sub/animegg-${number}`, audio: "sub" }); + if (src.hasDub) dub.push({ ...base, id: `watch/animegg/${anilistId}/dub/animegg-${number}`, 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: "animegg", + 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 episodes = await scrapeSeries(series.slug); + const ep = episodes.find((e) => e.number === providerEp); + if (!ep) return json({ error: `AnimeGG episode ${providerEp} not found` }, 404); + const watch = await scrapeEpisodeWatch(ep.epSlug, audio); + return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, title: watch.title, streams: watch.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\/animegg\/(\d+)\/(sub|dub)\/animegg-(\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/animenosub.js b/anivexa-api/providers/animenosub.js new file mode 100644 index 0000000000000000000000000000000000000000..5f7fb0df773b859456411ed9c7660753f1417964 --- /dev/null +++ b/anivexa-api/providers/animenosub.js @@ -0,0 +1,365 @@ +import crypto from "node:crypto"; +import { getMedia } from "../core/anilist.js"; +import { + buildTitles, + decodeEntities, + episodeMeta, + expectedCount, + fetchHtml, + findTopSlugs, + getPrequelOffset, + json, + selectSeries, +} from "../core/new-provider-utils.js"; +import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js"; + +const BASE = "https://animenosub.to"; +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +function b64u(buf) { return Buffer.from(buf).toString("base64url"); } +function b64uDec(s) { return Buffer.from(s, "base64url"); } + +const _be = 512, _lt = _be - 1, _dr = 2, _lr = 2654435761, _hr = 2246822519; +const _rot = (t, e) => (t << e | t >>> 32 - e) >>> 0; +const _mul = (t, e) => Math.imul(t, e) >>> 0; +function _mix(t) { + t[0] = t[0] + t[1] >>> 0; t[3] = _rot(t[3] ^ t[0], 16); + t[2] = t[2] + t[3] >>> 0; t[1] = _rot(t[1] ^ t[2], 12); + t[0] = t[0] + t[1] >>> 0; t[3] = _rot(t[3] ^ t[0], 8); + t[2] = t[2] + t[3] >>> 0; t[1] = _rot(t[1] ^ t[2], 7); +} +function _hash(t) { + const e = new Uint32Array([1779033703, 3144134277, 1013904242, 2773480762]); + for (let i = 0; i < t.length; i++) { e[0] = e[0] + t[i] >>> 0; e[0] = _rot(e[0], 7); _mix(e); } + for (let i = 0; i < 8; i++) _mix(e); + const r = new Uint32Array(_be); + for (let i = 0; i < _be; i++) { _mix(e); r[i] = (e[0] ^ e[2]) >>> 0; } + for (let i = 0; i < _dr; i++) { + for (let s = 0; s < _be; s++) { + const a = r[s] & _lt; + let c = r[s] + r[a] >>> 0; + c = _rot(c, 13); + c = (c ^ _mul(r[(s + 1) & _lt], _lr)) >>> 0; + r[s] = c; e[0] = (e[0] ^ c) >>> 0; _mix(e); + } + } + const n = new Uint32Array(8), o = _be / 8; + for (let i = 0; i < 8; i++) { + _mix(e); let s = e[0]; const a = i * o; + for (let c = 0; c < o; c++) { const d = r[a + c]; s = s + d >>> 0; s = _rot(s, 5); s = (s ^ _mul(d, _hr)) >>> 0; } + n[i] = (s ^ e[2]) >>> 0; + } + return n; +} +function _latin1Bytes(t) { const e = new Uint8Array(t.length); for (let r = 0; r < t.length; r++) e[r] = t.charCodeAt(r) & 255; return e; } +function _leadingZeros(t) { let e = 0; for (let r = 0; r < t.length; r++) { const n = t[r]; if (n === 0) { e += 32; continue; } return e + Math.clz32(n); } return e; } +function solvePoW(nonce, difficulty) { + const prefix = nonce + ":"; + for (let s = 0; ; s++) { if (_leadingZeros(_hash(_latin1Bytes(prefix + s))) >= difficulty) return String(s); } +} + +async function resolveByse(embedUrl) { + const code = embedUrl.match(/\/e\/([a-z0-9]+)/i)?.[1]; + if (!code) throw new Error(`Cannot extract Byse code from ${embedUrl}`); + + const det = await (await fetch(`https://bysesayeveum.com/api/videos/${code}/embed/details`, { + headers: { "User-Agent": UA, "Referer": embedUrl }, + })).json(); + + const frameUrl = det.embed_frame_url; + const frameBase = new URL(frameUrl).origin; + + const ch = await (await fetch(`${frameBase}/api/videos/access/challenge`, { + method: "POST", + headers: { "Content-Length": "0", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA }, + })).json(); + + const keyPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); + const pubJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, keyPair.privateKey, new TextEncoder().encode(ch.nonce)); + + const att = await (await fetch(`${frameBase}/api/videos/access/attest`, { + method: "POST", + headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA }, + body: JSON.stringify({ nonce: ch.nonce, challenge_id: ch.challenge_id, public_key: pubJwk, signature: b64u(sig) }), + })).json(); + + const viewerId = att.viewer_id, deviceId = att.device_id, fpToken = att.token, confidence = att.confidence; + const cookieStr = `byse_viewer_id=${viewerId}; byse_device_id=${deviceId}`; + const fingerprint = { token: fpToken, viewer_id: viewerId, device_id: deviceId, confidence }; + + const cap = await (await fetch(`${frameBase}/api/videos/${code}/embed/captcha`, { + method: "POST", + headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA, "Cookie": cookieStr, "X-Embed-Parent": embedUrl }, + body: "{}", + })).json(); + + const solution = solvePoW(cap.pow_nonce, cap.pow_difficulty); + + const ver = await (await fetch(`${frameBase}/api/videos/${code}/embed/captcha/verify`, { + method: "POST", + headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA, "Cookie": cookieStr, "X-Embed-Parent": embedUrl }, + body: JSON.stringify({ pow_token: cap.pow_token, solution, fingerprint }), + })).json(); + + const pbData = await (await fetch(`${frameBase}/api/videos/${code}/embed/playback`, { + method: "POST", + headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA, "Cookie": cookieStr, "X-Captcha-Token": ver.token, "X-Embed-Parent": embedUrl }, + body: JSON.stringify({ fingerprint }), + })).json(); + + const pb = pbData.playback; + const keyBytes = Buffer.concat(pb.key_parts.filter((k) => b64uDec(k).length === 16).map((k) => b64uDec(k))); + const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]); + const dec = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64uDec(pb.iv) }, aesKey, b64uDec(pb.payload)); + const playback = JSON.parse(new TextDecoder().decode(dec)); + + return playback.sources.map((s) => s.url); +} + +const NOVA_KEY = Buffer.from("6b69656d7469656e6d75613931316361", "hex"); +const NOVA_IV = Buffer.from("313233343536373839306f6975797472", "hex"); + +async function resolveNova(embedUrl) { + const id = embedUrl.match(/upn\.one\/#([A-Za-z0-9]+)/i)?.[1]; + if (!id) throw new Error(`Cannot extract Nova id from ${embedUrl}`); + + const res = await fetch(`https://nova.upn.one/api/v1/video?id=${id}&w=1920&h=1080&r=`, { + headers: { "User-Agent": UA, "Referer": "https://nova.upn.one/" }, + }); + if (!res.ok) throw new Error(`Nova fetch HTTP ${res.status}`); + const hex = (await res.text()).trim(); + const decipher = crypto.createDecipheriv("aes-128-cbc", NOVA_KEY, NOVA_IV); + const decrypted = Buffer.concat([decipher.update(Buffer.from(hex, "hex")), decipher.final()]); + const data = JSON.parse(decrypted.toString("utf8")); + const m3u8 = data.cf ?? data.source; + if (!m3u8) throw new Error("Nova response missing m3u8 url"); + return [m3u8]; +} + +async function resolveVidmoly(embedUrl) { + const url = embedUrl.startsWith("//") ? `https:${embedUrl}` : embedUrl; + const res = await fetch(url, { + headers: { "User-Agent": UA, "Referer": `${BASE}/` }, + redirect: "follow", + }); + if (!res.ok) throw new Error(`Vidmoly fetch HTTP ${res.status}`); + const html = await res.text(); + const m = html.match(/sources:\s*\[\s*\{\s*file:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/); + if (!m) throw new Error("Vidmoly m3u8 not found in embed HTML"); + return [m[1]]; +} + +async function search(query) { + const res = await fetch(`${BASE}/wp-admin/admin-ajax.php`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + Origin: BASE, + Referer: `${BASE}/`, + }, + body: `action=ts_ac_do_search&ts_ac_query=${encodeURIComponent(query)}`, + }); + if (!res.ok) throw new Error(`animenosub search HTTP ${res.status}`); + const data = await res.json(); + const results = []; + for (const item of data?.anime?.[0]?.all ?? []) { + const slug = item.post_link?.match(/\/anime\/([^/]+)\/?$/)?.[1]; + if (!slug) continue; + results.push({ slug, text: item.post_title ?? slug.replace(/-/g, " ") }); + } + return results; +} + +async function scrapeSeries(slug) { + const html = await fetchHtml(`${BASE}/anime/${slug}/`, { Referer: BASE }); + const isSlugDub = /-dub$/.test(slug) || /(?:^|[-\s])dub(?:$|[-\s])/i.test(slug); + const episodes = []; + const seen = new Set(); + const listRe = /]*data-index="\d+"[^>]*>[\s\S]*?([^<]+)<\/div>/gi; + for (const m of html.matchAll(listRe)) { + const epUrl = decodeEntities(m[1]); + const label = m[2].trim(); + let number; + if (/^movie$/i.test(label)) { + number = 1; + } else { + const n = parseFloat(label); + number = Number.isFinite(n) && n >= 1 ? Math.round(n) : null; + } + if (number === null || seen.has(number)) continue; + seen.add(number); + const isDub = isSlugDub || /-dub(?:$|\/)/.test(epUrl); + episodes.push({ number, title: /^movie$/i.test(label) ? "Movie" : `Episode ${number}`, epUrl, hasSub: !isDub, hasDub: isDub }); + } + episodes.sort((a, b) => a.number - b.number); + return episodes; +} + +async function scrapeEmbeds(epUrl) { + const html = await fetchHtml(epUrl, { Referer: `${BASE}/` }); + const streams = []; + for (const m of html.matchAll(/]*>([^<]+)<\/option>/gi)) { + const b64 = m[1]; + const serverName = m[2].trim(); + if (!serverName || /select video server/i.test(serverName)) continue; + let embedUrl = null; + try { + const decoded = atob(b64); + embedUrl = decoded.match(/src=["']([^"']+)["']/i)?.[1] ?? null; + } catch { continue; } + if (!embedUrl) continue; + const embedOrigin = (() => { try { const u = new URL(embedUrl.startsWith("//") ? `https:${embedUrl}` : embedUrl); return `${u.protocol}//${u.host}/`; } catch { return epUrl; } })(); + streams.push({ + url: embedUrl, + type: "embed", + server: serverName, + referer: embedOrigin, + priority: streams.length === 0 ? 2 : 1, + isActive: streams.length === 0, + }); + } + if (streams.length === 0) { + for (const m of html.matchAll(/]+src=["']([^"']+)["'][^>]*>/gi)) { + const src = m[1]; + if (/vidmoly|vtbe|streamtape|dood|filemoon|upn\.one|bysesa/i.test(src)) { + const embedOrigin = (() => { try { const u = new URL(src.startsWith("//") ? `https:${src}` : src); return `${u.protocol}//${u.host}/`; } catch { return epUrl; } })(); + streams.push({ url: src, type: "embed", server: "Direct", referer: embedOrigin, priority: 2, isActive: true }); + break; + } + } + } + return streams; +} + +async function resolveSeries(anilistId, ctx = {}) { + const cacheKey = `np:animenosub:${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(`animenosub 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({ ...base, id: `watch/animenosub/${anilistId}/sub/animenosub-${number}`, audio: "sub" }); + if (src.hasDub) dub.push({ ...base, id: `watch/animenosub/${anilistId}/dub/animenosub-${number}`, 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: "animenosub", + 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 withRetry(fn, attempts = 2) { + for (let i = 0; i < attempts; i++) { + try { return await fn(); } catch (_) { if (i === attempts - 1) return null; } + } + return null; +} + +function isByse(url) { return /bysesayeveum\.com\/e\//i.test(url); } +function isVidmoly(url) { return /vidmoly\.(net|biz|to)/i.test(url); } +function isNova(url) { return /upn\.one/i.test(url); } + +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 episodes = await scrapeSeries(series.slug); + const ep = episodes.find((e) => e.number === providerEp && (audio === "dub" ? e.hasDub : e.hasSub)) + ?? episodes.find((e) => e.number === providerEp); + if (!ep) throw new Error(`animenosub episode ${providerEp} not found`); + const embeds = await scrapeEmbeds(ep.epUrl); + + const resolvable = embeds.filter((s) => isByse(s.url) || isVidmoly(s.url) || isNova(s.url)); + const resolvedList = await Promise.all(resolvable.map((s) => { + if (isByse(s.url)) return withRetry(() => resolveByse(s.url)); + if (isVidmoly(s.url)) return withRetry(() => resolveVidmoly(s.url)); + if (isNova(s.url)) return withRetry(() => resolveNova(s.url)); + })); + const resolvedMap = new Map(resolvable.map((s, i) => [s.url, resolvedList[i]])); + + const streams = []; + for (const stream of embeds) { + const m3u8Urls = resolvedMap.get(stream.url); + if (m3u8Urls) { + const referer = isVidmoly(stream.url) + ? "https://vidmoly.biz/" + : isNova(stream.url) + ? "https://nova.upn.one/" + : "https://bysesayeveum.com/"; + for (const m3u8 of m3u8Urls) { + streams.push({ + url: m3u8, + type: "hls", + server: stream.server, + referer, + priority: stream.priority, + isActive: stream.isActive, + }); + } + } + streams.push(stream); + } + + 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\/animenosub\/(\d+)\/(sub|dub)\/animenosub-(\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/anineko.js b/anivexa-api/providers/anineko.js new file mode 100644 index 0000000000000000000000000000000000000000..553fb0aa51af300940ef822e51d14aaff5a4ac32 --- /dev/null +++ b/anivexa-api/providers/anineko.js @@ -0,0 +1,183 @@ +import { getMedia } from "../core/anilist.js"; +import { + attr, + buildTitles, + decodeEntities, + episodeMeta, + expectedCount, + fetchHtml, + findTopSlugs, + getPrequelOffset, + json, + selectSeries, + stripTags, +} from "../core/new-provider-utils.js"; +import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js"; + +const BASE = "https://anineko.to"; + +async function search(query) { + const html = await fetchHtml(`${BASE}/browser?keyword=${encodeURIComponent(query)}`); + const results = []; + for (const m of html.matchAll(/]*class=["'][^"']*nv-anime-thumb[^"']*["'][^>]*>[\s\S]*?<\/a>/gi)) { + const tag = m[0].match(/]*>/i)?.[0] ?? ""; + const href = attr(tag, "href"); + const slug = href.match(/\/watch\/([^/?#]+)/)?.[1]; + if (!slug) continue; + const titleMatch = m[0].match(/<(?:h3|[^>]+class=["'][^"']*nv-anime-title[^"']*["'][^>]*)>([\s\S]*?)<\/(?:h3|[^>]+)>/i); + results.push({ slug, text: titleMatch ? stripTags(titleMatch[1]) : slug.replace(/-/g, " ") }); + } + return results; +} + +async function scrapeSeries(slug) { + const html = await fetchHtml(`${BASE}/watch/${slug}`); + const episodes = []; + for (const m of html.matchAll(/]*class=["'][^"']*nv-info-episode-item[^"']*["'][^>]*>([\s\S]*?)<\/article>/gi)) { + const block = m[1]; + const link = block.match(/]*class=["'][^"']*nv-info-episode-main[^"']*["'][^>]*>/i)?.[0] ?? ""; + const href = attr(link, "href"); + const num = Number(href.match(/\/ep-(\d+)/)?.[1]); + if (!Number.isFinite(num)) continue; + const title = stripTags(block.match(/]*class=["'][^"']*nv-info-episode-main[^"']*["'][^>]*>[\s\S]*?]*>([\s\S]*?)<\/span>/i)?.[1] ?? ""); + const badges = [...block.matchAll(/]*>([\s\S]*?)<\/span>/gi)].map((b) => stripTags(b[1]).toLowerCase()); + episodes.push({ + number: num, + title: title || `Episode ${num}`, + epSlug: `ep-${num}`, + hasSub: badges.includes("sub"), + hasDub: badges.includes("dub"), + }); + } + episodes.sort((a, b) => a.number - b.number); + const seen = new Set(); + return episodes.filter((e) => seen.has(e.number) ? false : (seen.add(e.number), true)); +} + +async function extractHls(embedUrl) { + const html = await fetchHtml(embedUrl, { Referer: `${BASE}/` }).catch(() => ""); + const patterns = [ + /const\s+src\s*=\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i, + /file\s*:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i, + /["'](https?:\/\/[^"']+\/master\.m3u8[^"']*)["']/i, + /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i, + ]; + for (const pattern of patterns) { + const m = html.match(pattern); + if (m) return decodeEntities(m[1]); + } + return null; +} + +async function scrapeEpisodeWatch(seriesSlug, epSlug, audio) { + const html = await fetchHtml(`${BASE}/watch/${seriesSlug}/${epSlug}`, { Referer: `${BASE}/watch/${seriesSlug}` }); + const byAudio = { sub: [], dub: [] }; + for (const panel of html.matchAll(/]*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 = /]*)>/gi; + let t; + while ((t = trackRe.exec(html)) !== null) { + const attrs = t[1]; + const kind = attrs.match(/kind="([^"]*)"/i)?.[1] ?? ""; + if (kind !== "subtitles") continue; + const src = attrs.match(/src=["']?([^\s"'>]+)["']?/i)?.[1] ?? ""; + const label = attrs.match(/label="([^"]*)"/i)?.[1] ?? ""; + const srclang = attrs.match(/srclang="([^"]*)"/i)?.[1] ?? ""; + const dataType = attrs.match(/data-type="([^"]*)"/i)?.[1] ?? "vtt"; + const isDefault = /\bdefault\b/.test(attrs); + if (src) subtitles.push({ url: decodeEntities(src), label, srclang, format: dataType, default: isDefault }); + } + + const storyboardMatch = html.match(/thumbnails="([^"]+\.vtt[^"]*)"/i); + const storyboard = storyboardMatch ? decodeEntities(storyboardMatch[1]) : null; + + const chaptersMatch = html.match(/]*kind="chapters"[^>]*src=["']?([^\s"'>]+)["']?/i); + const chapters = chaptersMatch ? decodeEntities(chaptersMatch[1]) : null; + + return { hls, subtitles, storyboard, chapters }; +} + +async function searchFn(query) { + const r1 = await search(query); + // AniZone needs a plain alphanumeric token to surface all season variants + // e.g. "Re:ZERO -Starting Life..." → "ReZERO" finds all (2020)/(2021)/(2026) slugs + const compact = query.split(/\s+/)[0].replace(/[^a-zA-Z0-9]/g, ""); + if (compact.length >= 4 && compact.toLowerCase() !== query.toLowerCase()) { + try { + const r2 = await search(compact); + const seen = new Set(r1.map(r => r.slug)); + r2.forEach(r => { if (!seen.has(r.slug)) r1.push(r); }); + } catch {} + } + return r1; +} + +async function resolveSeries(anilistId, ctx = {}) { + const cacheKey = `np:anizone:${anilistId}`; + const cached = get(cacheKey); + if (isFresh(cached)) return cached.data; + + const media = ctx.media ?? await getMedia(anilistId); + const titles = buildTitles(media, ctx.anizip); + let candidates = await findCandidates(titles, searchFn); + + // AniZone uses "(YEAR)" suffixes for sequel seasons instead of slug numbers. + // When seasonYear is available and any candidate carries a year, re-score so the + // matching year wins decisively and wrong-year / year-less entries fall below threshold. + const seasonYear = media?.seasonYear; + if (seasonYear && candidates.some(c => /\(\d{4}\)/.test(c.title))) { + candidates = candidates.map(c => { + const m = c.title.match(/\((\d{4})\)/); + if (m) { + return parseInt(m[1]) === seasonYear + ? { ...c, score: Math.min(1, c.score * 1.3) } + : { ...c, score: c.score * 0.5 }; + } + // No year suffix = base/S1 entry; penalise when sequels are expected + return { ...c, score: c.score * 0.65 }; + }).sort((a, b) => b.score - a.score); + } + + 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(`AniZone 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/anizone/${anilistId}/sub/anizone-${number}`, ...base, audio: "sub" }); + if (src.hasDub) dub.push({ id: `watch/anizone/${anilistId}/dub/anizone-${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: "anizone", + 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 watch = await scrapeWatch(series.slug, providerEp); + if (!watch.hls) throw new Error(`No HLS stream found for AniZone episode ${providerEp}`); + return json({ + anilistId: Number(anilistId), + episode: Number(epNum), + providerEpisode: providerEp, + audio, + streams: [{ + url: watch.hls, + type: "hls", + server: "AniZone", + subtitles: watch.subtitles, + storyboard: watch.storyboard, + chapters: watch.chapters, + priority: 1, + isActive: true, + }], + }); +} + +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\/anizone\/(\d+)\/(sub|dub)\/anizone-(\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/kickassanime.js b/anivexa-api/providers/kickassanime.js new file mode 100644 index 0000000000000000000000000000000000000000..586bb87e55b027b631d1259fa361e855396a65b7 --- /dev/null +++ b/anivexa-api/providers/kickassanime.js @@ -0,0 +1,318 @@ +import { + buildTitles, + diceCoeff, + episodeMeta, + expectedCount, + json, +} from "../core/new-provider-utils.js"; +import { getMedia } from "../core/anilist.js"; +import { + get as cacheGet, + set as cacheSet, + isFresh, + SHOW_IDENTITY_TTL, +} from "../core/smartcache.js"; + +const BASE = "https://kaa.lt"; +const HLS_BASE = "https://hls.krussdomi.com/manifest"; +const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +const H = { "User-Agent": UA, Accept: "application/json" }; + +async function kaaSearch(query) { + const res = await fetch(`${BASE}/api/fsearch`, { + method: "POST", + headers: { ...H, "Content-Type": "application/json" }, + body: JSON.stringify({ page: 1, query }), + }); + if (!res.ok) throw new Error(`kaa fsearch HTTP ${res.status}`); + const data = await res.json(); + return Array.isArray(data?.result) ? data.result : []; +} + +async function kaaShowInfo(showSlug) { + const res = await fetch(`${BASE}/api/show/${showSlug}`, { headers: H }); + if (!res.ok) throw new Error(`kaa show HTTP ${res.status}: ${showSlug}`); + return res.json(); +} + +async function kaaEpisodePage(showSlug, ep) { + const res = await fetch( + `${BASE}/api/show/${showSlug}/episodes?ep=${ep}&lang=ja-JP`, + { headers: H } + ); + if (!res.ok) throw new Error(`kaa episodes HTTP ${res.status}`); + return res.json(); +} + +async function kaaAllEpisodes(showSlug) { + const first = await kaaEpisodePage(showSlug, 1); + const pages = Array.isArray(first.pages) ? first.pages : []; + const all = Array.isArray(first.result) ? [...first.result] : []; + + if (pages.length > 1) { + const rest = await Promise.all( + pages.slice(1).map(async (pg) => { + const startEp = pg.eps?.[0]; + if (!startEp) return []; + const d = await kaaEpisodePage(showSlug, startEp); + return Array.isArray(d.result) ? d.result : []; + }) + ); + for (const batch of rest) all.push(...batch); + } + + return all; +} + +async function kaaEpisodeServers(showSlug, fullEpSlug) { + const res = await fetch( + `${BASE}/api/show/${showSlug}/episode/${fullEpSlug}`, + { headers: H } + ); + if (!res.ok) throw new Error(`kaa episode servers HTTP ${res.status}`); + return res.json(); +} + +function buildKaaQueries(titles) { + const queries = new Set(); + for (const title of titles.slice(0, 4)) { + if (/[\u3000-\u9fff\u4e00-\u9faf]/.test(title)) continue; + const clean = title.replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim(); + if (!clean || clean.length < 3) continue; + const words = clean.split(" ").filter(Boolean); + if (words.length <= 3) { + queries.add(clean); + } else { + queries.add(words.slice(0, 2).join(" ")); + queries.add(words.slice(0, 3).join(" ")); + } + } + return [...queries]; +} + +function scoreCandidate(candidate, titles, seasonYear, anilistFormat) { + const titleEn = candidate.title_en || ""; + const titleJp = candidate.title || ""; + const kaaYear = Number(candidate.year); + const kaaType = (candidate.type || "").toLowerCase(); + + let base = 0; + for (const t of titles.slice(0, 3)) { + if (/[\u3000-\u9fff\u4e00-\u9faf]/.test(t)) continue; + base = Math.max(base, diceCoeff(t, titleEn), diceCoeff(t, titleJp)); + } + + let yearMult = 1.0; + if (seasonYear && kaaYear) { + const diff = Math.abs(Number(seasonYear) - kaaYear); + if (diff === 0) yearMult = 1.2; + else if (diff === 1) yearMult = 0.8; + else yearMult = 0.5; + } + + let typeMult = 1.0; + const af = (anilistFormat || "").toUpperCase(); + if (af === "MOVIE" && kaaType !== "movie") typeMult = 0.25; + else if (af !== "MOVIE" && kaaType === "movie") typeMult = 0.25; + else if ((af === "OVA" || af === "ONA" || af === "SPECIAL") && kaaType === "tv") typeMult = 0.5; + else if (af === "TV" && (kaaType === "ova" || kaaType === "special")) typeMult = 0.5; + + return Math.min(1, base * yearMult) * typeMult; +} + +async function resolveSeries(anilistId, ctx = {}) { + const cacheKey = `np:kaa:${anilistId}`; + const cached = cacheGet(cacheKey); + if (isFresh(cached)) return cached.data; + + const media = ctx.media ?? await getMedia(anilistId); + const titles = buildTitles(media, ctx.anizip); + const queries = buildKaaQueries(titles); + const seasonYear = media?.seasonYear; + const format = media?.format; + + if (!queries.length) throw new Error(`KAA: no usable search queries for AniList ${anilistId}`); + + const allCandidates = new Map(); + await Promise.all( + queries.map(async (q) => { + try { + const results = await kaaSearch(q); + for (const r of results) { + if (!allCandidates.has(r.slug)) allCandidates.set(r.slug, r); + } + } catch {} + }) + ); + + if (!allCandidates.size) throw new Error(`KAA: no search results for AniList ${anilistId}`); + + const scored = []; + for (const [, candidate] of allCandidates) { + const score = scoreCandidate(candidate, titles, seasonYear, format); + if (score >= 0.5) { + scored.push({ + slug: candidate.slug, + title: candidate.title_en || candidate.title, + locales: Array.isArray(candidate.locales) ? candidate.locales : [], + score, + }); + } + } + + scored.sort((a, b) => b.score - a.score); + + if (!scored.length) { + throw new Error(`KAA: no confident match for AniList ${anilistId}`); + } + + const best = scored[0]; + if (best.score < 0.6) { + throw new Error( + `KAA: low confidence match for AniList ${anilistId} — best "${best.slug}" score ${best.score.toFixed(3)}` + ); + } + + const data = { + slug: best.slug, + title: best.title, + locales: best.locales, + score: best.score, + }; + cacheSet(cacheKey, data, SHOW_IDENTITY_TTL); + return data; +} + +async function buildEpMap(showSlug, showInfo) { + if (showInfo?.type === "movie") { + const m = (showInfo.watch_uri || "").match(/\/(ep-(\d+)-([a-f0-9]+))$/i); + if (m) return [{ number: 1, fullSlug: m[1] }]; + return []; + } + const episodes = await kaaAllEpisodes(showSlug); + return episodes.map((e) => ({ + number: e.episode_number, + fullSlug: `ep-${e.episode_number}-${e.slug}`, + title: e.title, + duration: e.duration_ms ? Math.round(e.duration_ms / 1000) : null, + })); +} + +export async function getEpisodes(anilistId, ctx = {}) { + const media = ctx.media ?? await getMedia(anilistId); + const localCtx = { ...ctx, media }; + const series = await resolveSeries(anilistId, localCtx); + const showInfo = await kaaShowInfo(series.slug); + + const locales = Array.isArray(showInfo.locales) ? showInfo.locales : series.locales; + const hasDub = locales.includes("en-US"); + + const epMap = await buildEpMap(series.slug, showInfo); + if (!epMap.length) throw new Error(`KAA: no episodes found for AniList ${anilistId} (slug: ${series.slug})`); + + const expected = expectedCount(media, ctx.anizip, ctx.jikanEps); + const sub = []; + const dub = []; + + for (const ep of epMap) { + const num = ep.number; + if (!Number.isFinite(num) || num < 1) continue; + if (expected && num > expected) continue; + const meta = episodeMeta(num, localCtx); + const base = { + number: num, + title: meta.title ?? ep.title ?? `Episode ${num}`, + duration: meta.duration ?? ep.duration, + filler: meta.filler, + uncensored: false, + description: meta.description, + image: meta.image, + airDate: meta.airDate, + }; + sub.push({ id: `watch/kaa/${anilistId}/sub/kaa-${num}`, ...base, audio: "sub" }); + if (hasDub) { + dub.push({ id: `watch/kaa/${anilistId}/dub/kaa-${num}`, ...base, audio: "dub" }); + } + } + + return { + meta: { + id: series.slug, + title: series.title, + source: "kaa", + matchScore: Number(series.score.toFixed(3)), + }, + episodes: { sub, dub }, + }; +} + +async function handleWatch(anilistId, audio, epNum) { + const series = await resolveSeries(anilistId); + const showInfo = await kaaShowInfo(series.slug); + + const locales = Array.isArray(showInfo.locales) ? showInfo.locales : series.locales; + if (audio === "dub" && !locales.includes("en-US")) { + return json({ error: `KAA: no English dub for AniList ${anilistId}` }, 404); + } + + const epMap = await buildEpMap(series.slug, showInfo); + const ep = epMap.find((e) => e.number === Number(epNum)); + if (!ep) { + return json({ error: `KAA: episode ${epNum} not found for AniList ${anilistId}` }, 404); + } + + const episodeData = await kaaEpisodeServers(series.slug, ep.fullSlug); + const servers = Array.isArray(episodeData.servers) ? episodeData.servers : []; + if (!servers.length) { + return json({ error: `KAA: no streams for episode ${epNum} (AniList ${anilistId})` }, 404); + } + + const streams = []; + for (const s of servers) { + if (!s.src) continue; + const m = s.src.match(/[?&]id=([^&]+)/); + if (!m) continue; + streams.push({ + url: `${HLS_BASE}/${m[1]}/master.m3u8`, + type: "hls", + server: s.name || "KAA", + headers: { Referer: "https://krussdomi.com/" }, + priority: 1, + isActive: true, + }); + } + + if (!streams.length) { + return json({ error: `KAA: could not resolve stream for episode ${epNum}` }, 404); + } + + return json({ + anilistId: Number(anilistId), + episode: Number(epNum), + audio, + streams, + }); +} + +export default { + async fetch(request) { + 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": "*", + }, + }); + } + const url = new URL(request.url); + try { + const m = url.pathname.match(/^\/watch\/kaa\/(\d+)\/(sub|dub)\/kaa-(\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, stack: err.stack }, 500); + } + }, +}; diff --git a/anivexa-api/providers/reanime.js b/anivexa-api/providers/reanime.js new file mode 100644 index 0000000000000000000000000000000000000000..8fec66d7ccac94c2aa2548dd0b4a69d58d0850a4 --- /dev/null +++ b/anivexa-api/providers/reanime.js @@ -0,0 +1,756 @@ +const __name = (fn, _) => fn; +import { getMedia } from '../core/anilist.js'; +import { buildTitles } from '../core/new-provider-utils.js'; +import { get as cacheGet, set as cacheSet, isFresh as cacheIsFresh, SHOW_IDENTITY_TTL } from '../core/smartcache.js'; + +var BASE = "https://reanime.to"; +var FLIX = "https://flixcloud.cc"; +var ANIZIP2 = "https://api.ani.zip/mappings"; +var UA5 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; +var H = { "User-Agent": UA5, Accept: "application/json, */*" }; +var enc = new TextEncoder(); +var dec = new TextDecoder(); +async function sha256hex(s) { + const buf = await crypto.subtle.digest("SHA-256", typeof s === "string" ? enc.encode(s) : s); + return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} +__name(sha256hex, "sha256hex"); +function b64toU8(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} +__name(b64toU8, "b64toU8"); +async function deriveFields(seed) { + let e = seed; + for (let i = 0; i < 3; i++) e = await sha256hex(e + i); + let l = e; + for (let i = 0; i < 3; i++) l = await sha256hex(l + i); + return { + keyField: "kf_" + e.substring(8, 16), + ivField: "ivf_" + e.substring(16, 24), + containerName: "cd_" + e.substring(24, 32), + arrayName: "ad_" + e.substring(32, 40), + objectName: "od_" + e.substring(40, 48), + tokenField: e.substring(48, 64) + "_" + e.substring(56, 64), + keyFrag2Field: l.substring(0, 16) + "_" + l.substring(16, 24) + }; +} +__name(deriveFields, "deriveFields"); +function extractSsrObj(html) { + const m = html.match(/\{type:"data",data:(\{)/); + if (!m) throw new Error("SSR data block not found"); + let depth = 0; + const start = html.indexOf("{", m.index + m[0].length - 1); + for (let i = start; i < html.length; i++) { + if (html[i] === "{") depth++; + else if (html[i] === "}") { + if (--depth === 0) return html.slice(start, i + 1); + } + } + throw new Error("SSR brace matching failed"); +} +__name(extractSsrObj, "extractSsrObj"); +function parseJsLiteral(src) { + let i = 0; + function ws() { + while (i < src.length && /\s/.test(src[i])) i++; + } + __name(ws, "ws"); + function parseValue() { + ws(); + if (src[i] === "{") return parseObject(); + if (src[i] === "[") return parseArray(); + if (src[i] === '"') return parseDStr(); + if (src[i] === "'") return parseSStr(); + if (src.startsWith("true", i)) { + i += 4; + return true; + } + if (src.startsWith("false", i)) { + i += 5; + return false; + } + if (src.startsWith("null", i)) { + i += 4; + return null; + } + if (src.startsWith("undefined", i)) { + i += 9; + return null; + } + if (src.startsWith("!0", i)) { + i += 2; + return true; + } + if (src.startsWith("!1", i)) { + i += 2; + return false; + } + const m = src.slice(i).match(/^-?[\d.]+([eE][+-]?\d+)?/); + if (m) { + i += m[0].length; + return parseFloat(m[0]); + } + throw new Error(`JS parse error at pos ${i}: ...${src.slice(i, i + 20)}`); + } + __name(parseValue, "parseValue"); + function parseDStr() { + let r = ""; + i++; + while (i < src.length && src[i] !== '"') { + if (src[i] === "\\") { + i++; + const e = { n: "\n", t: " ", r: "\r", '"': '"', "\\": "\\" }; + r += e[src[i]] ?? src[i]; + i++; + } else r += src[i++]; + } + i++; + return r; + } + __name(parseDStr, "parseDStr"); + function parseSStr() { + let r = ""; + i++; + while (i < src.length && src[i] !== "'") { + if (src[i] === "\\") { + i++; + r += src[i] === "'" ? "'" : { n: "\n", t: " ", r: "\r", "\\": "\\" }[src[i]] ?? src[i]; + i++; + } else r += src[i++]; + } + i++; + return r; + } + __name(parseSStr, "parseSStr"); + function parseKey() { + ws(); + if (src[i] === '"') return parseDStr(); + if (src[i] === "'") return parseSStr(); + const m = src.slice(i).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/); + if (m) { + i += m[0].length; + return m[0]; + } + throw new Error(`Bad key at pos ${i}: ${src.slice(i, i + 20)}`); + } + __name(parseKey, "parseKey"); + function parseObject() { + const obj = {}; + i++; + ws(); + while (i < src.length && src[i] !== "}") { + if (src[i] === ",") { + i++; + ws(); + continue; + } + const k = parseKey(); + ws(); + i++; + obj[k] = parseValue(); + ws(); + } + i++; + return obj; + } + __name(parseObject, "parseObject"); + function parseArray() { + const arr = []; + i++; + ws(); + while (i < src.length && src[i] !== "]") { + if (src[i] === ",") { + i++; + ws(); + continue; + } + arr.push(parseValue()); + ws(); + } + i++; + return arr; + } + __name(parseArray, "parseArray"); + return parseValue(); +} +__name(parseJsLiteral, "parseJsLiteral"); +function parseWasmDecrypt(wasmBytes) { + const b = wasmBytes; + let pos = 8; + while (pos < b.length) { + const secId = b[pos++]; + let sz = 0, sh = 0, by; + do { + by = b[pos++]; + sz |= (by & 127) << sh; + sh += 7; + } while (by & 128); + if (secId === 10) { + pos++; + let sbs = 0, sh2 = 0, by2; + do { + by2 = b[pos++]; + sbs |= (by2 & 127) << sh2; + sh2 += 7; + } while (by2 & 128); + pos += sbs; + break; + } + pos += sz; + } + let rbs = 0, sh3 = 0, by3; + do { + by3 = b[pos++]; + rbs |= (by3 & 127) << sh3; + sh3 += 7; + } while (by3 & 128); + const r = b.slice(pos, pos + rbs); + function leb(arr, i) { + let v = 0, s = 0, b2; + do { + b2 = arr[i++]; + v |= (b2 & 127) << s; + s += 7; + } while (b2 & 128); + return [v, i]; + } + __name(leb, "leb"); + const XOR_END = [32, 2, 32, 5, 106, 45, 0, 0, 115, 33, 6]; + let txStart = -1; + outer: for (let i = 0; i < r.length - XOR_END.length; i++) { + for (let j = 0; j < XOR_END.length; j++) if (r[i + j] !== XOR_END[j]) continue outer; + txStart = i + XOR_END.length; + break; + } + if (txStart < 0) throw new Error("WASM: transform start not found"); + let txEnd = -1, step = 36; + for (let i = txStart; i < r.length - 4; i++) { + if (r[i] === 32 && r[i + 1] === 5 && r[i + 2] === 65) { + const [val, ni] = leb(r, i + 3); + if (r[ni] === 108) { + txEnd = i; + step = val; + break; + } + } + } + if (txEnd < 0) throw new Error("WASM: keystream not found"); + const code = r.slice(txStart, txEnd); + function transform(inputByte) { + let local6 = inputByte & 255; + const stk = []; + let i = 0; + while (i < code.length) { + const op = code[i++]; + if (op === 32) { + const [idx, ni] = leb(code, i); + i = ni; + stk.push(idx === 6 ? local6 : 0); + } else if (op === 33) { + const [idx, ni] = leb(code, i); + i = ni; + const v = stk.pop(); + if (idx === 6) local6 = v & 255; + } else if (op === 65) { + const [v, ni] = leb(code, i); + i = ni; + stk.push(v); + } else if (op === 106) { + const b2 = stk.pop(), a = stk.pop(); + stk.push(a + b2 & 255); + } else if (op === 107) { + const b2 = stk.pop(), a = stk.pop(); + stk.push(a - b2 + 256 & 255); + } else if (op === 113) { + const b2 = stk.pop(), a = stk.pop(); + stk.push(a & b2 & 255); + } else if (op === 114) { + const b2 = stk.pop(), a = stk.pop(); + stk.push((a | b2) & 255); + } else if (op === 115) { + const b2 = stk.pop(), a = stk.pop(); + stk.push((a ^ b2) & 255); + } else if (op === 116) { + const b2 = stk.pop(), a = stk.pop(); + stk.push(a << (b2 & 7) & 255); + } else if (op === 118) { + const b2 = stk.pop(), a = stk.pop(); + stk.push(a >>> (b2 & 7) & 255); + } + } + return local6; + } + __name(transform, "transform"); + return { step, transform }; +} +__name(parseWasmDecrypt, "parseWasmDecrypt"); +function runDecrypt(wasmBytes, frag1, kf2, T, seedInt) { + const { step, transform } = parseWasmDecrypt(wasmBytes); + const out = new Uint8Array(frag1.length); + for (let i = 0; i < frag1.length; i++) { + const c = (frag1[i] ^ kf2[i] ^ T[i]) & 255; + out[i] = transform(c) ^ i * step + seedInt & 255; + } + return out; +} +__name(runDecrypt, "runDecrypt"); +async function decryptEmbed(html) { + const raw = extractSsrObj(html); + const data = parseJsLiteral(raw); + const seed = data.obfuscation_seed; + if (!seed) { + const e = new Error("obfuscation_seed missing"); + e.debug = { topKeys: Object.keys(data).slice(0, 20) }; + throw e; + } + const fields = await deriveFields(seed); + const ocd = data.obfuscated_crypto_data; + if (!ocd) { + const e = new Error("obfuscated_crypto_data missing"); + e.debug = { fields, topKeys: Object.keys(data).slice(0, 20) }; + throw e; + } + const container = ocd[fields.containerName]; + if (!container) { + const e = new Error(`containerName "${fields.containerName}" not in ocd`); + e.debug = { fields, ocdKeys: Object.keys(ocd).slice(0, 10) }; + throw e; + } + const arr = container[fields.arrayName]; + if (!arr) { + const e = new Error(`arrayName "${fields.arrayName}" not in container`); + e.debug = { fields, containerKeys: Object.keys(container).slice(0, 10) }; + throw e; + } + const obj = arr[0][fields.objectName]; + if (!obj) { + const e = new Error(`objectName "${fields.objectName}" not in arr[0]`); + e.debug = { fields, arr0Keys: Object.keys(arr[0]).slice(0, 10) }; + throw e; + } + const frag1 = b64toU8(obj[fields.keyField]); + const iv = b64toU8(obj[fields.ivField]); + const kf2raw = data[fields.keyFrag2Field]; + if (!kf2raw) { + const e = new Error(`kf2 field "${fields.keyFrag2Field}" not in data`); + e.debug = { fields, topKeys: Object.keys(data).slice(0, 20) }; + throw e; + } + const kf2 = b64toU8(kf2raw); + const token = data[fields.tokenField]; + if (!token) { + const e = new Error(`tokenField "${fields.tokenField}" missing`); + e.debug = { fields, topKeys: Object.keys(data).slice(0, 20) }; + throw e; + } + const tokData = await fetch(`${FLIX}/api/m3u8/${token}`, { headers: { ...H, Referer: `${BASE}/` } }).then(async (r) => { + if (!r.ok) { const _raw = await r.text().catch(() => null); const _e = new Error(`Token API ${r.status}`); _e.rawBody = _raw; throw _e; } + return r.json(); + }); + const vidKey = (await sha256hex(token + "vid")).substring(0, 10); + const keyKey = (await sha256hex(token + "key")).substring(0, 10); + const v_bytes = b64toU8(tokData[vidKey]); + const T_bytes = b64toU8(tokData[keyKey]); + if (!v_bytes.length || !T_bytes.length) { + const e = new Error(`Token fields missing. vidKey="${vidKey}" keyKey="${keyKey}"`); + e.debug = { tokKeys: Object.keys(tokData).slice(0, 10) }; + throw e; + } + const seedInt = parseInt(seed.substring(0, 8), 16); + const wPayload = b64toU8(data.w_payload ?? ""); + if (!wPayload.length) throw new Error("w_payload missing from embed data"); + let wasmOut; + try { + wasmOut = runDecrypt(wPayload, frag1, kf2, T_bytes, seedInt); + } catch (pe) { + pe.wasmHex = Array.from(wPayload).map((b) => b.toString(16).padStart(2, "0")).join(""); + throw pe; + } + const keyMat = await crypto.subtle.importKey("raw", wasmOut, { name: "PBKDF2" }, false, ["deriveBits"]); + const derived = new Uint8Array(await crypto.subtle.deriveBits( + { name: "PBKDF2", salt: enc.encode(seed), iterations: 1e3, hash: "SHA-256" }, + keyMat, + 256 + )); + for (let i = 0; i < 32; i++) derived[i] ^= seed.charCodeAt(i % seed.length); + const aesKeyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", derived)); + const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-CBC" }, false, ["decrypt"]); + let plain; + try { + plain = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, aesKey, v_bytes); + } catch (err) { + err.debug = { + seedInt: "0x" + seedInt.toString(16), + frag1Len: frag1.length, + kf2Len: kf2.length, + T_bytesLen: T_bytes.length, + ivLen: iv.length, + v_bytesLen: v_bytes.length, + wPayloadLen: wPayload.length, + wasmOutHex: Array.from(wasmOut).map((b) => b.toString(16).padStart(2, "0")).join("") + }; + throw err; + } + const url = dec.decode(plain).trim().replace(/\0+$/, ""); + if (!url.startsWith("http")) throw new Error(`Unexpected decrypted value: ${url.substring(0, 60)}`); + return { + url, + subtitles: data.subtitles ?? [], + thumbnails_vtt: data.thumbnails_vtt ?? null, + video_title: data.video_title ?? null, + intro_chapter: data.intro_chapter ?? null, + outro_chapter: data.outro_chapter ?? null, + video_id: data.video_id ?? null + }; +} +__name(decryptEmbed, "decryptEmbed"); +async function searchReanime(query) { + const data = await fetch(`${BASE}/api/v1/search?${new URLSearchParams({ q: query, limit: 10 })}`, { headers: H }).then(async (r) => { + const _raw = await r.text(); + if (!r.ok) { const _e = new Error(`reanime search ${r.status}`); _e.rawBody = _raw; throw _e; } + try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; } + }); + return Array.isArray(data?.results) ? data.results : []; +} +__name(searchReanime, "searchReanime"); +async function fetchAnimeDetail(animeId) { + const res = await fetch(`${BASE}/api/v1/anime/${animeId}`, { headers: H }); + if (!res.ok) return null; + return res.json().catch(() => null); +} +__name(fetchAnimeDetail, "fetchAnimeDetail"); +// Extract AniList ID embedded in AniList CDN cover image URLs. +// e.g. https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx16498-xxxx.jpg → 16498 +function extractAnilistIdFromCover(coverImage) { + const urls = [coverImage?.extra_large, coverImage?.large, coverImage?.medium].filter(Boolean); + for (const url of urls) { + const m = url.match(/anilist\.co\/.*\/bx(\d+)-/); + if (m) return Number(m[1]); + } + return null; +} +__name(extractAnilistIdFromCover, "extractAnilistIdFromCover"); +async function resolveSeries(anilistId, ctx = {}) { + const cacheKey = `np:reanime:${anilistId}`; + const cached = cacheGet(cacheKey); + if (cacheIsFresh(cached)) return cached.data; + + const media = ctx.media ?? await getMedia(anilistId); + const malId = media?.idMal ?? null; + const queries = buildTitles(media, ctx.anizip).slice(0, 5); + + const candidates = new Map(); + await Promise.all(queries.map(async (q) => { + for (const r of await searchReanime(q).catch(() => [])) { + if (r?.anime_id && !candidates.has(r.anime_id)) candidates.set(r.anime_id, r); + } + })); + + // Fast pass: AniList CDN cover URLs embed the AniList ID as bx{id}-*. + // If a candidate's cover image already confirms our ID we can skip detail fetches entirely. + for (const [id, r] of candidates) { + const coverId = extractAnilistIdFromCover(r.cover_image); + if (coverId && coverId === Number(anilistId)) { + const data = { + animeId: id, + title: r.title?.english || r.title?.romaji || id, + anilistId: Number(anilistId), + malId: null, + subbed: Number.isFinite(r.subbed) ? r.subbed : null, + dubbed: Number.isFinite(r.dubbed) ? r.dubbed : null, + episodesCount: Number.isFinite(r.episodes) ? r.episodes : null, + matchType: "cover_image", + matchScore: 1, + }; + cacheSet(cacheKey, data, SHOW_IDENTITY_TTL); + return data; + } + } + + // Fallback: fetch detail pages only for candidates that had no AniList CDN cover + // (TMDB / MAL covers don't embed an ID we can read directly). + const needsDetail = [...candidates.keys()].filter( + (id) => extractAnilistIdFromCover(candidates.get(id)?.cover_image) === null + ); + const details = await Promise.all( + needsDetail.map(async (id) => ({ id, detail: await fetchAnimeDetail(id).catch(() => null) })) + ); + + for (const { id, detail } of details) { + if (detail?.anilist_id && Number(detail.anilist_id) === Number(anilistId)) { + const data = { + animeId: id, + title: detail.title?.english || detail.title?.romaji || candidates.get(id)?.title?.english || id, + anilistId: Number(anilistId), + malId: detail.mal_id || null, + subbed: Number.isFinite(detail.subbed) ? detail.subbed : null, + dubbed: Number.isFinite(detail.dubbed) ? detail.dubbed : null, + episodesCount: Number.isFinite(detail.episodes) ? detail.episodes : null, + matchType: "anilist", + matchScore: 1, + }; + cacheSet(cacheKey, data, SHOW_IDENTITY_TTL); + return data; + } + } + + if (malId) { + for (const { id, detail } of details) { + const detailMal = detail?.mal_id; + if (detailMal && Number(detailMal) === Number(malId)) { + const data = { + animeId: id, + title: detail.title?.english || detail.title?.romaji || id, + anilistId: Number(anilistId), + malId: Number(detailMal), + subbed: Number.isFinite(detail.subbed) ? detail.subbed : null, + dubbed: Number.isFinite(detail.dubbed) ? detail.dubbed : null, + episodesCount: Number.isFinite(detail.episodes) ? detail.episodes : null, + matchType: "mal", + matchScore: 0.9, + }; + cacheSet(cacheKey, data, SHOW_IDENTITY_TTL); + return data; + } + } + } + + throw new Error(`No confirmed reanime match for AniList ${anilistId}`); +} +__name(resolveSeries, "resolveSeries"); +async function fetchEpisodesList(animeId, limit = 2000) { + const data = await fetch(`${BASE}/api/v1/anime/${animeId}/episodes?${new URLSearchParams({ limit })}`, { headers: H }).then(async (r) => { + const _raw = await r.text(); + if (!r.ok) { const _e = new Error(`reanime episodes ${r.status}`); _e.rawBody = _raw; throw _e; } + try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; } + }); + return Array.isArray(data?.data) ? data.data : []; +} +__name(fetchEpisodesList, "fetchEpisodesList"); +async function fetchAnizip(anilistId) { + return fetch(`${ANIZIP2}?anilist_id=${anilistId}`).then((r) => r.json()).catch(() => null); +} +__name(fetchAnizip, "fetchAnizip"); +function mergeEpisode(anilistId, ep, meta, audio) { + const number = ep.episode_number; + return { + id: `watch/reanime/${anilistId}/${audio}/reanime-${number}`, + number, + title: meta?.title?.en || meta?.title?.["x-jat"] || ep.title || `Episode ${number}`, + titleJapanese: meta?.title?.ja || ep.title_japanese || null, + titleRomanji: meta?.title?.["x-jat"] || ep.title_romanji || null, + image: meta?.image || ep.thumbnail || null, + airDate: meta?.airdate || ep.aired || null, + duration: meta?.runtime ? meta.runtime * 60 : (ep.duration ? ep.duration * 60 : null), + score: null, + filler: ep.is_filler ?? meta?.filler ?? false, + recap: ep.is_recap ?? false, + description: meta?.overview || ep.description || null, + audio + }; +} +__name(mergeEpisode, "mergeEpisode"); +function json3(data, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }); +} +__name(json3, "json"); +async function handleEpisodes3(anilistId, url) { + const series = await resolveSeries(anilistId); + const [reanimeEps, anizip] = await Promise.all([ + fetchEpisodesList(series.animeId), + fetchAnizip(anilistId) + ]); + if (!reanimeEps.length) return json3({ error: `No reanime episodes found for AniList ID ${anilistId} (slug ${series.animeId})` }, 404); + const episodes = reanimeEps.map((ep) => { + const meta = anizip?.episodes?.[String(ep.episode_number)] ?? null; + return mergeEpisode(anilistId, ep, meta, "sub"); + }).sort((a, b) => a.number - b.number); + return json3({ + anime: series.title, + anilistId: Number(anilistId), + malId: series.malId, + animeId: series.animeId, + episodes, + pagination: { currentPage: 1, lastPage: 1, hasNextPage: false } + }); +} +__name(handleEpisodes3, "handleEpisodes"); +async function resolveStream3(anilistId, audio, ep) { + const series = await resolveSeries(anilistId); + const title2 = series.title; + const slug = series.animeId; + const order = { "HD-2": 0, "HD-1": 1 }; + const byPrio = (arr) => arr.slice().sort((a, b) => (order[a.serverName] ?? 9) - (order[b.serverName] ?? 9)); + const [watchRes, flixRes] = await Promise.allSettled([ + fetch(`${BASE}/api/watch/${slug}/${ep}`, { headers: H }).then(async (r) => { + const _raw = await r.text(); + if (!r.ok) { const _e = new Error(`watch ${r.status}`); _e.rawBody = _raw; throw _e; } + try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; } + }), + fetch(`${BASE}/api/flix/${anilistId}/${ep}`, { headers: H }).then(async (r) => { + const _raw = await r.text(); + if (!r.ok) { const _e = new Error(`flix ${r.status}`); _e.rawBody = _raw; throw _e; } + try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; } + }) + ]); + const watchData = watchRes.status === "fulfilled" ? watchRes.value : null; + const flixData = flixRes.status === "fulfilled" ? flixRes.value : null; + const links = [...watchData?.episode_links ?? []]; + if (flixData?.success && flixData?.servers) { + const seen = new Set(links.map((s) => s["$id"])); + for (const s of flixData.servers) { + if (!seen.has(s["$id"])) links.push(s); + } + } + const audioTypes = audio === "sub" ? ["sub", "s-sub"] : ["dub", "s-dub"]; + const servers = byPrio(links.filter((s) => audioTypes.includes(s.dataType))); + if (!servers.length) throw Object.assign(new Error(`No ${audio} servers for "${title2}" ep ${ep}`), { status: 404 }); + const embedRes = await fetch(servers[0].dataLink, { headers: { ...H, Referer: `${BASE}/` } }); + if (!embedRes.ok) throw Object.assign(new Error(`Embed fetch failed: ${embedRes.status}`), { status: 502 }); + const stream = await decryptEmbed(await embedRes.text()); + return { title: title2, slug, watchData, stream, server: servers[0].serverName, servers }; +} +__name(resolveStream3, "resolveStream"); +async function handleWatch3(anilistId, audio, epNum, origin) { + if (audio !== "sub" && audio !== "dub") return json3({ error: "audio must be sub or dub" }, 400); + const ep = parseInt(epNum); + if (isNaN(ep)) return json3({ error: `Invalid episode: ${epNum}` }, 400); + let resolved; + try { + resolved = await resolveStream3(anilistId, audio, ep); + } catch (e) { + return json3({ error: e.message, "Raw-ERROR": e.rawBody ?? null, stack: e.stack }, e.status ?? 500); + } + const { title: title2, slug, watchData, stream, server, servers } = resolved; + const redirectUrl = `${origin}/stream/reanime/${anilistId}/${audio}/${ep}`; + return json3({ + anime: title2, + slug, + ep, + audio, + server, + stream_url: stream.url, + redirect_url: redirectUrl, + streams: [ + { url: stream.url, type: "hls" }, + { url: redirectUrl, type: "hls-redirect" }, + ...servers.map((s) => ({ url: s.dataLink, type: "embed", server: s.serverName })) + ], + subtitles: stream.subtitles, + thumbnails_vtt: stream.thumbnails_vtt, + video_title: stream.video_title, + intro: stream.intro_chapter, + outro: stream.outro_chapter, + intro_start: watchData?.intro_start ?? null, + intro_end: watchData?.intro_end ?? null, + outro_start: watchData?.outro_start ?? null, + outro_end: watchData?.outro_end ?? null, + allServers: servers.map((s) => ({ name: s.serverName, type: s.dataType, embed: s.dataLink })) + }); +} +__name(handleWatch3, "handleWatch"); +async function handleStream3(anilistId, audio, epNum) { + if (audio !== "sub" && audio !== "dub") return json3({ error: "audio must be sub or dub" }, 400); + const ep = parseInt(epNum); + if (isNaN(ep)) return json3({ error: `Invalid episode: ${epNum}` }, 400); + let resolved; + try { + resolved = await resolveStream3(anilistId, audio, ep); + } catch (e) { + return json3({ error: e.message, "Raw-ERROR": e.rawBody ?? null, stack: e.stack }, e.status ?? 500); + } + return new Response(null, { + status: 302, + headers: { + "Location": resolved.stream.url, + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store" + } + }); +} +__name(handleStream3, "handleStream"); +async function handleProxy3(url) { + const target = url.searchParams.get("url"); + const referer = url.searchParams.get("referer") ?? `${FLIX}/`; + if (!target) return json3({ error: "Missing required ?url= param" }, 400); + let targetUrl; + try { + targetUrl = new URL(target); + } catch { + return json3({ error: "Invalid url param" }, 400); + } + const upstream = await fetch(target, { + headers: { + "User-Agent": UA5, + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Referer": referer, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "cross-site" + } + }); + const ct = upstream.headers.get("Content-Type") ?? ""; + const isM3U8 = ct.includes("mpegurl") || ct.includes("x-mpegurl") || targetUrl.pathname.endsWith(".m3u8") || targetUrl.pathname.endsWith(".m3u"); + const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" }; + if (!upstream.ok) { + return new Response(await upstream.text(), { status: upstream.status, headers: { "Content-Type": ct || "text/plain", ...corsHeaders } }); + } + if (isM3U8) { + const text = await upstream.text(); + const rewritten = rewriteM3U8(text, target, url.origin); + return new Response(rewritten, { status: 200, headers: { "Content-Type": "application/vnd.apple.mpegurl", ...corsHeaders } }); + } + return new Response(upstream.body, { status: upstream.status, headers: { "Content-Type": ct || "application/octet-stream", ...corsHeaders } }); +} +__name(handleProxy3, "handleProxy"); +var reanime_default = { + async fetch(request) { + const url = new URL(request.url); + const path = url.pathname; + 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 { + let m; + if (path === "/healthz") return json3({ status: "ok", provider: "reanime" }); + if (path === "/proxy") return await handleProxy3(url); + m = path.match(/^\/episodes\/(\d+)$/); + if (m) return await handleEpisodes3(m[1], url); + m = path.match(/^\/watch\/(\d+)\/(sub|dub)\/(\d+)$/); + if (m) return await handleWatch3(m[1], m[2], m[3], url.origin); + m = path.match(/^\/stream\/(\d+)\/(sub|dub)\/(\d+)$/); + if (m) return await handleStream3(m[1], m[2], m[3]); + return json3({ error: "Not found", routes: ["GET /episodes/:anilistId", "GET /watch/:anilistId/sub|dub/:ep", "GET /stream/:anilistId/sub|dub/:ep", "GET /proxy?url=&referer="] }, 404); + } catch (err) { + return json3({ error: err.message, "Raw-ERROR": err.rawBody ?? null, ...err.debug ? { debug: err.debug } : {}, stack: err.stack }, 500); + } + } +}; +async function getEpisodes3(anilistId, ctx = {}) { + const series = await resolveSeries(anilistId, ctx); + const anizip = ctx.anizip !== void 0 ? ctx.anizip : await fetchAnizip(anilistId); + const reanimeEps = await fetchEpisodesList(series.animeId); + if (!reanimeEps.length) throw new Error(`No reanime episodes found for AniList ${anilistId} (slug ${series.animeId})`); + + const hasSub = series.subbed == null || series.subbed > 0; + const dubCount = series.dubbed ?? 0; + const sub = [], dub = []; + for (const ep of reanimeEps) { + const meta = anizip?.episodes?.[String(ep.episode_number)] ?? null; + if (hasSub) sub.push(mergeEpisode(anilistId, ep, meta, "sub")); + if (dubCount > 0 && ep.episode_number <= dubCount) dub.push(mergeEpisode(anilistId, ep, meta, "dub")); + } + sub.sort((a, b) => a.number - b.number); + dub.sort((a, b) => a.number - b.number); + return { + meta: { title: series.title, malId: series.malId, animeId: series.animeId }, + episodes: { sub, dub } + }; +} +__name(getEpisodes3, "getEpisodes"); +export default reanime_default; +export { getEpisodes3 as getEpisodes }; \ No newline at end of file diff --git a/anivexa-api/providers/senshi.js b/anivexa-api/providers/senshi.js new file mode 100644 index 0000000000000000000000000000000000000000..fa171d0dc2785e0d4156a0c7c04299361861e17d --- /dev/null +++ b/anivexa-api/providers/senshi.js @@ -0,0 +1,213 @@ +import { json, episodeMeta } from "../core/new-provider-utils.js"; +import { getMedia } from "../core/anilist.js"; +import { get as cacheGet, set as cacheSet, isFresh, + SHOW_IDENTITY_TTL } from "../core/smartcache.js"; + +const BASE = "https://senshi.live"; +const UA = "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0"; +const H = { "User-Agent": UA, "Referer": `${BASE}/` }; + +async function fetchEpisodeList(malId) { + const res = await fetch(`${BASE}/episodes/${malId}`, { headers: H }); + if (!res.ok) throw new Error(`Senshi episodes ${res.status} (MAL ${malId})`); + const data = await res.json(); + return Array.isArray(data) ? data : []; +} + +async function fetchEmbeds(malId, epNum) { + const res = await fetch(`${BASE}/episode-embeds/${malId}/${epNum}`, { headers: H }); + if (!res.ok) throw new Error(`Senshi embeds ${res.status} (MAL ${malId} ep ${epNum})`); + const data = await res.json(); + return Array.isArray(data) ? data : []; +} + +async function resolveMalId(anilistId) { + const cacheKey = `np:senshi:${anilistId}`; + const cached = cacheGet(cacheKey); + if (isFresh(cached)) return cached.data; + + const media = await getMedia(anilistId); + if (!media?.idMal) throw new Error(`Senshi: no MAL ID found for AniList ${anilistId}`); + + cacheSet(cacheKey, media.idMal, SHOW_IDENTITY_TTL); + return media.idMal; +} + +function isDub(status) { + return (status ?? "").toLowerCase() === "dub"; +} + +export async function getEpisodes(anilistId, ctx = {}) { + const malId = await resolveMalId(anilistId); + const items = await fetchEpisodeList(malId); + + if (!items.length) { + throw new Error(`Senshi: no episodes for AniList ${anilistId} (MAL ${malId})`); + } + + let hasDub = false; + try { + const probe = await fetchEmbeds(malId, 1); + hasDub = probe.some(e => isDub(e.status)); + } catch { /* ignore */ } + + const sub = []; + const dub = []; + + for (const item of items) { + const num = item.ep_id; + const meta = episodeMeta(num, ctx); + const title = item.ep_title || meta.title || `Episode ${num}`; + const duration = meta.duration; + const filler = item.ep_filler || meta.filler || false; + const recap = item.ep_recap || false; + const description = meta.description; + const image = meta.image; + const airDate = meta.airDate; + + sub.push({ + id: `watch/senshi/${anilistId}/sub/senshi-${num}`, + number: num, + title, + duration, + audio: "sub", + filler, + recap, + uncensored: false, + description, + image, + airDate + }); + + if (hasDub) { + dub.push({ + id: `watch/senshi/${anilistId}/dub/senshi-${num}`, + number: num, + title, + duration, + audio: "dub", + filler, + recap, + uncensored: false, + description, + image, + airDate + }); + } + } + + sub.sort((a, b) => a.number - b.number); + dub.sort((a, b) => a.number - b.number); + + return { + meta: { + title: ctx.media?.title?.english ?? ctx.media?.title?.romaji ?? null, + malId, + source: "senshi", + }, + episodes: { sub, dub }, + }; +} + +async function handleWatch(anilistId, audio, epNum) { + const malId = await resolveMalId(anilistId); + const embeds = await fetchEmbeds(malId, epNum); + + if (!embeds.length) { + return json({ error: `Senshi: no sources for episode ${epNum}` }, 404); + } + + const wantDub = audio === "dub"; + const source = embeds.find(e => wantDub ? isDub(e.status) : !isDub(e.status)); + + if (!source) { + return json({ error: `Senshi: no ${audio} source for episode ${epNum}` }, 404); + } + + const list = await fetchEpisodeList(malId).catch(() => []); + const epItem = list.find(item => Number(item.ep_id) === Number(epNum)); + + const intro = { + start: epItem?.intro_start ?? 0, + end: epItem?.intro_end ?? 0, + }; + const outro = { + start: epItem?.outro_start ?? 0, + end: epItem?.outro_end ?? 0, + }; + + const streams = []; + const downloads = []; + + if (source.url) { + streams.push({ + url: source.url, + type: "hls", + server: "Senshi", + referer: `${BASE}/`, + priority: 5, + isActive: true, + }); + } + + if (source.server2) { + streams.push({ + url: source.server2, + type: "embed", + server: "StreamNin", + referer: `${BASE}/`, + priority: 3, + isActive: false, + }); + } + + if (source.serverFM) { + streams.push({ + url: source.serverFM, + type: "embed", + server: "FileMoon", + referer: `${BASE}/`, + priority: 2, + isActive: false, + }); + } + + if (source.download) { + downloads.push({ url: source.download, label: "Download" }); + } + + return json({ + anilistId: Number(anilistId), + malId, + episode: Number(epNum), + audio, + intro, + outro, + streams, + downloads, + headers: H, + }); +} + +export default { + async fetch(request) { + 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": "*", + }, + }); + } + const url = new URL(request.url); + try { + const m = url.pathname.match(/^\/watch\/senshi\/(\d+)\/(sub|dub)\/senshi-(\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, stack: err.stack }, 500); + } + }, +}; diff --git a/anivexa-api/proxy/worker.js b/anivexa-api/proxy/worker.js new file mode 100644 index 0000000000000000000000000000000000000000..f2845cee9b10ac745edea6fe5ff3b0eb07b9fd41 --- /dev/null +++ b/anivexa-api/proxy/worker.js @@ -0,0 +1,53 @@ +export default { + async fetch(request) { + const url = new URL(request.url); + const target = url.searchParams.get("url"); + const ref = url.searchParams.get("ref") ?? "https://anidb.app/"; + + if (!target) { + return new Response(JSON.stringify({ error: "Missing ?url= param" }), { + status: 400, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + let targetUrl; + try { targetUrl = new URL(target); } catch { + return new Response(JSON.stringify({ error: "Invalid URL" }), { + status: 400, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + if (!targetUrl.hostname.endsWith("anidb.app")) { + return new Response(JSON.stringify({ error: "Only anidb.app requests allowed" }), { + status: 403, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const res = await fetch(target, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,application/json,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Referer": ref, + "X-Requested-With": request.headers.get("X-Requested-With") ?? "", + }, + }).catch((e) => null); + + if (!res) { + return new Response(JSON.stringify({ error: "Fetch failed" }), { + status: 502, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const body = await res.arrayBuffer(); + const headers = new Headers(); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set("Content-Type", res.headers.get("Content-Type") ?? "text/plain"); + + return new Response(body, { status: res.status, headers }); + }, +}; diff --git a/anivexa-api/proxy/wrangler.toml b/anivexa-api/proxy/wrangler.toml new file mode 100644 index 0000000000000000000000000000000000000000..44f572d89e3f815b5f10ded1d4dac9c9c1fe5360 --- /dev/null +++ b/anivexa-api/proxy/wrangler.toml @@ -0,0 +1,3 @@ +name = "anidb-proxy" +main = "worker.js" +compatibility_date = "2024-01-01" diff --git a/anivexa-api/run.bat b/anivexa-api/run.bat new file mode 100644 index 0000000000000000000000000000000000000000..4b0f2c4d5a8a089488aad719c63685591a8062ab --- /dev/null +++ b/anivexa-api/run.bat @@ -0,0 +1,7 @@ +@echo off +REM Anivexa-API sidecar (Node, zero deps) — runs on http://127.0.0.1:8002 +REM (anidoom backend expects it there; see ANIVEXA_URL in backend/.env) +cd /d "%~dp0" +where node >nul 2>nul || (echo Node.js is required. Install from https://nodejs.org & exit /b 1) +set PORT=8002 +node server.js diff --git a/anivexa-api/run.sh b/anivexa-api/run.sh new file mode 100644 index 0000000000000000000000000000000000000000..8cf3d044757ab235d1d7fd723e3c38ad91a61ab2 --- /dev/null +++ b/anivexa-api/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Anivexa-API sidecar (Node, zero deps) — runs on http://127.0.0.1:8002 +# (anidoom backend expects it there; see ANIVEXA_URL in backend/.env) +set -e +cd "$(dirname "$0")" +command -v node >/dev/null || { echo "Node.js is required."; exit 1; } +export PORT=8002 +exec node server.js diff --git a/anivexa-api/server.js b/anivexa-api/server.js new file mode 100644 index 0000000000000000000000000000000000000000..513d9caafdfccf144789e0004854646a6ebb1d22 --- /dev/null +++ b/anivexa-api/server.js @@ -0,0 +1,78 @@ +import http from "node:http"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import worker from "./index.js"; + +const PORT = process.env.PORT ?? 4000; +const BASE = process.env.BASE_PATH ?? ""; +const __dir = dirname(fileURLToPath(import.meta.url)); + +const STATIC = { + "/": { file: "docs/landing.html", mime: "text/html" }, + "/docs": { file: "docs/index.html", mime: "text/html" }, + "/style.css": { file: "docs/style.css", mime: "text/css" }, + "/logo.svg": { file: "docs/logo.svg", mime: "image/svg+xml" }, +}; + +function serveStatic(res, entry) { + try { + const body = readFileSync(join(__dir, entry.file)); + res.writeHead(200, { + "Content-Type": entry.mime + "; charset=utf-8", + "Cache-Control": "no-cache", + }); + res.end(body); + } catch { + res.writeHead(404); + res.end("Not found"); + } +} + +async function nodeToRequest(req) { + const host = req.headers["host"] ?? `localhost:${PORT}`; + const stripped = BASE && req.url.startsWith(BASE) ? req.url.slice(BASE.length) || "/" : req.url; + const url = `http://${host}${stripped}`; + + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = chunks.length ? Buffer.concat(chunks) : null; + + return new Request(url, { + method: req.method, + headers: req.headers, + body: body?.length ? body : undefined, + duplex: "half", + }); +} + +const server = http.createServer(async (req, res) => { + console.log(`→ ${req.method} ${req.url}`); + + const pathname = req.url.split("?")[0]; + const staticEntry = STATIC[pathname]; + + if (req.method === "GET" && staticEntry) { + return serveStatic(res, staticEntry); + } + + try { + const request = await nodeToRequest(req); + const response = await worker.fetch(request, {}); + + res.statusCode = response.status; + for (const [k, v] of response.headers) res.setHeader(k, v); + + const buf = await response.arrayBuffer(); + res.end(Buffer.from(buf)); + } catch (err) { + console.error("Unhandled error:", err); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: err.message })); + } +}); + +server.listen(PORT, () => { + console.log(`Anivexa dev server → http://localhost:${PORT}`); +}); diff --git a/anivexa-api/sidecar-8002.err.log b/anivexa-api/sidecar-8002.err.log new file mode 100644 index 0000000000000000000000000000000000000000..ce7fdbf911b8923e5dd6d9c8e3a9d74607fa7637 --- /dev/null +++ b/anivexa-api/sidecar-8002.err.log @@ -0,0 +1,170 @@ +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 61048) +[ep:kaa] KAA: low confidence match for AniList 186863 — best "neko-to-ryuu-89c7" score 0.500 +[ep:2dhive] 2dhive: no player props for mal 61048 ep1 +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:anineko] HTTP 500 fetching https://anineko.to/watch/that-time-i-got-reincarnated-as-a-slime +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:anizone] HTTP 502 fetching https://anizone.to/anime/zldcbsft +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:anibd] anibd: no episodes found for AniList 141953 +[ep:senshi] Senshi episodes 502 (MAL 60568) +[ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568) +[ep:anineko] AniNeko match not found for AniList 141953 +[ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8) +[ep:animegg] AnimeGG match not found for AniList 141953 +[ep:anibd] anibd: no episodes found for AniList 141953 +[ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568) +[ep:senshi] Senshi episodes 502 (MAL 60568) +[ep:animegg] AnimeGG match not found for AniList 141953 +[ep:anineko] AniNeko match not found for AniList 141953 +[ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8) +[ep:anizone] AniZone match not found for AniList 141953 +[ep:anizone] AniZone match not found for AniList 141953 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:anizone] HTTP 502 fetching https://anizone.to/anime/q3n6aqt7 +[ep:anizone] HTTP 502 fetching https://anizone.to/anime/q3n6aqt7 +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:anibd] anibd: no episodes found for AniList 141953 +[ep:senshi] Senshi episodes 502 (MAL 60568) +[ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568) +[ep:anineko] AniNeko match not found for AniList 141953 +[ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8) +[ep:animegg] AnimeGG match not found for AniList 141953 +[ep:anibd] anibd: no episodes found for AniList 141953 +[ep:senshi] Senshi episodes 502 (MAL 60568) +[ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568) +[ep:anineko] AniNeko match not found for AniList 141953 +[ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8) +[ep:animegg] AnimeGG match not found for AniList 141953 +[ep:anizone] AniZone match not found for AniList 141953 +[ep:anizone] AniZone match not found for AniList 141953 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:anizone] HTTP 502 fetching https://anizone.to/anime/m1zauh0z +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:anizone] HTTP 502 fetching https://anizone.to/anime/z6cxc5zy +[ep:senshi] Senshi episodes 502 (MAL 59970) +[ep:2dhive] 2dhive: no player props for mal 59970 ep1 +[ep:senshi] Senshi episodes 502 (MAL 63537) +[ep:2dhive] 2dhive: no episodes found for AniList 208225 (MAL 63537) +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:senshi] Senshi episodes 502 (MAL 62001) +[ep:2dhive] 2dhive: no player props for mal 62001 ep1 +[ep:2dhive] 2dhive: no MAL ID found for AniList 214863 +[ep:senshi] Senshi: no MAL ID found for AniList 214863 +[ep:animedunya] AnimeDunya: no MAL ID found +[ep:kaa] KAA: no search results for AniList 214863 +[ep:anibd] anibd: no episodes found for AniList 214863 +[ep:reanime] No confirmed reanime match for AniList 214863 +[ep:anineko] AniNeko match not found for AniList 214863 +[ep:animenosub] animenosub match not found for AniList 214863 +[ep:allmanga] No AllAnime match for "Akuyaku no Ending wa Shi nomi" +[ep:anizone] AniZone match not found for AniList 214863 +[ep:animegg] AnimeGG match not found for AniList 214863 +[ep:anidbapp] AniDB.app match not found for AniList 214863 +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:senshi] Senshi episodes 502 (MAL 62001) +[ep:2dhive] 2dhive: no player props for mal 62001 ep1 +[ep:senshi] Senshi episodes 502 (MAL 30276) +[ep:2dhive] 2dhive: no player props for mal 30276 ep1 +[ep:senshi] Senshi episodes 502 (MAL 16498) +[ep:animenosub] animenosub match not found for AniList 16498 +[ep:2dhive] 2dhive: no player props for mal 16498 ep1 +[ep:senshi] Senshi episodes 502 (MAL 21) +[ep:2dhive] 2dhive: no player props for mal 21 ep1 +[ep:senshi] Senshi episodes 502 (MAL 62001) +[ep:2dhive] 2dhive: no player props for mal 62001 ep1 +[ep:senshi] Senshi episodes 502 (MAL 40748) +[ep:2dhive] 2dhive: no player props for mal 40748 ep1 +[ep:2dhive] 2dhive: no MAL ID found for AniList 201514 +[ep:senshi] Senshi: no MAL ID found for AniList 201514 +[ep:animedunya] AnimeDunya: no MAL ID found +[ep:senshi] Senshi episodes 502 (MAL 16498) +[ep:2dhive] 2dhive: no player props for mal 16498 ep1 +[ep:animenosub] animenosub match not found for AniList 16498 +[ep:senshi] Senshi episodes 502 (MAL 62001) +[ep:2dhive] 2dhive: no player props for mal 62001 ep1 +[ep:senshi] Senshi episodes 502 (MAL 51553) +[ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500 +[ep:2dhive] 2dhive: no player props for mal 51553 ep1 +[ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500 +[ep:senshi] Senshi episodes 502 (MAL 51553) +[ep:2dhive] 2dhive: no player props for mal 51553 ep1 +[ep:senshi] Senshi episodes 502 (MAL 51553) +[ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500 +[ep:2dhive] 2dhive: no player props for mal 51553 ep1 +[ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500 +[ep:senshi] Senshi episodes 502 (MAL 51553) +[ep:2dhive] 2dhive: no player props for mal 51553 ep1 +[ep:2dhive] 2dhive: no player props for mal 51553 ep1 +[ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500 +[ep:senshi] Senshi episodes 502 (MAL 51553) +[ep:senshi] Senshi episodes 502 (MAL 62001) +[ep:2dhive] 2dhive: no player props for mal 62001 ep1 +[ep:anibd] anibd: no episodes found for AniList 185874 +[ep:kaa] KAA: no search results for AniList 185874 +[ep:senshi] Senshi episodes 502 (MAL 60636) +[ep:2dhive] 2dhive: no episodes found for AniList 185874 (MAL 60636) +[ep:anibd] anibd: no episodes found for AniList 185874 +[ep:kaa] KAA: no search results for AniList 185874 +[ep:senshi] Senshi episodes 502 (MAL 60636) +[ep:2dhive] 2dhive: no episodes found for AniList 185874 (MAL 60636) +[ep:senshi] Senshi episodes 502 (MAL 269) +[ep:animenosub] animenosub match not found for AniList 269 +[ep:2dhive] 2dhive: no player props for mal 269 ep1 +[ep:senshi] Senshi episodes 502 (MAL 62001) +[ep:2dhive] 2dhive: no player props for mal 62001 ep1 +[ep:2dhive] 2dhive: no MAL ID found for AniList 201514 +[ep:senshi] Senshi: no MAL ID found for AniList 201514 +[ep:animedunya] AnimeDunya: no MAL ID found +[ep:allmanga] Could not resolve titles for AniList ID: 136312 +[ep:anibd] anibd: no episodes found for AniList 136312 +[ep:anikoto] No data found for AniList ID 136312 +[ep:animegg] No data found for AniList ID 136312 +[ep:anineko] No data found for AniList ID 136312 +[ep:anidbapp] No data found for AniList ID 136312 +[ep:animenosub] No data found for AniList ID 136312 +[ep:anizone] No data found for AniList ID 136312 +[ep:kaa] No data found for AniList ID 136312 +[ep:reanime] No data found for AniList ID 136312 +[ep:2dhive] No data found for AniList ID 136312 +[ep:senshi] No data found for AniList ID 136312 +[ep:animedunya] No data found for AniList ID 136312 +[ep:senshi] Senshi episodes 502 (MAL 61169) +[ep:2dhive] 2dhive: no player props for mal 61169 ep1 +[ep:senshi] Senshi episodes 502 (MAL 62001) +[ep:2dhive] 2dhive: no player props for mal 62001 ep1 diff --git a/anivexa-api/sidecar-8002.log b/anivexa-api/sidecar-8002.log new file mode 100644 index 0000000000000000000000000000000000000000..9ed4f055e5c07421c9c2867f115390eacb0bbad8 --- /dev/null +++ b/anivexa-api/sidecar-8002.log @@ -0,0 +1,276 @@ +Anivexa dev server → http://localhost:8002 +→ GET / +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/anikoto/182205/sub/anikoto-1 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /watch/anikoto/182205/sub/anikoto-1 +→ GET /episodes/182205 +→ GET /episodes/186863 +→ GET /watch/allmanga/186863/sub/allmanga-1 +→ GET /watch/allmanga/186863/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/allmanga/186863/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/reanime/186863/sub/reanime-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/reanime/182205/sub/reanime-1 +→ GET /watch/anikoto/186863/sub/anikoto-1 +→ GET /watch/reanime/182205/sub/reanime-1 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /watch/reanime/182205/sub/reanime-1 +→ GET /watch/anikoto/182205/sub/anikoto-1 +→ GET /watch/anineko/182205/sub/anineko-1 +→ GET /watch/anizone/182205/sub/anizone-1 +→ GET /episodes/21 +→ GET /watch/anibd/182205/sub/anibd-1 +→ GET /watch/kaa/182205/sub/kaa-1 +→ GET /watch/animegg/182205/sub/animegg-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET / +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /episodes/182205 +→ GET /episodes/182205 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /watch/anikoto/21/sub/anikoto-1 +→ GET /episodes/21 +→ GET /episodes/21 +→ GET /episodes/21 +→ GET /episodes/21 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/anidbapp/21/sub/anidbapp-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/anidbapp/21/sub/anidbapp-1 +→ GET /watch/animenosub/21/sub/animenosub-1 +→ GET /episodes/182205 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/anizone/21/sub/anizone-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/reanime/182205/sub/reanime-1 +→ GET /watch/anikoto/182205/sub/anikoto-1 +→ GET /watch/anibd/21/sub/anibd-1 +→ GET /watch/anibd/21/sub/anibd-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/reanime/182205/sub/reanime-1 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/allmanga/182205/sub/allmanga-1 +→ GET /watch/reanime/182205/sub/reanime-1 +→ GET /watch/anikoto/182205/sub/anikoto-1 +→ GET /watch/anikoto/21/sub/anikoto-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /episodes/141953 +→ GET /watch/anikoto/182205/sub/anikoto-1 +→ GET /watch/animegg/182205/dub/animegg-1 +→ GET /episodes/141953 +→ GET /watch/animegg/182205/sub/animegg-1 +→ GET /episodes/182205 +→ GET /watch/allmanga/141953/sub/allmanga-1 +→ GET /watch/allmanga/141953/sub/allmanga-1 +→ GET /watch/allmanga/141953/sub/allmanga-1 +→ GET /watch/reanime/141953/sub/reanime-1 +→ GET /watch/anikoto/141953/sub/anikoto-1 +→ GET /episodes/182205 +→ GET /episodes/182205 +→ GET /episodes/182205 +→ GET /episodes/21 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/allmanga/21/sub/allmanga-1 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /watch/anikoto/21/sub/anikoto-1 +→ GET /watch/anikoto/21/dub/anikoto-1 +→ GET /watch/animegg/21/dub/animegg-1 +→ GET /episodes/141953 +→ GET /episodes/141953 +→ GET /watch/animegg/182205/sub/animegg-1 +→ GET /episodes/182205 +→ GET /episodes/182205 +→ GET /watch/allmanga/141953/sub/allmanga-1 +→ GET /watch/allmanga/141953/sub/allmanga-3 +→ GET /watch/allmanga/141953/sub/allmanga-1 +→ GET /watch/reanime/141953/sub/reanime-1 +→ GET /watch/allmanga/141953/sub/allmanga-1 +→ GET /watch/reanime/141953/sub/reanime-3 +→ GET /watch/anikoto/141953/sub/anikoto-1 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET / +→ GET /episodes/182205 +→ GET /episodes/182205 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /episodes/182205 +→ GET /episodes/182205 +→ GET /episodes/182205 +→ GET /watch/animedunya/182205/sub/animedunya-1 +→ GET /episodes/208225 +→ GET /watch/allmanga/208225/sub/allmanga-1 +→ GET /watch/allmanga/208225/sub/allmanga-1 +→ GET /watch/allmanga/208225/sub/allmanga-1 +→ GET /watch/reanime/208225/sub/reanime-1 +→ GET /watch/anikoto/208225/sub/anikoto-1 +→ GET /episodes/21 +→ GET /episodes/195600 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/reanime/195600/sub/reanime-1 +→ GET /watch/anikoto/195600/sub/anikoto-1 +→ GET /episodes/214863 +→ GET /episodes/21 +→ GET /episodes/195600 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/reanime/195600/sub/reanime-1 +→ GET /watch/anikoto/195600/sub/anikoto-1 +→ GET /episodes/21087 +→ GET /watch/allmanga/21087/sub/allmanga-1 +→ GET /watch/allmanga/21087/sub/allmanga-1 +→ GET /watch/allmanga/21087/sub/allmanga-1 +→ GET /watch/reanime/21087/sub/reanime-1 +→ GET /watch/animegg/21087/sub/animegg-1 +→ GET /watch/anineko/21087/sub/anineko-1 +→ GET /watch/anidbapp/21087/sub/anidbapp-1 +→ GET /watch/reanime/21087/sub/reanime-1 +→ GET /watch/anikoto/21087/sub/anikoto-1 +→ GET /watch/kaa/21087/sub/kaa-1 +→ GET /watch/anibd/21087/sub/anibd-1 +→ GET /watch/anizone/21087/sub/anizone-1 +→ GET /watch/animedunya/21087/sub/animedunya-1 +→ GET /episodes/16498 +→ GET /watch/allmanga/16498/sub/allmanga-0 +→ GET /watch/allmanga/16498/dub/allmanga-1 +→ GET /watch/allmanga/16498/sub/allmanga-0 +→ GET /watch/allmanga/16498/dub/allmanga-1 +→ GET /watch/reanime/16498/sub/reanime-1 +→ GET /watch/reanime/16498/dub/reanime-1 +→ GET /episodes/21 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /watch/reanime/21/dub/reanime-1 +→ GET /watch/kaa/21/sub/kaa-1 +→ GET /watch/kaa/21/dub/kaa-1 +→ GET /watch/anikoto/21/sub/anikoto-1 +→ GET /watch/anikoto/21/dub/anikoto-1 +→ GET /watch/animegg/21/sub/animegg-1 +→ GET /watch/animegg/21/dub/animegg-1 +→ GET /watch/anineko/21/sub/anineko-1 +→ GET /watch/anineko/21/dub/anineko-1 +→ GET /watch/reanime/21/sub/reanime-1 +→ GET /watch/reanime/21/dub/reanime-1 +→ GET /episodes/195600 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/reanime/195600/sub/reanime-1 +→ GET /watch/anikoto/195600/sub/anikoto-1 +→ GET /episodes/113415 +→ GET /watch/allmanga/113415/sub/allmanga-1 +→ GET /watch/allmanga/113415/sub/allmanga-1 +→ GET /watch/allmanga/113415/sub/allmanga-1 +→ GET /watch/reanime/113415/sub/reanime-1 +→ GET /watch/anikoto/113415/sub/anikoto-1 +→ GET /watch/anikoto/113415/dub/anikoto-1 +→ GET /episodes/201514 +→ GET /watch/allmanga/201514/sub/allmanga-1 +→ GET /watch/allmanga/201514/sub/allmanga-1 +→ GET /watch/allmanga/201514/sub/allmanga-1 +→ GET /watch/reanime/201514/sub/reanime-1 +→ GET /episodes/16498 +→ GET /watch/allmanga/16498/sub/allmanga-0 +→ GET /watch/allmanga/16498/sub/allmanga-0 +→ GET /watch/allmanga/16498/sub/allmanga-0 +→ GET /watch/reanime/16498/sub/reanime-1 +→ GET /watch/anikoto/16498/sub/anikoto-1 +→ GET /watch/anikoto/16498/sub/anikoto-2 +→ GET /episodes/195600 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/reanime/195600/sub/reanime-1 +→ GET /watch/anikoto/195600/sub/anikoto-1 +→ GET /watch/anikoto/195600/sub/anikoto-1 +→ GET /episodes/147105 +→ GET /watch/allmanga/147105/sub/allmanga-1 +→ GET /watch/allmanga/147105/sub/allmanga-1 +→ GET /watch/allmanga/147105/sub/allmanga-1 +→ GET /watch/reanime/147105/sub/reanime-1 +→ GET /watch/anikoto/147105/sub/anikoto-1 +→ GET /watch/anikoto/147105/sub/anikoto-1 +→ GET /episodes/147105 +→ GET /watch/animegg/147105/sub/animegg-1 +→ GET /watch/anineko/147105/sub/anineko-1 +→ GET /watch/anidbapp/147105/sub/anidbapp-1 +→ GET /watch/animenosub/147105/sub/animenosub-1 +→ GET /episodes/147105 +→ GET /watch/animenosub/147105/sub/animenosub-1 +→ GET /episodes/147105 +→ GET /watch/animenosub/147105/sub/animenosub-1 +→ GET /episodes/147105 +→ GET /episodes/195600 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/reanime/195600/sub/reanime-1 +→ GET /watch/anikoto/195600/sub/anikoto-1 +→ GET /watch/animegg/195600/sub/animegg-1 +→ GET /watch/anineko/195600/sub/anineko-1 +→ GET /watch/anidbapp/195600/sub/anidbapp-1 +→ GET /episodes/185874 +→ GET /episodes/185874 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/reanime/195600/sub/reanime-1 +→ GET /episodes/269 +→ GET /episodes/195600 +→ GET /episodes/201514 +→ GET /episodes/136312 +→ GET /episodes/187538 +→ GET /watch/allmanga/187538/sub/allmanga-1 +→ GET /watch/allmanga/187538/sub/allmanga-1 +→ GET /watch/allmanga/187538/sub/allmanga-1 +→ GET /watch/reanime/187538/sub/reanime-1 +→ GET /watch/anikoto/187538/sub/anikoto-1 +→ GET /watch/animegg/187538/sub/animegg-1 +→ GET /watch/anineko/187538/sub/anineko-1 +→ GET /watch/anidbapp/187538/sub/anidbapp-1 +→ GET /watch/animenosub/187538/sub/animenosub-1 +→ GET /episodes/195600 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/allmanga/195600/sub/allmanga-1 +→ GET /watch/reanime/195600/sub/reanime-1 +→ GET /watch/anikoto/195600/sub/anikoto-1 +→ GET /watch/animegg/195600/sub/animegg-1 +→ GET /watch/anineko/195600/sub/anineko-1 +→ GET /watch/anidbapp/195600/sub/anidbapp-1 +→ GET /watch/animenosub/195600/sub/animenosub-1 +→ GET /watch/anizone/195600/sub/anizone-1 +→ GET /watch/anibd/195600/sub/anibd-1 +→ GET /watch/kaa/195600/sub/kaa-1 +→ GET /watch/animedunya/195600/sub/animedunya-1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..15f8fc3e3c68c6f0eb540071f89c12ede7c222d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: anidoom-backend + ports: + - "8000:8000" + environment: + - PYTHONUNBUFFERED=1 + volumes: + - ./backend:/app + restart: unless-stopped + + manga-vault: + build: ./manga-vault + container_name: anidoom-manga-vault + ports: + - "8001:8001" + environment: + - PYTHONUNBUFFERED=1 + volumes: + - ./manga-vault:/app + restart: unless-stopped + + anivexa-api: + build: ./anivexa-api + container_name: anidoom-anivexa-api + ports: + - "8002:8002" + volumes: + - ./anivexa-api:/app + restart: unless-stopped + + moviebox-api: + build: ./moviebox-api + container_name: anidoom-moviebox-api + ports: + - "8003:8003" + volumes: + - ./moviebox-api:/app + restart: unless-stopped + + frontend: + build: ./frontend + container_name: anidoom-frontend + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_URL=http://localhost:8000 + - VITE_STREAM_PROXY_URL=http://localhost:8787 + restart: unless-stopped + depends_on: + - backend diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000000000000000000000000000000000000..a262e6353394a8e7c8ac3208e4bb1f1a7080f465 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,228 @@ +# API + +Base URL: `http://localhost:8000` · Interactive docs at `/docs`. + +All endpoints return JSON. Collection endpoints return: + +```json +{ "page": 1, "perPage": 20, "total": 5000, "hasNextPage": true, "results": [ ... ] } +``` + +## Health & meta + +| Method | Path | Description | +| ------ | ------------- | ------------------------------------ | +| GET | `/api/health` | `{"status":"ok"}` + provider health | +| GET | `/api/img` | Image proxy. `?url=` (host allow-list)| + +## Catalog (AniList + Jikan) + +| Method | Path | Params | Description | +| ------ | --------------------------- | --------------------------------- | ----------- | +| GET | `/api/anime/search` | `q` (required), `page=1` | Full-text search, Jikan-enriched. | +| GET | `/api/anime/trending` | `page=1`, `perPage=20` | Trending now. | +| GET | `/api/anime/popular` | `page=1`, `perPage=20` | All-time most popular. | +| GET | `/api/anime/upcoming` | `page=1`, `perPage=20` | Not-yet-released, most anticipated. | +| GET | `/api/anime/recent` | `page=1`, `perPage=20` | Currently airing / this season. | +| GET | `/api/anime/schedule` | `page=1`, `perPage=20` | Airing schedule (with `airingAt`). | +| GET | `/api/anime/{id}` | — | Full anime details (rich AniList + Jikan fields). | +| GET | `/api/anime/{id}/mal` | — | Raw Jikan full details for the mapped MAL id (`idMal`). | + +### Media shape (catalog) + +```json +{ + "id": 21, + "idMal": 20, + "title": { "romaji": "...", "english": "...", "native": "..." }, + "synopsis": "…", + "coverImage": "https://s4.anilist.co/…/large.jpg", + "bannerImage": "https://s4.anilist.co/…/wide.jpg", + "format": "TV", + "season": "WINTER", + "seasonYear": 2026, + "episodes": 12, + "duration": 24, + "status": "RELEASING", + "score": 85, + "meanScore": 84, + "popularity": 9001, + "genres": ["Action", "Drama"], + "studios": [{ "name": "MAPPA", "isAnimationStudio": true }], + "nextAiringEpisode": { "episode": 3, "airingAt": 1735765200 }, + "startDate": "2026-01-04", + "endDate": null, + "trailer": { "id": "…", "site": "youtube" }, + "relations": [ ... ], + "characters": [ ... ], + "mal": { "score": 8.5, "members": 12345, "synopsis": "…", "url": "https://myanimelist.net/anime/20/…" } +} +``` + +## Streaming (Anivexa + Aniraku) + +| Method | Path | Description | +| ------ | ----------------------------- | ----------- | +| GET | `/api/anime/{id}/episodes` | Episode lists per provider & audio type. | +| GET | `/api/watch/{episodeId:path}` | Resolve m3u8 sources for one episode. | + +### Episodes + +``` +GET /api/anime/178005/episodes +``` + +```json +{ + "anilistId": 178005, + "mappings": { "anilistId": 178005, "malId": 56885, "kitsuId": "..." }, + "providers": [ + { "name": "anikoto", "sub": [ ... ], "dub": [ ... ] }, + { "name": "allmanga", "sub": [ ... ], "dub": [] }, + { "name": "reanime", "sub": [ ... ], "dub": [] }, + { "name": "aniraku", "sub": [ ... ], "dub": [] } // fallback, when Anivexa returns nothing + ] +} +``` + +Providers come from the **Anivexa sidecar** (`anikoto`, `allmanga`, `reanime`, +`anizone`, … 13 total) — the primary source. `aniraku` is a synthetic fallback +provider added when Anivexa returns nothing. Miruro providers appear only when +`MIRURO_ENABLED=true` (disabled by default — upstream 403s without a fresh +`cf_clearance`). Episode ids keep the same +`watch/{provider}/{anilistId}/{sub|dub}/{ref}` shape — the frontend uses them +directly in the watch route. + +### Sources + +``` +GET /api/watch/watch/kiwi/178005/sub/animepahe-1 +GET /api/watch/watch/anikoto/178005/sub/anikoto-1 +GET /api/watch/watch/aniraku/178005/sub/1 +``` + +```json +{ + "streams": [ + { "url": "https://.../master.m3u8", "type": "hls", "quality": "1080p", + "server": "vidcloud", "referer": "https://anikototv.to/" } + ], + "subtitles": [ { "file": "https://.../en.vtt", "label": "English", "kind": "captions" } ], + "intro": { "start": 0, "end": 90 }, + "outro": { "start": 1300, "end": 1420 }, + "provider": "anikoto", + "headers": { "Referer": "https://anikototv.to/" } +} +``` + +Stream resolution dispatches by provider: `aniraku` → Aniraku `/servers` + +`/stream`, Anivexa providers → Anivexa `/watch/...`, Miruro pipe → only when +`MIRURO_ENABLED=true`, anything else → `404`. `streams[].referer` (when +present) is forwarded to the HLS worker via `/hls?url=...&ref=...` so segments +fetch with the right Referer. + +## Movies & TV (vendored MovieBox-API sidecar) + +The movies endpoints are served by a **vendored copy of +[`DavidCyril1/moviebox-api`](https://github.com/DavidCyril1/moviebox-api)** +(`moviebox-api/`, run on `MOVIEBOX_URL`, default `:8003`) — a Node/Express +server that talks to MovieBox.ph's mobile BFF (`wefeed-h5-bff`) with an +app-like session. The backend (`app/providers/moviebox.py`) proxies it and +normalizes the shapes: home sections, paged catalogs, search, deep details, +signed MP4 stream URLs and captions. + +The sidecar was patched to add catalog/search/suggest/detail routes on +MovieBox's **web** BFF (`h5-api.aoneroom.com`), and its `/api/stream?url=` +proxy fetches signed MP4s with frontend-mirror Referer/Origin headers — this +bypasses the CDN rate-limit (429) that blocks bare browser requests and +Cloudflare egress, so movies actually play. + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `/api/movies/home` | Homepage: banner + genre/subject rows. | +| GET | `/api/movies/catalog` | Paged catalog. `type=movie\|tv\|animation`, `page`, `sort=RECOMMEND\|HOT\|NEW\|RATING\|POPULAR\|LATEST`. | +| GET | `/api/movies/search?q=` | Full-text search across movies, series & animation. | +| GET | `/api/movies/suggest?q=` | Autocomplete suggestions (titles only — search to deep-link). | +| GET | `/api/movies/{slug}` | Full metadata for one title. | +| GET | `/api/movies/{slug}/stream` | Direct MP4/HLS sources + captions. `se`/`ep` for series episodes. | + +### Movie shapes + +```jsonc +// /api/movies/home → [{ key, title, items: [{ id, slug, title, cover, rating, year, badge }] }] +// /api/movies/catalog → { page, perPage, total, hasNextPage, results: [ …cards ] } +// /api/movies/{slug} → +{ + "id": "9048868765454191080", "slug": "dune-WLVlz3JUrMa", "title": "Dune", + "description": "…", "cover": "https://pbcdnw.aoneroom.com/…", "banner": "…", + "genres": ["Action", "Adventure", "Sci-Fi"], "country": "USA", + "rating": "6.2", "imdbCount": null, "releaseDate": "…", "year": "2021", + "duration": 137, "type": 1, // 1 = movie, 2 = TV series (inferred from seasons) + "audioTracks": [ … ], "hasResource": false, "trailer": "", + "cast": [{ "name": "…", "character": "…", "avatar": "https://…" }], + "seasons": [{ "se": 1, "maxEp": 10 }, …] // empty for movies +} +// /api/movies/{slug}/stream → +{ + "subjectId": "…", "se": 0, "ep": 0, "hasResource": true, "note": null, + "freeEpisodes": null, "limited": false, + "streams": [{ "url": "http://127.0.0.1:8003/api/stream?url=…", "resolution": "1080p", + "format": "MP4", "size": "…", "duration": null, "type": "mp4", + "direct": true }], // direct = already proxied by the sidecar; play as-is + "captions": [{ "lang": "English", "label": "English", "url": "https://cacdn.hakunaymatata.com/…srt" }] +} +``` + +Stream URLs point at the **sidecar's own `/api/stream` proxy** (the player +plays them directly — no HLS-worker hop). Streams are signed with an expiry, +so the backend caches them briefly (`short_cache`, 60s). Captions arrive +inline with the stream response; the frontend converts them to WebVTT +client-side. Posters are proxied through `/api/img` (moviebox hosts are in +`IMG_PROXY_ALLOW`). + +## Manga (MangaVault sidecar) + +The manga endpoints proxy the vendored MangaVault sidecar (`manga-vault/`, +run on `MANGA_VAULT_URL`, default `:8001`). All three sources are aggregated: +`nato` (Manganato), `atsu` (Atsumaru), `comix` (Comix). + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `/api/manga/home` | Aggregated trending/latest sections from every enabled source. | +| GET | `/api/manga/search?q=` | Search across Atsumaru + Comix (Manganato has no search API). | +| GET | `/api/manga/{source}/{id}/details` | Metadata + chapter list for one manga. | +| GET | `/api/manga/chapter-images?path=` | Page image URLs for a chapter (`path` = chapter `imagePath`). | +| GET | `/api/manga/img?url=&src=` | Proxy chapter images with the source's Referer header. | + +### Manga shapes + +```jsonc +// /api/manga/home → [{ key, title, items: [{ source, id, title, cover, latest }] }] +// /api/manga/{source}/{id}/details → +{ + "source": "nato", "id": "bitch-im-a-young-lady-with-hax", + "title": "…", "cover": "https://…", "description": "…", + "authors": "…", "status": "Ongoing", "genres": ["Action"], + "views": "…", "updated": "…", + "chapters": [ + { "id": "chapter-313", "number": null, + "title": "Chapter 313", + "imagePath": "/nato/manga/bitch-…/chapter-313/images" } + ] +} +// /api/manga/chapter-images?path=… → ["https://cdn…/p1.jpg", …] +``` + +`chapter.images` is a *relative* manga-vault path — pass it straight to +`/api/manga/chapter-images`; the reader loads each page via `/api/manga/img`. + +### Errors + +| Status | Meaning | +| ------ | ------- | +| 400 | Bad request / malformed episode id | +| 401 | (Worker only) missing/invalid `x-stream-key` | +| 403 | Miruro Cloudflare block (only when `MIRURO_ENABLED=true`) — re-mint `cf_clearance` | +| 429 | Upstream rate-limited (AniList/Jikan) | +| 502 | Upstream provider (Anivexa/Aniraku) failed | +| 503 | Metadata provider error | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..86da94f35f835c343574d39be07999baf5e914bf --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,100 @@ +# Architecture + +## Overview + +anidoom is split into three deployable pieces plus docs: + +1. **`backend/`** — FastAPI application. + - **Catalog layer**: queries **AniList GraphQL** (search, trending, popular, upcoming, seasonal, details) and **Jikan** (MyAnimeList enrichment: synopsis, MAL scores, MAL images) and merges them into a unified media shape. + - **Streaming layer**: aggregates the **Anivexa-API sidecar** (`anivexa-api/` on `:8002`, 13 providers) into per-provider episode lists and resolves direct m3u8 URLs, with an **Aniraku** hosted fallback when Anivexa has nothing. (The original **Miruro pipe** provider is disabled by default — `MIRURO_ENABLED=false` — because upstream now 403s every call even with a fresh `cf_clearance`.) +2. **`anivexa-proxy/`** — vendored [`walterwhite-69/Anivexa-Proxy`](https://github.com/walterwhite-69/Anivexa-Proxy), a zero-dependency **stream proxy** (Cloudflare Worker / Node). + - The m3u8/mpd/segments live on provider CDNs (animepahe, etc.). The proxy fetches them with the right `Referer`/`Origin` (spoofed browser headers), **rewrites m3u8 + DASH playlist URIs** so every segment/variant/init also flows through the proxy, streams bodies efficiently, and forwards `Range` requests so seeking works. Path-agnostic — the frontend calls it as `/hls?url=&ref=`. + - Optional `STREAM_KEY` auth (anidoom patch) — see `docs/CLOUDFLARE.md`. +3. **`frontend/`** — React SPA. Talks only to the backend REST API and the worker's `/hls` endpoint. Plays m3u8 with **hls.js**. + +``` +Browser (React SPA) + │ fetch /api/* + ▼ +FastAPI backend ──────▶ AniList GraphQL (graphql.anilist.co) + │ metadata Jikan (api.jikan.moe/v4) + │ + │ plain HTTP + ▼ +Anivexa-API sidecar (:8002, 13 providers) ──▶ episode list + m3u8 URLs + │ (fallback: Aniraku hosted backend — servers + /stream) + │ (Miruro pipe: disabled unless MIRURO_ENABLED=true) + │ +Browser (hls.js) ────▶ Anivexa-Proxy /hls?url=&ref= ──▶ provider CDN + │ (rewrites m3u8/mpd → segments via proxy) + ▼ + provider CDN (.ts/.m4s/.vtt/.key) +``` + +## Data flow: finding a stream + +The backend exposes a 3-step flow: + +``` +1. GET /api/anime/{anilistId}/episodes + └─ Anivexa sidecar → providers: [ { name: "anikoto", sub: [...], dub: [...] }, { name: "allmanga", ... }, ... ] + Each episode has an id like "watch/anikoto/178005/sub/anikoto-1" + (aniraku fallback adds a synthetic provider when this list is empty) + +2. GET /api/watch/{episodeId} (episodeId is the full path, e.g. watch/anikoto/178005/sub/anikoto-1) + └─ Anivexa /watch (or Aniraku /servers+/stream) → { streams: [{ url, type, quality, referer }], + subtitles: [{ file, label, kind }], intro: { start, end }, outro: { start, end } } + +3. Frontend feeds streams[0].url (an m3u8) into hls.js through the proxy: + PLAYER_URL = https://anidoom-proxy.shawnmwask1234.workers.dev/hls?url=[&ref=] + The proxy rewrites the playlist so every segment request also goes through it. +``` + +## Why curl_cffi + cf_clearance (not requests/httpx) for Miruro + +> **Status: disabled by default.** The Miruro provider (`backend/app/providers/miruro.py`) is only queried when `MIRURO_ENABLED=true`. Upstream currently returns **403 on every pipe call** even with a cookie present — the clearance expires within hours/days and must be re-minted (see `docs/CLOUDFLARE.md`). Until then, streaming runs entirely on Anivexa + Aniraku, which need only plain HTTP. The notes below document the Miruro path for when you re-enable it. + +Miruro's pipe endpoint is protected by Cloudflare. Cloudflare checks, among other things: + +- **TLS fingerprint (JA3/JA4)** — standard `requests`/`httpx` TLS stacks are instantly flagged. +- **`cf_clearance` cookie** — minted when a real browser solves the JS challenge; bound to your **IP + User-Agent + TLS fingerprint**. +- **IP reputation** — datacenter ranges (Vercel, Render, AWS Lambda, **Cloudflare Workers**) get hard `403`s. + +The backend therefore: + +- Uses `curl_cffi` with `impersonate="chrome110"` (browser-grade TLS fingerprint). +- Sends a full same-origin header set (`sec-ch-ua`, `sec-fetch-*`, matching `Referer`/`Origin`). +- Reuses a `cf_clearance` cookie from `CF_CLEARANCE` env var, minted once by a real browser (see `scripts/mint_cf_clearance.py` and `docs/CLOUDFLARE.md`). +- Detects challenge responses (`cf-mitigated: challenge` header or `challenge-platform` in body) and surfaces a clear error telling you to re-mint. + +> **Hosting note**: run the backend on your own machine or a VPS with a *clean, non-datacenter* IP. Miruro's WAF blocks known cloud-provider IP ranges. + +## The Miruro pipe protocol (reverse-engineered) + +Derived from the open-source [`walterwhite-69/Miruro-API`](https://github.com/walterwhite-69/Miruro-API) project (see `docs/DATA_SOURCES.md`): + +- **Request**: `GET https://www.miruro.tv/api/secure/pipe?e=` + - `payload = base64url( json.dumps({ "path": ..., "method": "GET", "query": {...}, "body": null, "version": "0.1.0" }) )` with `=` padding stripped. + - `path` is `"episodes"` (query: `{"anilistId": }`) or the episode id itself (e.g. `watch/kiwi/178005/sub/animepahe-1`) to resolve sources. +- **Response**: `gzip.decompress( base64url_decode(body) )` → JSON. +- Domain rotation across `www.miruro.tv` / `miruro.to` / `miruro.ru` / `miruro.bz` is supported via `MIRURO_DOMAINS`. + +## Frontend design notes + +- React Router routes: `/`, `/search`, `/anime/:id`, `/watch/:episodeId`, `/manga`. +- `/watch/:episodeId` takes the full episode path (slashes included) as a single param; the router encodes it. +- hls.js handles the m3u8; quality selection maps to `Hls.levels`; skip-intro/outro buttons use the `intro`/`outro` timestamps returned by the sources endpoint. +- All image URLs are proxied by the backend (`/api/img?url=...`) to avoid referer/hotlink issues and let us swap posters between AniList/MAL. + +## Extending: adding a Malkan provider + +The metadata layer is provider-agnostic. A provider module implements a small async interface (see `backend/app/providers/`): + +```python +class MetadataProvider(Protocol): + async def search(self, query: str, page: int = 1) -> list[dict]: ... + async def details(self, media_id: int) -> dict | None: ... + async def trending(self, page: int = 1) -> list[dict]: ... +``` + +Register it in `catalog.py`'s provider list. When a real Malkan API becomes available, it slots in without touching routers or the frontend. diff --git a/docs/CLOUDFLARE.md b/docs/CLOUDFLARE.md new file mode 100644 index 0000000000000000000000000000000000000000..b87d76a3c34b7b8c87f75ab31ff0b776cf006b35 --- /dev/null +++ b/docs/CLOUDFLARE.md @@ -0,0 +1,107 @@ +# Cloudflare: cf_clearance & the HLS proxy Worker + +Two separate Cloudflare concerns: + +1. **`cf_clearance`** — needed by the *backend* **only if you re-enable the Miruro provider** (disabled by default — `MIRURO_ENABLED=false`). The default streaming stack (Anivexa sidecar + Aniraku fallback) needs no clearance. +2. **Anivexa-Proxy** (vendored, `anivexa-proxy/`) — needed by the *frontend* to play provider-CDN streams without CORS/referer issues. Replaces the old hand-rolled `worker/`. + +--- + +## 1. Getting a `cf_clearance` cookie + +> ⚠️ **Miruro is disabled by default.** Upstream currently returns **403 on +> every pipe call** even with a `cf_clearance` cookie present — the clearance +> expires within hours–days. If you want Miruro as an extra source: mint a +> fresh cookie (below), put it in `backend/.env`, **and** set +> `MIRURO_ENABLED=true` in `backend/.env`. Skip this entire section otherwise. + +Miruro's `https://www.miruro.tv/api/secure/pipe` sits behind Cloudflare. When a real browser visits the site, Cloudflare may issue a JS challenge; on success the browser is given a `cf_clearance` cookie. Cloudflare binds that cookie to **your IP, User-Agent, and TLS fingerprint**, so it must be minted **from the same machine/network** that runs the backend, and reused with the **same User-Agent + `curl_cffi` chrome impersonation** (which the backend does by default). + +### Option A — manual (fastest) + +1. Open `https://www.miruro.tv` in a normal Chrome/Edge browser **on the machine that will run the backend**. +2. Solve any challenge if it appears. +3. DevTools → Application → Cookies → `https://www.miruro.tv` → copy the `cf_clearance` value. +4. Put it in `backend/.env`: + ``` + CF_CLEARANCE=xxxxx.yyyyy.zzzzz + ``` + (value only — no `cf_clearance=` prefix, no quotes) + +### Option B — automated minter script + +`scripts/mint_cf_clearance.py` uses **nodriver** (undetected Chrome automation) to load Miruro, wait for the challenge to clear, extract the cookie, and write it to `backend/.env`. + +```bash +pip install nodriver +python scripts/mint_cf_clearance.py +``` + +The script prints the cookie and writes `CF_CLEARANCE=...` into `.env` for you. It exits with a clear message if a challenge can't be solved (e.g. Turnstile interactive CAPTCHA — solve those manually). + +### Why it fails sometimes (pitfalls) + +| Symptom | Cause / fix | +| ------- | ----------- | +| `403` from pipe, or `cf-mitigated: challenge` | Cookie expired (typically hours–days) → re-mint. | +| Re-challenge loop when reusing a cookie | You switched IP (VPN/proxy) or UA → re-mint from the *same* network & browser. | +| `403` on a VPS/cloud host | Datacenter IP blocked by WAF → use a residential IP or a VPS provider with clean ranges. | +| Works from browser, fails from code | `requests`/`httpx` TLS fingerprints are flagged → the backend already uses `curl_cffi` (chrome110). | + +The backend detects challenges: any response with header `cf-mitigated: challenge` or `challenge-platform` in the body raises a `403` with `detail.hint` explaining to re-mint. + +--- + +## 2. Anivexa-Proxy (stream proxy — replaces the old worker/) + +Stream providers return m3u8/mpd URLs that point at **provider CDNs** (animepahe, anikoto, …). Those CDNs are not Cloudflare-protected, but they may require a `Referer`/`Origin` and browsers hit CORS issues fetching segments cross-origin. **Anivexa-Proxy** (vendored from [`walterwhite-69/Anivexa-Proxy`](https://github.com/walterwhite-69/Anivexa-Proxy), zero-dependency, Web-Standard APIs) solves both: it fetches upstream with spoofed browser headers (a per-stream `Referer` via `?ref=`) and rewrites playlists so the browser only ever talks to your proxy. + +### How it works + +- **`/hls?url=&ref=`** (path is ignored — `/proxy` works too) — fetch the playlist upstream with browser headers, and rewrite: + - segment + variant lines (`foo/seg-1.ts`, `../master.m3u8`, absolute URLs) → `…/hls?url=&ref=…` + - `#EXT-X-KEY:…URI="…"`, `#EXT-X-MAP:URI="…"` (fMP4 init) and `#EXT-X-I-FRAME-STREAM-INF URI=…` → rewritten through the proxy +- **DASH**: `.mpd` manifests get ``, `initialization`, `media`, `sourceURL` rewritten. +- **MP4/segments** — stream the body through with correct `Content-Type`, forwarding the client's `Range` header (seeking, `206`) and propagating `Content-Range`. +- If no `ref` is given, it defaults to the target URL's origin. + +### Streaming (memory safety) + +Only manifests (small) are read as text and rewritten; media bodies stream through without buffering. + +### Auth (anidoom patch) + +Upstream Anivexa-Proxy is an **open proxy**. Our vendored copy adds optional `STREAM_KEY` auth: + +```bash +cd anivexa-proxy +npx wrangler secret put STREAM_KEY # then send: x-stream-key: from the frontend +``` + +Frontend: `VITE_STREAM_KEY=` (sent as an `x-stream-key` header on every hls.js request — already wired in `api.js`/`HlsPlayer.jsx`). Requests without the key get `401`. With no key configured the proxy stays open (fine for local dev). + +> ⚠️ **Safari native-HLS caveat:** if hls.js is unavailable (rare — hls.js works on Safari via MSE), `HlsPlayer` falls back to a native `