mwask commited on
Commit
c9c8bbf
·
verified ·
1 Parent(s): bd48297

Upload 55 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +25 -0
  2. Dockerfile +28 -0
  3. README.md +112 -0
  4. README_HF.md +50 -0
  5. anivexa-api/.dockerignore +14 -0
  6. anivexa-api/Dockerfile +13 -0
  7. anivexa-api/README.md +109 -0
  8. anivexa-api/api/handler.js +5 -0
  9. anivexa-api/api/index.js +25 -0
  10. anivexa-api/core/anilist.js +152 -0
  11. anivexa-api/core/episode-cache.js +212 -0
  12. anivexa-api/core/episode-strategy.js +271 -0
  13. anivexa-api/core/mapper.js +144 -0
  14. anivexa-api/core/new-provider-utils.js +227 -0
  15. anivexa-api/core/smartcache.js +230 -0
  16. anivexa-api/docs/index.html +786 -0
  17. anivexa-api/docs/landing.html +345 -0
  18. anivexa-api/docs/logo.svg +14 -0
  19. anivexa-api/docs/style.css +757 -0
  20. anivexa-api/index.js +302 -0
  21. anivexa-api/package.json +9 -0
  22. anivexa-api/providers/2dhive.js +270 -0
  23. anivexa-api/providers/allmanga.js +757 -0
  24. anivexa-api/providers/anibd.js +175 -0
  25. anivexa-api/providers/anidbapp.js +358 -0
  26. anivexa-api/providers/anikoto.js +523 -0
  27. anivexa-api/providers/animedunya.js +187 -0
  28. anivexa-api/providers/animegg.js +229 -0
  29. anivexa-api/providers/animenosub.js +365 -0
  30. anivexa-api/providers/anineko.js +183 -0
  31. anivexa-api/providers/anizone.js +296 -0
  32. anivexa-api/providers/kickassanime.js +318 -0
  33. anivexa-api/providers/reanime.js +756 -0
  34. anivexa-api/providers/senshi.js +213 -0
  35. anivexa-api/proxy/worker.js +53 -0
  36. anivexa-api/proxy/wrangler.toml +3 -0
  37. anivexa-api/run.bat +7 -0
  38. anivexa-api/run.sh +8 -0
  39. anivexa-api/server.js +78 -0
  40. anivexa-api/sidecar-8002.err.log +170 -0
  41. anivexa-api/sidecar-8002.log +276 -0
  42. docker-compose.yml +57 -0
  43. docs/API.md +228 -0
  44. docs/ARCHITECTURE.md +100 -0
  45. docs/CLOUDFLARE.md +107 -0
  46. docs/DATA_SOURCES.md +191 -0
  47. docs/SETUP.md +225 -0
  48. op_eps.json +0 -0
  49. scripts/mint_cf_clearance.py +97 -0
  50. tmp_watch.json +1 -0
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ .env
7
+ *.egg-info/
8
+
9
+ # Node
10
+ node_modules/
11
+ dist/
12
+ build/
13
+ .wrangler/
14
+ .dev.vars
15
+
16
+ # Editor / OS
17
+ .vscode/
18
+ .idea/
19
+ .DS_Store
20
+ Thumbs.db
21
+
22
+ # Logs / caches
23
+ *.log
24
+ .history/
25
+ .cache/
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS frontend-builder
2
+
3
+ WORKDIR /frontend
4
+
5
+ COPY frontend/package*.json ./
6
+ RUN npm install
7
+
8
+ COPY frontend/ ./
9
+ RUN npm run build
10
+
11
+ FROM python:3.11-slim
12
+
13
+ WORKDIR /app
14
+
15
+ RUN apt-get update && apt-get install -y \
16
+ curl \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ COPY backend/requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ COPY backend/ .
23
+
24
+ COPY --from=frontend-builder /frontend/dist /app/static
25
+
26
+ EXPOSE 7860
27
+
28
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # anidoom
2
+
3
+ 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.)
4
+
5
+ > ⚠️ **Educational / personal-use project.** anidoom aggregates metadata and stream links from third-party sources. Deploy responsibly and respect the upstream sites' terms.
6
+
7
+ ## Stack
8
+
9
+ | Layer | Tech |
10
+ | --------- | ----------------------------------------------------------- |
11
+ | Backend | Python 3.11+ · FastAPI · httpx · curl_cffi |
12
+ | Frontend | React 18 · Vite · react-router · hls.js |
13
+ | Edge | Anivexa-Proxy (Cloudflare Worker — HLS/DASH/MP4 proxy) |
14
+ | Streaming | Anivexa-API sidecar (13 providers) · Aniraku fallback · Miruro pipe (disabled) |
15
+ | Metadata | AniList GraphQL · Jikan (MAL) |
16
+ | Manga | vendored MangaVault sidecar (Manganato/Atsumaru/Comix) |
17
+ | Movies/TV | vendored MovieBox-API sidecar (Node/Express, :8003) with CDN-bypass stream proxy |
18
+
19
+ ```
20
+ ┌────────────┐ ┌──────────────────┐ ┌────────────────────────┐
21
+ │ React SPA │ ───▶ │ FastAPI backend │ ───▶ │ Anivexa-API sidecar │ m3u8
22
+ │ (frontend)│ ◀─── │ (backend) │ │ (13 providers, :8002) │ links
23
+ └────────────┘ └──────────────────┘ └────────────────────────┘
24
+ │ │ │
25
+ │ hls.js │ │
26
+ ▼ ▼ ▼
27
+ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────────┐
28
+ │ Anivexa-Proxy│ │ provider CDNs │ │ Aniraku fallback │
29
+ │ /hls (Worker)│ │ (animepahe…) │ │ (hosted backend) │
30
+ └──────────────┘ └──────────────────┘ └──────────────────────┘
31
+ ```
32
+
33
+ ## Repository layout
34
+
35
+ ```
36
+ anidoom/
37
+ ├── backend/ # FastAPI REST API (metadata + stream aggregation + manga proxy)
38
+ ├── anivexa-proxy/# vendored Anivexa-Proxy (MIT) — HLS/DASH/MP4 proxy, local :8787 / deploy to CF
39
+ ├── frontend/ # React SPA (browse, search, watch, manga + reader)
40
+ ├── manga-vault/# vendored MangaVault sidecar (MIT) — run on :8001
41
+ ├── anivexa-api/# vendored Anivexa-API sidecar (MIT) — run on :8002
42
+ ├── moviebox-api/ # vendored DavidCyril1/moviebox-api (MIT) — Movies & TV sidecar, run on :8003
43
+ ├── moviebox-worker/# reference copy of a MovieBox CF Worker (MIT) — ⚠️ not used; MovieBox 429s Cloudflare egress IPs
44
+ ├── worker/ # ⚠️ RETIRED — old HLS proxy, kept for reference only
45
+ ├── scripts/ # cf_clearance cookie minter
46
+ └── docs/ # architecture, setup, API + Cloudflare docs
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```bash
52
+ # 1. Backend (FastAPI) — see docs/SETUP.md for full details
53
+ cd backend
54
+ python -m venv .venv
55
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
56
+ pip install -r requirements.txt
57
+ cp .env.example .env # streaming defaults: Anivexa + Aniraku (no CF needed)
58
+ uvicorn app.main:app --reload --port 8000
59
+
60
+ # 2. MangaVault sidecar (manga section)
61
+ cd manga-vault
62
+ ./run.bat # Windows (creates venv, serves :8001)
63
+
64
+ # 3. Anivexa-API sidecar (extra streaming providers)
65
+ cd anivexa-api
66
+ ./run.bat # Windows (needs Node.js, serves :8002)
67
+
68
+ # 4. Anivexa-Proxy (HLS/DASH/MP4 proxy — replaces the old worker/)
69
+ cd anivexa-proxy
70
+ npm install # wrangler dev dependency (for deploy)
71
+ npm run dev # local proxy on :8787 (Vite proxies /hls → :8787)
72
+ # Deploy to Cloudflare when ready (one-time, see docs/CLOUDFLARE.md):
73
+ # npx wrangler login
74
+ # npm run deploy # → https://anidoom-proxy.shawnmwask1234.workers.dev/proxy
75
+ # npm run secret:set # optional: STREAM_KEY auth
76
+
77
+ # 4b. Movies & TV sidecar (vendored DavidCyril1/moviebox-api, Node/Express)
78
+ cd moviebox-api
79
+ ./run.bat # Windows (needs Node.js, serves :8003)
80
+
81
+ # 5. Frontend
82
+ cd frontend
83
+ npm install
84
+ cp .env.example .env # VITE_API_URL / VITE_STREAM_PROXY_URL / VITE_STREAM_KEY
85
+ npm run dev # http://localhost:5173
86
+ ```
87
+
88
+ ## Why a proxy worker at all?
89
+
90
+ 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.
91
+
92
+ 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).)
93
+
94
+ See [docs/CLOUDFLARE.md](docs/CLOUDFLARE.md) for the full picture.
95
+
96
+ ## Documentation
97
+
98
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — system design & data flow
99
+ - [docs/SETUP.md](docs/SETUP.md) — full local setup guide
100
+ - [docs/API.md](docs/API.md) — backend REST endpoints
101
+ - [docs/CLOUDFLARE.md](docs/CLOUDFLARE.md) — Cloudflare clearance + Worker deployment
102
+ - [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md) — AniList / Jikan / Miruro / Malkan notes
103
+
104
+ ## Roadmap
105
+
106
+ - [x] Anime metadata (AniList + MAL via Jikan)
107
+ - [x] Miruro episode/source resolution (m3u8)
108
+ - [x] HLS proxy worker + watch page
109
+ - [x] Manga section — MangaVault sidecar (Manganato/Atsumaru/Comix) + reader
110
+ - [x] Multi-source streaming — Anivexa (13 providers) + Aniraku fallback (Miruro disabled pending clearance)
111
+ - [ ] Malkan provider (pluggable; see docs/DATA_SOURCES.md)
112
+ - [x] Continue watching (local progress tracking + resume row on Home)
README_HF.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # anidoom - Hugging Face Space
2
+
3
+ Anime streaming platform deployed on Hugging Face Spaces.
4
+
5
+ ## What's Deployed
6
+
7
+ This Space includes:
8
+ - **Backend**: FastAPI API with AniList + MAL metadata
9
+ - **Frontend**: React SPA (built and served as static files)
10
+ - **Streaming**: Aniraku fallback (no sidecar needed)
11
+
12
+ ## What's NOT Deployed
13
+
14
+ Due to Hugging Face Space limitations, the following sidecar services are **not** included:
15
+ - `manga-vault` (manga section)
16
+ - `anivexa-api` (13 streaming providers)
17
+ - `moviebox-api` (movies & TV)
18
+
19
+ These services need to be deployed separately (see below).
20
+
21
+ ## Deployment Options for Sidecars
22
+
23
+ ### Option 1: Deploy sidecars to other platforms
24
+ - **manga-vault**: Deploy to Railway, Render, or any Python hosting
25
+ - **anivexa-api**: Deploy to Railway, Render, or any Node.js hosting
26
+ - **moviebox-api**: Deploy to Railway, Render, or any Node.js hosting
27
+
28
+ Then configure the backend `.env` to point to those external URLs:
29
+ ```
30
+ MANGA_VAULT_URL=https://your-manga-vault-url.com
31
+ ANIVEXA_API_URL=https://your-anivexa-api-url.com
32
+ MOVIEBOX_API_URL=https://your-moviebox-api-url.com
33
+ ```
34
+
35
+ ### Option 2: Use the existing Aniraku fallback
36
+ The backend already includes Aniraku as a fallback streaming source, so anime streaming will work without the sidecars (just fewer provider options).
37
+
38
+ ## Environment Variables
39
+
40
+ Set these in the Space's Settings > Secrets:
41
+ - `MIRURO_ENABLED`: Set to `true` if you have cf_clearance cookies (default: false)
42
+ - `CORS_ORIGINS`: Comma-separated list of allowed origins (default: *)
43
+
44
+ ## Local Development
45
+
46
+ See the main [README.md](README.md) for full local development setup with all sidecars.
47
+
48
+ ## License
49
+
50
+ Educational / personal-use project. See individual component licenses.
anivexa-api/.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ node_modules
4
+ npm-debug.log
5
+ *.log
6
+ .env
7
+ .env.local
8
+ .env.*.local
9
+ sidecar-*.log
10
+ sidecar-*.err.log
11
+ Dockerfile
12
+ .dockerignore
13
+ README.md
14
+ docs
anivexa-api/Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package*.json ./
6
+ RUN npm install --production
7
+
8
+ COPY . .
9
+
10
+ ENV PORT=8002
11
+ EXPOSE 8002
12
+
13
+ CMD ["npm", "start"]
anivexa-api/README.md ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+
4
+ <img src="docs/logo.svg" width="80" height="80"/>
5
+
6
+
7
+ # Anivexa API 2.2
8
+
9
+ **Anime streaming aggregator API — one endpoint, all your sources.**
10
+
11
+ ![Views](https://visitor-badge.laobi.icu/badge?page_id=walterwhite-69.Anivexa-API)
12
+ [![Discord](https://img.shields.io/badge/Join%20Discord-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/MARQ9z9QSX)
13
+ [![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)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## What is this?
20
+
21
+ 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.
22
+
23
+ It's the backbone powering **[Anivexa](https://github.com/walterwhite-69/Anivexa)**, a full anime streaming client built on top of this.
24
+
25
+ ---
26
+
27
+ ## Providers
28
+
29
+ | Provider | Status | Notes |
30
+ |---|---|---|
31
+ | **AllManga** | ✅ Active | Large Library |
32
+ | **AnimePahe** | ❌ Removed | Cloudflare JS Challenge — no reliable bypass |
33
+ | **Reanime** | ✅ Active | Solid source for a wide range of titles |
34
+ | **AniKoto** | ✅ Active | Good library, consistent |
35
+ | **AnimeGG** | ✅ Active | Fuzzy title matching + compact-query fix for sequels (e.g. Re:Zero S4) |
36
+ | **AniNeko** | ✅ Active | Reliable slug-based matching |
37
+ | **AniDB App** | ✅ Active | Language-aware, AniDB ID backed |
38
+ | **AniZone** | ✅ Active | HLS + subtitles, sub-only; year-based re-scoring prevents wrong-season matches |
39
+ | **2dhive** | ✅ Active | Uses MAL ID internally; AniList ID used everywhere else |
40
+ | **Anibd** | ✅ Active | Uses Anilist ID internally; AniList ID used everywhere else |
41
+ | **Kickassanime** | ✅ Active | Fuzzy search, medium library |
42
+ | **AnimeDunya** | ✅ Active | HLS + subtitles, sub-only, MAL ID backed |
43
+
44
+ ---
45
+
46
+ ## Routes
47
+
48
+ ```
49
+ GET /map/:anilistId
50
+ ```
51
+ Returns cross-platform ID mappings — MAL, TVDB, TMDB, Kitsu, AniDB, and more.
52
+
53
+ ```
54
+ GET /episodes/:anilistId
55
+ GET /episodes/:provider[/:provider...]/:anilistId
56
+ ```
57
+ 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.
58
+
59
+ ```
60
+ GET /watch/:provider/:anilistId/sub|dub/:provider-:ep
61
+ ```
62
+ Returns stream URLs for a specific episode from a specific provider.
63
+
64
+ ```
65
+ GET /stream/reanime/:id/sub|dub/:ep
66
+ ```
67
+ 302 redirect directly to the HLS stream.
68
+
69
+ ---
70
+
71
+ ## Self-hosted
72
+
73
+ ```bash
74
+ git clone https://github.com/walterwhite-69/Anivexa-API
75
+ cd Anivexa-API
76
+ node server.js
77
+ ```
78
+
79
+ Runs on Node.js. No build step needed.
80
+
81
+ ---
82
+
83
+ ## Deploying on Vercel
84
+
85
+ > ⚠️ **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!
86
+
87
+ ---
88
+
89
+ ## Contributing
90
+
91
+ > **Only request providers that self-host their content. No scrapers of third-party sites.**
92
+
93
+ Got a provider you'd like added? Open an issue or drop it in the Discord.
94
+
95
+ This project is community-kept-alive — if it helps you, please:
96
+
97
+ - ⭐ **Star the repo** so others can find it
98
+ - 💬 **[Join the Discord](https://discord.gg/MARQ9z9QSX)** to discuss, report issues, or suggest providers
99
+ - 🛠️ **Open a PR** if you want to add or fix something
100
+
101
+ ---
102
+
103
+ <div align="center">
104
+
105
+ hope it helped :3
106
+
107
+ [![Discord](https://img.shields.io/badge/Join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/MARQ9z9QSX)
108
+
109
+ </div>
anivexa-api/api/handler.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import worker from "../index.js";
2
+
3
+ export const config = { runtime: "edge" };
4
+
5
+ export default (request) => worker.fetch(request, {});
anivexa-api/api/index.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import worker from "../index.js";
2
+
3
+ export default async function handler(req, res) {
4
+ const host = req.headers["host"] ?? "localhost";
5
+ const url = `https://${host}${req.url}`;
6
+
7
+ const chunks = [];
8
+ for await (const chunk of req) chunks.push(chunk);
9
+ const body = chunks.length ? Buffer.concat(chunks) : null;
10
+
11
+ const request = new Request(url, {
12
+ method: req.method,
13
+ headers: req.headers,
14
+ body: body?.length ? body : undefined,
15
+ duplex: "half",
16
+ });
17
+
18
+ const response = await worker.fetch(request, {});
19
+
20
+ res.statusCode = response.status;
21
+ for (const [k, v] of response.headers) res.setHeader(k, v);
22
+
23
+ const buf = await response.arrayBuffer();
24
+ res.end(Buffer.from(buf));
25
+ }
anivexa-api/core/anilist.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const __name = (fn, _) => fn;
2
+
3
+ var resolved = new Map();
4
+ var inflight = new Map();
5
+ 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";
6
+ var ARM = "https://arm.haglund.dev/api/v2/ids";
7
+ var JIKAN = "https://api.jikan.moe/v4";
8
+ var STATUS_MAP = {
9
+ "Currently Airing": "RELEASING",
10
+ "Finished Airing": "FINISHED",
11
+ "Not yet aired": "NOT_YET_RELEASED",
12
+ "On Hiatus": "HIATUS"
13
+ };
14
+
15
+ const AL_STATUS_MAP = {
16
+ RELEASING: "RELEASING",
17
+ FINISHED: "FINISHED",
18
+ NOT_YET_RELEASED: "NOT_YET_RELEASED",
19
+ CANCELLED: "FINISHED",
20
+ HIATUS: "HIATUS",
21
+ };
22
+
23
+ async function fetchFromAniList(id) {
24
+ 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}}}`;
25
+ const res = await fetch("https://graphql.anilist.co", {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": UA },
28
+ body: JSON.stringify({ query: fullQuery, variables: { id } }),
29
+ }).catch(() => null);
30
+ if (!res || !res.ok) return null;
31
+ const json = await res.json();
32
+ return json.data?.Media ?? null;
33
+ }
34
+
35
+ async function getMedia(anilistId) {
36
+ const id = Number(anilistId);
37
+ if (resolved.has(id)) return resolved.get(id);
38
+ if (inflight.has(id)) return inflight.get(id);
39
+ const promise = (async () => {
40
+ const arm = await fetch(`${ARM}?source=anilist&id=${id}`, {
41
+ headers: { "User-Agent": UA, "Accept": "application/json" }
42
+ }).then((r) => {
43
+ if (!r.ok) return null;
44
+ return r.json();
45
+ }).catch(() => null);
46
+
47
+ const malId = arm?.myanimelist ?? null;
48
+
49
+ if (!malId) {
50
+ const al = await fetchFromAniList(id);
51
+ if (!al) throw new Error(`No data found for AniList ID ${id}`);
52
+ const media = {
53
+ id,
54
+ idMal: null,
55
+ title: {
56
+ english: al.title?.english ?? null,
57
+ romaji: al.title?.romaji ?? null,
58
+ native: al.title?.native ?? null,
59
+ },
60
+ status: AL_STATUS_MAP[al.status] ?? "RELEASING",
61
+ format: al.format ?? null,
62
+ episodes: al.episodes ?? null,
63
+ seasonYear: al.seasonYear ?? null,
64
+ startDate: al.startDate ?? null,
65
+ nextAiringEpisode: al.nextAiringEpisode ?? null,
66
+ synonyms: Array.isArray(al.synonyms) ? al.synonyms : [],
67
+ };
68
+ resolved.set(id, media);
69
+ inflight.delete(id);
70
+ return media;
71
+ }
72
+
73
+ const al = await fetchFromAniList(id).catch(() => null);
74
+ let jikan = null;
75
+ for (let attempt = 0; attempt <= 4; attempt++) {
76
+ const r = await fetch(`${JIKAN}/anime/${malId}`, { headers: { "User-Agent": UA, Accept: "application/json" } });
77
+ if (r.status === 429) {
78
+ const wait = (parseInt(r.headers.get("Retry-After") ?? "1") || 1) * 1e3 + attempt * 500;
79
+ if (attempt < 4) {
80
+ await new Promise((res) => setTimeout(res, wait));
81
+ continue;
82
+ }
83
+ throw new Error(`Jikan 429 for MAL ID ${malId} (exhausted retries)`);
84
+ }
85
+ // On 5xx / network errors, fall back to AniList-only data if available rather than hard-failing.
86
+ if (!r.ok) {
87
+ if (al) break; // exit loop, jikan stays null, fall through to AniList fallback below
88
+ throw new Error(`Jikan ${r.status}`);
89
+ }
90
+ jikan = await r.json();
91
+ break;
92
+ }
93
+ const d = jikan?.data ?? null;
94
+ // If Jikan was unavailable but we have AniList data, build a partial media object from AniList only.
95
+ if (!d && al) {
96
+ const media = {
97
+ id,
98
+ idMal: malId,
99
+ title: {
100
+ english: al.title?.english ?? null,
101
+ romaji: al.title?.romaji ?? null,
102
+ native: al.title?.native ?? null,
103
+ },
104
+ status: AL_STATUS_MAP[al.status] ?? "RELEASING",
105
+ format: al.format ?? null,
106
+ episodes: al.episodes ?? null,
107
+ seasonYear: al.seasonYear ?? null,
108
+ startDate: al.startDate ?? null,
109
+ nextAiringEpisode: al.nextAiringEpisode ?? null,
110
+ synonyms: Array.isArray(al.synonyms) ? al.synonyms : [],
111
+ };
112
+ resolved.set(id, media);
113
+ inflight.delete(id);
114
+ return media;
115
+ }
116
+ if (!d) throw new Error(`Jikan returned no data for MAL ID ${malId}`);
117
+ const media = {
118
+ id,
119
+ idMal: malId,
120
+ title: {
121
+ english: al?.title?.english ?? d.title_english ?? null,
122
+ romaji: al?.title?.romaji ?? d.title ?? null,
123
+ native: al?.title?.native ?? d.title_japanese ?? null,
124
+ },
125
+ status: AL_STATUS_MAP[al?.status] ?? STATUS_MAP[d.status] ?? "RELEASING",
126
+ format: al?.format ?? d.type ?? null,
127
+ episodes: al?.episodes ?? d.episodes ?? null,
128
+ seasonYear: al?.seasonYear ?? d.year ?? null,
129
+ startDate: al?.startDate ?? (d.aired?.from ? { year: new Date(d.aired.from).getFullYear() } : null),
130
+ nextAiringEpisode: al?.nextAiringEpisode ?? null,
131
+ synonyms: [
132
+ ...(d.titles?.map((t) => t.title).filter(Boolean) ?? []),
133
+ ...(Array.isArray(al?.synonyms) ? al.synonyms : []),
134
+ ],
135
+ };
136
+ resolved.set(id, media);
137
+ inflight.delete(id);
138
+ return media;
139
+ })().catch((e) => {
140
+ inflight.delete(id);
141
+ throw e;
142
+ });
143
+ inflight.set(id, promise);
144
+ return promise;
145
+ }
146
+ __name(getMedia, "getMedia");
147
+
148
+ function forgetMedia(anilistId) {
149
+ resolved.delete(Number(anilistId));
150
+ }
151
+
152
+ export { getMedia, forgetMedia };
anivexa-api/core/episode-cache.js ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { forgetMedia, getMedia } from "./anilist.js";
2
+ import { mapAnimeIds } from "./mapper.js";
3
+ import { buildEpisodesWithCache, buildFilteredEpisodesWithCache } from "./episode-strategy.js";
4
+ import { get, set, getAsync, setAsync, needsRefresh, delAsync, delByPrefixAsync } from "./smartcache.js";
5
+
6
+ const ANIZIP = "https://api.ani.zip/mappings";
7
+ const MIN = 60_000;
8
+ const HOUR = 60 * MIN;
9
+ const DAY = 24 * HOUR;
10
+ const FULL_TTL = 30 * DAY;
11
+ const NORMAL_PROBE_INTERVAL = 15 * MIN;
12
+ const AIRING_PROBE_INTERVAL = 5 * MIN;
13
+ const AIRING_EARLY_WINDOW = 10 * MIN;
14
+ const AIRING_FAST_WINDOW = 6 * HOUR;
15
+
16
+ const refreshing = new Set();
17
+
18
+ function runBackground(env, promise) {
19
+ const waitUntil = env?.context?.waitUntil ?? env?.waitUntil;
20
+ if (typeof waitUntil === "function") waitUntil.call(env.context ?? env, promise);
21
+ else promise.catch(() => {});
22
+ }
23
+
24
+ function latestEpisodeFromResponse(data) {
25
+ let max = 0;
26
+ for (const provider of Object.values(data ?? {})) {
27
+ const episodes = provider?.episodes;
28
+ if (!episodes || typeof episodes !== "object") continue;
29
+ for (const list of Object.values(episodes)) {
30
+ if (!Array.isArray(list)) continue;
31
+ for (const ep of list) {
32
+ const n = Number(ep?.number);
33
+ if (Number.isFinite(n) && n > max) max = n;
34
+ }
35
+ }
36
+ }
37
+ return max || null;
38
+ }
39
+
40
+ function hasCurrentProviders(data) {
41
+ return data &&
42
+ Object.prototype.hasOwnProperty.call(data, "anidbapp") &&
43
+ Object.prototype.hasOwnProperty.call(data, "anizone");
44
+ }
45
+
46
+ function latestEpisodeFromAniZip(anizip) {
47
+ const nums = Object.keys(anizip?.episodes ?? {}).map(Number).filter(Number.isFinite);
48
+ return nums.length ? Math.max(...nums) : null;
49
+ }
50
+
51
+ function resolveShared(anilistId, freshMedia = false) {
52
+ if (freshMedia) forgetMedia(anilistId);
53
+ return Promise.all([
54
+ getMedia(anilistId).catch(() => null),
55
+ fetch(`${ANIZIP}?anilist_id=${anilistId}`).then((r) => r.json()).catch(() => null),
56
+ ]);
57
+ }
58
+
59
+ async function clearProviderCache(anilistId, media) {
60
+ for (const p of ["pahe", "manga", "reanime", "anikoto", "animegg", "anineko", "anidbapp", "2dhive", "anizone"]) {
61
+ await delAsync(`epv:${p}:${anilistId}`);
62
+ }
63
+ if (media?.idMal) {
64
+ await delAsync(`jm:${media.idMal}`);
65
+ await delByPrefixAsync(`jp:${media.idMal}:`);
66
+ }
67
+ }
68
+
69
+ async function buildResponse(anilistId, media, anizip, forceRefresh = false) {
70
+ if (forceRefresh) await clearProviderCache(anilistId, media);
71
+
72
+ const [providerResult, mappingResult] = await Promise.all([
73
+ buildEpisodesWithCache(anilistId, media, anizip),
74
+ mapAnimeIds(anilistId).catch(() => null),
75
+ ]);
76
+
77
+ return {
78
+ page: 1,
79
+ type: "all",
80
+ mappings: mappingResult?.mappings ?? null,
81
+ ...providerResult,
82
+ };
83
+ }
84
+
85
+ function probeInterval(state) {
86
+ const airMs = state?.nextAiringAt ? state.nextAiringAt * 1000 : null;
87
+ if (!airMs) return NORMAL_PROBE_INTERVAL;
88
+ const now = Date.now();
89
+ return now >= airMs - AIRING_EARLY_WINDOW && now <= airMs + AIRING_FAST_WINDOW
90
+ ? AIRING_PROBE_INTERVAL
91
+ : NORMAL_PROBE_INTERVAL;
92
+ }
93
+
94
+ function shouldRebuild(entry, media, anizip) {
95
+ if ((media?.status ?? "RELEASING") === "FINISHED") return false;
96
+
97
+ const cachedLatest = latestEpisodeFromResponse(entry?.data) ?? 0;
98
+ const knownLatest = Math.max(
99
+ latestEpisodeFromAniZip(anizip) ?? 0,
100
+ Number(media?.episodes) || 0
101
+ );
102
+ if (knownLatest > cachedLatest) return true;
103
+
104
+ const next = media?.nextAiringEpisode;
105
+ if (next?.episode && cachedLatest >= Number(next.episode)) return false;
106
+ if (next?.airingAt) {
107
+ const airMs = Number(next.airingAt) * 1000;
108
+ const now = Date.now();
109
+ if (now < airMs - AIRING_EARLY_WINDOW) return false;
110
+ if (now <= airMs + AIRING_FAST_WINDOW) return true;
111
+ }
112
+
113
+ return needsRefresh(entry);
114
+ }
115
+
116
+ function writeSyncState(anilistId, state, ttl = FULL_TTL) {
117
+ set(`sync:${anilistId}`, state, ttl, NORMAL_PROBE_INTERVAL);
118
+ }
119
+
120
+ function scheduleRefresh(anilistId, entry, env) {
121
+ const key = `ep-bg:${anilistId}`;
122
+ if (refreshing.has(key)) return;
123
+
124
+ const syncKey = `sync:${anilistId}`;
125
+ const oldState = get(syncKey)?.data;
126
+ const now = Date.now();
127
+ if (oldState?.lastProbeAt && now - oldState.lastProbeAt < probeInterval(oldState)) return;
128
+
129
+ refreshing.add(key);
130
+ writeSyncState(anilistId, { ...oldState, lastProbeAt: now, syncing: true });
131
+
132
+ const task = (async () => {
133
+ const [media, anizip] = await resolveShared(anilistId, true);
134
+ const cachedLatest = latestEpisodeFromResponse(entry?.data);
135
+ const next = media?.nextAiringEpisode ?? null;
136
+
137
+ if (!shouldRebuild(entry, media, anizip)) {
138
+ writeSyncState(anilistId, {
139
+ lastProbeAt: Date.now(),
140
+ lastSyncAt: oldState?.lastSyncAt ?? null,
141
+ latestEpisode: cachedLatest,
142
+ nextEpisode: next?.episode ?? null,
143
+ nextAiringAt: next?.airingAt ?? null,
144
+ syncing: false,
145
+ });
146
+ return;
147
+ }
148
+
149
+ const result = await buildResponse(anilistId, media, anizip, true);
150
+ const latestEpisode = latestEpisodeFromResponse(result);
151
+ await setAsync(`episodes:${anilistId}`, result, FULL_TTL, NORMAL_PROBE_INTERVAL);
152
+ writeSyncState(anilistId, {
153
+ lastProbeAt: Date.now(),
154
+ lastSyncAt: Date.now(),
155
+ latestEpisode,
156
+ nextEpisode: next?.episode ?? null,
157
+ nextAiringAt: next?.airingAt ?? null,
158
+ syncing: false,
159
+ });
160
+ })()
161
+ .catch((e) => {
162
+ console.error(`[ep-bg:${anilistId}]`, e.message);
163
+ writeSyncState(anilistId, {
164
+ ...oldState,
165
+ lastProbeAt: Date.now(),
166
+ syncing: false,
167
+ error: e.message,
168
+ }, HOUR);
169
+ })
170
+ .finally(() => refreshing.delete(key));
171
+
172
+ runBackground(env, task);
173
+ }
174
+
175
+ export async function getEpisodesResponse(anilistId, env) {
176
+ const cacheKey = `episodes:${anilistId}`;
177
+ const entry = await getAsync(cacheKey);
178
+
179
+ if (entry && hasCurrentProviders(entry.data)) {
180
+ scheduleRefresh(anilistId, entry, env);
181
+ return entry.data;
182
+ }
183
+
184
+ const [media, anizip] = await resolveShared(anilistId);
185
+ const result = await buildResponse(anilistId, media, anizip);
186
+ await setAsync(cacheKey, result, FULL_TTL, NORMAL_PROBE_INTERVAL);
187
+ writeSyncState(anilistId, {
188
+ lastProbeAt: Date.now(),
189
+ lastSyncAt: Date.now(),
190
+ latestEpisode: latestEpisodeFromResponse(result),
191
+ nextEpisode: media?.nextAiringEpisode?.episode ?? null,
192
+ nextAiringAt: media?.nextAiringEpisode?.airingAt ?? null,
193
+ syncing: false,
194
+ });
195
+ return result;
196
+ }
197
+
198
+ export async function getFilteredEpisodesResponse(anilistId, providers, includeMap) {
199
+ const [media, anizip] = await resolveShared(anilistId);
200
+
201
+ const [providerResult, mappingResult] = await Promise.all([
202
+ buildFilteredEpisodesWithCache(anilistId, providers, media, anizip),
203
+ includeMap ? mapAnimeIds(anilistId).catch(() => null) : Promise.resolve(null),
204
+ ]);
205
+
206
+ return {
207
+ page: 1,
208
+ type: "filtered",
209
+ ...(includeMap ? { mappings: mappingResult?.mappings ?? null } : {}),
210
+ ...providerResult,
211
+ };
212
+ }
anivexa-api/core/episode-strategy.js ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getAsync, setAsync, isFresh, needsRefresh,
3
+ episodeTTL, jikanPageTTL,
4
+ } from "./smartcache.js";
5
+ import { getEpisodes as mangaEpisodes } from "../providers/allmanga.js";
6
+ import { getEpisodes as reanimeEpisodes } from "../providers/reanime.js";
7
+ import { getEpisodes as anikotoEpisodes } from "../providers/anikoto.js";
8
+ import { getEpisodes as animeggEpisodes } from "../providers/animegg.js";
9
+ import { getEpisodes as aninekoEpisodes } from "../providers/anineko.js";
10
+ import { getEpisodes as anidbappEpisodes } from "../providers/anidbapp.js";
11
+ import { getEpisodes as dhiveEpisodes } from "../providers/2dhive.js";
12
+ import { getEpisodes as animenosubEpisodes } from "../providers/animenosub.js";
13
+ import { getEpisodes as anizoneEpisodes } from "../providers/anizone.js";
14
+ import { getEpisodes as anibdEpisodes } from "../providers/anibd.js";
15
+ import { getEpisodes as senshiEpisodes } from "../providers/senshi.js";
16
+ import { getEpisodes as kaaEpisodes } from "../providers/kickassanime.js";
17
+ import { getEpisodes as animedunyaEpisodes } from "../providers/animedunya.js";
18
+ const JIKAN = "https://api.jikan.moe/v4";
19
+ 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";
20
+
21
+ const inflight = new Map();
22
+ const bgRunning = new Set();
23
+
24
+ function dedupe(key, fn) {
25
+ if (inflight.has(key)) return inflight.get(key);
26
+ const p = Promise.resolve().then(fn).finally(() => inflight.delete(key));
27
+ inflight.set(key, p);
28
+ return p;
29
+ }
30
+
31
+ function bg(key, fn) {
32
+ if (bgRunning.has(key)) return;
33
+ bgRunning.add(key);
34
+ Promise.resolve()
35
+ .then(fn)
36
+ .catch(e => console.error(`[bg:${key}]`, e.message))
37
+ .finally(() => bgRunning.delete(key));
38
+ }
39
+
40
+ async function jikanPage(malId, pageNum, retries = 3) {
41
+ for (let attempt = 0; attempt <= retries; attempt++) {
42
+ const res = await fetch(
43
+ `${JIKAN}/anime/${malId}/episodes?page=${pageNum}`,
44
+ { headers: { "User-Agent": UA, Accept: "application/json" } }
45
+ ).catch(() => null);
46
+
47
+ if (!res) return null;
48
+ if (res.status === 429) {
49
+ const wait = (parseInt(res.headers.get("Retry-After") ?? "1") || 1) * 1000
50
+ + attempt * 600;
51
+ if (attempt < retries) { await new Promise(r => setTimeout(r, wait)); continue; }
52
+ return null;
53
+ }
54
+ if (!res.ok) return null;
55
+ return res.json();
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export function fetchAllJikanWithCache(malId, status) {
61
+ return dedupe(`jikan:${malId}`, () => _jikanAll(malId, status));
62
+ }
63
+
64
+ async function _jikanAll(malId, status) {
65
+ const metaKey = `jm:${malId}`;
66
+ const meta = await getAsync(metaKey);
67
+
68
+ const isFinished = status === "FINISHED";
69
+ const mustCheckTotal = !isFinished && (!meta || needsRefresh(meta));
70
+ let lastPage = meta?.data?.lastPage ?? null;
71
+
72
+ if (mustCheckTotal || !lastPage) {
73
+ const p1 = await jikanPage(malId, 1);
74
+
75
+ if (!p1 && !lastPage) return [];
76
+ if (!p1 && lastPage) return _buildPages(malId, lastPage, status);
77
+
78
+ const newLast = p1.pagination?.last_visible_page ?? 1;
79
+ const isP1Last = newLast === 1;
80
+
81
+ const [p1ttl, p1ref] = jikanPageTTL(isP1Last, status);
82
+ await setAsync(`jp:${malId}:1`, p1.data ?? [], p1ttl, p1ref);
83
+
84
+ if (lastPage && newLast > lastPage) {
85
+ const [stableTtl] = jikanPageTTL(false, "FINISHED");
86
+ const oldLastEntry = await getAsync(`jp:${malId}:${lastPage}`);
87
+ if (oldLastEntry) await setAsync(`jp:${malId}:${lastPage}`, oldLastEntry.data, stableTtl, Infinity);
88
+
89
+ await Promise.all(
90
+ Array.from({ length: newLast - lastPage }, (_, i) => {
91
+ const pn = lastPage + 1 + i;
92
+ const isLast = pn === newLast;
93
+ return jikanPage(malId, pn).then(pd => {
94
+ const [t, r] = jikanPageTTL(isLast, status);
95
+ return setAsync(`jp:${malId}:${pn}`, pd?.data ?? [], t, r);
96
+ });
97
+ })
98
+ );
99
+ }
100
+
101
+ const [mttl, mref] = episodeTTL(status);
102
+ await setAsync(metaKey, { lastPage: newLast }, mttl, mref);
103
+ lastPage = newLast;
104
+ }
105
+
106
+ return _buildPages(malId, lastPage, status);
107
+ }
108
+
109
+ async function _buildPages(malId, lastPage, status) {
110
+ const pages = await Promise.all(
111
+ Array.from({ length: lastPage }, (_, i) => i + 1).map(async pn => {
112
+ const key = `jp:${malId}:${pn}`;
113
+ const isLast = pn === lastPage;
114
+ const entry = await getAsync(key);
115
+
116
+ if (isFresh(entry)) {
117
+ if (isLast && status === "RELEASING" && needsRefresh(entry)) {
118
+ bg(key, async () => {
119
+ const pd = await jikanPage(malId, pn);
120
+ if (pd) {
121
+ const [t, r] = jikanPageTTL(true, status);
122
+ await setAsync(key, pd.data ?? [], t, r);
123
+ }
124
+ });
125
+ }
126
+ return entry.data;
127
+ }
128
+
129
+ const pd = await jikanPage(malId, pn);
130
+ const data = pd?.data ?? [];
131
+ const [t, r] = jikanPageTTL(isLast, status);
132
+ await setAsync(key, data, t, r);
133
+ return data;
134
+ })
135
+ );
136
+
137
+ return pages.flat();
138
+ }
139
+
140
+ async function withCache(key, status, fetchFn) {
141
+ const [ttl, refreshAfter] = episodeTTL(status);
142
+ const entry = await getAsync(key);
143
+
144
+ if (isFresh(entry)) {
145
+ if (needsRefresh(entry)) {
146
+ bg(key, async () => {
147
+ const data = await fetchFn();
148
+ await setAsync(key, data, ttl, refreshAfter);
149
+ });
150
+ }
151
+ return entry.data;
152
+ }
153
+
154
+ const data = await fetchFn();
155
+ await setAsync(key, data, ttl, refreshAfter);
156
+ return data;
157
+ }
158
+
159
+ async function safe(label, fn) {
160
+ try { return { ok: true, data: await fn() }; }
161
+ catch (e) { console.error(`[ep:${label}]`, e.message); return { ok: false, error: e.message, stack: e.stack }; }
162
+ }
163
+
164
+ const PROVIDER_ALIASES = {
165
+ allmanga: "allmanga",
166
+ reanime: "reanime",
167
+ anikoto: "anikoto",
168
+ animegg: "animegg",
169
+ anineko: "anineko",
170
+ anidbapp: "anidbapp",
171
+ "2dhive": "2dhive",
172
+ animenosub: "animenosub",
173
+ anizone: "anizone",
174
+ anibd: "anibd",
175
+ senshi: "senshi",
176
+ kaa: "kaa",
177
+ animedunya: "animedunya",
178
+ };
179
+
180
+ export function resolveProviders(rawNames) {
181
+ const resolved = new Set();
182
+ const unknown = [];
183
+ for (const raw of rawNames) {
184
+ const name = PROVIDER_ALIASES[raw.toLowerCase()];
185
+ if (name) resolved.add(name);
186
+ else unknown.push(raw);
187
+ }
188
+ return { resolved, unknown };
189
+ }
190
+
191
+ function providerFns(anilistId, status, ctx) {
192
+ return {
193
+ allmanga: () => withCache(`epv:manga:${anilistId}`, status, () => mangaEpisodes(anilistId, ctx)),
194
+ reanime: () => withCache(`epv:reanime:${anilistId}`, status, () => reanimeEpisodes(anilistId, ctx)),
195
+ anikoto: () => withCache(`epv:anikoto:${anilistId}`, status, () => anikotoEpisodes(anilistId, ctx)),
196
+ animegg: () => withCache(`epv:animegg:${anilistId}`, status, () => animeggEpisodes(anilistId, ctx)),
197
+ anineko: () => withCache(`epv:anineko:${anilistId}`, status, () => aninekoEpisodes(anilistId, ctx)),
198
+ anidbapp: () => withCache(`epv:anidbapp:${anilistId}`, status, () => anidbappEpisodes(anilistId, ctx)),
199
+ "2dhive": () => withCache(`epv:2dhive:${anilistId}`, status, () => dhiveEpisodes(anilistId, ctx)),
200
+ animenosub: () => withCache(`epv:animenosub:${anilistId}`, status, () => animenosubEpisodes(anilistId, ctx)),
201
+ anizone: () => withCache(`epv:anizone:${anilistId}`, status, () => anizoneEpisodes(anilistId, ctx)),
202
+ anibd: () => withCache(`epv:anibd:${anilistId}`, status, () => anibdEpisodes(anilistId, ctx)),
203
+ senshi: () => withCache(`epv:senshi:${anilistId}`, status, () => senshiEpisodes(anilistId, ctx)),
204
+ kaa: () => withCache(`epv:kaa:${anilistId}`, status, () => kaaEpisodes(anilistId, ctx)),
205
+ animedunya: () => withCache(`epv:animedunya:${anilistId}`, status, () => animedunyaEpisodes(anilistId, ctx)),
206
+ };
207
+ }
208
+
209
+ export async function buildFilteredEpisodesWithCache(anilistId, providers, media, anizip) {
210
+ const status = media?.status ?? "RELEASING";
211
+ const malId = media?.idMal ?? null;
212
+
213
+ const jikanEps = malId
214
+ ? await fetchAllJikanWithCache(malId, status).catch(() => null)
215
+ : null;
216
+
217
+ const ctx = { media, anizip, jikanEps, maxPages: undefined };
218
+ const fns = providerFns(anilistId, status, ctx);
219
+
220
+ const pairs = await Promise.all(
221
+ [...providers].map(async (name) => {
222
+ const result = await safe(name, fns[name]);
223
+ return [name, result.ok ? result.data : { error: result.error, stack: result.stack }];
224
+ })
225
+ );
226
+
227
+ return Object.fromEntries(pairs);
228
+ }
229
+
230
+ export async function buildEpisodesWithCache(anilistId, media, anizip) {
231
+ const status = media?.status ?? "RELEASING";
232
+ const malId = media?.idMal ?? null;
233
+
234
+ const jikanEps = malId
235
+ ? await fetchAllJikanWithCache(malId, status).catch(() => null)
236
+ : null;
237
+
238
+ const ctx = { media, anizip, jikanEps, maxPages: undefined };
239
+
240
+ const [manga, reanime, anikoto, animegg, anineko, anidbapp, dhive, animenosub, anizone, anibd, senshi, kaa, animedunya] = await Promise.all([
241
+ safe("allmanga", () => withCache(`epv:manga:${anilistId}`, status, () => mangaEpisodes(anilistId, ctx))),
242
+ safe("reanime", () => withCache(`epv:reanime:${anilistId}`, status, () => reanimeEpisodes(anilistId, ctx))),
243
+ safe("anikoto", () => withCache(`epv:anikoto:${anilistId}`, status, () => anikotoEpisodes(anilistId, ctx))),
244
+ safe("animegg", () => withCache(`epv:animegg:${anilistId}`, status, () => animeggEpisodes(anilistId, ctx))),
245
+ safe("anineko", () => withCache(`epv:anineko:${anilistId}`, status, () => aninekoEpisodes(anilistId, ctx))),
246
+ safe("anidbapp", () => withCache(`epv:anidbapp:${anilistId}`, status, () => anidbappEpisodes(anilistId, ctx))),
247
+ safe("2dhive", () => withCache(`epv:2dhive:${anilistId}`, status, () => dhiveEpisodes(anilistId, ctx))),
248
+ safe("animenosub", () => withCache(`epv:animenosub:${anilistId}`, status, () => animenosubEpisodes(anilistId, ctx))),
249
+ safe("anizone", () => withCache(`epv:anizone:${anilistId}`, status, () => anizoneEpisodes(anilistId, ctx))),
250
+ safe("anibd", () => withCache(`epv:anibd:${anilistId}`, status, () => anibdEpisodes(anilistId, ctx))),
251
+ safe("senshi", () => withCache(`epv:senshi:${anilistId}`, status, () => senshiEpisodes(anilistId, ctx))),
252
+ safe("kaa", () => withCache(`epv:kaa:${anilistId}`, status, () => kaaEpisodes(anilistId, ctx))),
253
+ safe("animedunya", () => withCache(`epv:animedunya:${anilistId}`, status, () => animedunyaEpisodes(anilistId, ctx))),
254
+ ]);
255
+
256
+ return {
257
+ allmanga: manga.ok ? manga.data : { error: manga.error, stack: manga.stack },
258
+ reanime: reanime.ok ? reanime.data : { error: reanime.error, stack: reanime.stack },
259
+ anikoto: anikoto.ok ? anikoto.data : { error: anikoto.error, stack: anikoto.stack },
260
+ animegg: animegg.ok ? animegg.data : { error: animegg.error, stack: animegg.stack },
261
+ anineko: anineko.ok ? anineko.data : { error: anineko.error, stack: anineko.stack },
262
+ anidbapp: anidbapp.ok ? anidbapp.data : { error: anidbapp.error, stack: anidbapp.stack },
263
+ "2dhive": dhive.ok ? dhive.data : { error: dhive.error, stack: dhive.stack },
264
+ animenosub: animenosub.ok ? animenosub.data : { error: animenosub.error, stack: animenosub.stack },
265
+ anizone: anizone.ok ? anizone.data : { error: anizone.error, stack: anizone.stack },
266
+ anibd: anibd.ok ? anibd.data : { error: anibd.error, stack: anibd.stack },
267
+ senshi: senshi.ok ? senshi.data : { error: senshi.error, stack: senshi.stack },
268
+ kaa: kaa.ok ? kaa.data : { error: kaa.error, stack: kaa.stack },
269
+ animedunya: animedunya.ok ? animedunya.data : { error: animedunya.error, stack: animedunya.stack },
270
+ };
271
+ }
anivexa-api/core/mapper.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const __name = (fn, _) => fn;
2
+ import { getMedia } from './anilist.js';
3
+
4
+ var ARM2 = "https://arm.haglund.dev/api/v2/ids";
5
+ var UA2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0";
6
+ function hashFranchiseId(str) {
7
+ let h = 0;
8
+ for (let i = 0; i < str.length; i++) {
9
+ h = (h << 5) - h + str.charCodeAt(i) | 0;
10
+ }
11
+ return h >>> 0;
12
+ }
13
+ __name(hashFranchiseId, "hashFranchiseId");
14
+ async function fetchARM(anilistId) {
15
+ const res = await fetch(`${ARM2}?source=anilist&id=${anilistId}`, {
16
+ headers: { "User-Agent": UA2, "Accept": "application/json" }
17
+ }).catch(() => null);
18
+ if (!res || !res.ok) return null;
19
+ return res.json().catch(() => null);
20
+ }
21
+ __name(fetchARM, "fetchARM");
22
+ async function fetchAniListRelations(anilistId) {
23
+ const q = `
24
+ query ($id: Int) {
25
+ Media(id: $id, type: ANIME) {
26
+ id synonyms
27
+ relations {
28
+ edges {
29
+ relationType(version: 2)
30
+ node {
31
+ id type format title { romaji english native }
32
+ relations {
33
+ edges {
34
+ relationType(version: 2)
35
+ node { id type format title { romaji english native } }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }`;
43
+ try {
44
+ const res = await fetch("https://graphql.anilist.co", {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json", "Accept": "application/json" },
47
+ body: JSON.stringify({ query: q, variables: { id: Number(anilistId) } })
48
+ });
49
+ if (!res.ok) return null;
50
+ const json6 = await res.json();
51
+ return json6.data?.Media ?? null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ __name(fetchAniListRelations, "fetchAniListRelations");
57
+ async function mapAnimeIds(anilistId) {
58
+ const [arm, media, alRelations] = await Promise.all([
59
+ fetchARM(anilistId),
60
+ getMedia(anilistId).catch(() => null),
61
+ fetchAniListRelations(anilistId)
62
+ ]);
63
+ const malId = arm?.myanimelist ?? null;
64
+ const format = media?.format ?? null;
65
+ const year = media?.seasonYear ?? null;
66
+ const titleEn = media?.title?.english || null;
67
+ const titleRom = media?.title?.romaji || null;
68
+ const synonyms = [...(media?.synonyms ?? [])];
69
+ if (alRelations?.synonyms) {
70
+ for (const s of alRelations.synonyms) {
71
+ if (!synonyms.includes(s)) synonyms.push(s);
72
+ }
73
+ }
74
+ const franchiseMap = new Map();
75
+ if (alRelations?.relations?.edges) {
76
+ for (const e1 of alRelations.relations.edges) {
77
+ if (!franchiseMap.has(e1.node.id)) {
78
+ franchiseMap.set(e1.node.id, {
79
+ relation: e1.relationType,
80
+ anilistId: e1.node.id,
81
+ title: e1.node.title.romaji || e1.node.title.english,
82
+ type: e1.node.type,
83
+ format: e1.node.format
84
+ });
85
+ }
86
+ if (e1.node.relations?.edges) {
87
+ for (const e2 of e1.node.relations.edges) {
88
+ if (e2.node.id === Number(anilistId)) continue;
89
+ if (!franchiseMap.has(e2.node.id)) {
90
+ franchiseMap.set(e2.node.id, {
91
+ relation: e2.relationType,
92
+ anilistId: e2.node.id,
93
+ title: e2.node.title.romaji || e2.node.title.english,
94
+ type: e2.node.type,
95
+ format: e2.node.format
96
+ });
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ const thetvdbId = arm?.thetvdb ?? null;
103
+ const themoviedbId = arm?.themoviedb ?? null;
104
+ const imdbId = arm?.imdb ?? null;
105
+ return {
106
+ mappings: {
107
+ id: Number(anilistId),
108
+ title: titleEn || titleRom,
109
+ type: arm?.media ?? null,
110
+ format,
111
+ episodes: media?.episodes ?? null,
112
+ malId,
113
+ aniId: Number(anilistId),
114
+ anidbId: arm?.anidb ?? null,
115
+ animePlanetId: arm?.["anime-planet"] ?? null,
116
+ kitsuId: arm?.kitsu ?? null,
117
+ animeCountdownId: arm?.animecountdown ?? null,
118
+ anisearchId: arm?.anisearch ?? null,
119
+ notifyMoeId: null,
120
+ simklId: arm?.simkl ?? null,
121
+ imdbId,
122
+ themoviedbId,
123
+ thetvdbId,
124
+ livechartId: arm?.livechart ?? null,
125
+ annId: arm?.animenewsnetwork ?? null,
126
+ animescheduleId: null,
127
+ animethemesId: null,
128
+ animefillerlistId: null,
129
+ franchiseAnchor: thetvdbId ? `tvdb:${thetvdbId}` : null,
130
+ franchiseId: thetvdbId ? hashFranchiseId(`tvdb:${thetvdbId}`) : null,
131
+ defaultTvdbSeason: arm?.["thetvdb-season"] != null ? String(arm["thetvdb-season"]) : null,
132
+ tmdbSeason: arm?.["themoviedb-season"] != null ? String(arm["themoviedb-season"]) : null,
133
+ episodeOffset: null,
134
+ tmdbOffset: null,
135
+ malIds: null,
136
+ aniskip: null,
137
+ animefillerlist: null,
138
+ synonyms,
139
+ franchise: Array.from(franchiseMap.values())
140
+ }
141
+ };
142
+ }
143
+ __name(mapAnimeIds, "mapAnimeIds");
144
+ export { mapAnimeIds };
anivexa-api/core/new-provider-utils.js ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { get, set, isFresh, SHOW_IDENTITY_TTL } from "./smartcache.js";
2
+
3
+ export const UA =
4
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
5
+
6
+ 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}}}}}}}}}}}`;
7
+
8
+ export async function fetchHtml(url, headers = {}) {
9
+ const res = await fetch(url, {
10
+ headers: {
11
+ "User-Agent": UA,
12
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
13
+ "Accept-Language": "en-US,en;q=0.9",
14
+ ...headers,
15
+ },
16
+ });
17
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
18
+ return res.text();
19
+ }
20
+
21
+ export function decodeEntities(s = "") {
22
+ return s
23
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
24
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
25
+ .replace(/&quot;/g, '"')
26
+ .replace(/&#39;/g, "'")
27
+ .replace(/&amp;/g, "&")
28
+ .replace(/&lt;/g, "<")
29
+ .replace(/&gt;/g, ">")
30
+ .trim();
31
+ }
32
+
33
+ export function stripTags(html = "") {
34
+ return decodeEntities(html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " "));
35
+ }
36
+
37
+ export function attr(tag, name) {
38
+ const m = tag.match(new RegExp(`${name}=["']([^"']*)["']`, "i"));
39
+ return m ? decodeEntities(m[1]) : "";
40
+ }
41
+
42
+ export function norm(s = "") {
43
+ return s.toLowerCase().replace(/[^a-z0-9]/g, "");
44
+ }
45
+
46
+ export function diceCoeff(a, b) {
47
+ const na = norm(a);
48
+ const nb = norm(b);
49
+ if (na === nb) return 1;
50
+ if (na.length < 2 || nb.length < 2) return 0;
51
+ const bigrams = new Map();
52
+ for (let i = 0; i < na.length - 1; i++) {
53
+ const bg = na.slice(i, i + 2);
54
+ bigrams.set(bg, (bigrams.get(bg) ?? 0) + 1);
55
+ }
56
+ let hits = 0;
57
+ for (let i = 0; i < nb.length - 1; i++) {
58
+ const bg = nb.slice(i, i + 2);
59
+ const count = bigrams.get(bg) ?? 0;
60
+ if (count > 0) {
61
+ hits++;
62
+ bigrams.set(bg, count - 1);
63
+ }
64
+ }
65
+ return (2 * hits) / (na.length + nb.length - 2);
66
+ }
67
+
68
+ export function titleScore(query, candidate, slug) {
69
+ const base = Math.max(diceCoeff(query, candidate), diceCoeff(query, slug.replace(/-/g, " ")));
70
+ const queryFirstNum = norm(query).match(/\d+/)?.[0] ?? "";
71
+ const slugFirstNum = slug.match(/\d+/)?.[0] ?? "";
72
+ if (queryFirstNum && slugFirstNum && queryFirstNum !== slugFirstNum) return base * 0.65;
73
+ if (queryFirstNum && !slugFirstNum) return base * 0.65;
74
+ if (!queryFirstNum && slugFirstNum) {
75
+ const n = parseInt(slugFirstNum);
76
+ if (n > 1 && n < 1900) return base * (1 - 0.06 * (n - 1));
77
+ }
78
+ const isMovieQuery = /\b(movie|film|the movie)\b/i.test(query);
79
+ const isMovieMatch = /\b(movie|film)\b/i.test(candidate) || /movie|film/.test(slug);
80
+ if (isMovieQuery && !isMovieMatch) return base * 0.4;
81
+ const qLen = norm(query).length;
82
+ const sLen = norm(slug.replace(/-/g, " ")).length;
83
+ return sLen > qLen * 1.6 + 4 ? base * 0.8 : base;
84
+ }
85
+
86
+ function buildSearchQueries(title) {
87
+ const queries = new Set([title]);
88
+ const words = title.trim().split(/\s+/);
89
+ if (words.length > 4) queries.add(words.slice(0, 4).join(" "));
90
+ if (words.length > 3) queries.add(words.slice(0, 3).join(" "));
91
+ const stripped = title
92
+ .replace(/\bseason\s*\d+\b/gi, "")
93
+ .replace(/\bpart\s*\d+\b/gi, "")
94
+ .replace(/\b\d+rd\b|\b\d+th\b|\b\d+st\b|\b\d+nd\b/gi, "")
95
+ .replace(/\s+/g, " ")
96
+ .trim();
97
+ if (stripped && stripped !== title) queries.add(stripped);
98
+ return [...queries].filter((q) => q.length >= 3);
99
+ }
100
+
101
+ export async function findTopSlugs(titles, searchFn, n = 6) {
102
+ const allCandidates = new Map();
103
+ const searchQueries = new Set();
104
+ for (const title of titles.slice(0, 4)) {
105
+ for (const q of buildSearchQueries(title)) searchQueries.add(q);
106
+ }
107
+ await Promise.all([...searchQueries].map(async (q) => {
108
+ try {
109
+ const results = await searchFn(q);
110
+ for (const r of results) if (!allCandidates.has(r.slug)) allCandidates.set(r.slug, r.text);
111
+ } catch {}
112
+ }));
113
+ const scored = [];
114
+ for (const [slug, text] of allCandidates) {
115
+ let best = 0;
116
+ for (const title of titles.slice(0, 2)) best = Math.max(best, titleScore(title, text, slug));
117
+ if (best >= 0.5) scored.push({ slug, title: text, score: best });
118
+ }
119
+ return scored.sort((a, b) => b.score - a.score).slice(0, n);
120
+ }
121
+
122
+ async function anilistQuery(query, variables) {
123
+ const res = await fetch("https://graphql.anilist.co", {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
126
+ body: JSON.stringify({ query, variables }),
127
+ });
128
+ if (!res.ok) throw new Error(`AniList HTTP ${res.status}`);
129
+ const json = await res.json();
130
+ if (json.errors?.length) throw new Error(`AniList: ${json.errors[0].message}`);
131
+ return json.data;
132
+ }
133
+
134
+ function computePrequelOffset(relations, depth = 0) {
135
+ if (!relations || depth > 5) return 0;
136
+ const prequelEdge = relations.edges?.find(
137
+ (e) => e.relationType === "PREQUEL" && e.node.type === "ANIME" && (e.node.episodes ?? 0) >= 5
138
+ );
139
+ if (!prequelEdge) return 0;
140
+ return (prequelEdge.node.episodes ?? 0) + computePrequelOffset(prequelEdge.node.relations, depth + 1);
141
+ }
142
+
143
+ export async function getPrequelOffset(anilistId) {
144
+ const key = `np-offset:${anilistId}`;
145
+ const entry = get(key);
146
+ if (isFresh(entry)) return entry.data;
147
+ const data = await anilistQuery(
148
+ `query($id:Int){Media(id:$id,type:ANIME){relations{${RELATION_FRAGMENT}}}}`,
149
+ { id: Number(anilistId) }
150
+ );
151
+ const offset = computePrequelOffset(data?.Media?.relations);
152
+ set(key, offset, SHOW_IDENTITY_TTL);
153
+ return offset;
154
+ }
155
+
156
+ export function buildTitles(media, anizip) {
157
+ return [
158
+ media?.title?.english,
159
+ media?.title?.romaji,
160
+ media?.title?.native,
161
+ ...(media?.synonyms ?? []),
162
+ anizip?.titles?.en,
163
+ anizip?.titles?.["x-jat"],
164
+ anizip?.titles?.ja,
165
+ ].filter(Boolean);
166
+ }
167
+
168
+ export function expectedCount(media, anizip, jikanEps) {
169
+ const counts = [
170
+ media?.episodes,
171
+ ...Object.keys(anizip?.episodes ?? {}).map(Number).filter(Number.isFinite),
172
+ ...(jikanEps ?? []).map((e) => e.mal_id).filter(Number.isFinite),
173
+ ].filter((n) => Number.isFinite(n) && n > 0);
174
+ return counts.length ? Math.max(...counts) : null;
175
+ }
176
+
177
+ export function episodeMeta(n, ctx) {
178
+ const az = ctx.anizip?.episodes?.[String(n)] ?? {};
179
+ const jk = (ctx.jikanEps ?? []).find((e) => Number(e.mal_id) === Number(n));
180
+ const runtime = az.runtime ?? az.length ?? null;
181
+ return {
182
+ title: jk?.title ?? az.title?.en ?? az.title?.["x-jat"] ?? null,
183
+ duration: runtime ? runtime * 60 : null,
184
+ filler: jk?.filler ?? az.filler ?? false,
185
+ uncensored: false,
186
+ description: az.overview ?? az.summary ?? null,
187
+ image: az.image ?? ctx.anizip?.images?.cover ?? null,
188
+ airDate: jk?.aired ?? az.airdate ?? az.aired ?? null,
189
+ };
190
+ }
191
+
192
+ export function selectSeries(candidates, scrapeSeries, expected, status, offset, options = {}) {
193
+ return Promise.all(candidates.map(async (candidate) => {
194
+ const episodes = await scrapeSeries(candidate.slug);
195
+ const max = Math.max(0, ...episodes.map((e) => e.number));
196
+ const localHits = expected ? episodes.filter((e) => e.number >= 1 && e.number <= expected).length : episodes.length;
197
+ const offsetHits = expected && offset
198
+ ? episodes.filter((e) => e.number > offset && e.number <= offset + expected).length
199
+ : 0;
200
+ const mode = offsetHits > localHits ? "offset" : "local";
201
+ const hits = Math.max(localHits, offsetHits);
202
+ let countScore = 1;
203
+ if (expected && expected >= 6) {
204
+ const needed = status === "FINISHED" ? Math.ceil(expected * 0.9) : Math.max(1, expected - 3);
205
+ countScore = hits >= needed ? 1 : hits / needed;
206
+ }
207
+ return { ...candidate, episodes, max, mode, score: candidate.score * 0.7 + countScore * 0.3 };
208
+ })).then((results) => {
209
+ const minScore = options.minScore ?? 0.65;
210
+ const viable = results
211
+ .filter((r) => r.episodes.length && r.score >= minScore)
212
+ .sort((a, b) => b.score - a.score);
213
+ if (!viable.length) return null;
214
+ return viable[0];
215
+ });
216
+ }
217
+
218
+ export function json(data, status = 200) {
219
+ return new Response(JSON.stringify(data, null, 2), {
220
+ status,
221
+ headers: {
222
+ "Content-Type": "application/json",
223
+ "Access-Control-Allow-Origin": "*",
224
+ "Cache-Control": "public, max-age=300",
225
+ },
226
+ });
227
+ }
anivexa-api/core/smartcache.js ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const _CACHE_ENABLED = false; //change it to true and setup your upstash so you can cache your data
2
+
3
+ const IS_LOCAL_NODE = (() => {
4
+ try {
5
+ return (
6
+ typeof process !== "undefined" &&
7
+ typeof process.versions?.node === "string" &&
8
+ !process.env.VERCEL
9
+ );
10
+ } catch { return false; }
11
+ })();
12
+
13
+ const UPSTASH_REDIS_REST_URL = "YOUR_UPSTASH_REDIS_REST_URL"; //get it from upstash.com
14
+ const UPSTASH_REDIS_REST_TOKEN = "YOUR_UPSTASH_REDIS_REST_TOKEN";
15
+ const REDIS_ENABLED = Boolean(UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN);
16
+
17
+ function encodeEntry(entry) {
18
+ return JSON.stringify(entry, (_, value) => value === Infinity ? "__Infinity__" : value);
19
+ }
20
+
21
+ function decodeEntry(raw) {
22
+ return JSON.parse(raw, (_, value) => value === "__Infinity__" ? Infinity : value);
23
+ }
24
+
25
+ async function redisCommand(command) {
26
+ if (!REDIS_ENABLED || typeof fetch !== "function") return null;
27
+ const res = await fetch(UPSTASH_REDIS_REST_URL, {
28
+ method: "POST",
29
+ headers: {
30
+ Authorization: `Bearer ${UPSTASH_REDIS_REST_TOKEN}`,
31
+ "Content-Type": "application/json",
32
+ },
33
+ body: JSON.stringify(command),
34
+ }).catch(() => null);
35
+ if (!res?.ok) return null;
36
+ const json = await res.json().catch(() => null);
37
+ return json?.result ?? null;
38
+ }
39
+
40
+ async function redisWrite(key, entry) {
41
+ if (!REDIS_ENABLED) return;
42
+ const value = encodeEntry(entry);
43
+ if (Number.isFinite(entry.ttl) && entry.ttl > 0) {
44
+ await redisCommand(["SET", key, value, "PX", Math.ceil(entry.ttl)]);
45
+ return;
46
+ }
47
+ await redisCommand(["SET", key, value]);
48
+ }
49
+
50
+ let diskRead = () => null;
51
+ let diskWrite = () => {};
52
+ let diskDel = () => {};
53
+
54
+ if (IS_LOCAL_NODE) {
55
+ const { readFileSync, mkdirSync, existsSync } = await import("node:fs");
56
+ const { writeFile, unlink } = await import("node:fs/promises");
57
+ const { join, dirname } = await import("node:path");
58
+ const { fileURLToPath } = await import("node:url");
59
+
60
+ const __dir = dirname(fileURLToPath(import.meta.url));
61
+ const CACHE_DIR = join(__dir, ".cache");
62
+ try { mkdirSync(CACHE_DIR, { recursive: true }); } catch {}
63
+
64
+ const keyToPath = (key) =>
65
+ join(CACHE_DIR, key.replace(/[^a-zA-Z0-9_-]/g, "_") + ".json");
66
+
67
+ diskRead = (key) => {
68
+ try {
69
+ const p = keyToPath(key);
70
+ if (!existsSync(p)) return null;
71
+ return decodeEntry(readFileSync(p, "utf8"));
72
+ } catch { return null; }
73
+ };
74
+
75
+ diskWrite = (key, entry) => {
76
+ writeFile(keyToPath(key), encodeEntry(entry)).catch(() => {});
77
+ };
78
+
79
+ diskDel = (key) => {
80
+ unlink(keyToPath(key)).catch(() => {});
81
+ };
82
+ }
83
+
84
+ const MAX_MEM = 800;
85
+ const mem = new Map();
86
+
87
+ function evict() {
88
+ if (mem.size <= MAX_MEM) return;
89
+ const drop = mem.size - MAX_MEM;
90
+ let n = 0;
91
+ for (const k of mem.keys()) {
92
+ if (n++ >= drop) break;
93
+ mem.delete(k);
94
+ }
95
+ }
96
+
97
+ export function get(key) {
98
+ if (!_CACHE_ENABLED) return null;
99
+ let e = mem.get(key);
100
+ if (e) return e;
101
+
102
+ e = diskRead(key);
103
+ if (!e) return null;
104
+
105
+ mem.set(key, e);
106
+ evict();
107
+ return e;
108
+ }
109
+
110
+ export async function getAsync(key) {
111
+ if (!_CACHE_ENABLED) return null;
112
+ let e = get(key);
113
+ if (e) return e;
114
+
115
+ const raw = await redisCommand(["GET", key]);
116
+ if (!raw) return null;
117
+
118
+ try {
119
+ e = typeof raw === "string" ? decodeEntry(raw) : raw;
120
+ if (!isFresh(e)) {
121
+ await delAsync(key);
122
+ return null;
123
+ }
124
+ mem.set(key, e);
125
+ evict();
126
+ diskWrite(key, e);
127
+ return e;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function setLocal(key, data, ttlMs, refreshAfterMs) {
134
+ const now = Date.now();
135
+ const entry = {
136
+ data,
137
+ cachedAt: now,
138
+ ttl: ttlMs,
139
+ refreshAfter: refreshAfterMs ?? ttlMs,
140
+ expiresAt: now + ttlMs,
141
+ };
142
+ mem.delete(key);
143
+ mem.set(key, entry);
144
+ evict();
145
+ diskWrite(key, entry);
146
+ return entry;
147
+ }
148
+
149
+ export function set(key, data, ttlMs, refreshAfterMs) {
150
+ if (!_CACHE_ENABLED) return { data, cachedAt: Date.now(), ttl: ttlMs, refreshAfter: refreshAfterMs ?? ttlMs, expiresAt: Date.now() + ttlMs };
151
+ const entry = setLocal(key, data, ttlMs, refreshAfterMs);
152
+ redisWrite(key, entry).catch(() => {});
153
+ return entry;
154
+ }
155
+
156
+ export async function setAsync(key, data, ttlMs, refreshAfterMs) {
157
+ if (!_CACHE_ENABLED) return { data, cachedAt: Date.now(), ttl: ttlMs, refreshAfter: refreshAfterMs ?? ttlMs, expiresAt: Date.now() + ttlMs };
158
+ const entry = setLocal(key, data, ttlMs, refreshAfterMs);
159
+ await redisWrite(key, entry);
160
+ return entry;
161
+ }
162
+
163
+ export function isFresh(entry) {
164
+ return entry !== null && entry !== undefined && Date.now() < entry.expiresAt;
165
+ }
166
+
167
+ export function needsRefresh(entry) {
168
+ return !entry || Date.now() - entry.cachedAt > entry.refreshAfter;
169
+ }
170
+
171
+ function delLocal(key) {
172
+ mem.delete(key);
173
+ diskDel(key);
174
+ }
175
+
176
+ export function del(key) {
177
+ delLocal(key);
178
+ redisCommand(["DEL", key]).catch(() => {});
179
+ }
180
+
181
+ export async function delAsync(key) {
182
+ delLocal(key);
183
+ await redisCommand(["DEL", key]);
184
+ }
185
+
186
+ export function delByPrefix(prefix) {
187
+ for (const k of [...mem.keys()]) {
188
+ if (k.startsWith(prefix)) mem.delete(k);
189
+ }
190
+ }
191
+
192
+ export async function delByPrefixAsync(prefix) {
193
+ delByPrefix(prefix);
194
+ const keys = await redisCommand(["KEYS", `${prefix}*`]);
195
+ if (Array.isArray(keys) && keys.length) {
196
+ await redisCommand(["DEL", ...keys]);
197
+ }
198
+ }
199
+
200
+ const MIN = 60_000;
201
+ const HOUR = 60 * MIN;
202
+ const DAY = 24 * HOUR;
203
+
204
+ export function episodeTTL(status) {
205
+ switch (status) {
206
+ case "FINISHED": return [7 * DAY, Infinity];
207
+ case "RELEASING": return [2 * HOUR, 15 * MIN];
208
+ case "HIATUS": return [6 * HOUR, 60 * MIN];
209
+ case "NOT_YET_RELEASED": return [30 * MIN, 15 * MIN];
210
+ default: return [HOUR, 15 * MIN];
211
+ }
212
+ }
213
+
214
+ export function jikanPageTTL(isLastPage, status) {
215
+ if (!isLastPage || status === "FINISHED") return [7 * DAY, Infinity];
216
+ switch (status) {
217
+ case "RELEASING": return [2 * HOUR, 15 * MIN];
218
+ case "HIATUS": return [6 * HOUR, 60 * MIN];
219
+ case "NOT_YET_RELEASED": return [30 * MIN, 15 * MIN];
220
+ default: return [2 * HOUR, 15 * MIN];
221
+ }
222
+ }
223
+
224
+ export function mapTTL(status) {
225
+ return status === "FINISHED" ? 30 * DAY : 12 * HOUR;
226
+ }
227
+
228
+ export const WATCH_TTL = 3 * HOUR;
229
+ export const SHOW_IDENTITY_TTL = 24 * HOUR;
230
+ export const THIRTY_DAYS = 30 * DAY;
anivexa-api/docs/index.html ADDED
@@ -0,0 +1,786 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Anivexa API — Docs</title>
7
+ <meta name="description" content="Anivexa API documentation — a unified anime streaming aggregator API with 13 providers, exact-match identity resolution via AniList IDs."/>
8
+ <link rel="icon" type="image/svg+xml" href="logo.svg"/>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
12
+ <link rel="stylesheet" href="style.css"/>
13
+ </head>
14
+ <body>
15
+
16
+ <div class="cursor-dot" id="cursorDot" aria-hidden="true"></div>
17
+ <div class="cursor-ring" id="cursorRing" aria-hidden="true"></div>
18
+
19
+ <div class="bg-orbs" aria-hidden="true">
20
+ <div class="bg-orb bg-orb-1"></div>
21
+ <div class="bg-orb bg-orb-2"></div>
22
+ <div class="bg-orb bg-orb-3"></div>
23
+ </div>
24
+
25
+ <div class="overlay" id="overlay"></div>
26
+
27
+ <div class="topbar">
28
+ <img src="logo.svg" alt="Anivexa"/>
29
+ <span class="topbar-title">Anivexa</span>
30
+ <button class="menu-btn" id="menuBtn" aria-label="Toggle menu">
31
+ <span></span><span></span><span></span>
32
+ </button>
33
+ </div>
34
+
35
+ <nav class="sidebar" id="sidebar">
36
+ <div class="logo">
37
+ <img src="logo.svg" alt="Anivexa"/>
38
+ <span class="logo-text">Anivexa API</span>
39
+ <span class="logo-version">v2.2</span>
40
+ </div>
41
+ <div class="nav">
42
+ <div class="nav-section">
43
+ <div class="nav-label">Getting Started</div>
44
+ <a class="nav-item" href="#overview">Overview</a>
45
+ <a class="nav-item" href="#base-url">Base URL</a>
46
+ <a class="nav-item" href="#providers-ref">Providers</a>
47
+ </div>
48
+ <div class="nav-section">
49
+ <div class="nav-label">Core</div>
50
+ <a class="nav-item" href="#ep-info"><span class="method">GET</span>API Info</a>
51
+ <a class="nav-item" href="#ep-map"><span class="method">GET</span>Map IDs</a>
52
+ <a class="nav-item" href="#ep-episodes"><span class="method">GET</span>Episodes</a>
53
+ <a class="nav-item" href="#ep-episodes-filtered"><span class="method">GET</span>Episodes (Filtered)</a>
54
+ </div>
55
+ <div class="nav-section">
56
+ <div class="nav-label">Watch</div>
57
+ <a class="nav-item" href="#watch-allmanga"><span class="method">GET</span>AllManga</a>
58
+ <a class="nav-item" href="#watch-reanime"><span class="method">GET</span>Reanime</a>
59
+ <a class="nav-item" href="#watch-anikoto"><span class="method">GET</span>Anikoto</a>
60
+ <a class="nav-item" href="#watch-animegg"><span class="method">GET</span>AnimeGG</a>
61
+ <a class="nav-item" href="#watch-anineko"><span class="method">GET</span>AniNeko</a>
62
+ <a class="nav-item" href="#watch-anidbapp"><span class="method">GET</span>AniDBApp</a>
63
+ <a class="nav-item" href="#watch-2dhive"><span class="method">GET</span>2DHive</a>
64
+ <a class="nav-item" href="#watch-animenosub"><span class="method">GET</span>AnimeNoSub</a>
65
+ <a class="nav-item" href="#watch-anizone"><span class="method">GET</span>AniZone</a>
66
+ <a class="nav-item" href="#watch-anibd"><span class="method">GET</span>AniBD</a>
67
+ <a class="nav-item" href="#watch-senshi"><span class="method">GET</span>Senshi</a>
68
+ <a class="nav-item" href="#watch-kaa"><span class="method">GET</span>KickAssAnime</a>
69
+ <a class="nav-item" href="#watch-animedunya"><span class="method">GET</span>AnimeDunya</a>
70
+ </div>
71
+ </div>
72
+ </nav>
73
+
74
+ <div class="layout">
75
+
76
+ <main class="main">
77
+
78
+ <section class="section section-hero" id="overview">
79
+ <img src="logo.svg" alt="Anivexa" class="hero-logo"/>
80
+ <h1>Anivexa API</h1>
81
+ <p>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.</p>
82
+ <div class="callout">
83
+ <span class="callout-icon">💡</span>
84
+ <span>All endpoints are read-only. No authentication required. All responses are JSON with CORS headers included.</span>
85
+ </div>
86
+ </section>
87
+
88
+ <section class="section" id="base-url">
89
+ <h2>Base URL</h2>
90
+ <p>All requests are made to the root of the server. When running locally:</p>
91
+ <div class="endpoint">
92
+ <div class="endpoint-head">
93
+ <span class="method-pill get">BASE</span>
94
+ <span class="endpoint-path">http://localhost:4000</span>
95
+ </div>
96
+ </div>
97
+ <p>Replace with your deployed URL when in production. Every response includes <code>Access-Control-Allow-Origin: *</code>.</p>
98
+ </section>
99
+
100
+ <section class="section" id="providers-ref">
101
+ <h2>Providers</h2>
102
+ <p>Thirteen providers are available. Each uses exact-match identity resolution via AniList or MAL IDs — no blind fuzzy matching.</p>
103
+ <div class="provider-grid">
104
+ <div class="provider-card" style="--i:0"><div class="provider-name">allmanga</div><div class="provider-meta">AllManga</div></div>
105
+ <div class="provider-card" style="--i:1"><div class="provider-name">reanime</div><div class="provider-meta">Reanime</div></div>
106
+ <div class="provider-card" style="--i:2"><div class="provider-name">anikoto</div><div class="provider-meta">Anikoto</div></div>
107
+ <div class="provider-card" style="--i:3"><div class="provider-name">animegg</div><div class="provider-meta">AnimeGG</div></div>
108
+ <div class="provider-card" style="--i:4"><div class="provider-name">anineko</div><div class="provider-meta">AniNeko</div></div>
109
+ <div class="provider-card" style="--i:5"><div class="provider-name">anidbapp</div><div class="provider-meta">AniDBApp</div></div>
110
+ <div class="provider-card" style="--i:6"><div class="provider-name">2dhive</div><div class="provider-meta">2DHive</div></div>
111
+ <div class="provider-card" style="--i:7"><div class="provider-name">animenosub</div><div class="provider-meta">AnimeNoSub</div></div>
112
+ <div class="provider-card" style="--i:8"><div class="provider-name">anizone</div><div class="provider-meta">AniZone</div></div>
113
+ <div class="provider-card" style="--i:9"><div class="provider-name">anibd</div><div class="provider-meta">AniBD</div></div>
114
+ <div class="provider-card" style="--i:10"><div class="provider-name">senshi</div><div class="provider-meta">Senshi</div></div>
115
+ <div class="provider-card" style="--i:11"><div class="provider-name">kaa</div><div class="provider-meta">KickAssAnime</div></div>
116
+ <div class="provider-card" style="--i:12"><div class="provider-name">animedunya</div><div class="provider-meta">AnimeDunya</div></div>
117
+ </div>
118
+ </section>
119
+
120
+ <hr class="divider"/>
121
+
122
+ <section class="section" id="ep-info">
123
+ <h2>API Info</h2>
124
+ <div class="endpoint">
125
+ <div class="endpoint-head">
126
+ <span class="method-pill get">GET</span>
127
+ <span class="endpoint-path">/</span>
128
+ <a class="try-btn" href="/" target="_blank">Try it ↗</a>
129
+ </div>
130
+ <div class="endpoint-body">
131
+ <p class="endpoint-desc">Returns API metadata: version, cache status, list of active providers, and all registered routes.</p>
132
+ </div>
133
+ <div class="code-label">Response</div>
134
+ <pre><code><span class="p">{</span>
135
+ <span class="n">"name"</span><span class="p">:</span> <span class="s">"Anivexa API 2.2"</span><span class="p">,</span>
136
+ <span class="n">"cache"</span><span class="p">:</span> <span class="b">false</span><span class="p">,</span>
137
+ <span class="n">"providers"</span><span class="p">: [</span><span class="s">"allmanga"</span><span class="p">,</span> <span class="s">"reanime"</span><span class="p">,</span> <span class="s">"..."</span><span class="p">],</span>
138
+ <span class="n">"routes"</span><span class="p">: [</span><span class="s">"/map/:anilistId"</span><span class="p">,</span> <span class="s">"..."</span><span class="p">]</span>
139
+ <span class="p">}</span></code></pre>
140
+ </div>
141
+ </section>
142
+
143
+ <section class="section" id="ep-map">
144
+ <h2>Map IDs</h2>
145
+ <div class="endpoint">
146
+ <div class="endpoint-head">
147
+ <span class="method-pill get">GET</span>
148
+ <span class="endpoint-path">/map/<span class="param">:anilistId</span></span>
149
+ <a class="try-btn" href="/map/16498" target="_blank">Try it ↗</a>
150
+ </div>
151
+ <div class="endpoint-body">
152
+ <p class="endpoint-desc">Resolves an AniList ID to its equivalents across other databases (MAL, Kitsu, etc.).</p>
153
+ <table class="params-table">
154
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
155
+ <tbody>
156
+ <tr><td class="param-name">:anilistId</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
157
+ </tbody>
158
+ </table>
159
+ </div>
160
+ <div class="code-label">Example — /map/16498</div>
161
+ <pre><code><span class="p">{</span>
162
+ <span class="n">"anilistId"</span><span class="p">:</span> <span class="b">16498</span><span class="p">,</span>
163
+ <span class="n">"malId"</span><span class="p">:</span> <span class="b">16498</span><span class="p">,</span>
164
+ <span class="n">"kitsuId"</span><span class="p">:</span> <span class="b">7442</span>
165
+ <span class="p">}</span></code></pre>
166
+ </div>
167
+ </section>
168
+
169
+ <section class="section" id="ep-episodes">
170
+ <h2>Episodes</h2>
171
+ <div class="endpoint">
172
+ <div class="endpoint-head">
173
+ <span class="method-pill get">GET</span>
174
+ <span class="endpoint-path">/episodes/<span class="param">:anilistId</span></span>
175
+ <a class="try-btn" href="/episodes/16498" target="_blank">Try it ↗</a>
176
+ </div>
177
+ <div class="endpoint-body">
178
+ <p class="endpoint-desc">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.</p>
179
+ <table class="params-table">
180
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
181
+ <tbody>
182
+ <tr><td class="param-name">:anilistId</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
183
+ </tbody>
184
+ </table>
185
+ </div>
186
+ <div class="code-label">Response shape</div>
187
+ <pre><code><span class="p">{</span>
188
+ <span class="n">"reanime"</span><span class="p">: {</span>
189
+ <span class="n">"meta"</span><span class="p">: {</span> <span class="n">"title"</span><span class="p">:</span> <span class="s">"Attack on Titan"</span><span class="p">,</span> <span class="n">"malId"</span><span class="p">:</span> <span class="b">16498</span> <span class="p">},</span>
190
+ <span class="n">"episodes"</span><span class="p">: {</span>
191
+ <span class="n">"sub"</span><span class="p">: [</span>
192
+ <span class="p">{</span>
193
+ <span class="n">"id"</span><span class="p">:</span> <span class="s">"watch/reanime/16498/sub/reanime-1"</span><span class="p">,</span>
194
+ <span class="n">"number"</span><span class="p">:</span> <span class="b">1</span><span class="p">,</span>
195
+ <span class="n">"title"</span><span class="p">:</span> <span class="s">"To You, 2,000 Years in the Future"</span><span class="p">,</span>
196
+ <span class="n">"filler"</span><span class="p">:</span> <span class="b">false</span><span class="p">,</span>
197
+ <span class="n">"audio"</span><span class="p">:</span> <span class="s">"sub"</span>
198
+ <span class="p">}</span>
199
+ <span class="p">],</span>
200
+ <span class="n">"dub"</span><span class="p">: [</span> <span class="s">"..."</span> <span class="p">]</span>
201
+ <span class="p">}</span>
202
+ <span class="p">},</span>
203
+ <span class="n">"senshi"</span><span class="p">: {</span> <span class="s">"..."</span> <span class="p">},</span>
204
+ <span class="n">"anibd"</span><span class="p">:</span> <span class="p">{</span> <span class="n">"error"</span><span class="p">:</span> <span class="s">"..."</span> <span class="p">}</span>
205
+ <span class="p">}</span></code></pre>
206
+ </div>
207
+ </section>
208
+
209
+ <section class="section" id="ep-episodes-filtered">
210
+ <h2>Episodes (Filtered)</h2>
211
+ <div class="endpoint">
212
+ <div class="endpoint-head">
213
+ <span class="method-pill get">GET</span>
214
+ <span class="endpoint-path">/episodes/<span class="param">:provider</span>/<span class="param">:anilistId</span></span>
215
+ <a class="try-btn" href="/episodes/reanime/16498" target="_blank">Try it ↗</a>
216
+ </div>
217
+ <div class="endpoint-body">
218
+ <p class="endpoint-desc">Fetch episode data from one or more specific providers. Chain multiple provider names in the path. Use <code>?map=false</code> to skip the ID map lookup.</p>
219
+ <table class="params-table">
220
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
221
+ <tbody>
222
+ <tr><td class="param-name">:provider</td><td class="param-type">string</td><td class="param-desc">One or more provider slugs separated by <code>/</code></td></tr>
223
+ <tr><td class="param-name">:anilistId</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
224
+ <tr><td class="param-name">?map</td><td class="param-type">boolean</td><td class="param-desc">Include ID map in response (default: <code>true</code>)</td></tr>
225
+ </tbody>
226
+ </table>
227
+ </div>
228
+ <div class="code-label">Examples</div>
229
+ <pre><code><span class="k">GET</span> /episodes/reanime/16498
230
+ <span class="k">GET</span> /episodes/reanime/senshi/16498
231
+ <span class="k">GET</span> /episodes/reanime/senshi/anibd/16498<span class="p">?map=false</span></code></pre>
232
+ </div>
233
+ </section>
234
+
235
+ <hr class="divider"/>
236
+
237
+ <section class="section" id="watch-allmanga">
238
+ <h2>AllManga</h2>
239
+ <div class="endpoint">
240
+ <div class="endpoint-head">
241
+ <span class="method-pill get">GET</span>
242
+ <span class="endpoint-path">/watch/allmanga/<span class="param">:id</span>/<span class="param">sub|dub</span>/allmanga-<span class="param">:ep</span></span>
243
+ <a class="try-btn" href="/watch/allmanga/16498/sub/allmanga-1" target="_blank">Try it ↗</a>
244
+ </div>
245
+ <div class="endpoint-body">
246
+ <p class="endpoint-desc">Returns stream sources for the given AllManga episode. The episode ID comes directly from the <code>id</code> field in the episodes list response.</p>
247
+ <table class="params-table">
248
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
249
+ <tbody>
250
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
251
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
252
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
253
+ </tbody>
254
+ </table>
255
+ </div>
256
+ <div class="code-label">Response</div>
257
+ <pre><code><span class="p">{</span>
258
+ <span class="n">"anilistId"</span><span class="p">:</span> <span class="b">16498</span><span class="p">,</span>
259
+ <span class="n">"episode"</span><span class="p">:</span> <span class="b">1</span><span class="p">,</span>
260
+ <span class="n">"audio"</span><span class="p">:</span> <span class="s">"sub"</span><span class="p">,</span>
261
+ <span class="n">"streams"</span><span class="p">: [</span>
262
+ <span class="p">{</span>
263
+ <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://..."</span><span class="p">,</span>
264
+ <span class="n">"type"</span><span class="p">:</span> <span class="s">"hls"</span><span class="p">,</span>
265
+ <span class="n">"server"</span><span class="p">:</span> <span class="s">"Server Name"</span><span class="p">,</span>
266
+ <span class="n">"referer"</span><span class="p">:</span> <span class="s">"https://..."</span><span class="p">,</span>
267
+ <span class="n">"priority"</span><span class="p">:</span> <span class="b">5</span><span class="p">,</span>
268
+ <span class="n">"isActive"</span><span class="p">:</span> <span class="b">true</span>
269
+ <span class="p">}</span>
270
+ <span class="p">]</span>
271
+ <span class="p">}</span></code></pre>
272
+ </div>
273
+ </section>
274
+
275
+ <section class="section" id="watch-reanime">
276
+ <h2>Reanime</h2>
277
+ <div class="endpoint">
278
+ <div class="endpoint-head">
279
+ <span class="method-pill get">GET</span>
280
+ <span class="endpoint-path">/watch/reanime/<span class="param">:id</span>/<span class="param">sub|dub</span>/reanime-<span class="param">:ep</span></span>
281
+ <a class="try-btn" href="/watch/reanime/16498/sub/reanime-1" target="_blank">Try it ↗</a>
282
+ </div>
283
+ <div class="endpoint-body">
284
+ <p class="endpoint-desc">Resolves stream sources from Reanime using AniList ID confirmation via cover image CDN URLs and detail endpoint matching. Returns decrypted HLS streams.</p>
285
+ <table class="params-table">
286
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
287
+ <tbody>
288
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
289
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
290
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
291
+ </tbody>
292
+ </table>
293
+ </div>
294
+ </div>
295
+ <div class="endpoint">
296
+ <div class="endpoint-head">
297
+ <span class="method-pill get">GET</span>
298
+ <span class="endpoint-path">/stream/reanime/<span class="param">:id</span>/<span class="param">sub|dub</span>/<span class="param">:ep</span></span>
299
+ <a class="try-btn" href="/stream/reanime/16498/sub/1" target="_blank">Try it ↗</a>
300
+ </div>
301
+ <div class="endpoint-body">
302
+ <p class="endpoint-desc">Direct stream variant — returns the raw stream response without the watch wrapper. Useful for direct playback.</p>
303
+ </div>
304
+ </div>
305
+ </section>
306
+
307
+ <section class="section" id="watch-anikoto">
308
+ <h2>Anikoto</h2>
309
+ <div class="endpoint">
310
+ <div class="endpoint-head">
311
+ <span class="method-pill get">GET</span>
312
+ <span class="endpoint-path">/watch/anikoto/<span class="param">:id</span>/<span class="param">sub|dub</span>/anikoto-<span class="param">:ep</span></span>
313
+ <a class="try-btn" href="/watch/anikoto/16498/sub/anikoto-1" target="_blank">Try it ↗</a>
314
+ </div>
315
+ <div class="endpoint-body">
316
+ <p class="endpoint-desc">Returns stream sources from Anikoto for the specified episode.</p>
317
+ <table class="params-table">
318
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
319
+ <tbody>
320
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
321
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
322
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
323
+ </tbody>
324
+ </table>
325
+ </div>
326
+ </div>
327
+ </section>
328
+
329
+ <section class="section" id="watch-animegg">
330
+ <h2>AnimeGG</h2>
331
+ <div class="endpoint">
332
+ <div class="endpoint-head">
333
+ <span class="method-pill get">GET</span>
334
+ <span class="endpoint-path">/watch/animegg/<span class="param">:id</span>/<span class="param">sub|dub</span>/animegg-<span class="param">:ep</span></span>
335
+ <a class="try-btn" href="/watch/animegg/16498/sub/animegg-1" target="_blank">Try it ↗</a>
336
+ </div>
337
+ <div class="endpoint-body">
338
+ <p class="endpoint-desc">Returns stream sources from AnimeGG for the specified episode.</p>
339
+ <table class="params-table">
340
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
341
+ <tbody>
342
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
343
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
344
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
345
+ </tbody>
346
+ </table>
347
+ </div>
348
+ </div>
349
+ </section>
350
+
351
+ <section class="section" id="watch-anineko">
352
+ <h2>AniNeko</h2>
353
+ <div class="endpoint">
354
+ <div class="endpoint-head">
355
+ <span class="method-pill get">GET</span>
356
+ <span class="endpoint-path">/watch/anineko/<span class="param">:id</span>/<span class="param">sub|dub</span>/anineko-<span class="param">:ep</span></span>
357
+ <a class="try-btn" href="/watch/anineko/16498/sub/anineko-1" target="_blank">Try it ↗</a>
358
+ </div>
359
+ <div class="endpoint-body">
360
+ <p class="endpoint-desc">Returns stream sources from AniNeko for the specified episode.</p>
361
+ <table class="params-table">
362
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
363
+ <tbody>
364
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
365
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
366
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
367
+ </tbody>
368
+ </table>
369
+ </div>
370
+ </div>
371
+ </section>
372
+
373
+ <section class="section" id="watch-anidbapp">
374
+ <h2>AniDBApp</h2>
375
+ <div class="endpoint">
376
+ <div class="endpoint-head">
377
+ <span class="method-pill get">GET</span>
378
+ <span class="endpoint-path">/watch/anidbapp/<span class="param">:id</span>/<span class="param">sub|dub</span>/anidbapp-<span class="param">:ep</span></span>
379
+ <a class="try-btn" href="/watch/anidbapp/16498/sub/anidbapp-1" target="_blank">Try it ↗</a>
380
+ </div>
381
+ <div class="endpoint-body">
382
+ <p class="endpoint-desc">Returns stream sources from AniDBApp. Uses exact AniList ID confirmation after fuzzy title search before resolving streams.</p>
383
+ <table class="params-table">
384
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
385
+ <tbody>
386
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
387
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
388
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
389
+ </tbody>
390
+ </table>
391
+ </div>
392
+ </div>
393
+ </section>
394
+
395
+ <section class="section" id="watch-2dhive">
396
+ <h2>2DHive</h2>
397
+ <div class="endpoint">
398
+ <div class="endpoint-head">
399
+ <span class="method-pill get">GET</span>
400
+ <span class="endpoint-path">/watch/2dhive/<span class="param">:id</span>/<span class="param">sub|dub</span>/2dhive-<span class="param">:ep</span></span>
401
+ <a class="try-btn" href="/watch/2dhive/16498/sub/2dhive-1" target="_blank">Try it ↗</a>
402
+ </div>
403
+ <div class="endpoint-body">
404
+ <p class="endpoint-desc">Returns stream sources from 2DHive.</p>
405
+ <table class="params-table">
406
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
407
+ <tbody>
408
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
409
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
410
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
411
+ </tbody>
412
+ </table>
413
+ </div>
414
+ </div>
415
+ <div class="endpoint">
416
+ <div class="endpoint-head">
417
+ <span class="method-pill get">GET</span>
418
+ <span class="endpoint-path">/stream/2dhive/<span class="param">:id</span>/<span class="param">sub|dub</span>/<span class="param">:ep</span></span>
419
+ <a class="try-btn" href="/stream/2dhive/16498/sub/1" target="_blank">Try it ↗</a>
420
+ </div>
421
+ <div class="endpoint-body">
422
+ <p class="endpoint-desc">Direct stream variant for 2DHive.</p>
423
+ </div>
424
+ </div>
425
+ <div class="endpoint">
426
+ <div class="endpoint-head">
427
+ <span class="method-pill get">GET</span>
428
+ <span class="endpoint-path">/stream/2dhive/download/<span class="param">:id</span>/<span class="param">sub|dub</span>/<span class="param">:ep</span></span>
429
+ <a class="try-btn" href="/stream/2dhive/download/16498/sub/1" target="_blank">Try it ↗</a>
430
+ </div>
431
+ <div class="endpoint-body">
432
+ <p class="endpoint-desc">Download variant — returns a direct downloadable stream URL from 2DHive.</p>
433
+ </div>
434
+ </div>
435
+ </section>
436
+
437
+ <section class="section" id="watch-animenosub">
438
+ <h2>AnimeNoSub</h2>
439
+ <div class="endpoint">
440
+ <div class="endpoint-head">
441
+ <span class="method-pill get">GET</span>
442
+ <span class="endpoint-path">/watch/animenosub/<span class="param">:id</span>/<span class="param">sub|dub</span>/animenosub-<span class="param">:ep</span></span>
443
+ <a class="try-btn" href="/watch/animenosub/199547/sub/animenosub-1" target="_blank">Try it ↗</a>
444
+ </div>
445
+ <div class="endpoint-body">
446
+ <p class="endpoint-desc">Returns stream sources from AnimeNoSub for the specified episode.</p>
447
+ <table class="params-table">
448
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
449
+ <tbody>
450
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
451
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
452
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
453
+ </tbody>
454
+ </table>
455
+ </div>
456
+ </div>
457
+ </section>
458
+
459
+ <section class="section" id="watch-anizone">
460
+ <h2>AniZone</h2>
461
+ <div class="endpoint">
462
+ <div class="endpoint-head">
463
+ <span class="method-pill get">GET</span>
464
+ <span class="endpoint-path">/watch/anizone/<span class="param">:id</span>/<span class="param">sub|dub</span>/anizone-<span class="param">:ep</span></span>
465
+ <a class="try-btn" href="/watch/anizone/199547/sub/anizone-1" target="_blank">Try it ↗</a>
466
+ </div>
467
+ <div class="endpoint-body">
468
+ <p class="endpoint-desc">Returns stream sources from AniZone for the specified episode.</p>
469
+ <table class="params-table">
470
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
471
+ <tbody>
472
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
473
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
474
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
475
+ </tbody>
476
+ </table>
477
+ </div>
478
+ </div>
479
+ </section>
480
+
481
+ <section class="section" id="watch-anibd">
482
+ <h2>AniBD</h2>
483
+ <div class="endpoint">
484
+ <div class="endpoint-head">
485
+ <span class="method-pill get">GET</span>
486
+ <span class="endpoint-path">/watch/anibd/<span class="param">:id</span>/<span class="param">sub|dub</span>/anibd-<span class="param">:ep</span></span>
487
+ <a class="try-btn" href="/watch/anibd/16498/sub/anibd-1" target="_blank">Try it ↗</a>
488
+ </div>
489
+ <div class="endpoint-body">
490
+ <p class="endpoint-desc">Returns stream sources from AniBD. Resolves the player link, then extracts and returns the HLS URL directly.</p>
491
+ <table class="params-table">
492
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
493
+ <tbody>
494
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
495
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
496
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
497
+ </tbody>
498
+ </table>
499
+ </div>
500
+ </div>
501
+ </section>
502
+
503
+ <section class="section" id="watch-senshi">
504
+ <h2>Senshi</h2>
505
+ <div class="endpoint">
506
+ <div class="endpoint-head">
507
+ <span class="method-pill get">GET</span>
508
+ <span class="endpoint-path">/watch/senshi/<span class="param">:id</span>/<span class="param">sub|dub</span>/senshi-<span class="param">:ep</span></span>
509
+ <a class="try-btn" href="/watch/senshi/16498/sub/senshi-1" target="_blank">Try it ↗</a>
510
+ </div>
511
+ <div class="endpoint-body">
512
+ <p class="endpoint-desc">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.</p>
513
+ <table class="params-table">
514
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
515
+ <tbody>
516
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
517
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
518
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
519
+ </tbody>
520
+ </table>
521
+ </div>
522
+ <div class="code-label">Response</div>
523
+ <pre><code><span class="p">{</span>
524
+ <span class="n">"anilistId"</span><span class="p">:</span> <span class="b">16498</span><span class="p">,</span>
525
+ <span class="n">"malId"</span><span class="p">:</span> <span class="b">16498</span><span class="p">,</span>
526
+ <span class="n">"episode"</span><span class="p">:</span> <span class="b">1</span><span class="p">,</span>
527
+ <span class="n">"audio"</span><span class="p">:</span> <span class="s">"sub"</span><span class="p">,</span>
528
+ <span class="n">"intro"</span><span class="p">:</span> <span class="p">{</span> <span class="n">"start"</span><span class="p">:</span> <span class="b">123</span><span class="p">,</span> <span class="n">"end"</span><span class="p">:</span> <span class="b">215</span> <span class="p">},</span>
529
+ <span class="n">"outro"</span><span class="p">:</span> <span class="p">{</span> <span class="n">"start"</span><span class="p">:</span> <span class="b">1435</span><span class="p">,</span> <span class="n">"end"</span><span class="p">:</span> <span class="b">1525</span> <span class="p">},</span>
530
+ <span class="n">"streams"</span><span class="p">: [</span>
531
+ <span class="p">{</span> <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://ninstream.com/.../playlist.m3u8"</span><span class="p">,</span> <span class="n">"type"</span><span class="p">:</span> <span class="s">"hls"</span><span class="p">,</span> <span class="n">"server"</span><span class="p">:</span> <span class="s">"Senshi"</span><span class="p">,</span> <span class="n">"priority"</span><span class="p">:</span> <span class="b">5</span><span class="p">,</span> <span class="n">"isActive"</span><span class="p">:</span> <span class="b">true</span> <span class="p">},</span>
532
+ <span class="p">{</span> <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://streamnin.xyz/d/..."</span><span class="p">,</span> <span class="n">"type"</span><span class="p">:</span> <span class="s">"embed"</span><span class="p">,</span> <span class="n">"server"</span><span class="p">:</span> <span class="s">"StreamNin"</span><span class="p">,</span> <span class="n">"priority"</span><span class="p">:</span> <span class="b">3</span><span class="p">,</span> <span class="n">"isActive"</span><span class="p">:</span> <span class="b">false</span> <span class="p">},</span>
533
+ <span class="p">{</span> <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://bysesayeveum.com/e/..."</span><span class="p">,</span> <span class="n">"type"</span><span class="p">:</span> <span class="s">"embed"</span><span class="p">,</span> <span class="n">"server"</span><span class="p">:</span> <span class="s">"FileMoon"</span><span class="p">,</span> <span class="n">"priority"</span><span class="p">:</span> <span class="b">2</span><span class="p">,</span> <span class="n">"isActive"</span><span class="p">:</span> <span class="b">false</span> <span class="p">}</span>
534
+ <span class="p">],</span>
535
+ <span class="n">"downloads"</span><span class="p">: [</span>
536
+ <span class="p">{</span> <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://bzzhr.to/..."</span><span class="p">,</span> <span class="n">"label"</span><span class="p">:</span> <span class="s">"Download"</span> <span class="p">}</span>
537
+ <span class="p">]</span>
538
+ <span class="p">}</span></code></pre>
539
+ </div>
540
+ </section>
541
+
542
+ <section class="section" id="watch-kaa">
543
+ <h2>KickAssAnime</h2>
544
+ <div class="endpoint">
545
+ <div class="endpoint-head">
546
+ <span class="method-pill get">GET</span>
547
+ <span class="endpoint-path">/watch/kaa/<span class="param">:id</span>/<span class="param">sub|dub</span>/kaa-<span class="param">:ep</span></span>
548
+ <a class="try-btn" href="/watch/kaa/21/sub/kaa-1" target="_blank">Try it ↗</a>
549
+ </div>
550
+ <div class="endpoint-body">
551
+ <p class="endpoint-desc">Returns stream sources from KickAssAnime for the specified episode. Streams are served via CatStream (HLS) and require the included <code>Referer</code> header for playback.</p>
552
+ <table class="params-table">
553
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
554
+ <tbody>
555
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
556
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
557
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
558
+ </tbody>
559
+ </table>
560
+ </div>
561
+ <div class="code-label">Response</div>
562
+ <pre><code><span class="p">{</span>
563
+ <span class="n">"anilistId"</span><span class="p">:</span> <span class="b">21</span><span class="p">,</span>
564
+ <span class="n">"episode"</span><span class="p">:</span> <span class="b">1</span><span class="p">,</span>
565
+ <span class="n">"audio"</span><span class="p">:</span> <span class="s">"sub"</span><span class="p">,</span>
566
+ <span class="n">"streams"</span><span class="p">: [</span>
567
+ <span class="p">{</span>
568
+ <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://hls.krussdomi.com/manifest/.../master.m3u8"</span><span class="p">,</span>
569
+ <span class="n">"type"</span><span class="p">:</span> <span class="s">"hls"</span><span class="p">,</span>
570
+ <span class="n">"server"</span><span class="p">:</span> <span class="s">"CatStream"</span><span class="p">,</span>
571
+ <span class="n">"headers"</span><span class="p">: {</span> <span class="n">"Referer"</span><span class="p">:</span> <span class="s">"https://krussdomi.com/"</span> <span class="p">},</span>
572
+ <span class="n">"priority"</span><span class="p">:</span> <span class="b">1</span><span class="p">,</span>
573
+ <span class="n">"isActive"</span><span class="p">:</span> <span class="b">true</span>
574
+ <span class="p">}</span>
575
+ <span class="p">]</span>
576
+ <span class="p">}</span></code></pre>
577
+ </div>
578
+ </section>
579
+
580
+ <section class="section" id="watch-animedunya">
581
+ <h2>AnimeDunya</h2>
582
+ <div class="endpoint">
583
+ <div class="endpoint-head">
584
+ <span class="method-pill get">GET</span>
585
+ <span class="endpoint-path">/watch/animedunya/<span class="param">:id</span>/<span class="param">sub|dub</span>/animedunya-<span class="param">:ep</span></span>
586
+ <a class="try-btn" href="/watch/animedunya/16498/sub/animedunya-1" target="_blank">Try it ↗</a>
587
+ </div>
588
+ <div class="endpoint-body">
589
+ <p class="endpoint-desc">Returns stream sources from AnimeDunya for the specified episode. Streams are served as direct HLS playlists with multiple subtitle tracks included.</p>
590
+ <table class="params-table">
591
+ <thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
592
+ <tbody>
593
+ <tr><td class="param-name">:id</td><td class="param-type">integer</td><td class="param-desc">AniList media ID</td></tr>
594
+ <tr><td class="param-name">sub|dub</td><td class="param-type">string</td><td class="param-desc">Audio track preference</td></tr>
595
+ <tr><td class="param-name">:ep</td><td class="param-type">integer</td><td class="param-desc">Episode number</td></tr>
596
+ </tbody>
597
+ </table>
598
+ </div>
599
+ <div class="code-label">Response</div>
600
+ <pre><code><span class="p">{</span>
601
+ <span class="n">"anilistId"</span><span class="p">:</span> <span class="b">16498</span><span class="p">,</span>
602
+ <span class="n">"malId"</span><span class="p">:</span> <span class="b">16498</span><span class="p">,</span>
603
+ <span class="n">"episode"</span><span class="p">:</span> <span class="b">1</span><span class="p">,</span>
604
+ <span class="n">"audio"</span><span class="p">:</span> <span class="s">"sub"</span><span class="p">,</span>
605
+ <span class="n">"streams"</span><span class="p">: [</span>
606
+ <span class="p">{</span>
607
+ <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://fs2c.anime-dunya.com/files/.../master.m3u8"</span><span class="p">,</span>
608
+ <span class="n">"type"</span><span class="p">:</span> <span class="s">"hls"</span><span class="p">,</span>
609
+ <span class="n">"server"</span><span class="p">:</span> <span class="s">"AnimeDunya"</span><span class="p">,</span>
610
+ <span class="n">"referer"</span><span class="p">:</span> <span class="s">"https://anime-dunya.com/"</span><span class="p">,</span>
611
+ <span class="n">"subtitles"</span><span class="p">: [</span>
612
+ <span class="p">{</span> <span class="n">"url"</span><span class="p">:</span> <span class="s">"https://fs2c.anime-dunya.com/.../en.vtt"</span><span class="p">,</span> <span class="n">"label"</span><span class="p">:</span> <span class="s">"EN"</span><span class="p">,</span> <span class="n">"srclang"</span><span class="p">:</span> <span class="s">"en"</span><span class="p">,</span> <span class="n">"default"</span><span class="p">:</span> <span class="b">true</span> <span class="p">}</span>
613
+ <span class="p">],</span>
614
+ <span class="n">"priority"</span><span class="p">:</span> <span class="b">5</span><span class="p">,</span>
615
+ <span class="n">"isActive"</span><span class="p">:</span> <span class="b">true</span>
616
+ <span class="p">}</span>
617
+ <span class="p">]</span>
618
+ <span class="p">}</span></code></pre>
619
+ </div>
620
+ </section>
621
+
622
+ </main>
623
+ </div>
624
+
625
+ <script>
626
+ const sidebar = document.getElementById('sidebar');
627
+ const overlay = document.getElementById('overlay');
628
+ const menuBtn = document.getElementById('menuBtn');
629
+
630
+ const openSidebar = () => {
631
+ sidebar.classList.add('open');
632
+ overlay.classList.add('open');
633
+ menuBtn.classList.add('active');
634
+ document.body.style.overflow = 'hidden';
635
+ };
636
+
637
+ const closeSidebar = () => {
638
+ sidebar.classList.remove('open');
639
+ overlay.classList.remove('open');
640
+ menuBtn.classList.remove('active');
641
+ document.body.style.overflow = '';
642
+ };
643
+
644
+ menuBtn.addEventListener('click', () =>
645
+ sidebar.classList.contains('open') ? closeSidebar() : openSidebar()
646
+ );
647
+ overlay.addEventListener('click', closeSidebar);
648
+ sidebar.querySelectorAll('.nav-item').forEach(l =>
649
+ l.addEventListener('click', () => { if (window.innerWidth <= 768) closeSidebar(); })
650
+ );
651
+
652
+ const navItems = sidebar.querySelectorAll('.nav-item[href^="#"]');
653
+ const navObs = new IntersectionObserver(entries => {
654
+ entries.forEach(e => {
655
+ if (e.isIntersecting) {
656
+ navItems.forEach(n => n.classList.remove('active'));
657
+ const a = sidebar.querySelector(`.nav-item[href="#${e.target.id}"]`);
658
+ if (a) a.classList.add('active');
659
+ }
660
+ });
661
+ }, { rootMargin: '-20% 0px -70% 0px' });
662
+ document.querySelectorAll('.section[id]').forEach(s => navObs.observe(s));
663
+
664
+ const revealObs = new IntersectionObserver(entries => {
665
+ entries.forEach(e => {
666
+ if (e.isIntersecting) {
667
+ e.target.classList.add('visible');
668
+ revealObs.unobserve(e.target);
669
+ }
670
+ });
671
+ }, { threshold: 0.07, rootMargin: '0px 0px -40px 0px' });
672
+ document.querySelectorAll('.section').forEach(s => revealObs.observe(s));
673
+
674
+ const tiltables = document.querySelectorAll('.provider-card, .endpoint');
675
+ tiltables.forEach(el => {
676
+ let raf = null;
677
+ el.addEventListener('mousemove', e => {
678
+ if (raf) return;
679
+ raf = requestAnimationFrame(() => {
680
+ const r = el.getBoundingClientRect();
681
+ const x = (e.clientX - r.left) / r.width - 0.5;
682
+ const y = (e.clientY - r.top) / r.height - 0.5;
683
+ const maxDeg = el.classList.contains('provider-card') ? 10 : 5;
684
+ el.style.setProperty('--rx', `${(-y * maxDeg).toFixed(2)}deg`);
685
+ el.style.setProperty('--ry', `${( x * maxDeg).toFixed(2)}deg`);
686
+ raf = null;
687
+ });
688
+ }, { passive: true });
689
+ el.addEventListener('mouseleave', () => {
690
+ if (raf) { cancelAnimationFrame(raf); raf = null; }
691
+ el.style.setProperty('--rx', '0deg');
692
+ el.style.setProperty('--ry', '0deg');
693
+ }, { passive: true });
694
+ });
695
+
696
+ (function () {
697
+ const dot = document.getElementById('cursorDot');
698
+ const ring = document.getElementById('cursorRing');
699
+ if (!dot || !ring) return;
700
+
701
+ let mx = -200, my = -200;
702
+ let rx = -200, ry = -200;
703
+ let visible = false;
704
+
705
+ const LERP = 0.22;
706
+ const INTERACTIVES = 'a, button, .provider-card, .nav-item, .try-btn, .copy-btn, label, [role="button"]';
707
+
708
+ function tick() {
709
+ rx += (mx - rx) * LERP;
710
+ ry += (my - ry) * LERP;
711
+ dot.style.transform = `translate(${mx}px,${my}px)`;
712
+ ring.style.transform = `translate(${Math.round(rx * 10) / 10}px,${Math.round(ry * 10) / 10}px)`;
713
+ requestAnimationFrame(tick);
714
+ }
715
+ requestAnimationFrame(tick);
716
+
717
+ document.addEventListener('mousemove', e => {
718
+ mx = e.clientX; my = e.clientY;
719
+ if (!visible) {
720
+ visible = true;
721
+ dot.classList.remove('is-hidden');
722
+ ring.classList.remove('is-hidden');
723
+ rx = mx; ry = my;
724
+ }
725
+ }, { passive: true });
726
+
727
+ document.addEventListener('mouseleave', () => {
728
+ dot.classList.add('is-hidden');
729
+ ring.classList.add('is-hidden');
730
+ visible = false;
731
+ }, { passive: true });
732
+
733
+ document.addEventListener('mouseenter', () => {
734
+ dot.classList.remove('is-hidden');
735
+ ring.classList.remove('is-hidden');
736
+ visible = true;
737
+ }, { passive: true });
738
+
739
+ document.addEventListener('mousedown', () => {
740
+ ring.classList.add('is-clicking');
741
+ ring.classList.remove('is-hovering');
742
+ }, { passive: true });
743
+
744
+ document.addEventListener('mouseup', () => {
745
+ ring.classList.remove('is-clicking');
746
+ }, { passive: true });
747
+
748
+ document.addEventListener('mouseover', e => {
749
+ if (e.target.closest(INTERACTIVES)) {
750
+ dot.classList.add('is-hovering');
751
+ ring.classList.add('is-hovering');
752
+ }
753
+ }, { passive: true });
754
+
755
+ document.addEventListener('mouseout', e => {
756
+ if (e.target.closest(INTERACTIVES)) {
757
+ dot.classList.remove('is-hovering');
758
+ ring.classList.remove('is-hovering');
759
+ }
760
+ }, { passive: true });
761
+
762
+ window.addEventListener('touchstart', () => {
763
+ dot.style.display = 'none';
764
+ ring.style.display = 'none';
765
+ document.body.style.cursor = '';
766
+ }, { once: true, passive: true });
767
+ })();
768
+
769
+ document.querySelectorAll('pre').forEach(pre => {
770
+ const btn = document.createElement('button');
771
+ btn.className = 'copy-btn';
772
+ btn.textContent = 'Copy';
773
+ btn.addEventListener('click', () => {
774
+ const text = pre.innerText.replace(/\nCopy$/, '').replace(/\nCopied!$/, '').trim();
775
+ navigator.clipboard.writeText(text).then(() => {
776
+ btn.textContent = 'Copied!';
777
+ btn.classList.add('copied');
778
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1800);
779
+ }).catch(() => {});
780
+ });
781
+ pre.appendChild(btn);
782
+ });
783
+ </script>
784
+
785
+ </body>
786
+ </html>
anivexa-api/docs/landing.html ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Anivexa API</title>
7
+ <link rel="icon" type="image/svg+xml" href="logo.svg"/>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
11
+ <style>
12
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
13
+
14
+ :root {
15
+ --bg: #0c0c0f;
16
+ --border: #1e1e26;
17
+ --text: #e6e6f0;
18
+ --muted: #5a5a6e;
19
+ --accent: #818cf8;
20
+ --purple: #c084fc;
21
+ --surface: rgba(255,255,255,.04);
22
+ }
23
+
24
+ html, body {
25
+ height: 100%;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: 'Inter', system-ui, sans-serif;
29
+ -webkit-font-smoothing: antialiased;
30
+ cursor: none;
31
+ }
32
+
33
+ a, button, [role="button"] { cursor: none; }
34
+
35
+ .cursor-dot,
36
+ .cursor-ring {
37
+ position: fixed;
38
+ top: 0; left: 0;
39
+ pointer-events: none;
40
+ z-index: 99999;
41
+ will-change: transform;
42
+ border-radius: 50%;
43
+ }
44
+
45
+ .cursor-dot {
46
+ width: 6px; height: 6px;
47
+ background: #818cf8;
48
+ margin: -3px 0 0 -3px;
49
+ box-shadow: 0 0 10px rgba(129,140,248,.8);
50
+ transition: opacity .2s;
51
+ }
52
+
53
+ .cursor-ring {
54
+ width: 36px; height: 36px;
55
+ border: 1.5px solid rgba(129,140,248,.45);
56
+ margin: -18px 0 0 -18px;
57
+ transition: width .25s cubic-bezier(.22,1,.36,1),
58
+ height .25s cubic-bezier(.22,1,.36,1),
59
+ margin .25s cubic-bezier(.22,1,.36,1),
60
+ border-color .25s, background .25s, opacity .2s;
61
+ }
62
+
63
+ .cursor-ring.is-hovering {
64
+ width: 52px; height: 52px;
65
+ margin: -26px 0 0 -26px;
66
+ border-color: rgba(129,140,248,.7);
67
+ background: rgba(129,140,248,.06);
68
+ }
69
+
70
+ .cursor-dot.is-hovering { opacity: 0; }
71
+ .cursor-dot.is-hidden,
72
+ .cursor-ring.is-hidden { opacity: 0; }
73
+
74
+ .cursor-ring.is-clicking {
75
+ width: 28px; height: 28px;
76
+ margin: -14px 0 0 -14px;
77
+ border-color: rgba(129,140,248,.9);
78
+ background: rgba(129,140,248,.12);
79
+ }
80
+
81
+ @media (prefers-reduced-motion: reduce) {
82
+ .cursor-dot, .cursor-ring { display: none; }
83
+ body { cursor: auto; }
84
+ }
85
+
86
+ body {
87
+ display: flex;
88
+ flex-direction: column;
89
+ align-items: center;
90
+ justify-content: center;
91
+ min-height: 100vh;
92
+ padding: 24px;
93
+ position: relative;
94
+ overflow: hidden;
95
+ }
96
+
97
+ .glow {
98
+ position: absolute;
99
+ width: 600px;
100
+ height: 600px;
101
+ border-radius: 50%;
102
+ background: radial-gradient(circle, rgba(129,140,248,.08) 0%, transparent 70%);
103
+ top: 50%;
104
+ left: 50%;
105
+ transform: translate(-50%, -60%);
106
+ pointer-events: none;
107
+ }
108
+
109
+ .card {
110
+ position: relative;
111
+ display: flex;
112
+ flex-direction: column;
113
+ align-items: center;
114
+ text-align: center;
115
+ max-width: 520px;
116
+ width: 100%;
117
+ z-index: 1;
118
+ }
119
+
120
+ .logo {
121
+ width: 80px;
122
+ height: 80px;
123
+ margin-bottom: 28px;
124
+ filter: drop-shadow(0 0 28px rgba(129,140,248,.35));
125
+ }
126
+
127
+ h1 {
128
+ font-size: 42px;
129
+ font-weight: 800;
130
+ letter-spacing: -1.2px;
131
+ line-height: 1;
132
+ margin-bottom: 6px;
133
+ background: linear-gradient(135deg, #e6e6f0 30%, #818cf8 100%);
134
+ -webkit-background-clip: text;
135
+ -webkit-text-fill-color: transparent;
136
+ background-clip: text;
137
+ }
138
+
139
+ .tagline {
140
+ font-size: 13px;
141
+ font-weight: 600;
142
+ letter-spacing: .14em;
143
+ text-transform: uppercase;
144
+ color: var(--accent);
145
+ margin-bottom: 20px;
146
+ opacity: .8;
147
+ }
148
+
149
+ .desc {
150
+ font-size: 16px;
151
+ line-height: 1.65;
152
+ color: var(--muted);
153
+ max-width: 400px;
154
+ margin-bottom: 36px;
155
+ }
156
+
157
+ .actions {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 12px;
161
+ flex-wrap: wrap;
162
+ justify-content: center;
163
+ }
164
+
165
+ .btn-primary {
166
+ display: inline-flex;
167
+ align-items: center;
168
+ gap: 8px;
169
+ padding: 11px 26px;
170
+ background: var(--text);
171
+ color: var(--bg);
172
+ font-size: 14px;
173
+ font-weight: 700;
174
+ border-radius: 8px;
175
+ text-decoration: none;
176
+ transition: opacity .15s, transform .15s;
177
+ letter-spacing: -.1px;
178
+ }
179
+
180
+ .btn-primary:hover {
181
+ opacity: .88;
182
+ transform: translateY(-1px);
183
+ }
184
+
185
+ .btn-primary svg {
186
+ flex-shrink: 0;
187
+ }
188
+
189
+ .icon-group {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 8px;
193
+ }
194
+
195
+ .btn-icon {
196
+ display: inline-flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ width: 42px;
200
+ height: 42px;
201
+ border: 1px solid var(--border);
202
+ border-radius: 8px;
203
+ color: var(--muted);
204
+ background: transparent;
205
+ text-decoration: none;
206
+ transition: color .15s, border-color .15s, background .15s, transform .15s;
207
+ }
208
+
209
+ .btn-icon:hover {
210
+ color: var(--text);
211
+ border-color: rgba(255,255,255,.15);
212
+ background: var(--surface);
213
+ transform: translateY(-1px);
214
+ }
215
+
216
+ .badge-row {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 8px;
220
+ margin-top: 40px;
221
+ flex-wrap: wrap;
222
+ justify-content: center;
223
+ }
224
+
225
+ .badge {
226
+ font-size: 11px;
227
+ font-weight: 600;
228
+ letter-spacing: .05em;
229
+ padding: 4px 10px;
230
+ border-radius: 20px;
231
+ border: 1px solid var(--border);
232
+ color: var(--muted);
233
+ }
234
+
235
+ footer {
236
+ position: fixed;
237
+ bottom: 0; left: 0; right: 0;
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ padding: 20px;
242
+ font-size: 12.5px;
243
+ color: var(--muted);
244
+ opacity: .5;
245
+ transition: opacity .2s;
246
+ }
247
+
248
+ footer:hover { opacity: 1; }
249
+
250
+ @media (max-width: 480px) {
251
+ h1 { font-size: 32px; }
252
+ .logo { width: 64px; height: 64px; }
253
+ .desc { font-size: 15px; }
254
+ }
255
+ </style>
256
+ </head>
257
+ <body>
258
+
259
+ <div class="cursor-dot" id="cursorDot" aria-hidden="true"></div>
260
+ <div class="cursor-ring" id="cursorRing" aria-hidden="true"></div>
261
+
262
+ <div class="glow"></div>
263
+
264
+ <div class="card">
265
+ <img src="logo.svg" alt="Anivexa" class="logo"/>
266
+
267
+ <h1>Anivexa</h1>
268
+ <p class="tagline">Streaming Aggregator API</p>
269
+
270
+ <p class="desc">
271
+ 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.
272
+ </p>
273
+
274
+ <div class="actions">
275
+ <a href="/docs" class="btn-primary">
276
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
277
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
278
+ <polyline points="14 2 14 8 20 8"/>
279
+ <line x1="16" y1="13" x2="8" y2="13"/>
280
+ <line x1="16" y1="17" x2="8" y2="17"/>
281
+ <polyline points="10 9 9 9 8 9"/>
282
+ </svg>
283
+ Get Started
284
+ </a>
285
+
286
+ <div class="icon-group">
287
+ <a href="https://github.com/walterwhite-69/Anivexa-API/" target="_blank" rel="noopener noreferrer" class="btn-icon" aria-label="GitHub">
288
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor">
289
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
290
+ </svg>
291
+ </a>
292
+
293
+ <a href="https://discord.com/invite/MARQ9z9QSX" target="_blank" rel="noopener noreferrer" class="btn-icon" aria-label="Discord">
294
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor">
295
+ <path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2498-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8745-.6177-1.2498a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
296
+ </svg>
297
+ </a>
298
+ </div>
299
+ </div>
300
+
301
+ <div class="badge-row">
302
+ <span class="badge">13 Providers</span>
303
+ <span class="badge">AniList ID</span>
304
+ <span class="badge">No Auth</span>
305
+ <span class="badge">CORS Enabled</span>
306
+ </div>
307
+ </div>
308
+
309
+ <footer>© 2026 Anivexa. All rights reserved.</footer>
310
+
311
+ <script>
312
+ (function () {
313
+ const dot = document.getElementById('cursorDot');
314
+ const ring = document.getElementById('cursorRing');
315
+ if (!dot || !ring) return;
316
+
317
+ let mx = -200, my = -200, rx = -200, ry = -200, visible = false;
318
+ const LERP = 0.22;
319
+ const INTERACTIVES = 'a, button, [role="button"]';
320
+
321
+ (function tick() {
322
+ rx += (mx - rx) * LERP;
323
+ ry += (my - ry) * LERP;
324
+ dot.style.transform = `translate(${mx}px,${my}px)`;
325
+ ring.style.transform = `translate(${Math.round(rx * 10) / 10}px,${Math.round(ry * 10) / 10}px)`;
326
+ requestAnimationFrame(tick);
327
+ })();
328
+
329
+ document.addEventListener('mousemove', e => {
330
+ mx = e.clientX; my = e.clientY;
331
+ if (!visible) { visible = true; rx = mx; ry = my; dot.classList.remove('is-hidden'); ring.classList.remove('is-hidden'); }
332
+ }, { passive: true });
333
+
334
+ document.addEventListener('mouseleave', () => { dot.classList.add('is-hidden'); ring.classList.add('is-hidden'); visible = false; }, { passive: true });
335
+ document.addEventListener('mouseenter', () => { dot.classList.remove('is-hidden'); ring.classList.remove('is-hidden'); visible = true; }, { passive: true });
336
+ document.addEventListener('mousedown', () => { ring.classList.add('is-clicking'); ring.classList.remove('is-hovering'); }, { passive: true });
337
+ document.addEventListener('mouseup', () => { ring.classList.remove('is-clicking'); }, { passive: true });
338
+ document.addEventListener('mouseover', e => { if (e.target.closest(INTERACTIVES)) { dot.classList.add('is-hovering'); ring.classList.add('is-hovering'); } }, { passive: true });
339
+ document.addEventListener('mouseout', e => { if (e.target.closest(INTERACTIVES)) { dot.classList.remove('is-hovering'); ring.classList.remove('is-hovering'); } }, { passive: true });
340
+
341
+ window.addEventListener('touchstart', () => { dot.style.display = 'none'; ring.style.display = 'none'; document.body.style.cursor = ''; }, { once: true, passive: true });
342
+ })();
343
+ </script>
344
+ </body>
345
+ </html>
anivexa-api/docs/logo.svg ADDED
anivexa-api/docs/style.css ADDED
@@ -0,0 +1,757 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --bg: #09090d;
5
+ --sidebar: #0d0d12;
6
+ --border: #1c1c26;
7
+ --border-hi: #2a2a3a;
8
+ --surface: #111118;
9
+ --surface-hi: #16161f;
10
+ --text: #eaeaf4;
11
+ --muted: #63637a;
12
+ --faint: #1e1e2a;
13
+ --accent: #818cf8;
14
+ --accent-dim: rgba(129,140,248,.1);
15
+ --accent-glow: rgba(129,140,248,.18);
16
+ --accent-mid: rgba(129,140,248,.06);
17
+ --green: #4ade80;
18
+ --green-dim: rgba(74,222,128,.1);
19
+ --purple: #c084fc;
20
+ --purple-dim: rgba(192,132,252,.1);
21
+ --sidebar-w: 264px;
22
+ --header-h: 56px;
23
+ --radius: 10px;
24
+ --font: 'Inter', system-ui, sans-serif;
25
+ --mono: 'JetBrains Mono', 'Fira Code', monospace;
26
+ }
27
+
28
+ html { scroll-behavior: smooth; }
29
+
30
+ body {
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ font-family: var(--font);
34
+ font-size: 15px;
35
+ line-height: 1.7;
36
+ -webkit-font-smoothing: antialiased;
37
+ overflow-x: hidden;
38
+ }
39
+
40
+ a { color: var(--accent); text-decoration: none; }
41
+ a:hover { text-decoration: underline; }
42
+
43
+ .bg-orbs {
44
+ position: fixed;
45
+ inset: 0;
46
+ overflow: hidden;
47
+ pointer-events: none;
48
+ z-index: 0;
49
+ }
50
+
51
+ .bg-orb {
52
+ position: absolute;
53
+ border-radius: 50%;
54
+ filter: blur(90px);
55
+ will-change: transform;
56
+ }
57
+
58
+ .bg-orb-1 {
59
+ width: 640px; height: 640px;
60
+ background: radial-gradient(circle, rgba(129,140,248,.11) 0%, transparent 70%);
61
+ top: -8%; left: 15%;
62
+ animation: orbFloat1 18s ease-in-out infinite;
63
+ }
64
+
65
+ .bg-orb-2 {
66
+ width: 480px; height: 480px;
67
+ background: radial-gradient(circle, rgba(192,132,252,.08) 0%, transparent 70%);
68
+ top: 35%; right: -8%;
69
+ animation: orbFloat2 22s ease-in-out infinite;
70
+ }
71
+
72
+ .bg-orb-3 {
73
+ width: 360px; height: 360px;
74
+ background: radial-gradient(circle, rgba(74,222,128,.06) 0%, transparent 70%);
75
+ bottom: 8%; left: 28%;
76
+ animation: orbFloat3 26s ease-in-out infinite;
77
+ }
78
+
79
+ @keyframes orbFloat1 {
80
+ 0%,100% { transform: translate(0,0); }
81
+ 33% { transform: translate(30px,-40px); }
82
+ 66% { transform: translate(-20px,25px); }
83
+ }
84
+ @keyframes orbFloat2 {
85
+ 0%,100% { transform: translate(0,0); }
86
+ 40% { transform: translate(-35px,30px); }
87
+ 70% { transform: translate(20px,-20px); }
88
+ }
89
+ @keyframes orbFloat3 {
90
+ 0%,100% { transform: translate(0,0); }
91
+ 30% { transform: translate(25px,-30px); }
92
+ 65% { transform: translate(-15px,20px); }
93
+ }
94
+
95
+ .layout {
96
+ display: flex;
97
+ min-height: 100vh;
98
+ position: relative;
99
+ z-index: 1;
100
+ }
101
+
102
+ .sidebar {
103
+ position: fixed;
104
+ top: 0; left: 0;
105
+ width: var(--sidebar-w);
106
+ height: 100vh;
107
+ background: rgba(13,13,18,.92);
108
+ backdrop-filter: blur(24px) saturate(180%);
109
+ -webkit-backdrop-filter: blur(24px) saturate(180%);
110
+ border-right: 1px solid var(--border);
111
+ display: flex;
112
+ flex-direction: column;
113
+ overflow-y: auto;
114
+ z-index: 100;
115
+ scrollbar-width: thin;
116
+ scrollbar-color: var(--faint) transparent;
117
+ }
118
+
119
+ .sidebar::after {
120
+ content: '';
121
+ position: absolute;
122
+ top: 0; right: 0;
123
+ width: 1px;
124
+ height: 100%;
125
+ background: linear-gradient(to bottom, transparent, rgba(129,140,248,.15) 30%, rgba(129,140,248,.15) 70%, transparent);
126
+ pointer-events: none;
127
+ }
128
+
129
+ .sidebar::-webkit-scrollbar { width: 4px; }
130
+ .sidebar::-webkit-scrollbar-track { background: transparent; }
131
+ .sidebar::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; }
132
+
133
+ .logo {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 10px;
137
+ padding: 20px 20px 16px;
138
+ border-bottom: 1px solid var(--border);
139
+ position: relative;
140
+ }
141
+
142
+ .logo::before {
143
+ content: '';
144
+ position: absolute;
145
+ inset: 0;
146
+ background: linear-gradient(135deg, rgba(129,140,248,.05), transparent 60%);
147
+ pointer-events: none;
148
+ }
149
+
150
+ .logo img {
151
+ width: 28px; height: 28px;
152
+ flex-shrink: 0;
153
+ filter: drop-shadow(0 0 10px rgba(129,140,248,.4));
154
+ animation: logoPulse 4s ease-in-out infinite;
155
+ }
156
+
157
+ @keyframes logoPulse {
158
+ 0%,100% { filter: drop-shadow(0 0 10px rgba(129,140,248,.4)); }
159
+ 50% { filter: drop-shadow(0 0 18px rgba(129,140,248,.65)); }
160
+ }
161
+
162
+ .logo-text {
163
+ font-size: 15px;
164
+ font-weight: 700;
165
+ letter-spacing: -.3px;
166
+ color: var(--text);
167
+ }
168
+
169
+ .logo-version {
170
+ font-size: 10px;
171
+ font-weight: 600;
172
+ color: var(--accent);
173
+ background: var(--accent-dim);
174
+ border: 1px solid rgba(129,140,248,.2);
175
+ padding: 2px 7px;
176
+ border-radius: 20px;
177
+ margin-left: auto;
178
+ flex-shrink: 0;
179
+ letter-spacing: .04em;
180
+ }
181
+
182
+ .nav { padding: 12px 0 24px; flex: 1; }
183
+
184
+ .nav-section {
185
+ padding: 0 12px;
186
+ margin-bottom: 4px;
187
+ }
188
+
189
+ .nav-label {
190
+ font-size: 10px;
191
+ font-weight: 700;
192
+ letter-spacing: .1em;
193
+ text-transform: uppercase;
194
+ color: var(--muted);
195
+ padding: 16px 8px 6px;
196
+ }
197
+
198
+ .nav-item {
199
+ display: flex;
200
+ align-items: center;
201
+ padding: 6px 8px;
202
+ border-radius: 7px;
203
+ font-size: 13.5px;
204
+ color: var(--muted);
205
+ cursor: pointer;
206
+ transition: color .15s, background .15s, transform .15s;
207
+ white-space: nowrap;
208
+ overflow: hidden;
209
+ text-overflow: ellipsis;
210
+ position: relative;
211
+ }
212
+
213
+ .nav-item:hover {
214
+ color: var(--text);
215
+ background: var(--faint);
216
+ text-decoration: none;
217
+ transform: translateX(2px);
218
+ }
219
+
220
+ .nav-item.active {
221
+ color: var(--accent);
222
+ background: var(--accent-dim);
223
+ }
224
+
225
+ .nav-item.active::before {
226
+ content: '';
227
+ position: absolute;
228
+ left: 0; top: 20%; bottom: 20%;
229
+ width: 2px;
230
+ background: var(--accent);
231
+ border-radius: 2px;
232
+ box-shadow: 0 0 8px var(--accent);
233
+ }
234
+
235
+ .nav-item .method {
236
+ font-size: 10px;
237
+ font-family: var(--mono);
238
+ font-weight: 600;
239
+ color: var(--green);
240
+ background: var(--green-dim);
241
+ border: 1px solid rgba(74,222,128,.15);
242
+ padding: 1px 5px;
243
+ border-radius: 4px;
244
+ margin-right: 7px;
245
+ flex-shrink: 0;
246
+ }
247
+
248
+ .main {
249
+ margin-left: var(--sidebar-w);
250
+ min-width: 0;
251
+ flex: 1;
252
+ padding: 64px max(40px, calc((100% - 820px) / 2));
253
+ }
254
+
255
+ .topbar {
256
+ display: none;
257
+ position: fixed;
258
+ top: 0; left: 0; right: 0;
259
+ height: var(--header-h);
260
+ background: rgba(9,9,13,.95);
261
+ backdrop-filter: blur(20px) saturate(180%);
262
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
263
+ border-bottom: 1px solid var(--border);
264
+ align-items: center;
265
+ padding: 0 16px;
266
+ gap: 12px;
267
+ z-index: 200;
268
+ }
269
+
270
+ .topbar img { width: 24px; height: 24px; }
271
+ .topbar-title { font-size: 15px; font-weight: 700; color: var(--text); }
272
+
273
+ .menu-btn {
274
+ margin-left: auto;
275
+ background: none;
276
+ border: 1px solid var(--border);
277
+ border-radius: 7px;
278
+ padding: 6px 8px;
279
+ color: var(--text);
280
+ cursor: pointer;
281
+ display: flex;
282
+ flex-direction: column;
283
+ align-items: center;
284
+ gap: 4px;
285
+ transition: border-color .2s, background .2s;
286
+ }
287
+
288
+ .menu-btn:hover {
289
+ border-color: var(--accent);
290
+ background: var(--accent-dim);
291
+ }
292
+
293
+ .menu-btn span {
294
+ display: block;
295
+ width: 16px; height: 2px;
296
+ background: var(--text);
297
+ border-radius: 2px;
298
+ transition: transform .3s cubic-bezier(.22,1,.36,1), opacity .2s;
299
+ }
300
+
301
+ .menu-btn.active span:nth-child(1) { transform: rotate(45deg) translate(4px, 4px); }
302
+ .menu-btn.active span:nth-child(2) { opacity: 0; }
303
+ .menu-btn.active span:nth-child(3) { transform: rotate(-45deg) translate(4px, -4px); }
304
+
305
+ .section {
306
+ margin-bottom: 80px;
307
+ scroll-margin-top: 32px;
308
+ opacity: 0;
309
+ transform: translateY(28px);
310
+ transition: opacity .55s cubic-bezier(.22,1,.36,1), transform .55s cubic-bezier(.22,1,.36,1);
311
+ }
312
+
313
+ .section.visible {
314
+ opacity: 1;
315
+ transform: translateY(0);
316
+ }
317
+
318
+ .section-hero { margin-bottom: 72px; }
319
+
320
+ .hero-logo {
321
+ width: 56px; height: 56px;
322
+ margin-bottom: 24px;
323
+ filter: drop-shadow(0 0 24px rgba(129,140,248,.5));
324
+ animation: heroFloat 5s ease-in-out infinite;
325
+ transform-style: preserve-3d;
326
+ }
327
+
328
+ @keyframes heroFloat {
329
+ 0%,100% { transform: translateY(0) rotateY(0deg); }
330
+ 50% { transform: translateY(-6px) rotateY(8deg); }
331
+ }
332
+
333
+ h1 {
334
+ font-size: 36px;
335
+ font-weight: 800;
336
+ letter-spacing: -.8px;
337
+ line-height: 1.15;
338
+ margin-bottom: 14px;
339
+ background: linear-gradient(135deg, #eaeaf4 0%, #818cf8 55%, #c084fc 100%);
340
+ -webkit-background-clip: text;
341
+ -webkit-text-fill-color: transparent;
342
+ background-clip: text;
343
+ }
344
+
345
+ h2 {
346
+ font-size: 20px;
347
+ font-weight: 700;
348
+ letter-spacing: -.3px;
349
+ color: var(--text);
350
+ margin-bottom: 18px;
351
+ padding-bottom: 13px;
352
+ border-bottom: 1px solid var(--border);
353
+ position: relative;
354
+ }
355
+
356
+ h2::after {
357
+ content: '';
358
+ position: absolute;
359
+ bottom: -1px; left: 0;
360
+ width: 40px; height: 1px;
361
+ background: var(--accent);
362
+ box-shadow: 0 0 8px var(--accent);
363
+ border-radius: 2px;
364
+ }
365
+
366
+ h3 {
367
+ font-size: 15px;
368
+ font-weight: 600;
369
+ color: var(--text);
370
+ margin: 24px 0 8px;
371
+ }
372
+
373
+ p { color: var(--muted); margin-bottom: 16px; }
374
+ p:last-child { margin-bottom: 0; }
375
+
376
+ .endpoint {
377
+ background: var(--surface);
378
+ border: 1px solid var(--border);
379
+ border-radius: var(--radius);
380
+ margin-bottom: 20px;
381
+ overflow: hidden;
382
+ transition: border-color .2s, box-shadow .2s, transform .2s;
383
+ transform: perspective(1000px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg)) translateZ(0);
384
+ }
385
+
386
+ .endpoint:hover {
387
+ border-color: var(--border-hi);
388
+ box-shadow: 0 8px 40px rgba(0,0,0,.35), 0 0 0 1px rgba(129,140,248,.06);
389
+ }
390
+
391
+ .endpoint-head {
392
+ display: flex;
393
+ align-items: center;
394
+ gap: 12px;
395
+ padding: 14px 18px;
396
+ border-bottom: 1px solid var(--border);
397
+ background: linear-gradient(135deg, var(--sidebar) 0%, rgba(22,22,32,1) 100%);
398
+ position: relative;
399
+ }
400
+
401
+ .endpoint-head::before {
402
+ content: '';
403
+ position: absolute;
404
+ inset: 0;
405
+ background: linear-gradient(90deg, rgba(129,140,248,.03) 0%, transparent 100%);
406
+ pointer-events: none;
407
+ }
408
+
409
+ .method-pill {
410
+ font-family: var(--mono);
411
+ font-size: 11px;
412
+ font-weight: 700;
413
+ padding: 3px 9px;
414
+ border-radius: 5px;
415
+ flex-shrink: 0;
416
+ letter-spacing: .04em;
417
+ }
418
+
419
+ .method-pill.get {
420
+ color: var(--green);
421
+ background: var(--green-dim);
422
+ border: 1px solid rgba(74,222,128,.2);
423
+ }
424
+
425
+ .endpoint-path {
426
+ font-family: var(--mono);
427
+ font-size: 13.5px;
428
+ color: var(--text);
429
+ font-weight: 500;
430
+ word-break: break-all;
431
+ }
432
+
433
+ .endpoint-path .param { color: var(--accent); }
434
+
435
+ .try-btn {
436
+ margin-left: auto;
437
+ flex-shrink: 0;
438
+ font-size: 12px;
439
+ font-weight: 600;
440
+ color: var(--accent);
441
+ background: var(--accent-dim);
442
+ border: 1px solid rgba(129,140,248,.18);
443
+ border-radius: 6px;
444
+ padding: 4px 11px;
445
+ white-space: nowrap;
446
+ transition: background .15s, border-color .15s, box-shadow .15s;
447
+ text-decoration: none;
448
+ }
449
+
450
+ .try-btn:hover {
451
+ background: rgba(129,140,248,.18);
452
+ border-color: rgba(129,140,248,.4);
453
+ box-shadow: 0 0 12px rgba(129,140,248,.2);
454
+ text-decoration: none;
455
+ }
456
+
457
+ .endpoint-body { padding: 16px 18px; }
458
+
459
+ .endpoint-desc {
460
+ color: var(--muted);
461
+ font-size: 14px;
462
+ margin-bottom: 14px;
463
+ }
464
+
465
+ .params-table {
466
+ width: 100%;
467
+ border-collapse: collapse;
468
+ font-size: 13.5px;
469
+ margin-bottom: 16px;
470
+ }
471
+
472
+ .params-table th {
473
+ text-align: left;
474
+ padding: 8px 12px;
475
+ font-size: 11px;
476
+ font-weight: 700;
477
+ letter-spacing: .06em;
478
+ text-transform: uppercase;
479
+ color: var(--muted);
480
+ border-bottom: 1px solid var(--border);
481
+ }
482
+
483
+ .params-table td {
484
+ padding: 9px 12px;
485
+ border-bottom: 1px solid var(--faint);
486
+ vertical-align: top;
487
+ }
488
+
489
+ .params-table tr:last-child td { border-bottom: none; }
490
+
491
+ .param-name {
492
+ font-family: var(--mono);
493
+ font-size: 12.5px;
494
+ color: var(--accent);
495
+ white-space: nowrap;
496
+ }
497
+
498
+ .param-type {
499
+ font-family: var(--mono);
500
+ font-size: 11.5px;
501
+ color: var(--purple);
502
+ white-space: nowrap;
503
+ }
504
+
505
+ .param-desc { color: var(--muted); }
506
+
507
+ .code-label {
508
+ font-size: 11px;
509
+ font-weight: 600;
510
+ text-transform: uppercase;
511
+ letter-spacing: .07em;
512
+ color: var(--muted);
513
+ padding: 10px 18px 6px;
514
+ background: var(--surface);
515
+ border-top: 1px solid var(--border);
516
+ display: flex;
517
+ align-items: center;
518
+ justify-content: space-between;
519
+ }
520
+
521
+ pre {
522
+ background: var(--surface);
523
+ padding: 14px 18px 18px;
524
+ overflow-x: auto;
525
+ scrollbar-width: thin;
526
+ scrollbar-color: var(--faint) transparent;
527
+ position: relative;
528
+ transition: background .2s;
529
+ }
530
+
531
+ pre:hover { background: var(--surface-hi); }
532
+
533
+ pre::-webkit-scrollbar { height: 4px; }
534
+ pre::-webkit-scrollbar-track { background: transparent; }
535
+ pre::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; }
536
+
537
+ code {
538
+ font-family: var(--mono);
539
+ font-size: 12.5px;
540
+ line-height: 1.7;
541
+ color: var(--text);
542
+ }
543
+
544
+ .k { color: #c084fc; }
545
+ .s { color: #86efac; }
546
+ .n { color: #818cf8; }
547
+ .p { color: #555568; }
548
+ .b { color: #fb923c; }
549
+
550
+ .copy-btn {
551
+ position: absolute;
552
+ top: 10px; right: 12px;
553
+ font-size: 11px;
554
+ font-weight: 600;
555
+ font-family: var(--font);
556
+ color: var(--muted);
557
+ background: var(--faint);
558
+ border: 1px solid var(--border);
559
+ border-radius: 5px;
560
+ padding: 3px 9px;
561
+ cursor: pointer;
562
+ opacity: 0;
563
+ transition: opacity .2s, color .15s, background .15s;
564
+ z-index: 2;
565
+ }
566
+
567
+ pre:hover .copy-btn { opacity: 1; }
568
+
569
+ .copy-btn:hover {
570
+ color: var(--text);
571
+ background: var(--border-hi);
572
+ border-color: var(--border-hi);
573
+ }
574
+
575
+ .copy-btn.copied {
576
+ color: var(--green);
577
+ border-color: rgba(74,222,128,.3);
578
+ background: var(--green-dim);
579
+ opacity: 1;
580
+ }
581
+
582
+ .provider-grid {
583
+ display: grid;
584
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
585
+ gap: 12px;
586
+ margin-top: 18px;
587
+ }
588
+
589
+ .provider-card {
590
+ background: var(--surface);
591
+ border: 1px solid var(--border);
592
+ border-radius: var(--radius);
593
+ padding: 16px 18px;
594
+ transition: border-color .2s, box-shadow .2s;
595
+ transform: perspective(600px) rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));
596
+ will-change: transform;
597
+ cursor: default;
598
+ }
599
+
600
+ .provider-card:hover {
601
+ border-color: rgba(129,140,248,.35);
602
+ 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);
603
+ }
604
+
605
+ .provider-name {
606
+ font-family: var(--mono);
607
+ font-size: 13px;
608
+ font-weight: 600;
609
+ color: var(--text);
610
+ margin-bottom: 5px;
611
+ }
612
+
613
+ .provider-meta {
614
+ font-size: 12px;
615
+ color: var(--muted);
616
+ }
617
+
618
+ .callout {
619
+ display: flex;
620
+ gap: 12px;
621
+ background: linear-gradient(135deg, rgba(129,140,248,.08) 0%, rgba(129,140,248,.04) 100%);
622
+ border: 1px solid rgba(129,140,248,.18);
623
+ border-radius: var(--radius);
624
+ padding: 14px 18px;
625
+ margin-bottom: 24px;
626
+ font-size: 13.5px;
627
+ color: var(--text);
628
+ position: relative;
629
+ overflow: hidden;
630
+ }
631
+
632
+ .callout::before {
633
+ content: '';
634
+ position: absolute;
635
+ top: 0; left: 0;
636
+ width: 3px; height: 100%;
637
+ background: linear-gradient(to bottom, var(--accent), var(--purple));
638
+ border-radius: 2px 0 0 2px;
639
+ }
640
+
641
+ .callout-icon { flex-shrink: 0; font-size: 15px; }
642
+
643
+ .divider {
644
+ border: none;
645
+ height: 1px;
646
+ background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
647
+ margin: 56px 0;
648
+ }
649
+
650
+ .overlay {
651
+ display: none;
652
+ position: fixed;
653
+ inset: 0;
654
+ background: transparent;
655
+ z-index: 90;
656
+ }
657
+
658
+ .overlay.open { display: block; }
659
+
660
+ .provider-grid .provider-card { transition-delay: calc(var(--i, 0) * 40ms); }
661
+
662
+ @media (max-width: 768px) {
663
+ :root { --sidebar-w: 280px; }
664
+
665
+ .topbar { display: flex; }
666
+
667
+ .sidebar {
668
+ transform: translateX(-100%);
669
+ transition: transform .28s cubic-bezier(.4,0,.2,1);
670
+ background: var(--sidebar);
671
+ backdrop-filter: none;
672
+ -webkit-backdrop-filter: none;
673
+ -webkit-overflow-scrolling: touch;
674
+ overscroll-behavior: contain;
675
+ }
676
+
677
+ .sidebar.open {
678
+ transform: translateX(0);
679
+ box-shadow: 4px 0 24px rgba(0,0,0,.5);
680
+ }
681
+
682
+ .main {
683
+ margin-left: 0;
684
+ padding: calc(var(--header-h) + 32px) 20px 48px;
685
+ }
686
+
687
+ h1 { font-size: 26px; }
688
+ h2 { font-size: 17px; }
689
+ .bg-orb-2, .bg-orb-3 { display: none; }
690
+
691
+ .endpoint { transform: none !important; }
692
+ .provider-card { transform: none !important; }
693
+ }
694
+
695
+ @media (min-width: 769px) and (max-width: 1100px) {
696
+ .main { padding: 52px 36px; }
697
+ }
698
+
699
+ body, a, button, label, [role="button"] { cursor: none; }
700
+
701
+ .cursor-dot,
702
+ .cursor-ring {
703
+ position: fixed;
704
+ top: 0; left: 0;
705
+ pointer-events: none;
706
+ z-index: 99999;
707
+ will-change: transform;
708
+ border-radius: 50%;
709
+ }
710
+
711
+ .cursor-dot {
712
+ width: 6px; height: 6px;
713
+ background: var(--accent);
714
+ margin: -3px 0 0 -3px;
715
+ box-shadow: 0 0 10px rgba(129,140,248,.8);
716
+ transition: opacity .2s, transform .1s;
717
+ }
718
+
719
+ .cursor-ring {
720
+ width: 36px; height: 36px;
721
+ border: 1.5px solid rgba(129,140,248,.45);
722
+ margin: -18px 0 0 -18px;
723
+ transition: width .25s cubic-bezier(.22,1,.36,1),
724
+ height .25s cubic-bezier(.22,1,.36,1),
725
+ margin .25s cubic-bezier(.22,1,.36,1),
726
+ border-color .25s,
727
+ background .25s,
728
+ opacity .2s;
729
+ }
730
+
731
+ .cursor-ring.is-hovering {
732
+ width: 52px; height: 52px;
733
+ margin: -26px 0 0 -26px;
734
+ border-color: rgba(129,140,248,.7);
735
+ background: rgba(129,140,248,.06);
736
+ }
737
+
738
+ .cursor-dot.is-hovering { opacity: 0; }
739
+
740
+ .cursor-dot.is-hidden,
741
+ .cursor-ring.is-hidden { opacity: 0; }
742
+
743
+ .cursor-ring.is-clicking {
744
+ width: 28px; height: 28px;
745
+ margin: -14px 0 0 -14px;
746
+ border-color: rgba(129,140,248,.9);
747
+ background: rgba(129,140,248,.12);
748
+ }
749
+
750
+ input, textarea, select { cursor: text; }
751
+
752
+ @media (prefers-reduced-motion: reduce) {
753
+ .section { opacity: 1; transform: none; transition: none; }
754
+ .hero-logo, .logo img { animation: none; }
755
+ .bg-orb { animation: none; }
756
+ .provider-card, .endpoint { transform: none !important; }
757
+ }
anivexa-api/index.js ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMedia } from "./core/anilist.js";
2
+ import { mapAnimeIds } from "./core/mapper.js";
3
+ import mangaHandler from "./providers/allmanga.js";
4
+ import reanimeHandler from "./providers/reanime.js";
5
+ import anikotoHandler from "./providers/anikoto.js";
6
+ import animeggHandler from "./providers/animegg.js";
7
+ import aninekoHandler from "./providers/anineko.js";
8
+ import anidbappHandler from "./providers/anidbapp.js";
9
+ import dhiveHandler from "./providers/2dhive.js";
10
+ import animenosubHandler from "./providers/animenosub.js";
11
+ import anizoneHandler from "./providers/anizone.js";
12
+ import anibdHandler from "./providers/anibd.js";
13
+ import senshiHandler from "./providers/senshi.js";
14
+ import kaaHandler from "./providers/kickassanime.js";
15
+ import animedunyaHandler from "./providers/animedunya.js";
16
+ import { getEpisodesResponse, getFilteredEpisodesResponse } from "./core/episode-cache.js";
17
+ import { resolveProviders } from "./core/episode-strategy.js";
18
+ import { getAsync, setAsync, isFresh, mapTTL, WATCH_TTL, _CACHE_ENABLED } from "./core/smartcache.js";
19
+
20
+ function json(data, status = 200) {
21
+ return new Response(JSON.stringify(data, null, 2), {
22
+ status,
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ "Access-Control-Allow-Origin": "*",
26
+ "Cache-Control": "public, max-age=300",
27
+ },
28
+ });
29
+ }
30
+
31
+ function rewriteRequest(request, newPath) {
32
+ const u = new URL(request.url);
33
+ u.pathname = newPath;
34
+ return new Request(u.toString(), { method: request.method, headers: request.headers });
35
+ }
36
+
37
+ const watchInflight = new Map();
38
+
39
+ async function cachedWatch(cacheKey, handlerFn) {
40
+ const entry = await getAsync(cacheKey);
41
+ if (entry && isFresh(entry)) return json(entry.data);
42
+
43
+ if (watchInflight.has(cacheKey)) {
44
+ await watchInflight.get(cacheKey).catch(() => {});
45
+ const warm = await getAsync(cacheKey);
46
+ if (warm && isFresh(warm)) return json(warm.data);
47
+ return handlerFn();
48
+ }
49
+
50
+ const promise = (async () => {
51
+ const response = await handlerFn();
52
+ if (response.status === 200) {
53
+ try {
54
+ const data = await response.clone().json();
55
+ await setAsync(cacheKey, data, WATCH_TTL);
56
+ } catch {}
57
+ }
58
+ return response;
59
+ })();
60
+
61
+ watchInflight.set(cacheKey, promise);
62
+ try { return await promise; }
63
+ finally { watchInflight.delete(cacheKey); }
64
+ }
65
+
66
+ export default {
67
+ async fetch(request, env) {
68
+ const url = new URL(request.url);
69
+ const path = url.pathname;
70
+
71
+ if (request.method === "OPTIONS") {
72
+ return new Response(null, {
73
+ status: 204,
74
+ headers: {
75
+ "Access-Control-Allow-Origin": "*",
76
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
77
+ "Access-Control-Allow-Headers": "*",
78
+ },
79
+ });
80
+ }
81
+
82
+ let m = path.match(/^\/map\/(\d+)\/?$/);
83
+ if (m) {
84
+ const anilistId = m[1];
85
+ const cacheKey = `map:${anilistId}`;
86
+ const entry = await getAsync(cacheKey);
87
+ if (entry && isFresh(entry)) return json(entry.data);
88
+
89
+ try {
90
+ const [data, media] = await Promise.all([
91
+ mapAnimeIds(anilistId),
92
+ getMedia(anilistId).catch(() => null),
93
+ ]);
94
+ await setAsync(cacheKey, data, mapTTL(media?.status ?? "RELEASING"));
95
+ return json(data);
96
+ } catch (e) {
97
+ if (entry) return json(entry.data);
98
+ return json({ error: e.message }, 500);
99
+ }
100
+ }
101
+
102
+ m = path.match(/^\/episodes\/((?:[\w-]+\/)+)(\d+)\/?$/i);
103
+ if (m) {
104
+ const rawNames = m[1].replace(/\/$/, "").split("/");
105
+ const anilistId = m[2];
106
+ const includeMap = url.searchParams.get("map") !== "false";
107
+ const { resolved, unknown } = resolveProviders(rawNames);
108
+
109
+ if (resolved.size === 0) {
110
+ return json({ error: "No valid providers specified", unknown }, 400);
111
+ }
112
+
113
+ try {
114
+ const data = await getFilteredEpisodesResponse(anilistId, resolved, includeMap);
115
+ if (unknown.length) data._unknownProviders = unknown;
116
+ return json(data);
117
+ } catch (e) {
118
+ return json({ error: e.message }, 500);
119
+ }
120
+ }
121
+
122
+ m = path.match(/^\/episodes\/(\d+)\/?$/);
123
+ if (m) {
124
+ const anilistId = m[1];
125
+ try {
126
+ return json(await getEpisodesResponse(anilistId, env));
127
+ } catch (e) {
128
+ return json({ error: e.message }, 500);
129
+ }
130
+ }
131
+
132
+ m = path.match(/^\/watch\/allmanga\/(\d+)\/(sub|dub)\/allmanga-(\d+)\/?$/);
133
+ if (m) {
134
+ const [, id, audio, ep] = m;
135
+ return cachedWatch(
136
+ `watch:manga:${id}:${audio}:${ep}`,
137
+ () => mangaHandler.fetch(request)
138
+ );
139
+ }
140
+
141
+ m = path.match(/^\/watch\/reanime\/(\d+)\/(sub|dub)\/reanime-(\d+)\/?$/);
142
+ if (m) {
143
+ const [, id, audio, ep] = m;
144
+ return cachedWatch(
145
+ `watch:reanime:${id}:${audio}:${ep}`,
146
+ () => reanimeHandler.fetch(rewriteRequest(request, `/watch/${id}/${audio}/${ep}`))
147
+ );
148
+ }
149
+
150
+ m = path.match(/^\/stream\/reanime\/(\d+)\/(sub|dub)\/(\d+)\/?$/);
151
+ if (m) {
152
+ const [, id, audio, ep] = m;
153
+ return reanimeHandler.fetch(rewriteRequest(request, `/stream/${id}/${audio}/${ep}`));
154
+ }
155
+
156
+ m = path.match(/^\/watch\/anikoto\/(\d+)\/(sub|dub)\/anikoto-(\d+)\/?$/);
157
+ if (m) {
158
+ const [, id, audio, ep] = m;
159
+ return cachedWatch(
160
+ `watch:anikoto:${id}:${audio}:${ep}`,
161
+ () => anikotoHandler.fetch(request)
162
+ );
163
+ }
164
+
165
+ m = path.match(/^\/watch\/animegg\/(\d+)\/(sub|dub)\/animegg-(\d+)\/?$/);
166
+ if (m) {
167
+ const [, id, audio, ep] = m;
168
+ return cachedWatch(
169
+ `watch:animegg:${id}:${audio}:${ep}`,
170
+ () => animeggHandler.fetch(request)
171
+ );
172
+ }
173
+
174
+ m = path.match(/^\/watch\/anineko\/(\d+)\/(sub|dub)\/anineko-(\d+)\/?$/);
175
+ if (m) {
176
+ const [, id, audio, ep] = m;
177
+ return cachedWatch(
178
+ `watch:anineko:${id}:${audio}:${ep}`,
179
+ () => aninekoHandler.fetch(request)
180
+ );
181
+ }
182
+
183
+ m = path.match(/^\/watch\/anidbapp\/(\d+)\/(sub|dub)\/anidbapp-(\d+)\/?$/);
184
+ if (m) {
185
+ const [, id, audio, ep] = m;
186
+ return cachedWatch(
187
+ `watch:anidbapp:${id}:${audio}:${ep}`,
188
+ () => anidbappHandler.fetch(request)
189
+ );
190
+ }
191
+
192
+ m = path.match(/^\/watch\/2dhive\/(\d+)\/(sub|dub)\/2dhive-(\d+)\/?$/);
193
+ if (m) {
194
+ const [, id, audio, ep] = m;
195
+ return cachedWatch(
196
+ `watch:2dhive:${id}:${audio}:${ep}`,
197
+ () => dhiveHandler.fetch(request)
198
+ );
199
+ }
200
+
201
+ m = path.match(/^\/watch\/animenosub\/(\d+)\/(sub|dub)\/animenosub-(\d+)\/?$/);
202
+ if (m) {
203
+ const [, id, audio, ep] = m;
204
+ return cachedWatch(
205
+ `watch:animenosub:${id}:${audio}:${ep}`,
206
+ () => animenosubHandler.fetch(request)
207
+ );
208
+ }
209
+
210
+ m = path.match(/^\/watch\/anizone\/(\d+)\/(sub|dub)\/anizone-(\d+)\/?$/);
211
+ if (m) {
212
+ const [, id, audio, ep] = m;
213
+ return cachedWatch(
214
+ `watch:anizone:${id}:${audio}:${ep}`,
215
+ () => anizoneHandler.fetch(request)
216
+ );
217
+ }
218
+
219
+ m = path.match(/^\/watch\/anibd\/(\d+)\/(sub|dub)\/anibd-(\d+)\/?$/);
220
+ if (m) {
221
+ const [, id, audio, ep] = m;
222
+ return cachedWatch(
223
+ `watch:anibd:${id}:${audio}:${ep}`,
224
+ () => anibdHandler.fetch(request)
225
+ );
226
+ }
227
+
228
+ m = path.match(/^\/watch\/senshi\/(\d+)\/(sub|dub)\/senshi-(\d+)\/?$/);
229
+ if (m) {
230
+ const [, id, audio, ep] = m;
231
+ return cachedWatch(
232
+ `watch:senshi:${id}:${audio}:${ep}`,
233
+ () => senshiHandler.fetch(request)
234
+ );
235
+ }
236
+
237
+ m = path.match(/^\/watch\/kaa\/(\d+)\/(sub|dub)\/kaa-(\d+)\/?$/);
238
+ if (m) {
239
+ const [, id, audio, ep] = m;
240
+ return cachedWatch(
241
+ `watch:kaa:${id}:${audio}:${ep}`,
242
+ () => kaaHandler.fetch(request)
243
+ );
244
+ }
245
+
246
+ m = path.match(/^\/watch\/animedunya\/(\d+)\/(sub|dub)\/animedunya-(\d+)\/?$/);
247
+ if (m) {
248
+ const [, id, audio, ep] = m;
249
+ return cachedWatch(
250
+ `watch:animedunya:${id}:${audio}:${ep}`,
251
+ () => animedunyaHandler.fetch(request)
252
+ );
253
+ }
254
+
255
+ m = path.match(/^\/stream\/2dhive\/(\d+)\/(sub|dub)\/(\d+)\/?$/);
256
+ if (m) return dhiveHandler.fetch(request);
257
+
258
+ m = path.match(/^\/stream\/2dhive\/download\/(\d+)\/(sub|dub)\/(\d+)\/?$/);
259
+ if (m) return dhiveHandler.fetch(request);
260
+
261
+ return json({
262
+ 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
263
+ cache: _CACHE_ENABLED,
264
+ providers: [
265
+ "allmanga",
266
+ "reanime",
267
+ "anikoto",
268
+ "animegg",
269
+ "anineko",
270
+ "anidbapp",
271
+ "2dhive",
272
+ "animenosub",
273
+ "anizone",
274
+ "anibd",
275
+ "senshi",
276
+ "kaa",
277
+ "animedunya",
278
+ ],
279
+ routes: [
280
+ "/map/:anilistId",
281
+ "/episodes/:anilistId",
282
+ "/episodes/:provider[/:provider...]/:anilistId?map=true|false",
283
+ "/watch/allmanga/:id/sub|dub/allmanga-:ep",
284
+ "/watch/reanime/:id/sub|dub/reanime-:ep",
285
+ "/stream/reanime/:id/sub|dub/:ep",
286
+ "/watch/anikoto/:id/sub|dub/anikoto-:ep",
287
+ "/watch/animegg/:id/sub|dub/animegg-:ep",
288
+ "/watch/anineko/:id/sub|dub/anineko-:ep",
289
+ "/watch/anidbapp/:id/sub|dub/anidbapp-:ep",
290
+ "/watch/2dhive/:id/sub|dub/2dhive-:ep",
291
+ "/stream/2dhive/:id/sub|dub/:ep",
292
+ "/stream/2dhive/download/:id/sub|dub/:ep",
293
+ "/watch/animenosub/:id/sub|dub/animenosub-:ep",
294
+ "/watch/anizone/:id/sub|dub/anizone-:ep",
295
+ "/watch/anibd/:id/sub|dub/anibd-:ep",
296
+ "/watch/senshi/:id/sub|dub/senshi-:ep",
297
+ "/watch/kaa/:id/sub|dub/kaa-:ep",
298
+ "/watch/animedunya/:id/sub|dub/animedunya-:ep",
299
+ ],
300
+ });
301
+ },
302
+ };
anivexa-api/package.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "all-api",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ }
9
+ }
anivexa-api/providers/2dhive.js ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMedia } from "../core/anilist.js";
2
+ import { episodeMeta, expectedCount, json } from "../core/new-provider-utils.js";
3
+
4
+ async function getMalId(anilistId, ctx) {
5
+ const idMal = ctx?.media?.idMal ?? (await getMedia(anilistId)).idMal;
6
+ if (!idMal) throw new Error(`2dhive: no MAL ID found for AniList ${anilistId}`);
7
+ return idMal;
8
+ }
9
+
10
+ const BASE = "https://2dhive.com";
11
+ 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";
12
+
13
+ async function fetchPage(url) {
14
+ const res = await fetch(url, { headers: { "User-Agent": UA } });
15
+ if (!res.ok) throw new Error(`2dhive ${res.status}: ${url}`);
16
+ return res.text();
17
+ }
18
+
19
+ function extractPlayerProps(html) {
20
+ const idx = html.indexOf("prefetchedHls");
21
+ if (idx === -1) return null;
22
+ const propsIdx = html.lastIndexOf('props="', idx);
23
+ if (propsIdx === -1) return null;
24
+ const valueIdx = propsIdx + 7;
25
+ const endIdx = html.indexOf('"', valueIdx);
26
+ if (endIdx === -1) return null;
27
+ const raw = html.slice(valueIdx, endIdx)
28
+ .replace(/&quot;/g, '"')
29
+ .replace(/&amp;/g, "&")
30
+ .replace(/&#39;/g, "'")
31
+ .replace(/&lt;/g, "<")
32
+ .replace(/&gt;/g, ">");
33
+ try { return JSON.parse(raw); } catch { return null; }
34
+ }
35
+
36
+ function astroDecode(v) {
37
+ if (!Array.isArray(v)) return v;
38
+ const [type, data] = v;
39
+ if (type === 0) {
40
+ if (data === null || typeof data !== "object" || Array.isArray(data)) return data;
41
+ return Object.fromEntries(Object.entries(data).map(([k, val]) => [k, astroDecode(val)]));
42
+ }
43
+ if (type === 1) return Array.isArray(data) ? data.map(astroDecode) : data;
44
+ return data;
45
+ }
46
+
47
+ function decodeProps(raw) {
48
+ return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, astroDecode(v)]));
49
+ }
50
+
51
+ function parseEpisodeNums(html, malId) {
52
+ const re = new RegExp(`/episode\\?anime=${malId}&(?:amp;)?ep_num=(\\d+)`, "gi");
53
+ const nums = new Set();
54
+ for (const m of html.matchAll(re)) nums.add(Number(m[1]));
55
+ return [...nums].sort((a, b) => a - b);
56
+ }
57
+
58
+ async function fetchEpisodePage(malId, epNum) {
59
+ const html = await fetchPage(`${BASE}/episode?anime=${malId}&ep_num=${epNum}`);
60
+ const rawProps = extractPlayerProps(html);
61
+ if (!rawProps) throw new Error(`2dhive: no player props for mal ${malId} ep${epNum}`);
62
+ return decodeProps(rawProps);
63
+ }
64
+
65
+ export async function getEpisodes(anilistId, ctx = {}) {
66
+ const malId = await getMalId(anilistId, ctx);
67
+ const animeHtml = await fetchPage(`${BASE}/anime?anime=${malId}`);
68
+ const epNums = parseEpisodeNums(animeHtml, malId);
69
+ if (!epNums.length) throw new Error(`2dhive: no episodes found for AniList ${anilistId} (MAL ${malId})`);
70
+
71
+ const props = await fetchEpisodePage(malId, epNums[0]);
72
+ const hasDub = Boolean(props.prefetchedHls?.dub?.content);
73
+ const expected = expectedCount(ctx.media, ctx.anizip, ctx.jikanEps);
74
+
75
+ const sub = [], dub = [];
76
+ for (const num of epNums) {
77
+ if (expected && num > expected) continue;
78
+ const meta = episodeMeta(num, ctx);
79
+ const base = {
80
+ number: num,
81
+ title: meta.title ?? `Episode ${num}`,
82
+ duration: meta.duration ?? null,
83
+ filler: meta.filler ?? false,
84
+ uncensored: meta.uncensored ?? false,
85
+ description: meta.description ?? null,
86
+ image: meta.image ?? null,
87
+ airDate: meta.airDate ?? null,
88
+ };
89
+ sub.push({ id: `watch/2dhive/${anilistId}/sub/2dhive-${num}`, ...base, audio: "sub" });
90
+ if (hasDub) dub.push({ id: `watch/2dhive/${anilistId}/dub/2dhive-${num}`, ...base, audio: "dub" });
91
+ }
92
+
93
+ return {
94
+ meta: {
95
+ id: String(anilistId),
96
+ source: "2dhive",
97
+ matchScore: 1,
98
+ numbering: "standard",
99
+ episodeOffset: 0,
100
+ },
101
+ episodes: { sub, dub },
102
+ };
103
+ }
104
+
105
+ async function handleWatch(anilistId, audio, epNum) {
106
+ const malId = await getMalId(anilistId);
107
+ const referer = `${BASE}/episode?anime=${malId}&ep_num=${epNum}`;
108
+ const fileKey = `${malId}_${epNum}_${audio}`;
109
+
110
+ const [propsResult, hiAnimeResult, dlContent] = await Promise.allSettled([
111
+ fetchEpisodePage(malId, epNum),
112
+ audio !== "dub"
113
+ ? fetch(`${BASE}/api/hianime?mal_id=${malId}&ep_num=${epNum}`, {
114
+ headers: { "User-Agent": UA, "Referer": referer },
115
+ }).then(r => r.ok ? r.json() : null).catch(() => null)
116
+ : Promise.resolve(null),
117
+ fetchDownloadHls(malId, audio, epNum),
118
+ ]);
119
+
120
+ const streams = [];
121
+ const props = propsResult.status === "fulfilled" ? propsResult.value : null;
122
+
123
+ if (props) {
124
+ const hlsContent = audio === "dub"
125
+ ? props.prefetchedHls?.dub?.content
126
+ : props.prefetchedHls?.sub?.content;
127
+
128
+ if (hlsContent) {
129
+ streams.push({
130
+ server: audio === "dub" ? "HLS DUB" : "HLS SUB",
131
+ url: `/stream/2dhive/${anilistId}/${audio}/${epNum}`,
132
+ });
133
+ }
134
+
135
+ const rawServers = Array.isArray(props.servers) ? props.servers : [];
136
+ const hadfreeEntries = rawServers.filter(s =>
137
+ s.server_name === "HAdfree" && Boolean(s.dub) === (audio === "dub") && s.slug
138
+ );
139
+
140
+ const hadfreeResults = await Promise.allSettled(
141
+ hadfreeEntries.map(entry =>
142
+ fetch(`${BASE}/api/hadfree?slug=${encodeURIComponent(entry.slug)}`, {
143
+ headers: { "User-Agent": UA, "Referer": referer },
144
+ }).then(r => r.ok ? r.json() : null).catch(() => null)
145
+ )
146
+ );
147
+
148
+ for (const r of hadfreeResults) {
149
+ if (r.status === "fulfilled" && r.value?.streamUrl) {
150
+ streams.push({ server: "HAdfree", url: r.value.streamUrl });
151
+ }
152
+ }
153
+ }
154
+
155
+ streams.push({
156
+ server: audio === "dub" ? "MegaPlay Dub" : "MegaPlay Sub",
157
+ url: `https://megaplay.buzz/stream/mal/${malId}/${epNum}/${audio === "dub" ? "dub" : "sub"}`,
158
+ type: "embed",
159
+ });
160
+
161
+ const hiAnime = hiAnimeResult.status === "fulfilled" ? hiAnimeResult.value : null;
162
+ if (hiAnime?.m3u8) {
163
+ const entry = { server: "hiAnime", url: hiAnime.m3u8 };
164
+ if (hiAnime.subtitle) entry.subtitle = hiAnime.subtitle;
165
+ streams.push(entry);
166
+ }
167
+
168
+ if (dlContent.status === "fulfilled" && dlContent.value) {
169
+ streams.push({
170
+ server: "Download",
171
+ url: `/stream/2dhive/download/${anilistId}/${audio}/${epNum}`,
172
+ });
173
+ }
174
+
175
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), audio, streams });
176
+ }
177
+
178
+ async function fetchDownloadHls(malId, audio, epNum) {
179
+ const fileKey = `${malId}_${epNum}_${audio}`;
180
+ try {
181
+ const res = await fetch(`${BASE}/download?file=${encodeURIComponent(fileKey)}`, {
182
+ headers: {
183
+ "User-Agent": UA,
184
+ "Referer": `${BASE}/episode?anime=${malId}&ep_num=${epNum}`,
185
+ },
186
+ });
187
+ if (!res.ok) return null;
188
+ const html = await res.text();
189
+ const m = html.match(/downloadPayload\s*=\s*(\{.*?\});/s);
190
+ if (!m) return null;
191
+ const payload = JSON.parse(m[1]);
192
+ return payload.hlsContent || null;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ async function handleDownloadStream(anilistId, audio, epNum) {
199
+ const malId = await getMalId(anilistId);
200
+ const content = await fetchDownloadHls(malId, audio, epNum);
201
+ if (!content) {
202
+ return new Response(JSON.stringify({ error: "No download stream found" }), {
203
+ status: 404,
204
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
205
+ });
206
+ }
207
+ return new Response(content, {
208
+ status: 200,
209
+ headers: {
210
+ "Content-Type": "application/vnd.apple.mpegurl",
211
+ "Access-Control-Allow-Origin": "*",
212
+ "Cache-Control": "public, max-age=3600",
213
+ },
214
+ });
215
+ }
216
+
217
+ async function handleStream(anilistId, audio, epNum) {
218
+ const malId = await getMalId(anilistId);
219
+ const props = await fetchEpisodePage(malId, epNum);
220
+ const content = audio === "dub"
221
+ ? props.prefetchedHls?.dub?.content
222
+ : props.prefetchedHls?.sub?.content;
223
+
224
+ if (!content) {
225
+ return new Response(JSON.stringify({ error: "No HLS stream found" }), {
226
+ status: 404,
227
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
228
+ });
229
+ }
230
+
231
+ return new Response(content, {
232
+ status: 200,
233
+ headers: {
234
+ "Content-Type": "application/vnd.apple.mpegurl",
235
+ "Access-Control-Allow-Origin": "*",
236
+ "Cache-Control": "public, max-age=3600",
237
+ },
238
+ });
239
+ }
240
+
241
+ export default {
242
+ async fetch(request) {
243
+ if (request.method === "OPTIONS") {
244
+ return new Response(null, {
245
+ status: 204,
246
+ headers: {
247
+ "Access-Control-Allow-Origin": "*",
248
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
249
+ "Access-Control-Allow-Headers": "*",
250
+ },
251
+ });
252
+ }
253
+ const url = new URL(request.url);
254
+ const path = url.pathname;
255
+ try {
256
+ let m = path.match(/^\/watch\/2dhive\/(\d+)\/(sub|dub)\/2dhive-(\d+)\/?$/);
257
+ if (m) return await handleWatch(m[1], m[2], m[3]);
258
+
259
+ m = path.match(/^\/stream\/2dhive\/(\d+)\/(sub|dub)\/(\d+)\/?$/);
260
+ if (m) return await handleStream(m[1], m[2], m[3]);
261
+
262
+ m = path.match(/^\/stream\/2dhive\/download\/(\d+)\/(sub|dub)\/(\d+)\/?$/);
263
+ if (m) return await handleDownloadStream(m[1], m[2], m[3]);
264
+
265
+ return json({ error: "Not found" }, 404);
266
+ } catch (err) {
267
+ return json({ error: err.message, stack: err.stack }, 500);
268
+ }
269
+ },
270
+ };
anivexa-api/providers/allmanga.js ADDED
@@ -0,0 +1,757 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const __name = (fn, _) => fn;
2
+
3
+ var UA4 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0";
4
+ var API = "https://api.allanime.day";
5
+ var REFERER = "https://allmanga.to";
6
+ var ANIZIP = "https://api.ani.zip/mappings";
7
+ var PASSPHRASE = "Xot36i3lK3:v1";
8
+ 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
9
+ var HASHES = {
10
+ episode: "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
11
+ };
12
+ var HEX_TABLE = {
13
+ "79": "A",
14
+ "7a": "B",
15
+ "7b": "C",
16
+ "7c": "D",
17
+ "7d": "E",
18
+ "7e": "F",
19
+ "7f": "G",
20
+ "70": "H",
21
+ "71": "I",
22
+ "72": "J",
23
+ "73": "K",
24
+ "74": "L",
25
+ "75": "M",
26
+ "76": "N",
27
+ "77": "O",
28
+ "68": "P",
29
+ "69": "Q",
30
+ "6a": "R",
31
+ "6b": "S",
32
+ "6c": "T",
33
+ "6d": "U",
34
+ "6e": "V",
35
+ "6f": "W",
36
+ "60": "X",
37
+ "61": "Y",
38
+ "62": "Z",
39
+ "59": "a",
40
+ "5a": "b",
41
+ "5b": "c",
42
+ "5c": "d",
43
+ "5d": "e",
44
+ "5e": "f",
45
+ "5f": "g",
46
+ "50": "h",
47
+ "51": "i",
48
+ "52": "j",
49
+ "53": "k",
50
+ "54": "l",
51
+ "55": "m",
52
+ "56": "n",
53
+ "57": "o",
54
+ "48": "p",
55
+ "49": "q",
56
+ "4a": "r",
57
+ "4b": "s",
58
+ "4c": "t",
59
+ "4d": "u",
60
+ "4e": "v",
61
+ "4f": "w",
62
+ "40": "x",
63
+ "41": "y",
64
+ "42": "z",
65
+ "08": "0",
66
+ "09": "1",
67
+ "0a": "2",
68
+ "0b": "3",
69
+ "0c": "4",
70
+ "0d": "5",
71
+ "0e": "6",
72
+ "0f": "7",
73
+ "00": "8",
74
+ "01": "9",
75
+ "15": "-",
76
+ "16": ".",
77
+ "67": "_",
78
+ "46": "~",
79
+ "02": ":",
80
+ "17": "/",
81
+ "07": "?",
82
+ "1b": "#",
83
+ "63": "[",
84
+ "65": "]",
85
+ "78": "@",
86
+ "19": "!",
87
+ "1c": "$",
88
+ "1e": "&",
89
+ "10": "(",
90
+ "11": ")",
91
+ "12": "*",
92
+ "13": "+",
93
+ "14": ",",
94
+ "03": ";",
95
+ "05": "=",
96
+ "1d": "%"
97
+ };
98
+ var _aesKey = null;
99
+ async function getAESKey() {
100
+ if (_aesKey) return _aesKey;
101
+ const raw = new TextEncoder().encode(PASSPHRASE);
102
+ const hash = await crypto.subtle.digest("SHA-256", raw);
103
+ _aesKey = await crypto.subtle.importKey("raw", hash, { name: "AES-CTR" }, false, ["decrypt"]);
104
+ return _aesKey;
105
+ }
106
+ __name(getAESKey, "getAESKey");
107
+ async function decryptTobeparsed(b64) {
108
+ const buf = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
109
+ const iv12 = buf.slice(1, 13);
110
+ const counter = new Uint8Array(16);
111
+ counter.set(iv12, 0);
112
+ counter[12] = 0;
113
+ counter[13] = 0;
114
+ counter[14] = 0;
115
+ counter[15] = 2;
116
+ const ctLen = buf.length - 13 - 16;
117
+ const ciphertext = buf.slice(13, 13 + ctLen);
118
+ const key = await getAESKey();
119
+ const plain = await crypto.subtle.decrypt(
120
+ { name: "AES-CTR", counter, length: 32 },
121
+ key,
122
+ ciphertext
123
+ );
124
+ return new TextDecoder().decode(plain);
125
+ }
126
+ __name(decryptTobeparsed, "decryptTobeparsed");
127
+ function decodeHexUrl(hex) {
128
+ let out = "";
129
+ for (let i = 0; i < hex.length; i += 2) {
130
+ const pair = hex.substring(i, i + 2).toLowerCase();
131
+ out += HEX_TABLE[pair] ?? pair;
132
+ }
133
+ return out;
134
+ }
135
+ __name(decodeHexUrl, "decodeHexUrl");
136
+ function hexToBytes(hex) {
137
+ const c = hex.replace(/[^0-9a-f]/gi, "");
138
+ const b = new Uint8Array(c.length / 2);
139
+ for (let i = 0; i < b.length; i++) b[i] = parseInt(c.slice(i * 2, i * 2 + 2), 16);
140
+ return b;
141
+ }
142
+ __name(hexToBytes, "hexToBytes");
143
+ async function aesDecrypt(hex) {
144
+ const key = await crypto.subtle.importKey(
145
+ "raw",
146
+ new TextEncoder().encode("kiemtienmua911ca"),
147
+ { name: "AES-CBC" },
148
+ false,
149
+ ["decrypt"]
150
+ );
151
+ const plain = await crypto.subtle.decrypt(
152
+ { name: "AES-CBC", iv: new TextEncoder().encode("1234567890oiuytr") },
153
+ key,
154
+ hexToBytes(hex)
155
+ );
156
+ return new TextDecoder().decode(plain);
157
+ }
158
+ __name(aesDecrypt, "aesDecrypt");
159
+ async function extractMp4(id) {
160
+ try {
161
+ const r = await fetch(`https://www.mp4upload.com/embed-${id}.html`, {
162
+ headers: { "User-Agent": UA4, Referer: "https://allanime.to/" }
163
+ });
164
+ if (!r.ok) return null;
165
+ const h = await r.text();
166
+ 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[^"]*)"/);
167
+ return m?.[1]?.replace(/\\/g, "") || null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+ __name(extractMp4, "extractMp4");
173
+ async function extractUns(id) {
174
+ try {
175
+ const base = "https://allanime.uns.bio";
176
+ const r = await fetch(`${base}/api/v1/video?id=${id}&w=1280&h=720&r=`, {
177
+ headers: { "User-Agent": UA4, Referer: `${base}/#${id}`, Origin: base }
178
+ });
179
+ if (!r.ok) return null;
180
+ const hex = (await r.text()).trim();
181
+ if (!hex || !/^[0-9a-f]+$/i.test(hex)) return null;
182
+ const p = JSON.parse(await aesDecrypt(hex));
183
+ return p.source || p.cf || null;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+ __name(extractUns, "extractUns");
189
+ async function extractOk(id) {
190
+ try {
191
+ const r = await fetch(`https://ok.ru/videoembed/${id}`, {
192
+ headers: { "User-Agent": UA4, Referer: "https://ok.ru/" }
193
+ });
194
+ if (!r.ok) return null;
195
+ const h = await r.text();
196
+ const m = h.match(/ondemandHls\\&quot;:\\&quot;(https?:\/\/.*?)\\&quot;/);
197
+ if (!m) return null;
198
+ return m[1].replace(/\\u0026/g, "&");
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ __name(extractOk, "extractOk");
204
+ async function extractStreamSB(id) {
205
+ try {
206
+ const baseHeaders = {
207
+ "User-Agent": UA4,
208
+ "Referer": "https://allmanga.to/",
209
+ "watchsb": "streamsb",
210
+ "Accept": "application/json, text/plain, */*",
211
+ "Accept-Language": "en-US,en;q=0.9"
212
+ };
213
+ const r1 = await fetch(`https://streamsb.net/api/v1/video?id=${id}`, { headers: baseHeaders });
214
+ const sid = (r1.headers.get("set-cookie") || "").match(/sid=([^;]+)/)?.[1] ?? "";
215
+ const html1 = await r1.text();
216
+ const m = html1.match(/window\.location\.replace\('([^']+)'\)/);
217
+ if (!m) return null;
218
+ const r2 = await fetch(m[1], {
219
+ headers: { ...baseHeaders, "Cookie": `sid=${sid}`, "Referer": `https://streamsb.net/e/${id}.html` }
220
+ });
221
+ if (!r2.ok) return null;
222
+ const ct = r2.headers.get("content-type") ?? "";
223
+ if (!ct.includes("json")) return null;
224
+ const data = await r2.json();
225
+ return data?.stream_data?.file ?? data?.data?.file ?? null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+ __name(extractStreamSB, "extractStreamSB");
231
+ async function extractStreamlare(id) {
232
+ try {
233
+ const r = await fetch("https://streamlare.com/api/video/stream/get", {
234
+ method: "POST",
235
+ headers: {
236
+ "Content-Type": "application/json",
237
+ "User-Agent": UA4,
238
+ "Referer": "https://streamlare.com/",
239
+ "Origin": "https://streamlare.com",
240
+ "Accept": "application/json, */*"
241
+ },
242
+ body: JSON.stringify({ id })
243
+ });
244
+ if (!r.ok) return null;
245
+ const data = await r.json();
246
+ return data?.data?.file ?? null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ __name(extractStreamlare, "extractStreamlare");
252
+ function embedMediaType(url) {
253
+ if (!url) return null;
254
+ if (url.includes(".m3u8")) return "hls";
255
+ if (url.includes(".mp4")) return "mp4";
256
+ return "direct";
257
+ }
258
+ __name(embedMediaType, "embedMediaType");
259
+ async function apiFetch(url) {
260
+ const res = await fetch(url, {
261
+ headers: { "User-Agent": UA4, "Referer": REFERER, "Origin": REFERER }
262
+ });
263
+ if (!res.ok) { const _raw = await res.text().catch(() => null); const _e = new Error(`API ${res.status}`); _e.rawBody = _raw; throw _e; }
264
+ const json6 = await res.json();
265
+ if (json6?.data?.tobeparsed) {
266
+ const decrypted = await decryptTobeparsed(json6.data.tobeparsed);
267
+ json6.data = JSON.parse(decrypted);
268
+ }
269
+ return json6.data;
270
+ }
271
+ __name(apiFetch, "apiFetch");
272
+ function buildApiUrl(variables, hash) {
273
+ const v = encodeURIComponent(JSON.stringify(variables));
274
+ const e = encodeURIComponent(JSON.stringify({ persistedQuery: { version: 1, sha256Hash: hash } }));
275
+ return `${API}/api?variables=${v}&extensions=${e}`;
276
+ }
277
+ __name(buildApiUrl, "buildApiUrl");
278
+ async function apiPost(query, variables) {
279
+ const res = await fetch(`${API}/api`, {
280
+ method: "POST",
281
+ headers: {
282
+ "User-Agent": UA4,
283
+ "Referer": REFERER,
284
+ "Origin": REFERER,
285
+ "Content-Type": "application/json"
286
+ },
287
+ body: JSON.stringify({ variables, query })
288
+ });
289
+ if (!res.ok) { const _raw = await res.text().catch(() => null); const _e = new Error(`API POST ${res.status}`); _e.rawBody = _raw; throw _e; }
290
+ const json6 = await res.json();
291
+ if (json6?.data?.tobeparsed) {
292
+ const decrypted = await decryptTobeparsed(json6.data.tobeparsed);
293
+ json6.data = JSON.parse(decrypted);
294
+ }
295
+ return json6.data;
296
+ }
297
+ __name(apiPost, "apiPost");
298
+ async function searchAllAnime(query, mode = "sub") {
299
+ 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}}}`;
300
+ const data = await apiPost(gql, {
301
+ search: { allowAdult: false, allowUnknown: false, query },
302
+ limit: 40,
303
+ page: 1,
304
+ translationType: mode,
305
+ countryOrigin: "ALL"
306
+ });
307
+ return data?.shows?.edges ?? [];
308
+ }
309
+ __name(searchAllAnime, "searchAllAnime");
310
+ async function getEpisodeSources(showId, epNum, audio = "sub") {
311
+ const url = buildApiUrl(
312
+ { showId, translationType: audio, episodeString: String(epNum) },
313
+ HASHES.episode
314
+ );
315
+ const data = await apiFetch(url);
316
+ return data?.episode ?? null;
317
+ }
318
+ __name(getEpisodeSources, "getEpisodeSources");
319
+ async function fetchAniZip(anilistId) {
320
+ const res = await fetch(`${ANIZIP}?anilist_id=${anilistId}`);
321
+ if (!res.ok) return null;
322
+ return res.json();
323
+ }
324
+ __name(fetchAniZip, "fetchAniZip");
325
+ function normalize(s) {
326
+ return (s || "").toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
327
+ }
328
+ __name(normalize, "normalize");
329
+ function extractYear(title2) {
330
+ if (!title2) return null;
331
+ const m = title2.match(/\b(19\d{2}|20\d{2})\b/);
332
+ return m ? parseInt(m[1]) : null;
333
+ }
334
+ __name(extractYear, "extractYear");
335
+ function findBestMatch(results, titles, targetYear, targetId) {
336
+ const normalizedTitles = titles.map(normalize).filter(Boolean);
337
+ let bestShow = null;
338
+ let maxScore = -Infinity;
339
+ for (const r of results) {
340
+ if (targetId && r.aniListId && String(r.aniListId) === String(targetId)) {
341
+ return r;
342
+ }
343
+ const names = [r.name, r.englishName, r.nativeName].map(normalize).filter(Boolean);
344
+ let nameScore = 0;
345
+ let isExact = false;
346
+ for (const n of names) {
347
+ if (normalizedTitles.includes(n)) {
348
+ nameScore = 100;
349
+ isExact = true;
350
+ break;
351
+ }
352
+ }
353
+ if (!isExact) {
354
+ let maxFuzzy = 0;
355
+ for (const rName of names) {
356
+ for (const t of normalizedTitles) {
357
+ if (t.includes(rName) || rName.includes(t)) {
358
+ const score = Math.min(rName.length, t.length);
359
+ const lengthPenalty = Math.abs(rName.length - t.length) * 0.1;
360
+ const finalFuzzy = score - lengthPenalty;
361
+ if (finalFuzzy > maxFuzzy) maxFuzzy = finalFuzzy;
362
+ }
363
+ }
364
+ }
365
+ nameScore = maxFuzzy;
366
+ }
367
+ let yearScore = 0;
368
+ const rYear = extractYear(r.name) || extractYear(r.englishName) || extractYear(r.nativeName);
369
+ if (targetYear && rYear) {
370
+ yearScore = rYear === targetYear ? 50 : -200;
371
+ }
372
+ const totalScore = nameScore + yearScore;
373
+ if (totalScore > maxScore) {
374
+ maxScore = totalScore;
375
+ bestShow = r;
376
+ }
377
+ }
378
+ return bestShow || results[0];
379
+ }
380
+ __name(findBestMatch, "findBestMatch");
381
+ async function fetchAniListMedia(anilistId) {
382
+ try {
383
+ const q = "query ($id: Int) { Media (id: $id, type: ANIME) { seasonYear startDate { year } title { romaji english native } } }";
384
+ const res = await fetch("https://graphql.anilist.co", {
385
+ method: "POST",
386
+ headers: {
387
+ "Content-Type": "application/json",
388
+ "Accept": "application/json",
389
+ "User-Agent": UA4,
390
+ "Origin": "https://anilist.co"
391
+ },
392
+ body: JSON.stringify({ query: q, variables: { id: Number(anilistId) } })
393
+ });
394
+ if (!res.ok) return null;
395
+ const json6 = await res.json();
396
+ return json6.data?.Media ?? null;
397
+ } catch (e) {
398
+ console.error("AniList titles fetch failed:", e);
399
+ return null;
400
+ }
401
+ }
402
+ __name(fetchAniListMedia, "fetchAniListMedia");
403
+ async function resolveAllAnimeId(anilistId, ctx = {}) {
404
+ const [anizipRes, alMedia] = await Promise.all([
405
+ ctx.anizip ? Promise.resolve(ctx.anizip) : fetchAniZip(anilistId).catch(() => ({})),
406
+ ctx.media ? Promise.resolve({
407
+ title: ctx.media.title,
408
+ seasonYear: ctx.media.seasonYear,
409
+ startDate: ctx.media.startDate
410
+ }) : fetchAniListMedia(anilistId).catch(() => null)
411
+ ]);
412
+ const anizip = anizipRes || {};
413
+ let titlesToTry = [];
414
+ if (anizip.titles) {
415
+ titlesToTry = [
416
+ anizip.titles.en,
417
+ anizip.titles.ja,
418
+ anizip.titles["x-jat"],
419
+ ...Object.values(anizip.titles)
420
+ ].filter(Boolean);
421
+ }
422
+ if (alMedia?.title) {
423
+ const alTitles = [alMedia.title.english, alMedia.title.romaji, alMedia.title.native].filter(Boolean);
424
+ titlesToTry = [...new Set([...alTitles, ...titlesToTry])];
425
+ }
426
+ if (!titlesToTry.length && anizip.mappings) {
427
+ const apId = anizip.mappings.animeplanet_id;
428
+ if (apId) {
429
+ const cleanApTitle = apId.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
430
+ titlesToTry = [cleanApTitle];
431
+ }
432
+ }
433
+ if (!titlesToTry.length) {
434
+ throw new Error(`Could not resolve titles for AniList ID: ${anilistId}`);
435
+ }
436
+ const targetYear = alMedia?.seasonYear || alMedia?.startDate?.year || null;
437
+ let allResults = [];
438
+ for (const title2 of titlesToTry.slice(0, 3)) {
439
+ const results = await searchAllAnime(title2, "sub");
440
+ allResults.push(...results);
441
+ }
442
+ const seen = new Set();
443
+ allResults = allResults.filter((r) => {
444
+ if (seen.has(r._id)) return false;
445
+ seen.add(r._id);
446
+ return true;
447
+ });
448
+ if (!allResults.length) {
449
+ throw new Error(`No AllAnime match for "${titlesToTry[0]}"`);
450
+ }
451
+ const match = findBestMatch(allResults, titlesToTry, targetYear, anilistId);
452
+ return { showId: match._id, show: match, anizip };
453
+ }
454
+ __name(resolveAllAnimeId, "resolveAllAnimeId");
455
+ async function fetchAniListFull(anilistId) {
456
+ const q = `
457
+ query ($id: Int) {
458
+ Media(id: $id, type: ANIME) {
459
+ id
460
+ idMal
461
+ title { romaji english native }
462
+ synonyms
463
+ format
464
+ episodes
465
+ seasonYear
466
+ startDate { year }
467
+ type
468
+ relations {
469
+ edges { relationType(version: 2) node { id type format title { romaji english native } } }
470
+ }
471
+ }
472
+ }`;
473
+ const res = await fetch("https://graphql.anilist.co", {
474
+ method: "POST",
475
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": UA4, "Origin": "https://anilist.co" },
476
+ body: JSON.stringify({ query: q, variables: { id: Number(anilistId) } })
477
+ });
478
+ if (!res.ok) throw new Error("AniList fetch failed");
479
+ const json6 = await res.json();
480
+ return json6.data?.Media;
481
+ }
482
+ __name(fetchAniListFull, "fetchAniListFull");
483
+ async function fetchKitsuId(malId) {
484
+ if (!malId) return null;
485
+ try {
486
+ const res = await fetch(`https://kitsu.io/api/edge/mappings?filter[externalSite]=myanimelist/anime&filter[externalId]=${malId}`);
487
+ const json6 = await res.json();
488
+ const mapping = json6.data?.[0];
489
+ if (mapping && mapping.relationships?.item?.links?.related) {
490
+ const itemRes = await fetch(mapping.relationships.item.links.related);
491
+ const itemJson = await itemRes.json();
492
+ return itemJson.data?.id ? Number(itemJson.data.id) : null;
493
+ }
494
+ } catch (e) {
495
+ console.error("Kitsu Error:", e);
496
+ }
497
+ return null;
498
+ }
499
+ __name(fetchKitsuId, "fetchKitsuId");
500
+ async function fetchTMDB(titles, year, format) {
501
+ let tmdbType = format === "MOVIE" || format === "OVA" || format === "SPECIAL" ? "movie" : "tv";
502
+ let result = null;
503
+ for (const title2 of titles) {
504
+ if (!title2) continue;
505
+ try {
506
+ const searchUrl = `https://api.themoviedb.org/3/search/${tmdbType}?query=${encodeURIComponent(title2)}&first_air_date_year=${year}&year=${year}`;
507
+ const res = await fetch(searchUrl, {
508
+ headers: { "Authorization": `Bearer ${TMDB_TOKEN}`, "Accept": "application/json" }
509
+ });
510
+ const json6 = await res.json();
511
+ if (json6.results && json6.results.length > 0) {
512
+ result = json6.results[0];
513
+ break;
514
+ }
515
+ } catch (e) {
516
+ console.error("TMDB Search Error:", e);
517
+ }
518
+ }
519
+ if (!result) return { themoviedbId: null, imdbId: null, thetvdbId: null };
520
+ let externalIds = {};
521
+ try {
522
+ const extUrl = `https://api.themoviedb.org/3/${tmdbType}/${result.id}/external_ids`;
523
+ const extRes = await fetch(extUrl, {
524
+ headers: { "Authorization": `Bearer ${TMDB_TOKEN}`, "Accept": "application/json" }
525
+ });
526
+ externalIds = await extRes.json();
527
+ } catch (e) {
528
+ console.error("TMDB External IDs Error:", e);
529
+ }
530
+ return {
531
+ themoviedbId: result.id,
532
+ imdbId: externalIds.imdb_id || null,
533
+ thetvdbId: externalIds.tvdb_id || null
534
+ };
535
+ }
536
+ __name(fetchTMDB, "fetchTMDB");
537
+ async function handleMap(anilistId) {
538
+ const al = await fetchAniListFull(anilistId);
539
+ if (!al) throw new Error("AniList entry not found");
540
+ const year = al.seasonYear || al.startDate?.year;
541
+ const titlesToSearch = [al.title.english, al.title.romaji, al.title.native].filter(Boolean);
542
+ const [kitsuId, tmdbData] = await Promise.all([
543
+ fetchKitsuId(al.idMal),
544
+ fetchTMDB(titlesToSearch, year, al.format)
545
+ ]);
546
+ return {
547
+ mappings: {
548
+ id: Number(anilistId),
549
+ title: al.title.english || al.title.romaji,
550
+ type: al.type,
551
+ format: al.format,
552
+ episodes: al.episodes,
553
+ malId: al.idMal,
554
+ aniId: Number(anilistId),
555
+ anidbId: null,
556
+ animePlanetId: null,
557
+ kitsuId,
558
+ imdbId: tmdbData.imdbId,
559
+ themoviedbId: tmdbData.themoviedbId,
560
+ thetvdbId: tmdbData.thetvdbId,
561
+ livechartId: null,
562
+ annId: null,
563
+ synonyms: al.synonyms || [],
564
+ franchise: al.relations?.edges?.map((e) => ({
565
+ relation: e.relationType,
566
+ id: e.node.id,
567
+ title: e.node.title.romaji || e.node.title.english,
568
+ type: e.node.type,
569
+ format: e.node.format
570
+ })) || []
571
+ }
572
+ };
573
+ }
574
+ __name(handleMap, "handleMap");
575
+ async function handleEpisodes2(anilistId) {
576
+ const { showId, show, anizip } = await resolveAllAnimeId(anilistId);
577
+ const epDetail = show.availableEpisodesDetail || {};
578
+ const subEps = (epDetail.sub || []).map(Number).sort((a, b) => a - b);
579
+ const dubEps = (epDetail.dub || []).map(Number).sort((a, b) => a - b);
580
+ const buildEpList = __name((nums, audio) => nums.map((n) => {
581
+ const meta = anizip.episodes?.[String(n)] ?? {};
582
+ return {
583
+ id: `watch/allmanga/${anilistId}/${audio}/allmanga-${n}`,
584
+ number: n,
585
+ title: meta.title?.en || meta.title?.["x-jat"] || `Episode ${n}`,
586
+ duration: meta.runtime ?? meta.length ?? 0,
587
+ audio,
588
+ filler: meta.filler ?? false,
589
+ uncensored: false,
590
+ description: meta.overview || meta.summary || "",
591
+ image: meta.image || anizip.images?.cover || "",
592
+ airDate: meta.airdate || meta.aired || ""
593
+ };
594
+ }), "buildEpList");
595
+ return {
596
+ anilistId: Number(anilistId),
597
+ allAnimeId: showId,
598
+ title: show.englishName || show.name,
599
+ sub: buildEpList(subEps, "sub"),
600
+ dub: buildEpList(dubEps, "dub")
601
+ };
602
+ }
603
+ __name(handleEpisodes2, "handleEpisodes");
604
+ async function handleWatch2(anilistId, audio, epNum) {
605
+ const { showId, anizip } = await resolveAllAnimeId(anilistId);
606
+ const episode = await getEpisodeSources(showId, epNum, audio);
607
+ if (!episode) throw new Error("Episode not found");
608
+ const sources = await Promise.all((episode.sourceUrls || []).map(async (src) => {
609
+ let url = src.sourceUrl;
610
+ if (url && url.startsWith("--")) url = decodeHexUrl(url.slice(2));
611
+ if (url && url.startsWith("/apivtwo/clock")) {
612
+ url = "https://allanime.day" + url.replace("/clock", "/clock.json");
613
+ }
614
+ let extractedUrl = null;
615
+ const name = src.sourceName || "";
616
+ if (url?.includes("mp4upload.com")) {
617
+ const m = url.match(/embed-([a-zA-Z0-9]+)\.html/);
618
+ if (m?.[1]) extractedUrl = await extractMp4(m[1]);
619
+ } else if (url?.includes("allanime.uns.bio")) {
620
+ const id = url.split("#").pop();
621
+ if (id && id.length > 2) extractedUrl = await extractUns(id);
622
+ } else if (url?.includes("ok.ru")) {
623
+ const id = url.split("/").pop();
624
+ if (id) extractedUrl = await extractOk(id);
625
+ } else if (url?.includes("streamsb.net")) {
626
+ const m = url.match(/\/(?:e\/|embed-)([a-zA-Z0-9]+)(?:\.html)?/);
627
+ if (m?.[1]) extractedUrl = await extractStreamSB(m[1]);
628
+ } else if (url?.includes("streamlare.com")) {
629
+ const m = url.match(/\/e\/([a-zA-Z0-9]+)/);
630
+ if (m?.[1]) extractedUrl = await extractStreamlare(m[1]);
631
+ }
632
+ return {
633
+ name,
634
+ url,
635
+ extractedUrl,
636
+ extractedType: embedMediaType(extractedUrl),
637
+ type: src.type,
638
+ priority: src.priority,
639
+ headers: {
640
+ "Referer": "https://allmanga.to",
641
+ "User-Agent": UA4
642
+ },
643
+ downloads: src.downloads || null
644
+ };
645
+ }));
646
+ sources.sort((a, b) => b.priority - a.priority);
647
+ const epMeta = anizip?.episodes?.[String(epNum)] ?? {};
648
+ const intro = epMeta.intro ?? null;
649
+ const outro = epMeta.outro ?? null;
650
+ return {
651
+ anilistId: Number(anilistId),
652
+ allAnimeId: showId,
653
+ episode: Number(epNum),
654
+ audio,
655
+ intro,
656
+ outro,
657
+ sources
658
+ };
659
+ }
660
+ __name(handleWatch2, "handleWatch");
661
+ function json2(data, status = 200) {
662
+ return new Response(JSON.stringify(data, null, 2), {
663
+ status,
664
+ headers: {
665
+ "Content-Type": "application/json",
666
+ "Access-Control-Allow-Origin": "*",
667
+ "Cache-Control": "public, max-age=300"
668
+ }
669
+ });
670
+ }
671
+ __name(json2, "json");
672
+ function matchRoute(pathname) {
673
+ let m = pathname.match(/^\/episodes\/(\d+)\/?$/);
674
+ if (m) return { handler: "episodes", anilistId: m[1] };
675
+ m = pathname.match(/^\/watch\/allmanga\/(\d+)\/(sub|dub)\/allmanga-(\d+)\/?$/);
676
+ if (m) return { handler: "watch", anilistId: m[1], audio: m[2], ep: m[3] };
677
+ m = pathname.match(/^\/map\/(\d+)\/?$/);
678
+ if (m) return { handler: "map", anilistId: m[1] };
679
+ return null;
680
+ }
681
+ __name(matchRoute, "matchRoute");
682
+ var allmanga_default = {
683
+ async fetch(request) {
684
+ const url = new URL(request.url);
685
+ if (request.method === "OPTIONS") {
686
+ return new Response(null, {
687
+ headers: {
688
+ "Access-Control-Allow-Origin": "*",
689
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
690
+ "Access-Control-Allow-Headers": "*"
691
+ }
692
+ });
693
+ }
694
+ const route = matchRoute(url.pathname);
695
+ if (!route) {
696
+ return json2({
697
+ error: "Not found",
698
+ routes: [
699
+ "GET /episodes/:anilistId",
700
+ "GET /watch/allmanga/:anilistId/:audio/allmanga-:ep",
701
+ "GET /map/:anilistId"
702
+ ]
703
+ }, 404);
704
+ }
705
+ try {
706
+ if (route.handler === "map") {
707
+ const data = await handleMap(route.anilistId);
708
+ return json2(data);
709
+ }
710
+ if (route.handler === "episodes") {
711
+ const data = await handleEpisodes2(route.anilistId);
712
+ return json2(data);
713
+ }
714
+ if (route.handler === "watch") {
715
+ const data = await handleWatch2(route.anilistId, route.audio, route.ep);
716
+ return json2(data);
717
+ }
718
+ } catch (err) {
719
+ return json2({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500);
720
+ }
721
+ }
722
+ };
723
+ async function getEpisodes2(anilistId, ctx = {}) {
724
+ const { showId, show, anizip } = await resolveAllAnimeId(anilistId, ctx);
725
+ const epDetail = show.availableEpisodesDetail || {};
726
+ const subEps = (epDetail.sub || []).map(Number).sort((a, b) => a - b);
727
+ const dubEps = (epDetail.dub || []).map(Number).sort((a, b) => a - b);
728
+ const buildList = __name((nums, audio) => nums.map((n) => {
729
+ const meta = anizip.episodes?.[String(n)] ?? {};
730
+ return {
731
+ id: `watch/allmanga/${anilistId}/${audio}/allmanga-${n}`,
732
+ number: n,
733
+ title: meta.title?.en || meta.title?.["x-jat"] || null,
734
+ duration: meta.runtime ?? meta.length ?? 0,
735
+ audio,
736
+ filler: meta.filler ?? false,
737
+ uncensored: false,
738
+ description: meta.overview || meta.summary || null,
739
+ image: meta.image || anizip.images?.cover || null,
740
+ airDate: meta.airdate || meta.aired || null
741
+ };
742
+ }), "buildList");
743
+ return {
744
+ meta: {
745
+ id: showId,
746
+ title: show.englishName || show.name
747
+ },
748
+ episodes: {
749
+ sub: buildList(subEps, "sub"),
750
+ dub: buildList(dubEps, "dub"),
751
+ raw: []
752
+ }
753
+ };
754
+ }
755
+ __name(getEpisodes2, "getEpisodes");
756
+ export default allmanga_default;
757
+ export { getEpisodes2 as getEpisodes };
anivexa-api/providers/anibd.js ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { episodeMeta, expectedCount, json } from "../core/new-provider-utils.js";
2
+
3
+ const BASE = "https://epeng.animeapps.top";
4
+ 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";
5
+
6
+ async function fetchJson(url) {
7
+ const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json" } });
8
+ if (!res.ok) throw new Error(`anibd ${res.status}: ${url}`);
9
+ return res.json();
10
+ }
11
+
12
+ async function fetchHtml(url, referer) {
13
+ const res = await fetch(url, {
14
+ headers: {
15
+ "User-Agent": UA,
16
+ Accept: "text/html,application/xhtml+xml",
17
+ ...(referer ? { Referer: referer } : {}),
18
+ },
19
+ });
20
+ if (!res.ok) throw new Error(`anibd ${res.status}: ${url}`);
21
+ return res.text();
22
+ }
23
+
24
+ async function fetchServers(anilistId) {
25
+ const data = await fetchJson(`${BASE}/api2.php?epid=${anilistId}`);
26
+ return Array.isArray(data) ? data : [];
27
+ }
28
+
29
+ async function fetchPlayerLinks(providerLink) {
30
+ const data = await fetchJson(`${BASE}/apilink.php?data=${encodeURIComponent(providerLink)}`);
31
+ return Array.isArray(data) ? data : [];
32
+ }
33
+
34
+ function extractVideoUrl(html, origin) {
35
+ const m = html.match(/videoUrl\s*:\s*"([^"]+)"/);
36
+ if (!m) return null;
37
+ const raw = m[1];
38
+ if (/^https?:\/\//i.test(raw)) return raw;
39
+ return `${origin}${raw.startsWith("/") ? "" : "/"}${raw}`;
40
+ }
41
+
42
+ async function resolvePlayerStream(playerLink) {
43
+ const origin = new URL(playerLink).origin;
44
+ const referer = `${origin}/`;
45
+ const html = await fetchHtml(playerLink, referer);
46
+ const hls = extractVideoUrl(html, origin);
47
+ if (!hls) throw new Error(`anibd: no videoUrl found at ${playerLink}`);
48
+ return { hls, referer };
49
+ }
50
+
51
+ function audioFromServerName(name = "") {
52
+ return /dub/i.test(name) ? "dub" : "sub";
53
+ }
54
+
55
+ function buildEpisodeLists(anilistId, groups, ctx, expected) {
56
+ const sub = [];
57
+ const dub = [];
58
+ const seenSub = new Set();
59
+ const seenDub = new Set();
60
+ for (const group of groups) {
61
+ const audio = audioFromServerName(group.server_name);
62
+ for (const ep of group.server_data ?? []) {
63
+ const number = Number(ep.name ?? ep.slug);
64
+ if (!Number.isFinite(number) || number < 1) continue;
65
+ if (expected && number > expected) continue;
66
+ const bucket = audio === "dub" ? dub : sub;
67
+ const seen = audio === "dub" ? seenDub : seenSub;
68
+ if (seen.has(number)) continue;
69
+ seen.add(number);
70
+ const meta = episodeMeta(number, ctx);
71
+ bucket.push({
72
+ id: `watch/anibd/${anilistId}/${audio}/anibd-${number}`,
73
+ number,
74
+ title: meta.title ?? `Episode ${number}`,
75
+ duration: meta.duration,
76
+ filler: meta.filler,
77
+ uncensored: meta.uncensored,
78
+ description: meta.description,
79
+ image: meta.image,
80
+ airDate: meta.airDate,
81
+ sourceLink: ep.link,
82
+ audio,
83
+ });
84
+ }
85
+ }
86
+ sub.sort((a, b) => a.number - b.number);
87
+ dub.sort((a, b) => a.number - b.number);
88
+ return { sub, dub };
89
+ }
90
+
91
+ export async function getEpisodes(anilistId, ctx = {}) {
92
+ const groups = await fetchServers(anilistId);
93
+ if (!groups.length) throw new Error(`anibd: no episodes found for AniList ${anilistId}`);
94
+ const expected = expectedCount(ctx.media, ctx.anizip, ctx.jikanEps);
95
+ return {
96
+ meta: {
97
+ id: String(anilistId),
98
+ source: "anibd",
99
+ matchScore: 1,
100
+ numbering: "standard",
101
+ episodeOffset: 0,
102
+ },
103
+ episodes: buildEpisodeLists(anilistId, groups, ctx, expected),
104
+ };
105
+ }
106
+
107
+ async function findEpisodeLink(anilistId, audio, epNum) {
108
+ const groups = await fetchServers(anilistId);
109
+ for (const group of groups) {
110
+ if (audioFromServerName(group.server_name) !== audio) continue;
111
+ for (const ep of group.server_data ?? []) {
112
+ if (Number(ep.name ?? ep.slug) === Number(epNum)) return ep.link;
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ async function handleWatch(anilistId, audio, epNum) {
119
+ const providerLink = await findEpisodeLink(anilistId, audio, epNum);
120
+ if (!providerLink) return json({ error: `anibd episode ${epNum} not found` }, 404);
121
+
122
+ const servers = await fetchPlayerLinks(providerLink);
123
+ const streams = [];
124
+ let activeAssigned = false;
125
+
126
+ for (const entry of servers) {
127
+ if (!entry?.link) continue;
128
+ try {
129
+ const { hls, referer } = await resolvePlayerStream(entry.link);
130
+ streams.push({
131
+ url: hls,
132
+ type: "hls",
133
+ server: entry.server ?? "AniBD",
134
+ referer,
135
+ priority: activeAssigned ? 4 : 5,
136
+ isActive: !activeAssigned,
137
+ });
138
+ activeAssigned = true;
139
+ } catch {
140
+ streams.push({
141
+ url: entry.link,
142
+ type: "embed",
143
+ server: entry.server ?? "AniBD",
144
+ referer: `${new URL(entry.link).origin}/`,
145
+ priority: 1,
146
+ isActive: false,
147
+ });
148
+ }
149
+ }
150
+
151
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), audio, streams });
152
+ }
153
+
154
+ export default {
155
+ async fetch(request) {
156
+ if (request.method === "OPTIONS") {
157
+ return new Response(null, {
158
+ status: 204,
159
+ headers: {
160
+ "Access-Control-Allow-Origin": "*",
161
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
162
+ "Access-Control-Allow-Headers": "*",
163
+ },
164
+ });
165
+ }
166
+ const url = new URL(request.url);
167
+ try {
168
+ const m = url.pathname.match(/^\/watch\/anibd\/(\d+)\/(sub|dub)\/anibd-(\d+)\/?$/);
169
+ if (m) return await handleWatch(m[1], m[2], m[3]);
170
+ return json({ error: "Not found" }, 404);
171
+ } catch (err) {
172
+ return json({ error: err.message, stack: err.stack }, 500);
173
+ }
174
+ },
175
+ };
anivexa-api/providers/anidbapp.js ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMedia } from "../core/anilist.js";
2
+ import {
3
+ attr,
4
+ buildTitles,
5
+ decodeEntities,
6
+ episodeMeta,
7
+ expectedCount,
8
+ json,
9
+ stripTags,
10
+ } from "../core/new-provider-utils.js";
11
+ import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js";
12
+ import { execFile } from "child_process";
13
+ import { promisify } from "util";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ const BASE = "https://anidb.app";
18
+ 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";
19
+ const COOKIE_JAR = "/tmp/anidbapp_cookies.txt";
20
+
21
+ const NAV_HEADERS = [
22
+ "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
23
+ "Accept-Language: en-US,en;q=0.9",
24
+ "sec-ch-ua: \"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"",
25
+ "sec-ch-ua-mobile: ?0",
26
+ "sec-ch-ua-platform: \"Windows\"",
27
+ "sec-fetch-dest: document",
28
+ "sec-fetch-mode: navigate",
29
+ "sec-fetch-site: none",
30
+ "sec-fetch-user: ?1",
31
+ "upgrade-insecure-requests: 1",
32
+ ];
33
+
34
+ const XHR_HEADERS = [
35
+ "Accept: application/json, text/html, */*;q=0.8",
36
+ "Accept-Language: en-US,en;q=0.9",
37
+ "sec-ch-ua: \"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"",
38
+ "sec-ch-ua-mobile: ?0",
39
+ "sec-ch-ua-platform: \"Windows\"",
40
+ "sec-fetch-dest: empty",
41
+ "sec-fetch-mode: cors",
42
+ "sec-fetch-site: same-origin",
43
+ "X-Requested-With: XMLHttpRequest",
44
+ ];
45
+
46
+ async function curlFetch(url, headers, extraArgs = []) {
47
+ const args = [
48
+ "-s",
49
+ "--compressed",
50
+ "-A", UA,
51
+ "-c", COOKIE_JAR,
52
+ "-b", COOKIE_JAR,
53
+ "-w", "\n__STATUS:%{http_code}",
54
+ ...headers.flatMap(h => ["-H", h]),
55
+ ...extraArgs,
56
+ url,
57
+ ];
58
+ const { stdout } = await execFileAsync("curl", args, { maxBuffer: 8 * 1024 * 1024 });
59
+ const sep = stdout.lastIndexOf("\n__STATUS:");
60
+ const status = sep >= 0 ? Number(stdout.slice(sep + 10)) : 0;
61
+ const body = sep >= 0 ? stdout.slice(0, sep) : stdout;
62
+ if (status < 200 || status >= 300) {
63
+ const err = new Error(`HTTP ${status} fetching ${url}`);
64
+ err.rawBody = body;
65
+ throw err;
66
+ }
67
+ return body;
68
+ }
69
+
70
+ async function fetchAnidbHtml(url, referer) {
71
+ const headers = referer ? [...NAV_HEADERS, `Referer: ${referer}`] : NAV_HEADERS;
72
+ return curlFetch(url, headers);
73
+ }
74
+
75
+ async function fetchXhr(url, referer) {
76
+ const headers = referer ? [...XHR_HEADERS, `Referer: ${referer}`] : XHR_HEADERS;
77
+ return curlFetch(url, headers);
78
+ }
79
+
80
+ async function fetchJson(url, referer) {
81
+ const text = await fetchXhr(url, referer);
82
+ return JSON.parse(text);
83
+ }
84
+
85
+ async function search(query) {
86
+ const html = await fetchXhr(`${BASE}/search/suggestions?q=${encodeURIComponent(query)}`, `${BASE}/home`).catch(() => "");
87
+ const results = [];
88
+ for (const m of html.matchAll(/<a\b[^>]*data-search-item\b[^>]*>[\s\S]*?<\/a>/gi)) {
89
+ const tag = m[0].match(/<a\b[^>]*>/i)?.[0] ?? "";
90
+ const href = attr(tag, "href");
91
+ const path = href.startsWith("http") ? new URL(href).pathname : href;
92
+ const slug = path.match(/^\/anime\/([^/?#]+)/)?.[1];
93
+ if (!slug) continue;
94
+ const title = stripTags(m[0].match(/<p\b[^>]*class=["'][^"']*text-sm[^"']*["'][^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? "");
95
+ const meta = stripTags(m[0].match(/<p\b[^>]*class=["'][^"']*text-xs[^"']*["'][^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? "");
96
+ const siteId = Number(slug.match(/-(\d+)$/)?.[1]);
97
+ results.push({ slug, title: title || slug.replace(/-/g, " "), meta, siteId });
98
+ }
99
+ if (results.length) return results;
100
+
101
+ const browseHtml = await fetchAnidbHtml(`${BASE}/browse?q=${encodeURIComponent(query)}`, `${BASE}/home`).catch(() => "");
102
+ const seen = new Set();
103
+ for (const m of browseHtml.matchAll(/<a\b[^>]*href=["'](?:https:\/\/anidb\.app)?\/anime\/([^"']+)["'][^>]*class=["'][^"']*\banime-card\b[^"']*["'][^>]*>[\s\S]*?<\/a>/gi)) {
104
+ const slug = m[1];
105
+ if (seen.has(slug)) continue;
106
+ seen.add(slug);
107
+ const title = stripTags(m[0].match(/title=["']([^"']+)["']/i)?.[1] ?? "")
108
+ || stripTags(m[0].match(/alt=["']([^"']+)["']/i)?.[1] ?? "")
109
+ || slug.replace(/-/g, " ");
110
+ const siteId = Number(slug.match(/-(\d+)$/)?.[1]);
111
+ results.push({ slug, title, meta: "", siteId });
112
+ }
113
+ return results;
114
+ }
115
+
116
+ function parseExternalIds(html) {
117
+ return {
118
+ anilistId: Number(html.match(/https:\/\/anilist\.co\/anime\/(\d+)/i)?.[1]) || null,
119
+ malId: Number(html.match(/https:\/\/myanimelist\.net\/anime\/(\d+)/i)?.[1]) || null,
120
+ anidbId: Number(html.match(/https:\/\/anidb\.net\/anime\/(\d+)/i)?.[1]) || null,
121
+ kitsuId: Number(html.match(/https:\/\/kitsu\.app\/anime\/(\d+)/i)?.[1]) || null,
122
+ };
123
+ }
124
+
125
+ function parsePageTitle(html) {
126
+ return stripTags(html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? "");
127
+ }
128
+
129
+ function searchQueries(media, anizip) {
130
+ const titles = buildTitles(media, anizip);
131
+ const out = new Set();
132
+ for (const title of titles.slice(0, 5)) {
133
+ out.add(title);
134
+ const words = title.trim().split(/\s+/);
135
+ if (words.length > 4) out.add(words.slice(0, 4).join(" "));
136
+ }
137
+ return [...out].filter((q) => q.length >= 2);
138
+ }
139
+
140
+ async function resolveSeries(anilistId, ctx = {}) {
141
+ const cacheKey = `np:anidbapp:${anilistId}`;
142
+ const cached = get(cacheKey);
143
+ if (isFresh(cached)) return cached.data;
144
+
145
+ const media = ctx.media ?? await getMedia(anilistId);
146
+ const queries = searchQueries(media, ctx.anizip);
147
+ const candidates = new Map();
148
+ await Promise.all(queries.map(async (q) => {
149
+ for (const r of await search(q).catch(() => [])) {
150
+ if (!candidates.has(r.slug)) candidates.set(r.slug, r);
151
+ }
152
+ }));
153
+
154
+ for (const candidate of candidates.values()) {
155
+ const html = await fetchAnidbHtml(`${BASE}/anime/${candidate.slug}`, `${BASE}/home`).catch(() => "");
156
+ if (!html) continue;
157
+ const ids = parseExternalIds(html);
158
+ if (ids.anilistId !== Number(anilistId)) continue;
159
+ const data = {
160
+ slug: candidate.slug,
161
+ siteId: candidate.siteId || Number(candidate.slug.match(/-(\d+)$/)?.[1]),
162
+ title: parsePageTitle(html) || candidate.title,
163
+ matchType: "anilist",
164
+ matchScore: 1,
165
+ ...ids,
166
+ };
167
+ set(cacheKey, data, SHOW_IDENTITY_TTL);
168
+ return data;
169
+ }
170
+
171
+ const malId = media?.idMal ?? null;
172
+ if (malId) {
173
+ for (const candidate of candidates.values()) {
174
+ const html = await fetchAnidbHtml(`${BASE}/anime/${candidate.slug}`, `${BASE}/home`).catch(() => "");
175
+ if (!html) continue;
176
+ const ids = parseExternalIds(html);
177
+ if (ids.anilistId || ids.malId !== Number(malId)) continue;
178
+ const data = {
179
+ slug: candidate.slug,
180
+ siteId: candidate.siteId || Number(candidate.slug.match(/-(\d+)$/)?.[1]),
181
+ title: parsePageTitle(html) || candidate.title,
182
+ matchType: "mal",
183
+ matchScore: 0.9,
184
+ ...ids,
185
+ };
186
+ set(cacheKey, data, SHOW_IDENTITY_TTL);
187
+ return data;
188
+ }
189
+ }
190
+
191
+ throw new Error(`AniDB.app match not found for AniList ${anilistId}`);
192
+ }
193
+
194
+ async function fetchProviderEpisodes(siteId) {
195
+ const data = await fetchJson(`${BASE}/api/frontend/anime/${siteId}/episodes`, `${BASE}/anime/${siteId}`);
196
+ return Array.isArray(data.episodes) ? data.episodes : [];
197
+ }
198
+
199
+ function inferOffset(providerEpisodes, expected) {
200
+ const nums = providerEpisodes.map((e) => Number(e.number)).filter((n) => Number.isFinite(n) && n > 0);
201
+ if (!nums.length || !expected) return 0;
202
+ const min = Math.min(...nums);
203
+ const max = Math.max(...nums);
204
+ if (min > expected) return min - 1;
205
+ if (min > 1 && max - min + 1 >= expected) return min - 1;
206
+ return 0;
207
+ }
208
+
209
+ async function fetchLanguages(episodeId, seriesSlug) {
210
+ const data = await fetchJson(`${BASE}/api/frontend/episode/${episodeId}/languages`, `${BASE}/anime/${seriesSlug}`).catch(() => null);
211
+ return Array.isArray(data?.languages) ? data.languages : [];
212
+ }
213
+
214
+ function hasLanguage(languages, audio) {
215
+ return Boolean(languageForAudio(languages, audio)?.embed_url);
216
+ }
217
+
218
+ function buildEpisodeLists(anilistId, providerEpisodes, ctx, expected, offset, availability) {
219
+ const sub = [];
220
+ const dub = [];
221
+ for (const src of providerEpisodes) {
222
+ const sourceNumber = Number(src.number);
223
+ const number = sourceNumber - offset;
224
+ if (!Number.isFinite(number) || number < 1) continue;
225
+ if (expected && number > expected) continue;
226
+ const meta = episodeMeta(number, ctx);
227
+ const base = {
228
+ number,
229
+ title: meta.title ?? `Episode ${number}`,
230
+ duration: meta.duration,
231
+ filler: src.filler ?? meta.filler,
232
+ uncensored: meta.uncensored,
233
+ description: meta.description,
234
+ image: meta.image,
235
+ airDate: meta.airDate,
236
+ sourceNumber,
237
+ sourceId: src.id,
238
+ };
239
+ if (availability.hasSub) sub.push({ ...base, id: `watch/anidbapp/${anilistId}/sub/anidbapp-${number}`, audio: "sub" });
240
+ if (availability.hasDub) dub.push({ ...base, id: `watch/anidbapp/${anilistId}/dub/anidbapp-${number}`, audio: "dub" });
241
+ }
242
+ return { sub, dub };
243
+ }
244
+
245
+ function languageForAudio(languages, audio) {
246
+ const preferred = audio === "sub" ? ["jpn", "ja", "japanese"] : ["eng", "en", "english"];
247
+ return languages.find((l) => preferred.includes(String(l.code ?? "").toLowerCase()))
248
+ ?? languages.find((l) => preferred.includes(String(l.name ?? "").toLowerCase()))
249
+ ?? null;
250
+ }
251
+
252
+ function extractHls(html) {
253
+ const patterns = [
254
+ /file\s*:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i,
255
+ /sources\s*:\s*\[\s*\{[^}]*file\s*:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i,
256
+ /["'](https?:\/\/[^"']+\/master\.m3u8[^"']*)["']/i,
257
+ /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i,
258
+ ];
259
+ for (const pattern of patterns) {
260
+ const m = html.match(pattern);
261
+ if (m?.[1]) return decodeEntities(m[1]);
262
+ }
263
+ return null;
264
+ }
265
+
266
+ async function streamsForEmbed(embedUrl, audio, language) {
267
+ const html = await fetchAnidbHtml(embedUrl, { Referer: `${BASE}/` }).catch(() => "");
268
+ const hls = html ? extractHls(html) : null;
269
+ const streams = [];
270
+ if (hls) {
271
+ streams.push({
272
+ url: hls,
273
+ type: "hls",
274
+ audio,
275
+ language: language.code,
276
+ server: "AniDB.app",
277
+ embed: embedUrl,
278
+ referer: `${new URL(embedUrl).origin}/`,
279
+ priority: 5,
280
+ isActive: true,
281
+ });
282
+ }
283
+ streams.push({
284
+ url: embedUrl,
285
+ type: "embed",
286
+ audio,
287
+ language: language.code,
288
+ server: "AniDB.app-embed",
289
+ referer: `${BASE}/`,
290
+ priority: 4,
291
+ isActive: !hls,
292
+ });
293
+ return streams;
294
+ }
295
+
296
+ export async function getEpisodes(anilistId, ctx = {}) {
297
+ const media = ctx.media ?? await getMedia(anilistId);
298
+ const localCtx = { ...ctx, media };
299
+ const series = await resolveSeries(anilistId, localCtx);
300
+ const episodes = await fetchProviderEpisodes(series.siteId);
301
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
302
+ const offset = inferOffset(episodes, expected);
303
+ const sampleLanguages = episodes[0]?.id ? await fetchLanguages(episodes[0].id, series.slug) : [];
304
+ const availability = {
305
+ hasSub: hasLanguage(sampleLanguages, "sub") || !sampleLanguages.length,
306
+ hasDub: hasLanguage(sampleLanguages, "dub"),
307
+ };
308
+ return {
309
+ meta: {
310
+ id: series.slug,
311
+ siteId: series.siteId,
312
+ title: series.title,
313
+ source: "anidbapp",
314
+ matchScore: series.matchScore,
315
+ matchType: series.matchType,
316
+ anilistId: series.anilistId,
317
+ malId: series.malId,
318
+ numbering: offset ? "offset" : "local",
319
+ episodeOffset: offset,
320
+ },
321
+ episodes: buildEpisodeLists(anilistId, episodes, localCtx, expected, offset, availability),
322
+ };
323
+ }
324
+
325
+ async function handleWatch(anilistId, audio, epNum, ctx = {}) {
326
+ const series = await resolveSeries(anilistId, ctx);
327
+ const episodes = await fetchProviderEpisodes(series.siteId);
328
+ const media = ctx.media ?? await getMedia(anilistId).catch(() => null);
329
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
330
+ const offset = inferOffset(episodes, expected);
331
+ const providerEp = Number(epNum) + offset;
332
+ const episode = episodes.find((e) => Number(e.number) === providerEp);
333
+ if (!episode) return json({ error: `AniDB.app episode ${epNum} not found` }, 404);
334
+ const languages = await fetchLanguages(episode.id, series.slug);
335
+ const language = languageForAudio(languages, audio);
336
+ if (!language?.embed_url) {
337
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, streams: [] });
338
+ }
339
+ const embedUrl = decodeEntities(language.embed_url);
340
+ const streams = await streamsForEmbed(embedUrl, audio, language);
341
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, language: language.code, streams });
342
+ }
343
+
344
+ export default {
345
+ async fetch(request) {
346
+ const url = new URL(request.url);
347
+ if (request.method === "OPTIONS") {
348
+ return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "*" } });
349
+ }
350
+ try {
351
+ const m = url.pathname.match(/^\/watch\/anidbapp\/(\d+)\/(sub|dub)\/anidbapp-(\d+)\/?$/);
352
+ if (m) return await handleWatch(m[1], m[2], m[3]);
353
+ return json({ error: "Not found" }, 404);
354
+ } catch (err) {
355
+ return json({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500);
356
+ }
357
+ },
358
+ };
anivexa-api/providers/anikoto.js ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMedia } from '../core/anilist.js';
2
+
3
+ const ANIKOTO = "https://anikototv.to";
4
+ const MAPPER = "https://mapper.nekostream.site/api/mal";
5
+ const ANIZIP = "https://api.ani.zip/mappings";
6
+ const SPOOF_REF = "https://hianimes.re/";
7
+ 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";
8
+
9
+ const LANG_MAP = {
10
+ en: "en", english: "en", ja: "ja", japanese: "ja",
11
+ fr: "fr", french: "fr", de: "de", german: "de",
12
+ es: "es", spanish: "es", pt: "pt", portuguese: "pt"
13
+ };
14
+
15
+ function normalize(s) {
16
+ return (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
17
+ }
18
+
19
+ async function httpGet(url, headers = {}) {
20
+ const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "text/html,*/*", ...headers } });
21
+ if (!res.ok) {
22
+ const _raw = await res.text().catch(() => null);
23
+ const _e = new Error(`HTTP ${res.status} fetching ${url}`);
24
+ _e.rawBody = _raw;
25
+ throw _e;
26
+ }
27
+ return res.text();
28
+ }
29
+
30
+ async function getJSON(url, headers = {}) {
31
+ const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json,*/*", ...headers } });
32
+ if (!res.ok) {
33
+ const _raw = await res.text().catch(() => null);
34
+ const _e = new Error(`HTTP ${res.status} fetching ${url}`);
35
+ _e.rawBody = _raw;
36
+ throw _e;
37
+ }
38
+ return res.json();
39
+ }
40
+
41
+ const MODIFIERS = [
42
+ "ova", "movie", "special", "specials", "tales", "journal", "part", "season", "kanwa", "spin-off", "theatre"
43
+ ];
44
+
45
+ function scoreCandidate(cand, primaryEn, primaryRom, synonyms) {
46
+ let score = 0;
47
+ const candNameNorm = normalize(cand.name);
48
+ const candJpNorm = normalize(cand.jp);
49
+ const candSlugNorm = normalize(cand.slug);
50
+
51
+ const normEn = normalize(primaryEn);
52
+ const normRom = normalize(primaryRom);
53
+
54
+ if (normEn && candNameNorm === normEn) score += 1000;
55
+ if (normRom && candNameNorm === normRom) score += 900;
56
+ if (normRom && candJpNorm === normRom) score += 800;
57
+
58
+ const targetText = `${primaryEn || ""} ${primaryRom || ""} ${(synonyms || []).join(" ")}`.toLowerCase();
59
+
60
+ for (const mod of MODIFIERS) {
61
+ const candHasMod = candNameNorm.includes(mod) || candSlugNorm.includes(mod);
62
+ const targetHasMod = targetText.includes(mod);
63
+ if (candHasMod && !targetHasMod) {
64
+ score -= 300;
65
+ }
66
+ }
67
+
68
+ for (const t of [primaryEn, primaryRom, ...(synonyms || [])]) {
69
+ const normT = normalize(t);
70
+ if (!normT || normT.length < 3) continue;
71
+
72
+ if (candNameNorm === normT) score += 200;
73
+ else if (candNameNorm.startsWith(normT) || normT.startsWith(candNameNorm)) score += 80;
74
+ else if (candNameNorm.includes(normT) || normT.includes(candNameNorm)) score += 40;
75
+
76
+ if (candJpNorm && candJpNorm === normT) score += 100;
77
+ }
78
+
79
+ const lengthDiff = Math.abs(candNameNorm.length - (normEn || normRom || "").length);
80
+ score -= lengthDiff * 2;
81
+
82
+ return score;
83
+ }
84
+
85
+ async function searchAnikoto(query) {
86
+ const searchHtml = await httpGet(`${ANIKOTO}/filter?keyword=${encodeURIComponent(query)}`, { Referer: `${ANIKOTO}/` });
87
+ const candidates = [];
88
+
89
+ const re = /<a\s+class="name d-title"\s+href="https:\/\/anikototv\.to\/watch\/([^"/]+)(?:\/ep-\d+)?"[^>]*data-jp="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
90
+ let m;
91
+ while ((m = re.exec(searchHtml)) !== null) {
92
+ const slug = m[1];
93
+ const jp = m[2].trim();
94
+ const name = m[3].replace(/<[^>]*>/g, "").trim();
95
+ candidates.push({ slug, name, jp });
96
+ }
97
+
98
+ if (!candidates.length) {
99
+ const reFallback = /<a\s+href="https:\/\/anikototv\.to\/watch\/([^"/]+)(?:\/ep-\d+)?"[^>]*>([\s\S]*?)<\/a>/g;
100
+ while ((m = reFallback.exec(searchHtml)) !== null) {
101
+ candidates.push({ slug: m[1], name: m[1], jp: "" });
102
+ }
103
+ }
104
+
105
+ const seen = new Set();
106
+ return candidates.filter(c => {
107
+ if (seen.has(c.slug)) return false;
108
+ seen.add(c.slug);
109
+ return true;
110
+ });
111
+ }
112
+
113
+ async function findAnikotoShow(media) {
114
+ const primaryEn = media.title?.english;
115
+ const primaryRom = media.title?.romaji;
116
+ const synonyms = media.synonyms || [];
117
+
118
+ const keywords = [...new Set([primaryEn, primaryRom, ...synonyms].filter(Boolean))];
119
+ const allCandidatesMap = new Map();
120
+
121
+ for (const k of keywords.slice(0, 5)) {
122
+ const res = await searchAnikoto(k).catch(() => []);
123
+ for (const c of res) {
124
+ allCandidatesMap.set(c.slug, c);
125
+ }
126
+ }
127
+
128
+ const candidates = Array.from(allCandidatesMap.values());
129
+ if (!candidates.length) {
130
+ throw new Error(`No results found on Anikoto for: ${primaryEn || primaryRom}`);
131
+ }
132
+
133
+ const scored = candidates.map(c => ({
134
+ ...c,
135
+ score: scoreCandidate(c, primaryEn, primaryRom, synonyms)
136
+ })).sort((a, b) => b.score - a.score);
137
+
138
+ const chosen = scored[0];
139
+ const watchHtml = await httpGet(`${ANIKOTO}/watch/${chosen.slug}`, { Referer: `${ANIKOTO}/` });
140
+ const showIdMatch = watchHtml.match(/data-id="(\d+)"/);
141
+ if (!showIdMatch) throw new Error(`Could not find show ID for slug: ${chosen.slug}`);
142
+
143
+ return { slug: chosen.slug, showId: showIdMatch[1], title: chosen.name };
144
+ }
145
+
146
+ function mapTrack(t, source) {
147
+ const label = t.label ?? "";
148
+ const langKey = label.toLowerCase().split(" ")[0];
149
+ return {
150
+ url: t.file,
151
+ label: label || "English",
152
+ srclang: LANG_MAP[langKey] ?? "en",
153
+ default: t.default ?? false,
154
+ source
155
+ };
156
+ }
157
+
158
+ async function extractEmbedSource(embedUrl) {
159
+ try {
160
+ const pageHtml = await httpGet(embedUrl, { Referer: SPOOF_REF, "Accept-Language": "en-US,en;q=0.9" });
161
+ const m = pageHtml.match(/data-id="([^"]*)"/);
162
+ if (!m?.[1]) return null;
163
+ const fileId = m[1];
164
+ const origin = new URL(embedUrl).origin;
165
+ const data = await getJSON(`${origin}/stream/getSources?id=${fileId}&id=${fileId}`, { Referer: `${origin}/`, "X-Requested-With": "XMLHttpRequest" });
166
+ return { fileId, data, origin };
167
+ } catch (e) {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ export async function getEpisodes(anilistId, ctx = {}) {
173
+ const media = ctx.media || await getMedia(anilistId);
174
+ if (!media) throw new Error(`Could not resolve media for AniList ID: ${anilistId}`);
175
+
176
+ const [show, anizipRes] = await Promise.all([
177
+ findAnikotoShow(media),
178
+ ctx.anizip
179
+ ? Promise.resolve(ctx.anizip)
180
+ : getJSON(`${ANIZIP}?anilist_id=${anilistId}`).catch(() => null)
181
+ ]);
182
+
183
+ const listJson = await getJSON(`${ANIKOTO}/ajax/episode/list/${show.showId}`, {
184
+ "X-Requested-With": "XMLHttpRequest",
185
+ Referer: `${ANIKOTO}/watch/${show.slug}`
186
+ });
187
+
188
+ const html = listJson.result || "";
189
+ const sub = [];
190
+ const dub = [];
191
+
192
+ let firstMal = media.idMal || null;
193
+
194
+ const re = /<a\s+[^>]*data-id="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
195
+ let m;
196
+ while ((m = re.exec(html)) !== null) {
197
+ const tag = m[0];
198
+ const inner = m[2];
199
+ const getAttr = (attr) => {
200
+ const x = tag.match(new RegExp(`data-${attr}="([^"]*)"`));
201
+ return x ? x[1] : "";
202
+ };
203
+
204
+ const numStr = getAttr("num");
205
+ if (!numStr) continue;
206
+ const num = parseInt(numStr);
207
+ const hasSub = getAttr("sub") === "1";
208
+ const hasDub = getAttr("dub") === "1";
209
+ const malAttr = getAttr("mal");
210
+ if (!firstMal && malAttr) firstMal = parseInt(malAttr);
211
+
212
+ const titleMatch = inner.match(/<span class="d-title"[^>]*>([\s\S]*?)<\/span>/);
213
+ const parsedTitle = titleMatch ? titleMatch[1].replace(/<[^>]*>/g, "").trim() : "";
214
+ const epTitle = parsedTitle || `Episode ${num}`;
215
+
216
+ const azEp = anizipRes?.episodes?.[String(num)] ?? {};
217
+ const img = azEp.image || null;
218
+ const desc = azEp.overview || azEp.summary || null;
219
+ const airDate = azEp.airDate || azEp.airdate || null;
220
+
221
+ const base = {
222
+ number: num,
223
+ title: epTitle,
224
+ duration: null,
225
+ filler: false,
226
+ uncensored: false,
227
+ description: desc,
228
+ image: img,
229
+ airDate: airDate
230
+ };
231
+
232
+ if (hasSub) {
233
+ sub.push({
234
+ id: `watch/anikoto/${anilistId}/sub/anikoto-${num}`,
235
+ ...base,
236
+ audio: "sub"
237
+ });
238
+ }
239
+ if (hasDub) {
240
+ dub.push({
241
+ id: `watch/anikoto/${anilistId}/dub/anikoto-${num}`,
242
+ ...base,
243
+ audio: "dub"
244
+ });
245
+ }
246
+ }
247
+
248
+ sub.sort((a, b) => a.number - b.number);
249
+ dub.sort((a, b) => a.number - b.number);
250
+
251
+ return {
252
+ meta: {
253
+ title: show.title,
254
+ slug: show.slug,
255
+ malId: firstMal,
256
+ source: "anikoto"
257
+ },
258
+ episodes: { sub, dub }
259
+ };
260
+ }
261
+
262
+ async function handleWatch(anilistId, audio, epNum, ctx = {}) {
263
+ if (audio !== "sub" && audio !== "dub") {
264
+ return jsonResponse({ error: "audio must be sub or dub" }, 400);
265
+ }
266
+
267
+ const media = ctx.media || await getMedia(anilistId);
268
+ if (!media) {
269
+ return jsonResponse({ error: `Could not resolve media for AniList ID: ${anilistId}` }, 400);
270
+ }
271
+
272
+ const show = await findAnikotoShow(media);
273
+ const listJson = await getJSON(`${ANIKOTO}/ajax/episode/list/${show.showId}`, {
274
+ "X-Requested-With": "XMLHttpRequest",
275
+ Referer: `${ANIKOTO}/watch/${show.slug}`
276
+ });
277
+
278
+ const html = listJson.result || "";
279
+ let targetEp = null;
280
+ const re = /<a\s+[^>]*data-id="([^"]*)"[^>]*>/g;
281
+ let m;
282
+ while ((m = re.exec(html)) !== null) {
283
+ const tag = m[0];
284
+ const getAttr = (attr) => {
285
+ const x = tag.match(new RegExp(`data-${attr}="([^"]*)"`));
286
+ return x ? x[1] : "";
287
+ };
288
+ if (parseInt(getAttr("num")) === epNum) {
289
+ targetEp = {
290
+ ids: getAttr("ids"),
291
+ mal: getAttr("mal"),
292
+ slug: getAttr("slug"),
293
+ timestamp: getAttr("timestamp")
294
+ };
295
+ break;
296
+ }
297
+ }
298
+
299
+ if (!targetEp?.ids) {
300
+ return jsonResponse({ error: `Episode ${epNum} not found for show: ${show.title}` }, 404);
301
+ }
302
+
303
+ const malIdNum = media.idMal || (targetEp.mal ? parseInt(targetEp.mal) : null);
304
+
305
+ const [serverDataRes, mapperRes] = await Promise.allSettled([
306
+ getJSON(`${ANIKOTO}/ajax/server/list?servers=${encodeURIComponent(targetEp.ids)}`, {
307
+ "X-Requested-With": "XMLHttpRequest",
308
+ Referer: `${ANIKOTO}/`
309
+ }),
310
+ (targetEp.mal && targetEp.slug && targetEp.timestamp)
311
+ ? getJSON(`${MAPPER}/${targetEp.mal}/${targetEp.slug}/${targetEp.timestamp}`, { Referer: `${ANIKOTO}/` })
312
+ : Promise.resolve(null)
313
+ ]);
314
+
315
+ const serverData = serverDataRes.status === "fulfilled" ? serverDataRes.value : null;
316
+ const mapperData = mapperRes.status === "fulfilled" ? mapperRes.value : null;
317
+
318
+ const serverHtml = serverData?.result || "";
319
+ const serverItems = [];
320
+ const downloadItems = [];
321
+
322
+ const typeRe = /<div class="type" data-type="([^"]+)">([\s\S]*?)<\/ul>\s*<\/div>/g;
323
+ let typeM;
324
+ while ((typeM = typeRe.exec(serverHtml)) !== null) {
325
+ const typeName = typeM[1];
326
+ for (const li of typeM[2].matchAll(/<li\s+([^>]*data-link-id[^>]*)>([\s\S]*?)<\/li>/g)) {
327
+ const linkId = li[1].match(/data-link-id="([^"]+)"/)?.[1];
328
+ const name = li[2].replace(/<[^>]+>/g, "").trim();
329
+ if (!linkId) continue;
330
+
331
+ if (typeName === "dl" || name.toLowerCase().includes("download") || name.toLowerCase().includes("kiwi")) {
332
+ downloadItems.push({ linkId, name });
333
+ } else if (typeName === audio) {
334
+ serverItems.push({ linkId, name });
335
+ }
336
+ }
337
+ }
338
+
339
+ if (mapperData) {
340
+ for (const [sKey, sObj] of Object.entries(mapperData)) {
341
+ if (sKey === "status") continue;
342
+ const cleanName = sKey.replace(/[-_]+$/, "").trim();
343
+ if (sObj?.[audio]?.url) {
344
+ serverItems.push({ linkId: sObj[audio].url, name: cleanName });
345
+ }
346
+ if (sObj?.[audio]?.download) {
347
+ for (const [dLabel, dUrl] of Object.entries(sObj[audio].download)) {
348
+ if (dUrl && typeof dUrl === "string") {
349
+ downloadItems.push({ url: dUrl, name: cleanName });
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ const streams = [];
357
+ const subtitles = [];
358
+ const downloads = [];
359
+
360
+ const serverSeen = new Set();
361
+ const subSeen = new Set();
362
+ const dlSeen = new Set();
363
+
364
+ for (const item of serverItems) {
365
+ if (serverSeen.has(item.name)) continue;
366
+ serverSeen.add(item.name);
367
+
368
+ const resolved = item.linkId.startsWith("http")
369
+ ? { result: { url: item.linkId } }
370
+ : await getJSON(`${ANIKOTO}/ajax/server?get=${encodeURIComponent(item.linkId)}`, {
371
+ "X-Requested-With": "XMLHttpRequest",
372
+ Referer: `${ANIKOTO}/`
373
+ }).catch(() => null);
374
+
375
+ const embedUrl = resolved?.result?.url;
376
+ if (!embedUrl) continue;
377
+
378
+ let serverIntro = { start: 0, end: 0 };
379
+ let serverOutro = { start: 0, end: 0 };
380
+
381
+ if (resolved?.result?.skip_data?.intro?.length === 2) {
382
+ const [s, e] = resolved.result.skip_data.intro;
383
+ if (s || e) serverIntro = { start: Number(s) || 0, end: Number(e) || 0 };
384
+ }
385
+ if (resolved?.result?.skip_data?.outro?.length === 2) {
386
+ const [s, e] = resolved.result.skip_data.outro;
387
+ if (s || e) serverOutro = { start: Number(s) || 0, end: Number(e) || 0 };
388
+ }
389
+
390
+ let hlsUrl = null;
391
+
392
+ if (embedUrl.includes("#aHR0c")) {
393
+ const b64 = embedUrl.split("#")[1];
394
+ try {
395
+ const decodedUrl = atob(b64);
396
+ if (decodedUrl.includes(".m3u8")) {
397
+ hlsUrl = decodedUrl;
398
+ }
399
+ } catch (e) {}
400
+ }
401
+
402
+ const extracted = await extractEmbedSource(embedUrl);
403
+ const itemSubs = [];
404
+
405
+ if (extracted?.data?.sources?.file) {
406
+ hlsUrl = extracted.data.sources.file;
407
+
408
+ for (const t of extracted.data.tracks ?? []) {
409
+ const mapped = mapTrack(t, item.name);
410
+ itemSubs.push(mapped);
411
+ if (!subSeen.has(mapped.url)) {
412
+ subSeen.add(mapped.url);
413
+ subtitles.push(mapped);
414
+ }
415
+ }
416
+
417
+ if (extracted.data.intro?.start || extracted.data.intro?.end) {
418
+ serverIntro = { start: Number(extracted.data.intro.start) || 0, end: Number(extracted.data.intro.end) || 0 };
419
+ }
420
+ if (extracted.data.outro?.start || extracted.data.outro?.end) {
421
+ serverOutro = { start: Number(extracted.data.outro.start) || 0, end: Number(extracted.data.outro.end) || 0 };
422
+ }
423
+ }
424
+
425
+ if (hlsUrl) {
426
+ const streamObj = {
427
+ url: hlsUrl,
428
+ type: "hls",
429
+ server: item.name,
430
+ embedUrl,
431
+ referer: extracted?.origin ? `${extracted.origin}/` : `${new URL(embedUrl).origin}/`,
432
+ subtitles: itemSubs,
433
+ priority: 5,
434
+ isActive: streams.length === 0
435
+ };
436
+ if (serverIntro.start || serverIntro.end) streamObj.intro = serverIntro;
437
+ if (serverOutro.start || serverOutro.end) streamObj.outro = serverOutro;
438
+ streams.push(streamObj);
439
+ } else {
440
+ const streamObj = {
441
+ url: embedUrl,
442
+ type: "embed",
443
+ server: item.name,
444
+ referer: `${new URL(embedUrl).origin}/`,
445
+ priority: 4,
446
+ isActive: streams.length === 0
447
+ };
448
+ if (serverIntro.start || serverIntro.end) streamObj.intro = serverIntro;
449
+ if (serverOutro.start || serverOutro.end) streamObj.outro = serverOutro;
450
+ streams.push(streamObj);
451
+ }
452
+ }
453
+
454
+ for (const dl of downloadItems) {
455
+ let dlUrl = dl.url;
456
+ if (!dlUrl && dl.linkId) {
457
+ const resolved = await getJSON(`${ANIKOTO}/ajax/server?get=${encodeURIComponent(dl.linkId)}`, {
458
+ "X-Requested-With": "XMLHttpRequest",
459
+ Referer: `${ANIKOTO}/`
460
+ }).catch(() => null);
461
+ dlUrl = resolved?.result?.url;
462
+ }
463
+
464
+ if (dlUrl && !dlSeen.has(dlUrl)) {
465
+ dlSeen.add(dlUrl);
466
+ downloads.push({
467
+ url: dlUrl,
468
+ label: dl.name
469
+ });
470
+ }
471
+ }
472
+
473
+ return jsonResponse({
474
+ anilistId: parseInt(anilistId),
475
+ malId: malIdNum,
476
+ episode: epNum,
477
+ audio,
478
+ streams,
479
+ subtitles,
480
+ downloads,
481
+ headers: {
482
+ "User-Agent": UA,
483
+ "Referer": streams[0]?.referer || "https://anikototv.to/"
484
+ }
485
+ });
486
+ }
487
+
488
+ function jsonResponse(data, status = 200) {
489
+ return new Response(JSON.stringify(data, null, 2), {
490
+ status,
491
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
492
+ });
493
+ }
494
+
495
+ export default {
496
+ async fetch(request) {
497
+ const url = new URL(request.url);
498
+ const path = url.pathname;
499
+ if (request.method === "OPTIONS") {
500
+ return new Response(null, {
501
+ status: 204,
502
+ headers: {
503
+ "Access-Control-Allow-Origin": "*",
504
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
505
+ "Access-Control-Allow-Headers": "*"
506
+ }
507
+ });
508
+ }
509
+ try {
510
+ let m = path.match(/^\/watch\/anikoto\/(\d+)\/(sub|dub)\/anikoto-(\d+)\/?$/);
511
+ if (m) return await handleWatch(m[1], m[2], parseInt(m[3]));
512
+
513
+ m = path.match(/^\/episodes\/anikoto\/(\d+)\/?$/);
514
+ if (m) {
515
+ const data = await getEpisodes(parseInt(m[1]));
516
+ return jsonResponse(data);
517
+ }
518
+ return jsonResponse({ error: "Not found" }, 404);
519
+ } catch (err) {
520
+ return jsonResponse({ error: err.message, stack: err.stack }, 500);
521
+ }
522
+ }
523
+ };
anivexa-api/providers/animedunya.js ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import child_process from "node:child_process";
2
+ import { json, episodeMeta } from "../core/new-provider-utils.js";
3
+ import { getMedia } from "../core/anilist.js";
4
+ import { get as cacheGet, set as cacheSet, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js";
5
+
6
+ const BASE = "https://anime-dunya.com";
7
+
8
+ async function resolveMalId(anilistId) {
9
+ const cacheKey = `np:animedunya:${anilistId}`;
10
+ const cached = cacheGet(cacheKey);
11
+ if (isFresh(cached)) return cached.data;
12
+
13
+ const media = await getMedia(anilistId);
14
+ if (!media?.idMal) throw new Error("AnimeDunya: no MAL ID found");
15
+
16
+ cacheSet(cacheKey, media.idMal, SHOW_IDENTITY_TTL);
17
+ return media.idMal;
18
+ }
19
+
20
+ function fetchHtml(url) {
21
+ 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}"`;
22
+ return child_process.execSync(cmd, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
23
+ }
24
+
25
+ function extractEpisodesList(html) {
26
+ const match = html.match(/\\?"episodes\\?":\s*\[/);
27
+ if (!match) return [];
28
+ const idx = match.index;
29
+ const matchLen = match[0].length;
30
+ let braceCount = 1;
31
+ let result = "[";
32
+ for (let i = idx + matchLen; i < html.length; i++) {
33
+ const char = html[i];
34
+ if (char === "[") braceCount++;
35
+ else if (char === "]") braceCount--;
36
+ result += char;
37
+ if (braceCount === 0) break;
38
+ }
39
+ try {
40
+ const cleanStr = result.replace(/\\u0026/g, "&").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
41
+ return JSON.parse(cleanStr);
42
+ } catch (e) {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function extractStream(html) {
48
+ const match = html.match(/\\?"stream\\?":\s*/);
49
+ if (!match) return null;
50
+ const idx = match.index;
51
+ const matchLen = match[0].length;
52
+ let braceCount = 0;
53
+ let started = false;
54
+ let result = "";
55
+ for (let i = idx + matchLen; i < html.length; i++) {
56
+ const char = html[i];
57
+ if (char === "{") {
58
+ braceCount++;
59
+ started = true;
60
+ } else if (char === "}") {
61
+ braceCount--;
62
+ }
63
+ if (started) {
64
+ result += char;
65
+ if (braceCount === 0) break;
66
+ }
67
+ }
68
+ try {
69
+ const cleanStr = result.replace(/\\u0026/g, "&").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
70
+ return JSON.parse(cleanStr);
71
+ } catch (e) {
72
+ const sourceMatch = html.match(/"source"\s*:\s*"([^"]+)"/);
73
+ if (sourceMatch) {
74
+ return { source: sourceMatch[1].replace(/\\/g, "") };
75
+ }
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export async function getEpisodes(anilistId, ctx = {}) {
81
+ const malId = await resolveMalId(anilistId);
82
+ const html = fetchHtml(`${BASE}/en/anime/${malId}`);
83
+ if (!html) throw new Error("AnimeDunya: episodes fetch failed");
84
+
85
+ let cdnBase = "https://cdn.anime-dunya.com/thumbnail/";
86
+ let cdnExt = "small.jpg";
87
+
88
+ const thumbMatch = html.match(/(https?:\/\/[^\s"'`<>]+?\/thumbnail\/)([a-zA-Z0-9]+?)\/((?:small|large)\.jpg)/);
89
+ if (thumbMatch) {
90
+ cdnBase = thumbMatch[1];
91
+ cdnExt = thumbMatch[3];
92
+ }
93
+
94
+ const episodes = extractEpisodesList(html);
95
+ const watchable = episodes.filter(ep => ep.streamId !== null && ep.streamId !== undefined);
96
+ const sub = [];
97
+
98
+ for (const ep of watchable) {
99
+ const epNum = ep.episodeNumber;
100
+ const meta = episodeMeta(epNum, ctx);
101
+ const customTitle = Array.isArray(ep.translations)
102
+ ? ep.translations.find(t => t.language === "en")?.title
103
+ : ep.translations?.title;
104
+ sub.push({
105
+ id: `watch/animedunya/${anilistId}/sub/animedunya-${epNum}`,
106
+ number: epNum,
107
+ title: customTitle || meta.title || `Episode ${epNum}`,
108
+ duration: meta.duration,
109
+ audio: "sub",
110
+ filler: ep.filler || meta.filler || false,
111
+ uncensored: false,
112
+ description: meta.description,
113
+ image: ep.streamId ? `${cdnBase}${ep.streamId}/${cdnExt}` : meta.image,
114
+ airDate: meta.airDate
115
+ });
116
+ }
117
+
118
+ sub.sort((a, b) => a.number - b.number);
119
+
120
+ return {
121
+ meta: {
122
+ title: ctx.media?.title?.english ?? ctx.media?.title?.romaji ?? null,
123
+ malId,
124
+ source: "animedunya"
125
+ },
126
+ episodes: { sub, dub: [] }
127
+ };
128
+ }
129
+
130
+ async function handleWatch(anilistId, audio, epNum) {
131
+ const malId = await resolveMalId(anilistId);
132
+ const html = fetchHtml(`${BASE}/en/play/${malId}/${epNum}`);
133
+ if (!html) return json({ error: "AnimeDunya watch fetch failed" }, 500);
134
+
135
+ const streamData = extractStream(html);
136
+ if (!streamData || !streamData.source) {
137
+ return json({ error: "AnimeDunya: stream source not found" }, 404);
138
+ }
139
+
140
+ const subtitles = (streamData.subtitles || []).map(s => ({
141
+ url: s.src,
142
+ label: s.label,
143
+ srclang: s.srclang,
144
+ default: s.default || false
145
+ }));
146
+
147
+ const streams = [{
148
+ url: streamData.source,
149
+ type: "hls",
150
+ server: "AnimeDunya",
151
+ referer: `${BASE}/`,
152
+ subtitles,
153
+ priority: 5,
154
+ isActive: true
155
+ }];
156
+
157
+ return json({
158
+ anilistId: Number(anilistId),
159
+ malId,
160
+ episode: Number(epNum),
161
+ audio,
162
+ streams
163
+ });
164
+ }
165
+
166
+ export default {
167
+ async fetch(request) {
168
+ if (request.method === "OPTIONS") {
169
+ return new Response(null, {
170
+ status: 204,
171
+ headers: {
172
+ "Access-Control-Allow-Origin": "*",
173
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
174
+ "Access-Control-Allow-Headers": "*"
175
+ }
176
+ });
177
+ }
178
+ const url = new URL(request.url);
179
+ try {
180
+ const m = url.pathname.match(/^\/watch\/animedunya\/(\d+)\/(sub|dub)\/animedunya-(\d+)\/?$/);
181
+ if (m) return await handleWatch(m[1], m[2], m[3]);
182
+ return json({ error: "Not found" }, 404);
183
+ } catch (err) {
184
+ return json({ error: err.message, stack: err.stack }, 500);
185
+ }
186
+ }
187
+ };
anivexa-api/providers/animegg.js ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMedia } from "../core/anilist.js";
2
+ import {
3
+ attr,
4
+ buildTitles,
5
+ decodeEntities,
6
+ episodeMeta,
7
+ expectedCount,
8
+ fetchHtml,
9
+ findTopSlugs,
10
+ getPrequelOffset,
11
+ json,
12
+ selectSeries,
13
+ stripTags,
14
+ } from "../core/new-provider-utils.js";
15
+ import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js";
16
+
17
+ const BASE = "https://www.animegg.org";
18
+
19
+ async function search(query) {
20
+ const html = await fetchHtml(`${BASE}/search/?q=${encodeURIComponent(query)}`);
21
+ const results = [];
22
+ for (const m of html.matchAll(/<a\b[^>]*class=["'][^"']*\bmse\b[^"']*["'][^>]*>[\s\S]*?<\/a>/gi)) {
23
+ const tag = m[0].match(/<a\b[^>]*>/i)?.[0] ?? "";
24
+ const href = attr(tag, "href");
25
+ const slug = href.match(/^\/series\/([^/?#]+)/)?.[1];
26
+ if (!slug) continue;
27
+ const strong = m[0].match(/<strong[^>]*>([\s\S]*?)<\/strong>/i)?.[1];
28
+ results.push({ slug, text: strong ? stripTags(strong) : slug.replace(/-/g, " ") });
29
+ }
30
+ return results;
31
+ }
32
+
33
+ async function scrapeSeries(slug) {
34
+ const html = await fetchHtml(`${BASE}/series/${slug}`);
35
+ const episodes = [];
36
+ for (const m of html.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/gi)) {
37
+ const block = m[1];
38
+ if (!/\banm_det_pop\b/.test(block)) continue;
39
+ const link = block.match(/<a\b[^>]*class=["'][^"']*anm_det_pop[^"']*["'][^>]*>/i)?.[0] ?? "";
40
+ const href = attr(link, "href").replace(/#.*$/, "").replace(/^\//, "");
41
+ const strong = stripTags(block.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i)?.[1] ?? "");
42
+ const rangeMatch = strong.match(/(\d+)-(\d+)\s*$/);
43
+ const numMatch = rangeMatch || strong.match(/(\d+)\s*$/);
44
+ if (!numMatch || !href) continue;
45
+ const number = parseInt(numMatch[1]);
46
+ const title = stripTags(block.match(/<i\b[^>]*class=["'][^"']*anititle[^"']*["'][^>]*>([\s\S]*?)<\/i>/i)?.[1] ?? "") || strong;
47
+ const audio = [];
48
+ if (/\bbtn-subbed\b/.test(block)) audio.push("sub");
49
+ if (/\bbtn-dubbed\b/.test(block)) audio.push("dub");
50
+ episodes.push({ number, title, epSlug: href, hasSub: audio.includes("sub"), hasDub: audio.includes("dub") });
51
+ }
52
+ episodes.sort((a, b) => a.number - b.number);
53
+ const seen = new Set();
54
+ return episodes.filter((e) => seen.has(e.number) ? false : (seen.add(e.number), true));
55
+ }
56
+
57
+ async function scrapeEmbed(embedId) {
58
+ const html = await fetchHtml(`${BASE}/embed/${embedId}`, { Referer: BASE });
59
+ const m = html.match(/var\s+videoSources\s*=\s*(\[[\s\S]*?\]);/);
60
+ if (!m) return [];
61
+ let parsed = [];
62
+ try {
63
+ const asJson = m[1]
64
+ .replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
65
+ .replace(/:\s*'([^']*)'/g, ': "$1"');
66
+ parsed = JSON.parse(asJson);
67
+ } catch {
68
+ return [];
69
+ }
70
+ return parsed.map((s) => {
71
+ let backup = null;
72
+ if (s.bk) {
73
+ try { backup = decodeURIComponent(atob(s.bk)); }
74
+ catch { backup = null; }
75
+ }
76
+ return {
77
+ quality: s.label || "unknown",
78
+ url: s.file ? (s.file.startsWith("http") ? s.file : `${BASE}${s.file}`) : "",
79
+ backup,
80
+ };
81
+ }).filter((s) => s.url);
82
+ }
83
+
84
+ async function scrapeEpisodeWatch(epSlug, audio) {
85
+ const html = await fetchHtml(`${BASE}/${epSlug}`, { Referer: BASE });
86
+ const title = stripTags(html.match(/<div\b[^>]*class=["'][^"']*info[^"']*["'][^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/i)?.[1] ?? "");
87
+ const tabs = [];
88
+ for (const m of html.matchAll(/<a\b[^>]*data-toggle=["']tab["'][^>]*>/gi)) {
89
+ const tag = m[0];
90
+ const embedId = attr(tag, "data-id");
91
+ const server = attr(tag, "data-mirror") || "AnimeGG";
92
+ const version = attr(tag, "data-version") || "subbed";
93
+ if (!embedId) continue;
94
+ const normalized = version.startsWith("dub") ? "dub" : "sub";
95
+ if (audio === "all" || normalized === audio) {
96
+ tabs.push({ embedId, embedUrl: `${BASE}/embed/${embedId}`, server, normalized });
97
+ }
98
+ }
99
+ const results = await Promise.allSettled(tabs.map(async (tab, i) => {
100
+ const sources = await scrapeEmbed(tab.embedId);
101
+ const streams = sources.map((s, j) => ({
102
+ url: s.url,
103
+ type: s.url.includes(".m3u8") ? "hls" : "mp4",
104
+ quality: s.quality,
105
+ backup: s.backup,
106
+ audio: tab.normalized,
107
+ server: tab.server,
108
+ embed: tab.embedUrl,
109
+ referer: `${new URL(tab.embedUrl).origin}/`,
110
+ priority: tabs.length - i,
111
+ isActive: i === 0 && j === 0,
112
+ }));
113
+ streams.push({
114
+ url: tab.embedUrl,
115
+ type: "embed",
116
+ audio: tab.normalized,
117
+ server: `${tab.server}-embed`,
118
+ referer: `${new URL(tab.embedUrl).origin}/`,
119
+ priority: 1,
120
+ isActive: false,
121
+ });
122
+ return streams;
123
+ }));
124
+ return { title, streams: results.flatMap((r) => r.status === "fulfilled" ? r.value : []) };
125
+ }
126
+
127
+ async function searchFn(query) {
128
+ const r1 = await search(query);
129
+ // AnimeGG needs a plain alphanumeric token to surface all season variants.
130
+ // "Re:Zero" → 0 results; "ReZero" → all slugs including season-4.
131
+ const compact = query.split(/\s+/)[0].replace(/[^a-zA-Z0-9]/g, "");
132
+ if (compact.length >= 4 && compact.toLowerCase() !== query.toLowerCase()) {
133
+ try {
134
+ const r2 = await search(compact);
135
+ const seen = new Set(r1.map(r => r.slug));
136
+ r2.forEach(r => { if (!seen.has(r.slug)) r1.push(r); });
137
+ } catch {}
138
+ }
139
+ return r1;
140
+ }
141
+
142
+ async function resolveSeries(anilistId, ctx = {}) {
143
+ const cacheKey = `np:animegg:${anilistId}`;
144
+ const cached = get(cacheKey);
145
+ if (isFresh(cached)) return cached.data;
146
+
147
+ const media = ctx.media ?? await getMedia(anilistId);
148
+ const titles = buildTitles(media, ctx.anizip);
149
+ const candidates = await findTopSlugs(titles, searchFn);
150
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
151
+ const offset = await getPrequelOffset(anilistId).catch(() => 0);
152
+ const isSingleMovie = String(media?.format ?? "").toUpperCase() === "MOVIE" || expected === 1;
153
+ const selected = await selectSeries(candidates, scrapeSeries, expected, media?.status, offset, {
154
+ minScore: isSingleMovie ? 0.9 : 0.65,
155
+ });
156
+ if (!selected) throw new Error(`AnimeGG match not found for AniList ${anilistId}`);
157
+ const data = { slug: selected.slug, title: selected.title, mode: selected.mode, offset, score: selected.score };
158
+ set(cacheKey, data, SHOW_IDENTITY_TTL);
159
+ return data;
160
+ }
161
+
162
+ function buildEpisodeLists(anilistId, series, providerEpisodes, ctx, expected) {
163
+ const sub = [], dub = [];
164
+ for (const src of providerEpisodes) {
165
+ const number = series.mode === "offset" ? src.number - series.offset : src.number;
166
+ if (number < 1) continue;
167
+ if (expected && number > expected) continue;
168
+ const meta = episodeMeta(number, ctx);
169
+ const base = {
170
+ number,
171
+ title: meta.title ?? src.title ?? `Episode ${number}`,
172
+ duration: meta.duration,
173
+ filler: meta.filler,
174
+ uncensored: meta.uncensored,
175
+ description: meta.description,
176
+ image: meta.image,
177
+ airDate: meta.airDate,
178
+ sourceNumber: src.number,
179
+ };
180
+ if (src.hasSub) sub.push({ ...base, id: `watch/animegg/${anilistId}/sub/animegg-${number}`, audio: "sub" });
181
+ if (src.hasDub) dub.push({ ...base, id: `watch/animegg/${anilistId}/dub/animegg-${number}`, audio: "dub" });
182
+ }
183
+ return { sub, dub };
184
+ }
185
+
186
+ export async function getEpisodes(anilistId, ctx = {}) {
187
+ const media = ctx.media ?? await getMedia(anilistId);
188
+ const localCtx = { ...ctx, media };
189
+ const series = await resolveSeries(anilistId, localCtx);
190
+ const episodes = await scrapeSeries(series.slug);
191
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
192
+ return {
193
+ meta: {
194
+ id: series.slug,
195
+ title: series.title,
196
+ source: "animegg",
197
+ matchScore: Number(series.score.toFixed(3)),
198
+ numbering: series.mode,
199
+ episodeOffset: series.mode === "offset" ? series.offset : 0,
200
+ },
201
+ episodes: buildEpisodeLists(anilistId, series, episodes, localCtx, expected),
202
+ };
203
+ }
204
+
205
+ async function handleWatch(anilistId, audio, epNum, ctx = {}) {
206
+ const series = await resolveSeries(anilistId, ctx);
207
+ const providerEp = series.mode === "offset" ? Number(epNum) + series.offset : Number(epNum);
208
+ const episodes = await scrapeSeries(series.slug);
209
+ const ep = episodes.find((e) => e.number === providerEp);
210
+ if (!ep) return json({ error: `AnimeGG episode ${providerEp} not found` }, 404);
211
+ const watch = await scrapeEpisodeWatch(ep.epSlug, audio);
212
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, title: watch.title, streams: watch.streams });
213
+ }
214
+
215
+ export default {
216
+ async fetch(request) {
217
+ const url = new URL(request.url);
218
+ if (request.method === "OPTIONS") {
219
+ return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "*" } });
220
+ }
221
+ try {
222
+ const m = url.pathname.match(/^\/watch\/animegg\/(\d+)\/(sub|dub)\/animegg-(\d+)\/?$/);
223
+ if (m) return await handleWatch(m[1], m[2], m[3]);
224
+ return json({ error: "Not found" }, 404);
225
+ } catch (err) {
226
+ return json({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500);
227
+ }
228
+ },
229
+ };
anivexa-api/providers/animenosub.js ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "node:crypto";
2
+ import { getMedia } from "../core/anilist.js";
3
+ import {
4
+ buildTitles,
5
+ decodeEntities,
6
+ episodeMeta,
7
+ expectedCount,
8
+ fetchHtml,
9
+ findTopSlugs,
10
+ getPrequelOffset,
11
+ json,
12
+ selectSeries,
13
+ } from "../core/new-provider-utils.js";
14
+ import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js";
15
+
16
+ const BASE = "https://animenosub.to";
17
+ 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";
18
+
19
+ function b64u(buf) { return Buffer.from(buf).toString("base64url"); }
20
+ function b64uDec(s) { return Buffer.from(s, "base64url"); }
21
+
22
+ const _be = 512, _lt = _be - 1, _dr = 2, _lr = 2654435761, _hr = 2246822519;
23
+ const _rot = (t, e) => (t << e | t >>> 32 - e) >>> 0;
24
+ const _mul = (t, e) => Math.imul(t, e) >>> 0;
25
+ function _mix(t) {
26
+ t[0] = t[0] + t[1] >>> 0; t[3] = _rot(t[3] ^ t[0], 16);
27
+ t[2] = t[2] + t[3] >>> 0; t[1] = _rot(t[1] ^ t[2], 12);
28
+ t[0] = t[0] + t[1] >>> 0; t[3] = _rot(t[3] ^ t[0], 8);
29
+ t[2] = t[2] + t[3] >>> 0; t[1] = _rot(t[1] ^ t[2], 7);
30
+ }
31
+ function _hash(t) {
32
+ const e = new Uint32Array([1779033703, 3144134277, 1013904242, 2773480762]);
33
+ for (let i = 0; i < t.length; i++) { e[0] = e[0] + t[i] >>> 0; e[0] = _rot(e[0], 7); _mix(e); }
34
+ for (let i = 0; i < 8; i++) _mix(e);
35
+ const r = new Uint32Array(_be);
36
+ for (let i = 0; i < _be; i++) { _mix(e); r[i] = (e[0] ^ e[2]) >>> 0; }
37
+ for (let i = 0; i < _dr; i++) {
38
+ for (let s = 0; s < _be; s++) {
39
+ const a = r[s] & _lt;
40
+ let c = r[s] + r[a] >>> 0;
41
+ c = _rot(c, 13);
42
+ c = (c ^ _mul(r[(s + 1) & _lt], _lr)) >>> 0;
43
+ r[s] = c; e[0] = (e[0] ^ c) >>> 0; _mix(e);
44
+ }
45
+ }
46
+ const n = new Uint32Array(8), o = _be / 8;
47
+ for (let i = 0; i < 8; i++) {
48
+ _mix(e); let s = e[0]; const a = i * o;
49
+ 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; }
50
+ n[i] = (s ^ e[2]) >>> 0;
51
+ }
52
+ return n;
53
+ }
54
+ 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; }
55
+ 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; }
56
+ function solvePoW(nonce, difficulty) {
57
+ const prefix = nonce + ":";
58
+ for (let s = 0; ; s++) { if (_leadingZeros(_hash(_latin1Bytes(prefix + s))) >= difficulty) return String(s); }
59
+ }
60
+
61
+ async function resolveByse(embedUrl) {
62
+ const code = embedUrl.match(/\/e\/([a-z0-9]+)/i)?.[1];
63
+ if (!code) throw new Error(`Cannot extract Byse code from ${embedUrl}`);
64
+
65
+ const det = await (await fetch(`https://bysesayeveum.com/api/videos/${code}/embed/details`, {
66
+ headers: { "User-Agent": UA, "Referer": embedUrl },
67
+ })).json();
68
+
69
+ const frameUrl = det.embed_frame_url;
70
+ const frameBase = new URL(frameUrl).origin;
71
+
72
+ const ch = await (await fetch(`${frameBase}/api/videos/access/challenge`, {
73
+ method: "POST",
74
+ headers: { "Content-Length": "0", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA },
75
+ })).json();
76
+
77
+ const keyPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]);
78
+ const pubJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
79
+ const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, keyPair.privateKey, new TextEncoder().encode(ch.nonce));
80
+
81
+ const att = await (await fetch(`${frameBase}/api/videos/access/attest`, {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA },
84
+ body: JSON.stringify({ nonce: ch.nonce, challenge_id: ch.challenge_id, public_key: pubJwk, signature: b64u(sig) }),
85
+ })).json();
86
+
87
+ const viewerId = att.viewer_id, deviceId = att.device_id, fpToken = att.token, confidence = att.confidence;
88
+ const cookieStr = `byse_viewer_id=${viewerId}; byse_device_id=${deviceId}`;
89
+ const fingerprint = { token: fpToken, viewer_id: viewerId, device_id: deviceId, confidence };
90
+
91
+ const cap = await (await fetch(`${frameBase}/api/videos/${code}/embed/captcha`, {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA, "Cookie": cookieStr, "X-Embed-Parent": embedUrl },
94
+ body: "{}",
95
+ })).json();
96
+
97
+ const solution = solvePoW(cap.pow_nonce, cap.pow_difficulty);
98
+
99
+ const ver = await (await fetch(`${frameBase}/api/videos/${code}/embed/captcha/verify`, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA, "Cookie": cookieStr, "X-Embed-Parent": embedUrl },
102
+ body: JSON.stringify({ pow_token: cap.pow_token, solution, fingerprint }),
103
+ })).json();
104
+
105
+ const pbData = await (await fetch(`${frameBase}/api/videos/${code}/embed/playback`, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json", "Origin": frameBase, "Referer": frameUrl, "User-Agent": UA, "Cookie": cookieStr, "X-Captcha-Token": ver.token, "X-Embed-Parent": embedUrl },
108
+ body: JSON.stringify({ fingerprint }),
109
+ })).json();
110
+
111
+ const pb = pbData.playback;
112
+ const keyBytes = Buffer.concat(pb.key_parts.filter((k) => b64uDec(k).length === 16).map((k) => b64uDec(k)));
113
+ const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
114
+ const dec = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64uDec(pb.iv) }, aesKey, b64uDec(pb.payload));
115
+ const playback = JSON.parse(new TextDecoder().decode(dec));
116
+
117
+ return playback.sources.map((s) => s.url);
118
+ }
119
+
120
+ const NOVA_KEY = Buffer.from("6b69656d7469656e6d75613931316361", "hex");
121
+ const NOVA_IV = Buffer.from("313233343536373839306f6975797472", "hex");
122
+
123
+ async function resolveNova(embedUrl) {
124
+ const id = embedUrl.match(/upn\.one\/#([A-Za-z0-9]+)/i)?.[1];
125
+ if (!id) throw new Error(`Cannot extract Nova id from ${embedUrl}`);
126
+
127
+ const res = await fetch(`https://nova.upn.one/api/v1/video?id=${id}&w=1920&h=1080&r=`, {
128
+ headers: { "User-Agent": UA, "Referer": "https://nova.upn.one/" },
129
+ });
130
+ if (!res.ok) throw new Error(`Nova fetch HTTP ${res.status}`);
131
+ const hex = (await res.text()).trim();
132
+ const decipher = crypto.createDecipheriv("aes-128-cbc", NOVA_KEY, NOVA_IV);
133
+ const decrypted = Buffer.concat([decipher.update(Buffer.from(hex, "hex")), decipher.final()]);
134
+ const data = JSON.parse(decrypted.toString("utf8"));
135
+ const m3u8 = data.cf ?? data.source;
136
+ if (!m3u8) throw new Error("Nova response missing m3u8 url");
137
+ return [m3u8];
138
+ }
139
+
140
+ async function resolveVidmoly(embedUrl) {
141
+ const url = embedUrl.startsWith("//") ? `https:${embedUrl}` : embedUrl;
142
+ const res = await fetch(url, {
143
+ headers: { "User-Agent": UA, "Referer": `${BASE}/` },
144
+ redirect: "follow",
145
+ });
146
+ if (!res.ok) throw new Error(`Vidmoly fetch HTTP ${res.status}`);
147
+ const html = await res.text();
148
+ const m = html.match(/sources:\s*\[\s*\{\s*file:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/);
149
+ if (!m) throw new Error("Vidmoly m3u8 not found in embed HTML");
150
+ return [m[1]];
151
+ }
152
+
153
+ async function search(query) {
154
+ const res = await fetch(`${BASE}/wp-admin/admin-ajax.php`, {
155
+ method: "POST",
156
+ headers: {
157
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
158
+ "X-Requested-With": "XMLHttpRequest",
159
+ "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",
160
+ Origin: BASE,
161
+ Referer: `${BASE}/`,
162
+ },
163
+ body: `action=ts_ac_do_search&ts_ac_query=${encodeURIComponent(query)}`,
164
+ });
165
+ if (!res.ok) throw new Error(`animenosub search HTTP ${res.status}`);
166
+ const data = await res.json();
167
+ const results = [];
168
+ for (const item of data?.anime?.[0]?.all ?? []) {
169
+ const slug = item.post_link?.match(/\/anime\/([^/]+)\/?$/)?.[1];
170
+ if (!slug) continue;
171
+ results.push({ slug, text: item.post_title ?? slug.replace(/-/g, " ") });
172
+ }
173
+ return results;
174
+ }
175
+
176
+ async function scrapeSeries(slug) {
177
+ const html = await fetchHtml(`${BASE}/anime/${slug}/`, { Referer: BASE });
178
+ const isSlugDub = /-dub$/.test(slug) || /(?:^|[-\s])dub(?:$|[-\s])/i.test(slug);
179
+ const episodes = [];
180
+ const seen = new Set();
181
+ const listRe = /<li\b[^>]*data-index="\d+"[^>]*>[\s\S]*?<a\s+href="(https?:\/\/animenosub\.to\/[^"]+)"[\s\S]*?<div\s+class="epl-num">([^<]+)<\/div>/gi;
182
+ for (const m of html.matchAll(listRe)) {
183
+ const epUrl = decodeEntities(m[1]);
184
+ const label = m[2].trim();
185
+ let number;
186
+ if (/^movie$/i.test(label)) {
187
+ number = 1;
188
+ } else {
189
+ const n = parseFloat(label);
190
+ number = Number.isFinite(n) && n >= 1 ? Math.round(n) : null;
191
+ }
192
+ if (number === null || seen.has(number)) continue;
193
+ seen.add(number);
194
+ const isDub = isSlugDub || /-dub(?:$|\/)/.test(epUrl);
195
+ episodes.push({ number, title: /^movie$/i.test(label) ? "Movie" : `Episode ${number}`, epUrl, hasSub: !isDub, hasDub: isDub });
196
+ }
197
+ episodes.sort((a, b) => a.number - b.number);
198
+ return episodes;
199
+ }
200
+
201
+ async function scrapeEmbeds(epUrl) {
202
+ const html = await fetchHtml(epUrl, { Referer: `${BASE}/` });
203
+ const streams = [];
204
+ for (const m of html.matchAll(/<option\s+value="([A-Za-z0-9+/=]+)"\s+data-index="\d+"[^>]*>([^<]+)<\/option>/gi)) {
205
+ const b64 = m[1];
206
+ const serverName = m[2].trim();
207
+ if (!serverName || /select video server/i.test(serverName)) continue;
208
+ let embedUrl = null;
209
+ try {
210
+ const decoded = atob(b64);
211
+ embedUrl = decoded.match(/src=["']([^"']+)["']/i)?.[1] ?? null;
212
+ } catch { continue; }
213
+ if (!embedUrl) continue;
214
+ const embedOrigin = (() => { try { const u = new URL(embedUrl.startsWith("//") ? `https:${embedUrl}` : embedUrl); return `${u.protocol}//${u.host}/`; } catch { return epUrl; } })();
215
+ streams.push({
216
+ url: embedUrl,
217
+ type: "embed",
218
+ server: serverName,
219
+ referer: embedOrigin,
220
+ priority: streams.length === 0 ? 2 : 1,
221
+ isActive: streams.length === 0,
222
+ });
223
+ }
224
+ if (streams.length === 0) {
225
+ for (const m of html.matchAll(/<iframe[^>]+src=["']([^"']+)["'][^>]*>/gi)) {
226
+ const src = m[1];
227
+ if (/vidmoly|vtbe|streamtape|dood|filemoon|upn\.one|bysesa/i.test(src)) {
228
+ const embedOrigin = (() => { try { const u = new URL(src.startsWith("//") ? `https:${src}` : src); return `${u.protocol}//${u.host}/`; } catch { return epUrl; } })();
229
+ streams.push({ url: src, type: "embed", server: "Direct", referer: embedOrigin, priority: 2, isActive: true });
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ return streams;
235
+ }
236
+
237
+ async function resolveSeries(anilistId, ctx = {}) {
238
+ const cacheKey = `np:animenosub:${anilistId}`;
239
+ const cached = get(cacheKey);
240
+ if (isFresh(cached)) return cached.data;
241
+
242
+ const media = ctx.media ?? await getMedia(anilistId);
243
+ const titles = buildTitles(media, ctx.anizip);
244
+ const candidates = await findTopSlugs(titles, search);
245
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
246
+ const offset = await getPrequelOffset(anilistId).catch(() => 0);
247
+ const selected = await selectSeries(candidates, scrapeSeries, expected, media?.status, offset);
248
+ if (!selected) throw new Error(`animenosub match not found for AniList ${anilistId}`);
249
+ const data = { slug: selected.slug, title: selected.title, mode: selected.mode, offset, score: selected.score };
250
+ set(cacheKey, data, SHOW_IDENTITY_TTL);
251
+ return data;
252
+ }
253
+
254
+ function buildEpisodeLists(anilistId, series, providerEpisodes, ctx, expected) {
255
+ const sub = [], dub = [];
256
+ for (const src of providerEpisodes) {
257
+ const number = series.mode === "offset" ? src.number - series.offset : src.number;
258
+ if (number < 1) continue;
259
+ if (expected && number > expected) continue;
260
+ const meta = episodeMeta(number, ctx);
261
+ const base = {
262
+ number,
263
+ title: meta.title ?? src.title ?? `Episode ${number}`,
264
+ duration: meta.duration,
265
+ filler: meta.filler,
266
+ uncensored: meta.uncensored,
267
+ description: meta.description,
268
+ image: meta.image,
269
+ airDate: meta.airDate,
270
+ sourceNumber: src.number,
271
+ };
272
+ if (src.hasSub) sub.push({ ...base, id: `watch/animenosub/${anilistId}/sub/animenosub-${number}`, audio: "sub" });
273
+ if (src.hasDub) dub.push({ ...base, id: `watch/animenosub/${anilistId}/dub/animenosub-${number}`, audio: "dub" });
274
+ }
275
+ return { sub, dub };
276
+ }
277
+
278
+ export async function getEpisodes(anilistId, ctx = {}) {
279
+ const media = ctx.media ?? await getMedia(anilistId);
280
+ const localCtx = { ...ctx, media };
281
+ const series = await resolveSeries(anilistId, localCtx);
282
+ const episodes = await scrapeSeries(series.slug);
283
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
284
+ return {
285
+ meta: {
286
+ id: series.slug,
287
+ title: series.title,
288
+ source: "animenosub",
289
+ matchScore: Number(series.score.toFixed(3)),
290
+ numbering: series.mode,
291
+ episodeOffset: series.mode === "offset" ? series.offset : 0,
292
+ },
293
+ episodes: buildEpisodeLists(anilistId, series, episodes, localCtx, expected),
294
+ };
295
+ }
296
+
297
+ async function withRetry(fn, attempts = 2) {
298
+ for (let i = 0; i < attempts; i++) {
299
+ try { return await fn(); } catch (_) { if (i === attempts - 1) return null; }
300
+ }
301
+ return null;
302
+ }
303
+
304
+ function isByse(url) { return /bysesayeveum\.com\/e\//i.test(url); }
305
+ function isVidmoly(url) { return /vidmoly\.(net|biz|to)/i.test(url); }
306
+ function isNova(url) { return /upn\.one/i.test(url); }
307
+
308
+ async function handleWatch(anilistId, audio, epNum, ctx = {}) {
309
+ const series = await resolveSeries(anilistId, ctx);
310
+ const providerEp = series.mode === "offset" ? Number(epNum) + series.offset : Number(epNum);
311
+ const episodes = await scrapeSeries(series.slug);
312
+ const ep = episodes.find((e) => e.number === providerEp && (audio === "dub" ? e.hasDub : e.hasSub))
313
+ ?? episodes.find((e) => e.number === providerEp);
314
+ if (!ep) throw new Error(`animenosub episode ${providerEp} not found`);
315
+ const embeds = await scrapeEmbeds(ep.epUrl);
316
+
317
+ const resolvable = embeds.filter((s) => isByse(s.url) || isVidmoly(s.url) || isNova(s.url));
318
+ const resolvedList = await Promise.all(resolvable.map((s) => {
319
+ if (isByse(s.url)) return withRetry(() => resolveByse(s.url));
320
+ if (isVidmoly(s.url)) return withRetry(() => resolveVidmoly(s.url));
321
+ if (isNova(s.url)) return withRetry(() => resolveNova(s.url));
322
+ }));
323
+ const resolvedMap = new Map(resolvable.map((s, i) => [s.url, resolvedList[i]]));
324
+
325
+ const streams = [];
326
+ for (const stream of embeds) {
327
+ const m3u8Urls = resolvedMap.get(stream.url);
328
+ if (m3u8Urls) {
329
+ const referer = isVidmoly(stream.url)
330
+ ? "https://vidmoly.biz/"
331
+ : isNova(stream.url)
332
+ ? "https://nova.upn.one/"
333
+ : "https://bysesayeveum.com/";
334
+ for (const m3u8 of m3u8Urls) {
335
+ streams.push({
336
+ url: m3u8,
337
+ type: "hls",
338
+ server: stream.server,
339
+ referer,
340
+ priority: stream.priority,
341
+ isActive: stream.isActive,
342
+ });
343
+ }
344
+ }
345
+ streams.push(stream);
346
+ }
347
+
348
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, streams });
349
+ }
350
+
351
+ export default {
352
+ async fetch(request) {
353
+ const url = new URL(request.url);
354
+ if (request.method === "OPTIONS") {
355
+ return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "*" } });
356
+ }
357
+ try {
358
+ const m = url.pathname.match(/^\/watch\/animenosub\/(\d+)\/(sub|dub)\/animenosub-(\d+)\/?$/);
359
+ if (m) return await handleWatch(m[1], m[2], m[3]);
360
+ return json({ error: "Not found" }, 404);
361
+ } catch (err) {
362
+ return json({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500);
363
+ }
364
+ },
365
+ };
anivexa-api/providers/anineko.js ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMedia } from "../core/anilist.js";
2
+ import {
3
+ attr,
4
+ buildTitles,
5
+ decodeEntities,
6
+ episodeMeta,
7
+ expectedCount,
8
+ fetchHtml,
9
+ findTopSlugs,
10
+ getPrequelOffset,
11
+ json,
12
+ selectSeries,
13
+ stripTags,
14
+ } from "../core/new-provider-utils.js";
15
+ import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js";
16
+
17
+ const BASE = "https://anineko.to";
18
+
19
+ async function search(query) {
20
+ const html = await fetchHtml(`${BASE}/browser?keyword=${encodeURIComponent(query)}`);
21
+ const results = [];
22
+ for (const m of html.matchAll(/<a\b[^>]*class=["'][^"']*nv-anime-thumb[^"']*["'][^>]*>[\s\S]*?<\/a>/gi)) {
23
+ const tag = m[0].match(/<a\b[^>]*>/i)?.[0] ?? "";
24
+ const href = attr(tag, "href");
25
+ const slug = href.match(/\/watch\/([^/?#]+)/)?.[1];
26
+ if (!slug) continue;
27
+ const titleMatch = m[0].match(/<(?:h3|[^>]+class=["'][^"']*nv-anime-title[^"']*["'][^>]*)>([\s\S]*?)<\/(?:h3|[^>]+)>/i);
28
+ results.push({ slug, text: titleMatch ? stripTags(titleMatch[1]) : slug.replace(/-/g, " ") });
29
+ }
30
+ return results;
31
+ }
32
+
33
+ async function scrapeSeries(slug) {
34
+ const html = await fetchHtml(`${BASE}/watch/${slug}`);
35
+ const episodes = [];
36
+ for (const m of html.matchAll(/<article\b[^>]*class=["'][^"']*nv-info-episode-item[^"']*["'][^>]*>([\s\S]*?)<\/article>/gi)) {
37
+ const block = m[1];
38
+ const link = block.match(/<a\b[^>]*class=["'][^"']*nv-info-episode-main[^"']*["'][^>]*>/i)?.[0] ?? "";
39
+ const href = attr(link, "href");
40
+ const num = Number(href.match(/\/ep-(\d+)/)?.[1]);
41
+ if (!Number.isFinite(num)) continue;
42
+ const title = stripTags(block.match(/<a\b[^>]*class=["'][^"']*nv-info-episode-main[^"']*["'][^>]*>[\s\S]*?<span[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? "");
43
+ const badges = [...block.matchAll(/<span\b[^>]*>([\s\S]*?)<\/span>/gi)].map((b) => stripTags(b[1]).toLowerCase());
44
+ episodes.push({
45
+ number: num,
46
+ title: title || `Episode ${num}`,
47
+ epSlug: `ep-${num}`,
48
+ hasSub: badges.includes("sub"),
49
+ hasDub: badges.includes("dub"),
50
+ });
51
+ }
52
+ episodes.sort((a, b) => a.number - b.number);
53
+ const seen = new Set();
54
+ return episodes.filter((e) => seen.has(e.number) ? false : (seen.add(e.number), true));
55
+ }
56
+
57
+ async function extractHls(embedUrl) {
58
+ const html = await fetchHtml(embedUrl, { Referer: `${BASE}/` }).catch(() => "");
59
+ const patterns = [
60
+ /const\s+src\s*=\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i,
61
+ /file\s*:\s*["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i,
62
+ /["'](https?:\/\/[^"']+\/master\.m3u8[^"']*)["']/i,
63
+ /["'](https?:\/\/[^"']+\.m3u8[^"']*)["']/i,
64
+ ];
65
+ for (const pattern of patterns) {
66
+ const m = html.match(pattern);
67
+ if (m) return decodeEntities(m[1]);
68
+ }
69
+ return null;
70
+ }
71
+
72
+ async function scrapeEpisodeWatch(seriesSlug, epSlug, audio) {
73
+ const html = await fetchHtml(`${BASE}/watch/${seriesSlug}/${epSlug}`, { Referer: `${BASE}/watch/${seriesSlug}` });
74
+ const byAudio = { sub: [], dub: [] };
75
+ for (const panel of html.matchAll(/<div\b[^>]*class=["'][^"']*nv-server-grid[^"']*["'][^>]*data-id=["']([^"']+)["'][^>]*>([\s\S]*?)(?=<div\b[^>]*class=["'][^"']*nv-server-grid|$)/gi)) {
76
+ const rawAudio = panel[1].toLowerCase();
77
+ const panelAudio = rawAudio.includes("dub") ? "dub" : "sub";
78
+ for (const btn of panel[2].matchAll(/data-video=["']([^"']+)["']/gi)) byAudio[panelAudio].push(decodeEntities(btn[1]));
79
+ }
80
+ const audios = audio === "all" ? ["sub", "dub"] : [audio];
81
+ const streams = [];
82
+ await Promise.all(audios.map(async (aud) => {
83
+ const embeds = byAudio[aud] ?? [];
84
+ const resolved = await Promise.all(embeds.map(async (embed, i) => {
85
+ const hls = await extractHls(embed);
86
+ return {
87
+ url: hls ?? embed,
88
+ type: hls ? "hls" : "embed",
89
+ embed,
90
+ audio: aud,
91
+ server: "AniNeko",
92
+ priority: embeds.length - i,
93
+ referer: `${new URL(embed).origin}/`,
94
+ isActive: i === 0,
95
+ };
96
+ }));
97
+ streams.push(...resolved);
98
+ }));
99
+ return streams;
100
+ }
101
+
102
+ async function resolveSeries(anilistId, ctx = {}) {
103
+ const cacheKey = `np:anineko:${anilistId}`;
104
+ const cached = get(cacheKey);
105
+ if (isFresh(cached)) return cached.data;
106
+
107
+ const media = ctx.media ?? await getMedia(anilistId);
108
+ const titles = buildTitles(media, ctx.anizip);
109
+ const candidates = await findTopSlugs(titles, search);
110
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
111
+ const offset = await getPrequelOffset(anilistId).catch(() => 0);
112
+ const selected = await selectSeries(candidates, scrapeSeries, expected, media?.status, offset);
113
+ if (!selected) throw new Error(`AniNeko match not found for AniList ${anilistId}`);
114
+ const data = { slug: selected.slug, title: selected.title, mode: selected.mode, offset, score: selected.score };
115
+ set(cacheKey, data, SHOW_IDENTITY_TTL);
116
+ return data;
117
+ }
118
+
119
+ function buildEpisodeLists(anilistId, series, providerEpisodes, ctx, expected) {
120
+ const sub = [], dub = [];
121
+ for (const src of providerEpisodes) {
122
+ const number = series.mode === "offset" ? src.number - series.offset : src.number;
123
+ if (number < 1) continue;
124
+ if (expected && number > expected) continue;
125
+ const meta = episodeMeta(number, ctx);
126
+ const base = {
127
+ number,
128
+ title: meta.title ?? src.title ?? `Episode ${number}`,
129
+ duration: meta.duration,
130
+ filler: meta.filler,
131
+ uncensored: meta.uncensored,
132
+ description: meta.description,
133
+ image: meta.image,
134
+ airDate: meta.airDate,
135
+ sourceNumber: src.number,
136
+ };
137
+ if (src.hasSub) sub.push({ id: `watch/anineko/${anilistId}/sub/anineko-${number}`, ...base, audio: "sub" });
138
+ if (src.hasDub) dub.push({ id: `watch/anineko/${anilistId}/dub/anineko-${number}`, ...base, audio: "dub" });
139
+ }
140
+ return { sub, dub };
141
+ }
142
+
143
+ export async function getEpisodes(anilistId, ctx = {}) {
144
+ const media = ctx.media ?? await getMedia(anilistId);
145
+ const localCtx = { ...ctx, media };
146
+ const series = await resolveSeries(anilistId, localCtx);
147
+ const episodes = await scrapeSeries(series.slug);
148
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
149
+ return {
150
+ meta: {
151
+ id: series.slug,
152
+ title: series.title,
153
+ source: "anineko",
154
+ matchScore: Number(series.score.toFixed(3)),
155
+ numbering: series.mode,
156
+ episodeOffset: series.mode === "offset" ? series.offset : 0,
157
+ },
158
+ episodes: buildEpisodeLists(anilistId, series, episodes, localCtx, expected),
159
+ };
160
+ }
161
+
162
+ async function handleWatch(anilistId, audio, epNum, ctx = {}) {
163
+ const series = await resolveSeries(anilistId, ctx);
164
+ const providerEp = series.mode === "offset" ? Number(epNum) + series.offset : Number(epNum);
165
+ const streams = await scrapeEpisodeWatch(series.slug, `ep-${providerEp}`, audio);
166
+ return json({ anilistId: Number(anilistId), episode: Number(epNum), providerEpisode: providerEp, audio, streams });
167
+ }
168
+
169
+ export default {
170
+ async fetch(request) {
171
+ const url = new URL(request.url);
172
+ if (request.method === "OPTIONS") {
173
+ return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "*" } });
174
+ }
175
+ try {
176
+ const m = url.pathname.match(/^\/watch\/anineko\/(\d+)\/(sub|dub)\/anineko-(\d+)\/?$/);
177
+ if (m) return await handleWatch(m[1], m[2], m[3]);
178
+ return json({ error: "Not found" }, 404);
179
+ } catch (err) {
180
+ return json({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500);
181
+ }
182
+ },
183
+ };
anivexa-api/providers/anizone.js ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getMedia } from "../core/anilist.js";
2
+ import {
3
+ buildTitles,
4
+ decodeEntities,
5
+ diceCoeff,
6
+ episodeMeta,
7
+ expectedCount,
8
+ fetchHtml,
9
+ getPrequelOffset,
10
+ json,
11
+ norm,
12
+ selectSeries,
13
+ } from "../core/new-provider-utils.js";
14
+ import { get, set, isFresh, SHOW_IDENTITY_TTL } from "../core/smartcache.js";
15
+
16
+ const BASE = "https://anizone.to";
17
+
18
+ function scoreCandidate(query, candidate, slug) {
19
+ const base = Math.max(diceCoeff(query, candidate), diceCoeff(query, slug.replace(/-/g, " ")));
20
+ const isMovieQuery = /\b(movie|film|the movie)\b/i.test(query);
21
+ const isMovieMatch = /\b(movie|film)\b/i.test(candidate) || /movie|film/.test(slug);
22
+ if (isMovieQuery && !isMovieMatch) return base * 0.4;
23
+ const qLen = norm(query).length;
24
+ const sLen = norm(slug.replace(/-/g, " ")).length;
25
+ return sLen > qLen * 1.6 + 4 ? base * 0.8 : base;
26
+ }
27
+
28
+ function buildSearchQueries(title) {
29
+ const queries = new Set([title]);
30
+ const words = title.trim().split(/\s+/);
31
+ if (words.length > 4) queries.add(words.slice(0, 4).join(" "));
32
+ if (words.length > 3) queries.add(words.slice(0, 3).join(" "));
33
+ const stripped = title
34
+ .replace(/\bseason\s*\d+\b/gi, "")
35
+ .replace(/\bpart\s*\d+\b/gi, "")
36
+ .replace(/\b\d+rd\b|\b\d+th\b|\b\d+st\b|\b\d+nd\b/gi, "")
37
+ .replace(/\s+/g, " ")
38
+ .trim();
39
+ if (stripped && stripped !== title) queries.add(stripped);
40
+ return [...queries].filter((q) => q.length >= 3);
41
+ }
42
+
43
+ async function findCandidates(titles, searchFn, n = 6) {
44
+ const allCandidates = new Map();
45
+ const searchQueries = new Set();
46
+ for (const title of titles.slice(0, 4)) {
47
+ for (const q of buildSearchQueries(title)) searchQueries.add(q);
48
+ }
49
+ await Promise.all([...searchQueries].map(async (q) => {
50
+ try {
51
+ const results = await searchFn(q);
52
+ for (const r of results) if (!allCandidates.has(r.slug)) allCandidates.set(r.slug, r.text);
53
+ } catch {}
54
+ }));
55
+ const scored = [];
56
+ for (const [slug, text] of allCandidates) {
57
+ let best = 0;
58
+ for (const title of titles.slice(0, 2)) best = Math.max(best, scoreCandidate(title, text, slug));
59
+ if (best >= 0.5) scored.push({ slug, title: text, score: best });
60
+ }
61
+ return scored.sort((a, b) => b.score - a.score).slice(0, n);
62
+ }
63
+
64
+ function processJsonArg(raw) {
65
+ const PH = "\x01U\x01";
66
+ let s = raw.replace(/\\\\u([0-9a-fA-F]{4})/g, `${PH}$1`);
67
+ s = s.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
68
+ s = s.replace(/\x01U\x01([0-9a-fA-F]{4})/g, "\\u$1");
69
+ try { return JSON.parse(s); } catch { return {}; }
70
+ }
71
+
72
+ function pickTitle(titles) {
73
+ return titles["1"] || titles["5"] || titles["8"] || Object.values(titles)[0] || "";
74
+ }
75
+
76
+ function extractSlug(ctx) {
77
+ const m = ctx.match(/href="(?:https:\/\/anizone\.to)?\/anime\/([a-z0-9-]+)"/);
78
+ return m ? m[1] : null;
79
+ }
80
+
81
+ function extractJsonArg(xdata, key) {
82
+ const re = new RegExp(`${key}:\\s*JSON\\.parse\\('((?:[^'\\\\]|\\\\.)*)'\\)`);
83
+ const m = xdata.match(re);
84
+ return m ? m[1] : null;
85
+ }
86
+
87
+ async function search(query) {
88
+ const html = await fetchHtml(`${BASE}/anime?search=${encodeURIComponent(query)}`);
89
+ const results = [];
90
+ const xdataRe = /x-data="(\{[^"]*anmTitles[^"]*\})"/g;
91
+ let m;
92
+ while ((m = xdataRe.exec(html)) !== null) {
93
+ const ctxStart = Math.max(0, m.index - 300);
94
+ const ctxEnd = Math.min(html.length, m.index + m[0].length + 800);
95
+ const ctx = html.slice(ctxStart, ctxEnd);
96
+ const slug = extractSlug(ctx);
97
+ if (!slug) continue;
98
+ const xdata = decodeEntities(m[1]);
99
+ const raw = extractJsonArg(xdata, "anmTitles");
100
+ if (!raw) continue;
101
+ const titles = processJsonArg(raw);
102
+ const title = pickTitle(titles);
103
+ if (title) results.push({ slug, text: title });
104
+ }
105
+ return results;
106
+ }
107
+
108
+ async function scrapeSeries(slug) {
109
+ const html = await fetchHtml(`${BASE}/anime/${slug}`);
110
+ const episodes = [];
111
+ const xdataRe = /x-data="(\{[^"]*epsTitles[^"]*\})"/g;
112
+ let m;
113
+ while ((m = xdataRe.exec(html)) !== null) {
114
+ const ctxStart = Math.max(0, m.index - 400);
115
+ const ctxEnd = Math.min(html.length, m.index + m[0].length + 800);
116
+ const ctx = html.slice(ctxStart, ctxEnd);
117
+ const numMatch = ctx.match(/href="(?:https:\/\/anizone\.to)?\/anime\/[a-z0-9-]+\/(\d+)"/);
118
+ if (!numMatch) continue;
119
+ const num = Number(numMatch[1]);
120
+ if (!Number.isFinite(num) || num < 1) continue;
121
+ const xdata = decodeEntities(m[1]);
122
+ const raw = extractJsonArg(xdata, "epsTitles");
123
+ let title = `Episode ${num}`;
124
+ if (raw) {
125
+ const titles = processJsonArg(raw);
126
+ title = pickTitle(titles) || title;
127
+ }
128
+ episodes.push({ number: num, title, hasSub: true, hasDub: false });
129
+ }
130
+ const seen = new Set();
131
+ return episodes
132
+ .filter(e => seen.has(e.number) ? false : (seen.add(e.number), true))
133
+ .sort((a, b) => a.number - b.number);
134
+ }
135
+
136
+ async function scrapeWatch(slug, episodeNum) {
137
+ const html = await fetchHtml(`${BASE}/anime/${slug}/${episodeNum}`);
138
+
139
+ const hlsMatch = html.match(/<media-player[^>]+src="([^"]+\.m3u8[^"]*)"/i);
140
+ const hls = hlsMatch ? decodeEntities(hlsMatch[1]) : null;
141
+
142
+ const subtitles = [];
143
+ const trackRe = /<track\b([^>]*)>/gi;
144
+ let t;
145
+ while ((t = trackRe.exec(html)) !== null) {
146
+ const attrs = t[1];
147
+ const kind = attrs.match(/kind="([^"]*)"/i)?.[1] ?? "";
148
+ if (kind !== "subtitles") continue;
149
+ const src = attrs.match(/src=["']?([^\s"'>]+)["']?/i)?.[1] ?? "";
150
+ const label = attrs.match(/label="([^"]*)"/i)?.[1] ?? "";
151
+ const srclang = attrs.match(/srclang="([^"]*)"/i)?.[1] ?? "";
152
+ const dataType = attrs.match(/data-type="([^"]*)"/i)?.[1] ?? "vtt";
153
+ const isDefault = /\bdefault\b/.test(attrs);
154
+ if (src) subtitles.push({ url: decodeEntities(src), label, srclang, format: dataType, default: isDefault });
155
+ }
156
+
157
+ const storyboardMatch = html.match(/thumbnails="([^"]+\.vtt[^"]*)"/i);
158
+ const storyboard = storyboardMatch ? decodeEntities(storyboardMatch[1]) : null;
159
+
160
+ const chaptersMatch = html.match(/<track\b[^>]*kind="chapters"[^>]*src=["']?([^\s"'>]+)["']?/i);
161
+ const chapters = chaptersMatch ? decodeEntities(chaptersMatch[1]) : null;
162
+
163
+ return { hls, subtitles, storyboard, chapters };
164
+ }
165
+
166
+ async function searchFn(query) {
167
+ const r1 = await search(query);
168
+ // AniZone needs a plain alphanumeric token to surface all season variants
169
+ // e.g. "Re:ZERO -Starting Life..." → "ReZERO" finds all (2020)/(2021)/(2026) slugs
170
+ const compact = query.split(/\s+/)[0].replace(/[^a-zA-Z0-9]/g, "");
171
+ if (compact.length >= 4 && compact.toLowerCase() !== query.toLowerCase()) {
172
+ try {
173
+ const r2 = await search(compact);
174
+ const seen = new Set(r1.map(r => r.slug));
175
+ r2.forEach(r => { if (!seen.has(r.slug)) r1.push(r); });
176
+ } catch {}
177
+ }
178
+ return r1;
179
+ }
180
+
181
+ async function resolveSeries(anilistId, ctx = {}) {
182
+ const cacheKey = `np:anizone:${anilistId}`;
183
+ const cached = get(cacheKey);
184
+ if (isFresh(cached)) return cached.data;
185
+
186
+ const media = ctx.media ?? await getMedia(anilistId);
187
+ const titles = buildTitles(media, ctx.anizip);
188
+ let candidates = await findCandidates(titles, searchFn);
189
+
190
+ // AniZone uses "(YEAR)" suffixes for sequel seasons instead of slug numbers.
191
+ // When seasonYear is available and any candidate carries a year, re-score so the
192
+ // matching year wins decisively and wrong-year / year-less entries fall below threshold.
193
+ const seasonYear = media?.seasonYear;
194
+ if (seasonYear && candidates.some(c => /\(\d{4}\)/.test(c.title))) {
195
+ candidates = candidates.map(c => {
196
+ const m = c.title.match(/\((\d{4})\)/);
197
+ if (m) {
198
+ return parseInt(m[1]) === seasonYear
199
+ ? { ...c, score: Math.min(1, c.score * 1.3) }
200
+ : { ...c, score: c.score * 0.5 };
201
+ }
202
+ // No year suffix = base/S1 entry; penalise when sequels are expected
203
+ return { ...c, score: c.score * 0.65 };
204
+ }).sort((a, b) => b.score - a.score);
205
+ }
206
+
207
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
208
+ const offset = await getPrequelOffset(anilistId).catch(() => 0);
209
+ const selected = await selectSeries(candidates, scrapeSeries, expected, media?.status, offset);
210
+ if (!selected) throw new Error(`AniZone match not found for AniList ${anilistId}`);
211
+ const data = { slug: selected.slug, title: selected.title, mode: selected.mode, offset, score: selected.score };
212
+ set(cacheKey, data, SHOW_IDENTITY_TTL);
213
+ return data;
214
+ }
215
+
216
+ function buildEpisodeLists(anilistId, series, providerEpisodes, ctx, expected) {
217
+ const sub = [], dub = [];
218
+ for (const src of providerEpisodes) {
219
+ const number = series.mode === "offset" ? src.number - series.offset : src.number;
220
+ if (number < 1) continue;
221
+ if (expected && number > expected) continue;
222
+ const meta = episodeMeta(number, ctx);
223
+ const base = {
224
+ number,
225
+ title: meta.title ?? src.title ?? `Episode ${number}`,
226
+ duration: meta.duration,
227
+ filler: meta.filler,
228
+ uncensored: meta.uncensored,
229
+ description: meta.description,
230
+ image: meta.image,
231
+ airDate: meta.airDate,
232
+ sourceNumber: src.number,
233
+ };
234
+ if (src.hasSub) sub.push({ id: `watch/anizone/${anilistId}/sub/anizone-${number}`, ...base, audio: "sub" });
235
+ if (src.hasDub) dub.push({ id: `watch/anizone/${anilistId}/dub/anizone-${number}`, ...base, audio: "dub" });
236
+ }
237
+ return { sub, dub };
238
+ }
239
+
240
+ export async function getEpisodes(anilistId, ctx = {}) {
241
+ const media = ctx.media ?? await getMedia(anilistId);
242
+ const localCtx = { ...ctx, media };
243
+ const series = await resolveSeries(anilistId, localCtx);
244
+ const episodes = await scrapeSeries(series.slug);
245
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
246
+ return {
247
+ meta: {
248
+ id: series.slug,
249
+ title: series.title,
250
+ source: "anizone",
251
+ matchScore: Number(series.score.toFixed(3)),
252
+ numbering: series.mode,
253
+ episodeOffset: series.mode === "offset" ? series.offset : 0,
254
+ },
255
+ episodes: buildEpisodeLists(anilistId, series, episodes, localCtx, expected),
256
+ };
257
+ }
258
+
259
+ async function handleWatch(anilistId, audio, epNum, ctx = {}) {
260
+ const series = await resolveSeries(anilistId, ctx);
261
+ const providerEp = series.mode === "offset" ? Number(epNum) + series.offset : Number(epNum);
262
+ const watch = await scrapeWatch(series.slug, providerEp);
263
+ if (!watch.hls) throw new Error(`No HLS stream found for AniZone episode ${providerEp}`);
264
+ return json({
265
+ anilistId: Number(anilistId),
266
+ episode: Number(epNum),
267
+ providerEpisode: providerEp,
268
+ audio,
269
+ streams: [{
270
+ url: watch.hls,
271
+ type: "hls",
272
+ server: "AniZone",
273
+ subtitles: watch.subtitles,
274
+ storyboard: watch.storyboard,
275
+ chapters: watch.chapters,
276
+ priority: 1,
277
+ isActive: true,
278
+ }],
279
+ });
280
+ }
281
+
282
+ export default {
283
+ async fetch(request) {
284
+ const url = new URL(request.url);
285
+ if (request.method === "OPTIONS") {
286
+ return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "*" } });
287
+ }
288
+ try {
289
+ const m = url.pathname.match(/^\/watch\/anizone\/(\d+)\/(sub|dub)\/anizone-(\d+)\/?$/);
290
+ if (m) return await handleWatch(m[1], m[2], m[3]);
291
+ return json({ error: "Not found" }, 404);
292
+ } catch (err) {
293
+ return json({ error: err.message, "Raw-ERROR": err.rawBody ?? null, stack: err.stack }, 500);
294
+ }
295
+ },
296
+ };
anivexa-api/providers/kickassanime.js ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ buildTitles,
3
+ diceCoeff,
4
+ episodeMeta,
5
+ expectedCount,
6
+ json,
7
+ } from "../core/new-provider-utils.js";
8
+ import { getMedia } from "../core/anilist.js";
9
+ import {
10
+ get as cacheGet,
11
+ set as cacheSet,
12
+ isFresh,
13
+ SHOW_IDENTITY_TTL,
14
+ } from "../core/smartcache.js";
15
+
16
+ const BASE = "https://kaa.lt";
17
+ const HLS_BASE = "https://hls.krussdomi.com/manifest";
18
+ 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";
19
+ const H = { "User-Agent": UA, Accept: "application/json" };
20
+
21
+ async function kaaSearch(query) {
22
+ const res = await fetch(`${BASE}/api/fsearch`, {
23
+ method: "POST",
24
+ headers: { ...H, "Content-Type": "application/json" },
25
+ body: JSON.stringify({ page: 1, query }),
26
+ });
27
+ if (!res.ok) throw new Error(`kaa fsearch HTTP ${res.status}`);
28
+ const data = await res.json();
29
+ return Array.isArray(data?.result) ? data.result : [];
30
+ }
31
+
32
+ async function kaaShowInfo(showSlug) {
33
+ const res = await fetch(`${BASE}/api/show/${showSlug}`, { headers: H });
34
+ if (!res.ok) throw new Error(`kaa show HTTP ${res.status}: ${showSlug}`);
35
+ return res.json();
36
+ }
37
+
38
+ async function kaaEpisodePage(showSlug, ep) {
39
+ const res = await fetch(
40
+ `${BASE}/api/show/${showSlug}/episodes?ep=${ep}&lang=ja-JP`,
41
+ { headers: H }
42
+ );
43
+ if (!res.ok) throw new Error(`kaa episodes HTTP ${res.status}`);
44
+ return res.json();
45
+ }
46
+
47
+ async function kaaAllEpisodes(showSlug) {
48
+ const first = await kaaEpisodePage(showSlug, 1);
49
+ const pages = Array.isArray(first.pages) ? first.pages : [];
50
+ const all = Array.isArray(first.result) ? [...first.result] : [];
51
+
52
+ if (pages.length > 1) {
53
+ const rest = await Promise.all(
54
+ pages.slice(1).map(async (pg) => {
55
+ const startEp = pg.eps?.[0];
56
+ if (!startEp) return [];
57
+ const d = await kaaEpisodePage(showSlug, startEp);
58
+ return Array.isArray(d.result) ? d.result : [];
59
+ })
60
+ );
61
+ for (const batch of rest) all.push(...batch);
62
+ }
63
+
64
+ return all;
65
+ }
66
+
67
+ async function kaaEpisodeServers(showSlug, fullEpSlug) {
68
+ const res = await fetch(
69
+ `${BASE}/api/show/${showSlug}/episode/${fullEpSlug}`,
70
+ { headers: H }
71
+ );
72
+ if (!res.ok) throw new Error(`kaa episode servers HTTP ${res.status}`);
73
+ return res.json();
74
+ }
75
+
76
+ function buildKaaQueries(titles) {
77
+ const queries = new Set();
78
+ for (const title of titles.slice(0, 4)) {
79
+ if (/[\u3000-\u9fff\u4e00-\u9faf]/.test(title)) continue;
80
+ const clean = title.replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim();
81
+ if (!clean || clean.length < 3) continue;
82
+ const words = clean.split(" ").filter(Boolean);
83
+ if (words.length <= 3) {
84
+ queries.add(clean);
85
+ } else {
86
+ queries.add(words.slice(0, 2).join(" "));
87
+ queries.add(words.slice(0, 3).join(" "));
88
+ }
89
+ }
90
+ return [...queries];
91
+ }
92
+
93
+ function scoreCandidate(candidate, titles, seasonYear, anilistFormat) {
94
+ const titleEn = candidate.title_en || "";
95
+ const titleJp = candidate.title || "";
96
+ const kaaYear = Number(candidate.year);
97
+ const kaaType = (candidate.type || "").toLowerCase();
98
+
99
+ let base = 0;
100
+ for (const t of titles.slice(0, 3)) {
101
+ if (/[\u3000-\u9fff\u4e00-\u9faf]/.test(t)) continue;
102
+ base = Math.max(base, diceCoeff(t, titleEn), diceCoeff(t, titleJp));
103
+ }
104
+
105
+ let yearMult = 1.0;
106
+ if (seasonYear && kaaYear) {
107
+ const diff = Math.abs(Number(seasonYear) - kaaYear);
108
+ if (diff === 0) yearMult = 1.2;
109
+ else if (diff === 1) yearMult = 0.8;
110
+ else yearMult = 0.5;
111
+ }
112
+
113
+ let typeMult = 1.0;
114
+ const af = (anilistFormat || "").toUpperCase();
115
+ if (af === "MOVIE" && kaaType !== "movie") typeMult = 0.25;
116
+ else if (af !== "MOVIE" && kaaType === "movie") typeMult = 0.25;
117
+ else if ((af === "OVA" || af === "ONA" || af === "SPECIAL") && kaaType === "tv") typeMult = 0.5;
118
+ else if (af === "TV" && (kaaType === "ova" || kaaType === "special")) typeMult = 0.5;
119
+
120
+ return Math.min(1, base * yearMult) * typeMult;
121
+ }
122
+
123
+ async function resolveSeries(anilistId, ctx = {}) {
124
+ const cacheKey = `np:kaa:${anilistId}`;
125
+ const cached = cacheGet(cacheKey);
126
+ if (isFresh(cached)) return cached.data;
127
+
128
+ const media = ctx.media ?? await getMedia(anilistId);
129
+ const titles = buildTitles(media, ctx.anizip);
130
+ const queries = buildKaaQueries(titles);
131
+ const seasonYear = media?.seasonYear;
132
+ const format = media?.format;
133
+
134
+ if (!queries.length) throw new Error(`KAA: no usable search queries for AniList ${anilistId}`);
135
+
136
+ const allCandidates = new Map();
137
+ await Promise.all(
138
+ queries.map(async (q) => {
139
+ try {
140
+ const results = await kaaSearch(q);
141
+ for (const r of results) {
142
+ if (!allCandidates.has(r.slug)) allCandidates.set(r.slug, r);
143
+ }
144
+ } catch {}
145
+ })
146
+ );
147
+
148
+ if (!allCandidates.size) throw new Error(`KAA: no search results for AniList ${anilistId}`);
149
+
150
+ const scored = [];
151
+ for (const [, candidate] of allCandidates) {
152
+ const score = scoreCandidate(candidate, titles, seasonYear, format);
153
+ if (score >= 0.5) {
154
+ scored.push({
155
+ slug: candidate.slug,
156
+ title: candidate.title_en || candidate.title,
157
+ locales: Array.isArray(candidate.locales) ? candidate.locales : [],
158
+ score,
159
+ });
160
+ }
161
+ }
162
+
163
+ scored.sort((a, b) => b.score - a.score);
164
+
165
+ if (!scored.length) {
166
+ throw new Error(`KAA: no confident match for AniList ${anilistId}`);
167
+ }
168
+
169
+ const best = scored[0];
170
+ if (best.score < 0.6) {
171
+ throw new Error(
172
+ `KAA: low confidence match for AniList ${anilistId} — best "${best.slug}" score ${best.score.toFixed(3)}`
173
+ );
174
+ }
175
+
176
+ const data = {
177
+ slug: best.slug,
178
+ title: best.title,
179
+ locales: best.locales,
180
+ score: best.score,
181
+ };
182
+ cacheSet(cacheKey, data, SHOW_IDENTITY_TTL);
183
+ return data;
184
+ }
185
+
186
+ async function buildEpMap(showSlug, showInfo) {
187
+ if (showInfo?.type === "movie") {
188
+ const m = (showInfo.watch_uri || "").match(/\/(ep-(\d+)-([a-f0-9]+))$/i);
189
+ if (m) return [{ number: 1, fullSlug: m[1] }];
190
+ return [];
191
+ }
192
+ const episodes = await kaaAllEpisodes(showSlug);
193
+ return episodes.map((e) => ({
194
+ number: e.episode_number,
195
+ fullSlug: `ep-${e.episode_number}-${e.slug}`,
196
+ title: e.title,
197
+ duration: e.duration_ms ? Math.round(e.duration_ms / 1000) : null,
198
+ }));
199
+ }
200
+
201
+ export async function getEpisodes(anilistId, ctx = {}) {
202
+ const media = ctx.media ?? await getMedia(anilistId);
203
+ const localCtx = { ...ctx, media };
204
+ const series = await resolveSeries(anilistId, localCtx);
205
+ const showInfo = await kaaShowInfo(series.slug);
206
+
207
+ const locales = Array.isArray(showInfo.locales) ? showInfo.locales : series.locales;
208
+ const hasDub = locales.includes("en-US");
209
+
210
+ const epMap = await buildEpMap(series.slug, showInfo);
211
+ if (!epMap.length) throw new Error(`KAA: no episodes found for AniList ${anilistId} (slug: ${series.slug})`);
212
+
213
+ const expected = expectedCount(media, ctx.anizip, ctx.jikanEps);
214
+ const sub = [];
215
+ const dub = [];
216
+
217
+ for (const ep of epMap) {
218
+ const num = ep.number;
219
+ if (!Number.isFinite(num) || num < 1) continue;
220
+ if (expected && num > expected) continue;
221
+ const meta = episodeMeta(num, localCtx);
222
+ const base = {
223
+ number: num,
224
+ title: meta.title ?? ep.title ?? `Episode ${num}`,
225
+ duration: meta.duration ?? ep.duration,
226
+ filler: meta.filler,
227
+ uncensored: false,
228
+ description: meta.description,
229
+ image: meta.image,
230
+ airDate: meta.airDate,
231
+ };
232
+ sub.push({ id: `watch/kaa/${anilistId}/sub/kaa-${num}`, ...base, audio: "sub" });
233
+ if (hasDub) {
234
+ dub.push({ id: `watch/kaa/${anilistId}/dub/kaa-${num}`, ...base, audio: "dub" });
235
+ }
236
+ }
237
+
238
+ return {
239
+ meta: {
240
+ id: series.slug,
241
+ title: series.title,
242
+ source: "kaa",
243
+ matchScore: Number(series.score.toFixed(3)),
244
+ },
245
+ episodes: { sub, dub },
246
+ };
247
+ }
248
+
249
+ async function handleWatch(anilistId, audio, epNum) {
250
+ const series = await resolveSeries(anilistId);
251
+ const showInfo = await kaaShowInfo(series.slug);
252
+
253
+ const locales = Array.isArray(showInfo.locales) ? showInfo.locales : series.locales;
254
+ if (audio === "dub" && !locales.includes("en-US")) {
255
+ return json({ error: `KAA: no English dub for AniList ${anilistId}` }, 404);
256
+ }
257
+
258
+ const epMap = await buildEpMap(series.slug, showInfo);
259
+ const ep = epMap.find((e) => e.number === Number(epNum));
260
+ if (!ep) {
261
+ return json({ error: `KAA: episode ${epNum} not found for AniList ${anilistId}` }, 404);
262
+ }
263
+
264
+ const episodeData = await kaaEpisodeServers(series.slug, ep.fullSlug);
265
+ const servers = Array.isArray(episodeData.servers) ? episodeData.servers : [];
266
+ if (!servers.length) {
267
+ return json({ error: `KAA: no streams for episode ${epNum} (AniList ${anilistId})` }, 404);
268
+ }
269
+
270
+ const streams = [];
271
+ for (const s of servers) {
272
+ if (!s.src) continue;
273
+ const m = s.src.match(/[?&]id=([^&]+)/);
274
+ if (!m) continue;
275
+ streams.push({
276
+ url: `${HLS_BASE}/${m[1]}/master.m3u8`,
277
+ type: "hls",
278
+ server: s.name || "KAA",
279
+ headers: { Referer: "https://krussdomi.com/" },
280
+ priority: 1,
281
+ isActive: true,
282
+ });
283
+ }
284
+
285
+ if (!streams.length) {
286
+ return json({ error: `KAA: could not resolve stream for episode ${epNum}` }, 404);
287
+ }
288
+
289
+ return json({
290
+ anilistId: Number(anilistId),
291
+ episode: Number(epNum),
292
+ audio,
293
+ streams,
294
+ });
295
+ }
296
+
297
+ export default {
298
+ async fetch(request) {
299
+ if (request.method === "OPTIONS") {
300
+ return new Response(null, {
301
+ status: 204,
302
+ headers: {
303
+ "Access-Control-Allow-Origin": "*",
304
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
305
+ "Access-Control-Allow-Headers": "*",
306
+ },
307
+ });
308
+ }
309
+ const url = new URL(request.url);
310
+ try {
311
+ const m = url.pathname.match(/^\/watch\/kaa\/(\d+)\/(sub|dub)\/kaa-(\d+)\/?$/);
312
+ if (m) return await handleWatch(m[1], m[2], m[3]);
313
+ return json({ error: "Not found" }, 404);
314
+ } catch (err) {
315
+ return json({ error: err.message, stack: err.stack }, 500);
316
+ }
317
+ },
318
+ };
anivexa-api/providers/reanime.js ADDED
@@ -0,0 +1,756 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const __name = (fn, _) => fn;
2
+ import { getMedia } from '../core/anilist.js';
3
+ import { buildTitles } from '../core/new-provider-utils.js';
4
+ import { get as cacheGet, set as cacheSet, isFresh as cacheIsFresh, SHOW_IDENTITY_TTL } from '../core/smartcache.js';
5
+
6
+ var BASE = "https://reanime.to";
7
+ var FLIX = "https://flixcloud.cc";
8
+ var ANIZIP2 = "https://api.ani.zip/mappings";
9
+ 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";
10
+ var H = { "User-Agent": UA5, Accept: "application/json, */*" };
11
+ var enc = new TextEncoder();
12
+ var dec = new TextDecoder();
13
+ async function sha256hex(s) {
14
+ const buf = await crypto.subtle.digest("SHA-256", typeof s === "string" ? enc.encode(s) : s);
15
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
16
+ }
17
+ __name(sha256hex, "sha256hex");
18
+ function b64toU8(b64) {
19
+ const bin = atob(b64);
20
+ const out = new Uint8Array(bin.length);
21
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
22
+ return out;
23
+ }
24
+ __name(b64toU8, "b64toU8");
25
+ async function deriveFields(seed) {
26
+ let e = seed;
27
+ for (let i = 0; i < 3; i++) e = await sha256hex(e + i);
28
+ let l = e;
29
+ for (let i = 0; i < 3; i++) l = await sha256hex(l + i);
30
+ return {
31
+ keyField: "kf_" + e.substring(8, 16),
32
+ ivField: "ivf_" + e.substring(16, 24),
33
+ containerName: "cd_" + e.substring(24, 32),
34
+ arrayName: "ad_" + e.substring(32, 40),
35
+ objectName: "od_" + e.substring(40, 48),
36
+ tokenField: e.substring(48, 64) + "_" + e.substring(56, 64),
37
+ keyFrag2Field: l.substring(0, 16) + "_" + l.substring(16, 24)
38
+ };
39
+ }
40
+ __name(deriveFields, "deriveFields");
41
+ function extractSsrObj(html) {
42
+ const m = html.match(/\{type:"data",data:(\{)/);
43
+ if (!m) throw new Error("SSR data block not found");
44
+ let depth = 0;
45
+ const start = html.indexOf("{", m.index + m[0].length - 1);
46
+ for (let i = start; i < html.length; i++) {
47
+ if (html[i] === "{") depth++;
48
+ else if (html[i] === "}") {
49
+ if (--depth === 0) return html.slice(start, i + 1);
50
+ }
51
+ }
52
+ throw new Error("SSR brace matching failed");
53
+ }
54
+ __name(extractSsrObj, "extractSsrObj");
55
+ function parseJsLiteral(src) {
56
+ let i = 0;
57
+ function ws() {
58
+ while (i < src.length && /\s/.test(src[i])) i++;
59
+ }
60
+ __name(ws, "ws");
61
+ function parseValue() {
62
+ ws();
63
+ if (src[i] === "{") return parseObject();
64
+ if (src[i] === "[") return parseArray();
65
+ if (src[i] === '"') return parseDStr();
66
+ if (src[i] === "'") return parseSStr();
67
+ if (src.startsWith("true", i)) {
68
+ i += 4;
69
+ return true;
70
+ }
71
+ if (src.startsWith("false", i)) {
72
+ i += 5;
73
+ return false;
74
+ }
75
+ if (src.startsWith("null", i)) {
76
+ i += 4;
77
+ return null;
78
+ }
79
+ if (src.startsWith("undefined", i)) {
80
+ i += 9;
81
+ return null;
82
+ }
83
+ if (src.startsWith("!0", i)) {
84
+ i += 2;
85
+ return true;
86
+ }
87
+ if (src.startsWith("!1", i)) {
88
+ i += 2;
89
+ return false;
90
+ }
91
+ const m = src.slice(i).match(/^-?[\d.]+([eE][+-]?\d+)?/);
92
+ if (m) {
93
+ i += m[0].length;
94
+ return parseFloat(m[0]);
95
+ }
96
+ throw new Error(`JS parse error at pos ${i}: ...${src.slice(i, i + 20)}`);
97
+ }
98
+ __name(parseValue, "parseValue");
99
+ function parseDStr() {
100
+ let r = "";
101
+ i++;
102
+ while (i < src.length && src[i] !== '"') {
103
+ if (src[i] === "\\") {
104
+ i++;
105
+ const e = { n: "\n", t: " ", r: "\r", '"': '"', "\\": "\\" };
106
+ r += e[src[i]] ?? src[i];
107
+ i++;
108
+ } else r += src[i++];
109
+ }
110
+ i++;
111
+ return r;
112
+ }
113
+ __name(parseDStr, "parseDStr");
114
+ function parseSStr() {
115
+ let r = "";
116
+ i++;
117
+ while (i < src.length && src[i] !== "'") {
118
+ if (src[i] === "\\") {
119
+ i++;
120
+ r += src[i] === "'" ? "'" : { n: "\n", t: " ", r: "\r", "\\": "\\" }[src[i]] ?? src[i];
121
+ i++;
122
+ } else r += src[i++];
123
+ }
124
+ i++;
125
+ return r;
126
+ }
127
+ __name(parseSStr, "parseSStr");
128
+ function parseKey() {
129
+ ws();
130
+ if (src[i] === '"') return parseDStr();
131
+ if (src[i] === "'") return parseSStr();
132
+ const m = src.slice(i).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
133
+ if (m) {
134
+ i += m[0].length;
135
+ return m[0];
136
+ }
137
+ throw new Error(`Bad key at pos ${i}: ${src.slice(i, i + 20)}`);
138
+ }
139
+ __name(parseKey, "parseKey");
140
+ function parseObject() {
141
+ const obj = {};
142
+ i++;
143
+ ws();
144
+ while (i < src.length && src[i] !== "}") {
145
+ if (src[i] === ",") {
146
+ i++;
147
+ ws();
148
+ continue;
149
+ }
150
+ const k = parseKey();
151
+ ws();
152
+ i++;
153
+ obj[k] = parseValue();
154
+ ws();
155
+ }
156
+ i++;
157
+ return obj;
158
+ }
159
+ __name(parseObject, "parseObject");
160
+ function parseArray() {
161
+ const arr = [];
162
+ i++;
163
+ ws();
164
+ while (i < src.length && src[i] !== "]") {
165
+ if (src[i] === ",") {
166
+ i++;
167
+ ws();
168
+ continue;
169
+ }
170
+ arr.push(parseValue());
171
+ ws();
172
+ }
173
+ i++;
174
+ return arr;
175
+ }
176
+ __name(parseArray, "parseArray");
177
+ return parseValue();
178
+ }
179
+ __name(parseJsLiteral, "parseJsLiteral");
180
+ function parseWasmDecrypt(wasmBytes) {
181
+ const b = wasmBytes;
182
+ let pos = 8;
183
+ while (pos < b.length) {
184
+ const secId = b[pos++];
185
+ let sz = 0, sh = 0, by;
186
+ do {
187
+ by = b[pos++];
188
+ sz |= (by & 127) << sh;
189
+ sh += 7;
190
+ } while (by & 128);
191
+ if (secId === 10) {
192
+ pos++;
193
+ let sbs = 0, sh2 = 0, by2;
194
+ do {
195
+ by2 = b[pos++];
196
+ sbs |= (by2 & 127) << sh2;
197
+ sh2 += 7;
198
+ } while (by2 & 128);
199
+ pos += sbs;
200
+ break;
201
+ }
202
+ pos += sz;
203
+ }
204
+ let rbs = 0, sh3 = 0, by3;
205
+ do {
206
+ by3 = b[pos++];
207
+ rbs |= (by3 & 127) << sh3;
208
+ sh3 += 7;
209
+ } while (by3 & 128);
210
+ const r = b.slice(pos, pos + rbs);
211
+ function leb(arr, i) {
212
+ let v = 0, s = 0, b2;
213
+ do {
214
+ b2 = arr[i++];
215
+ v |= (b2 & 127) << s;
216
+ s += 7;
217
+ } while (b2 & 128);
218
+ return [v, i];
219
+ }
220
+ __name(leb, "leb");
221
+ const XOR_END = [32, 2, 32, 5, 106, 45, 0, 0, 115, 33, 6];
222
+ let txStart = -1;
223
+ outer: for (let i = 0; i < r.length - XOR_END.length; i++) {
224
+ for (let j = 0; j < XOR_END.length; j++) if (r[i + j] !== XOR_END[j]) continue outer;
225
+ txStart = i + XOR_END.length;
226
+ break;
227
+ }
228
+ if (txStart < 0) throw new Error("WASM: transform start not found");
229
+ let txEnd = -1, step = 36;
230
+ for (let i = txStart; i < r.length - 4; i++) {
231
+ if (r[i] === 32 && r[i + 1] === 5 && r[i + 2] === 65) {
232
+ const [val, ni] = leb(r, i + 3);
233
+ if (r[ni] === 108) {
234
+ txEnd = i;
235
+ step = val;
236
+ break;
237
+ }
238
+ }
239
+ }
240
+ if (txEnd < 0) throw new Error("WASM: keystream not found");
241
+ const code = r.slice(txStart, txEnd);
242
+ function transform(inputByte) {
243
+ let local6 = inputByte & 255;
244
+ const stk = [];
245
+ let i = 0;
246
+ while (i < code.length) {
247
+ const op = code[i++];
248
+ if (op === 32) {
249
+ const [idx, ni] = leb(code, i);
250
+ i = ni;
251
+ stk.push(idx === 6 ? local6 : 0);
252
+ } else if (op === 33) {
253
+ const [idx, ni] = leb(code, i);
254
+ i = ni;
255
+ const v = stk.pop();
256
+ if (idx === 6) local6 = v & 255;
257
+ } else if (op === 65) {
258
+ const [v, ni] = leb(code, i);
259
+ i = ni;
260
+ stk.push(v);
261
+ } else if (op === 106) {
262
+ const b2 = stk.pop(), a = stk.pop();
263
+ stk.push(a + b2 & 255);
264
+ } else if (op === 107) {
265
+ const b2 = stk.pop(), a = stk.pop();
266
+ stk.push(a - b2 + 256 & 255);
267
+ } else if (op === 113) {
268
+ const b2 = stk.pop(), a = stk.pop();
269
+ stk.push(a & b2 & 255);
270
+ } else if (op === 114) {
271
+ const b2 = stk.pop(), a = stk.pop();
272
+ stk.push((a | b2) & 255);
273
+ } else if (op === 115) {
274
+ const b2 = stk.pop(), a = stk.pop();
275
+ stk.push((a ^ b2) & 255);
276
+ } else if (op === 116) {
277
+ const b2 = stk.pop(), a = stk.pop();
278
+ stk.push(a << (b2 & 7) & 255);
279
+ } else if (op === 118) {
280
+ const b2 = stk.pop(), a = stk.pop();
281
+ stk.push(a >>> (b2 & 7) & 255);
282
+ }
283
+ }
284
+ return local6;
285
+ }
286
+ __name(transform, "transform");
287
+ return { step, transform };
288
+ }
289
+ __name(parseWasmDecrypt, "parseWasmDecrypt");
290
+ function runDecrypt(wasmBytes, frag1, kf2, T, seedInt) {
291
+ const { step, transform } = parseWasmDecrypt(wasmBytes);
292
+ const out = new Uint8Array(frag1.length);
293
+ for (let i = 0; i < frag1.length; i++) {
294
+ const c = (frag1[i] ^ kf2[i] ^ T[i]) & 255;
295
+ out[i] = transform(c) ^ i * step + seedInt & 255;
296
+ }
297
+ return out;
298
+ }
299
+ __name(runDecrypt, "runDecrypt");
300
+ async function decryptEmbed(html) {
301
+ const raw = extractSsrObj(html);
302
+ const data = parseJsLiteral(raw);
303
+ const seed = data.obfuscation_seed;
304
+ if (!seed) {
305
+ const e = new Error("obfuscation_seed missing");
306
+ e.debug = { topKeys: Object.keys(data).slice(0, 20) };
307
+ throw e;
308
+ }
309
+ const fields = await deriveFields(seed);
310
+ const ocd = data.obfuscated_crypto_data;
311
+ if (!ocd) {
312
+ const e = new Error("obfuscated_crypto_data missing");
313
+ e.debug = { fields, topKeys: Object.keys(data).slice(0, 20) };
314
+ throw e;
315
+ }
316
+ const container = ocd[fields.containerName];
317
+ if (!container) {
318
+ const e = new Error(`containerName "${fields.containerName}" not in ocd`);
319
+ e.debug = { fields, ocdKeys: Object.keys(ocd).slice(0, 10) };
320
+ throw e;
321
+ }
322
+ const arr = container[fields.arrayName];
323
+ if (!arr) {
324
+ const e = new Error(`arrayName "${fields.arrayName}" not in container`);
325
+ e.debug = { fields, containerKeys: Object.keys(container).slice(0, 10) };
326
+ throw e;
327
+ }
328
+ const obj = arr[0][fields.objectName];
329
+ if (!obj) {
330
+ const e = new Error(`objectName "${fields.objectName}" not in arr[0]`);
331
+ e.debug = { fields, arr0Keys: Object.keys(arr[0]).slice(0, 10) };
332
+ throw e;
333
+ }
334
+ const frag1 = b64toU8(obj[fields.keyField]);
335
+ const iv = b64toU8(obj[fields.ivField]);
336
+ const kf2raw = data[fields.keyFrag2Field];
337
+ if (!kf2raw) {
338
+ const e = new Error(`kf2 field "${fields.keyFrag2Field}" not in data`);
339
+ e.debug = { fields, topKeys: Object.keys(data).slice(0, 20) };
340
+ throw e;
341
+ }
342
+ const kf2 = b64toU8(kf2raw);
343
+ const token = data[fields.tokenField];
344
+ if (!token) {
345
+ const e = new Error(`tokenField "${fields.tokenField}" missing`);
346
+ e.debug = { fields, topKeys: Object.keys(data).slice(0, 20) };
347
+ throw e;
348
+ }
349
+ const tokData = await fetch(`${FLIX}/api/m3u8/${token}`, { headers: { ...H, Referer: `${BASE}/` } }).then(async (r) => {
350
+ if (!r.ok) { const _raw = await r.text().catch(() => null); const _e = new Error(`Token API ${r.status}`); _e.rawBody = _raw; throw _e; }
351
+ return r.json();
352
+ });
353
+ const vidKey = (await sha256hex(token + "vid")).substring(0, 10);
354
+ const keyKey = (await sha256hex(token + "key")).substring(0, 10);
355
+ const v_bytes = b64toU8(tokData[vidKey]);
356
+ const T_bytes = b64toU8(tokData[keyKey]);
357
+ if (!v_bytes.length || !T_bytes.length) {
358
+ const e = new Error(`Token fields missing. vidKey="${vidKey}" keyKey="${keyKey}"`);
359
+ e.debug = { tokKeys: Object.keys(tokData).slice(0, 10) };
360
+ throw e;
361
+ }
362
+ const seedInt = parseInt(seed.substring(0, 8), 16);
363
+ const wPayload = b64toU8(data.w_payload ?? "");
364
+ if (!wPayload.length) throw new Error("w_payload missing from embed data");
365
+ let wasmOut;
366
+ try {
367
+ wasmOut = runDecrypt(wPayload, frag1, kf2, T_bytes, seedInt);
368
+ } catch (pe) {
369
+ pe.wasmHex = Array.from(wPayload).map((b) => b.toString(16).padStart(2, "0")).join("");
370
+ throw pe;
371
+ }
372
+ const keyMat = await crypto.subtle.importKey("raw", wasmOut, { name: "PBKDF2" }, false, ["deriveBits"]);
373
+ const derived = new Uint8Array(await crypto.subtle.deriveBits(
374
+ { name: "PBKDF2", salt: enc.encode(seed), iterations: 1e3, hash: "SHA-256" },
375
+ keyMat,
376
+ 256
377
+ ));
378
+ for (let i = 0; i < 32; i++) derived[i] ^= seed.charCodeAt(i % seed.length);
379
+ const aesKeyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", derived));
380
+ const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-CBC" }, false, ["decrypt"]);
381
+ let plain;
382
+ try {
383
+ plain = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, aesKey, v_bytes);
384
+ } catch (err) {
385
+ err.debug = {
386
+ seedInt: "0x" + seedInt.toString(16),
387
+ frag1Len: frag1.length,
388
+ kf2Len: kf2.length,
389
+ T_bytesLen: T_bytes.length,
390
+ ivLen: iv.length,
391
+ v_bytesLen: v_bytes.length,
392
+ wPayloadLen: wPayload.length,
393
+ wasmOutHex: Array.from(wasmOut).map((b) => b.toString(16).padStart(2, "0")).join("")
394
+ };
395
+ throw err;
396
+ }
397
+ const url = dec.decode(plain).trim().replace(/\0+$/, "");
398
+ if (!url.startsWith("http")) throw new Error(`Unexpected decrypted value: ${url.substring(0, 60)}`);
399
+ return {
400
+ url,
401
+ subtitles: data.subtitles ?? [],
402
+ thumbnails_vtt: data.thumbnails_vtt ?? null,
403
+ video_title: data.video_title ?? null,
404
+ intro_chapter: data.intro_chapter ?? null,
405
+ outro_chapter: data.outro_chapter ?? null,
406
+ video_id: data.video_id ?? null
407
+ };
408
+ }
409
+ __name(decryptEmbed, "decryptEmbed");
410
+ async function searchReanime(query) {
411
+ const data = await fetch(`${BASE}/api/v1/search?${new URLSearchParams({ q: query, limit: 10 })}`, { headers: H }).then(async (r) => {
412
+ const _raw = await r.text();
413
+ if (!r.ok) { const _e = new Error(`reanime search ${r.status}`); _e.rawBody = _raw; throw _e; }
414
+ try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; }
415
+ });
416
+ return Array.isArray(data?.results) ? data.results : [];
417
+ }
418
+ __name(searchReanime, "searchReanime");
419
+ async function fetchAnimeDetail(animeId) {
420
+ const res = await fetch(`${BASE}/api/v1/anime/${animeId}`, { headers: H });
421
+ if (!res.ok) return null;
422
+ return res.json().catch(() => null);
423
+ }
424
+ __name(fetchAnimeDetail, "fetchAnimeDetail");
425
+ // Extract AniList ID embedded in AniList CDN cover image URLs.
426
+ // e.g. https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx16498-xxxx.jpg → 16498
427
+ function extractAnilistIdFromCover(coverImage) {
428
+ const urls = [coverImage?.extra_large, coverImage?.large, coverImage?.medium].filter(Boolean);
429
+ for (const url of urls) {
430
+ const m = url.match(/anilist\.co\/.*\/bx(\d+)-/);
431
+ if (m) return Number(m[1]);
432
+ }
433
+ return null;
434
+ }
435
+ __name(extractAnilistIdFromCover, "extractAnilistIdFromCover");
436
+ async function resolveSeries(anilistId, ctx = {}) {
437
+ const cacheKey = `np:reanime:${anilistId}`;
438
+ const cached = cacheGet(cacheKey);
439
+ if (cacheIsFresh(cached)) return cached.data;
440
+
441
+ const media = ctx.media ?? await getMedia(anilistId);
442
+ const malId = media?.idMal ?? null;
443
+ const queries = buildTitles(media, ctx.anizip).slice(0, 5);
444
+
445
+ const candidates = new Map();
446
+ await Promise.all(queries.map(async (q) => {
447
+ for (const r of await searchReanime(q).catch(() => [])) {
448
+ if (r?.anime_id && !candidates.has(r.anime_id)) candidates.set(r.anime_id, r);
449
+ }
450
+ }));
451
+
452
+ // Fast pass: AniList CDN cover URLs embed the AniList ID as bx{id}-*.
453
+ // If a candidate's cover image already confirms our ID we can skip detail fetches entirely.
454
+ for (const [id, r] of candidates) {
455
+ const coverId = extractAnilistIdFromCover(r.cover_image);
456
+ if (coverId && coverId === Number(anilistId)) {
457
+ const data = {
458
+ animeId: id,
459
+ title: r.title?.english || r.title?.romaji || id,
460
+ anilistId: Number(anilistId),
461
+ malId: null,
462
+ subbed: Number.isFinite(r.subbed) ? r.subbed : null,
463
+ dubbed: Number.isFinite(r.dubbed) ? r.dubbed : null,
464
+ episodesCount: Number.isFinite(r.episodes) ? r.episodes : null,
465
+ matchType: "cover_image",
466
+ matchScore: 1,
467
+ };
468
+ cacheSet(cacheKey, data, SHOW_IDENTITY_TTL);
469
+ return data;
470
+ }
471
+ }
472
+
473
+ // Fallback: fetch detail pages only for candidates that had no AniList CDN cover
474
+ // (TMDB / MAL covers don't embed an ID we can read directly).
475
+ const needsDetail = [...candidates.keys()].filter(
476
+ (id) => extractAnilistIdFromCover(candidates.get(id)?.cover_image) === null
477
+ );
478
+ const details = await Promise.all(
479
+ needsDetail.map(async (id) => ({ id, detail: await fetchAnimeDetail(id).catch(() => null) }))
480
+ );
481
+
482
+ for (const { id, detail } of details) {
483
+ if (detail?.anilist_id && Number(detail.anilist_id) === Number(anilistId)) {
484
+ const data = {
485
+ animeId: id,
486
+ title: detail.title?.english || detail.title?.romaji || candidates.get(id)?.title?.english || id,
487
+ anilistId: Number(anilistId),
488
+ malId: detail.mal_id || null,
489
+ subbed: Number.isFinite(detail.subbed) ? detail.subbed : null,
490
+ dubbed: Number.isFinite(detail.dubbed) ? detail.dubbed : null,
491
+ episodesCount: Number.isFinite(detail.episodes) ? detail.episodes : null,
492
+ matchType: "anilist",
493
+ matchScore: 1,
494
+ };
495
+ cacheSet(cacheKey, data, SHOW_IDENTITY_TTL);
496
+ return data;
497
+ }
498
+ }
499
+
500
+ if (malId) {
501
+ for (const { id, detail } of details) {
502
+ const detailMal = detail?.mal_id;
503
+ if (detailMal && Number(detailMal) === Number(malId)) {
504
+ const data = {
505
+ animeId: id,
506
+ title: detail.title?.english || detail.title?.romaji || id,
507
+ anilistId: Number(anilistId),
508
+ malId: Number(detailMal),
509
+ subbed: Number.isFinite(detail.subbed) ? detail.subbed : null,
510
+ dubbed: Number.isFinite(detail.dubbed) ? detail.dubbed : null,
511
+ episodesCount: Number.isFinite(detail.episodes) ? detail.episodes : null,
512
+ matchType: "mal",
513
+ matchScore: 0.9,
514
+ };
515
+ cacheSet(cacheKey, data, SHOW_IDENTITY_TTL);
516
+ return data;
517
+ }
518
+ }
519
+ }
520
+
521
+ throw new Error(`No confirmed reanime match for AniList ${anilistId}`);
522
+ }
523
+ __name(resolveSeries, "resolveSeries");
524
+ async function fetchEpisodesList(animeId, limit = 2000) {
525
+ const data = await fetch(`${BASE}/api/v1/anime/${animeId}/episodes?${new URLSearchParams({ limit })}`, { headers: H }).then(async (r) => {
526
+ const _raw = await r.text();
527
+ if (!r.ok) { const _e = new Error(`reanime episodes ${r.status}`); _e.rawBody = _raw; throw _e; }
528
+ try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; }
529
+ });
530
+ return Array.isArray(data?.data) ? data.data : [];
531
+ }
532
+ __name(fetchEpisodesList, "fetchEpisodesList");
533
+ async function fetchAnizip(anilistId) {
534
+ return fetch(`${ANIZIP2}?anilist_id=${anilistId}`).then((r) => r.json()).catch(() => null);
535
+ }
536
+ __name(fetchAnizip, "fetchAnizip");
537
+ function mergeEpisode(anilistId, ep, meta, audio) {
538
+ const number = ep.episode_number;
539
+ return {
540
+ id: `watch/reanime/${anilistId}/${audio}/reanime-${number}`,
541
+ number,
542
+ title: meta?.title?.en || meta?.title?.["x-jat"] || ep.title || `Episode ${number}`,
543
+ titleJapanese: meta?.title?.ja || ep.title_japanese || null,
544
+ titleRomanji: meta?.title?.["x-jat"] || ep.title_romanji || null,
545
+ image: meta?.image || ep.thumbnail || null,
546
+ airDate: meta?.airdate || ep.aired || null,
547
+ duration: meta?.runtime ? meta.runtime * 60 : (ep.duration ? ep.duration * 60 : null),
548
+ score: null,
549
+ filler: ep.is_filler ?? meta?.filler ?? false,
550
+ recap: ep.is_recap ?? false,
551
+ description: meta?.overview || ep.description || null,
552
+ audio
553
+ };
554
+ }
555
+ __name(mergeEpisode, "mergeEpisode");
556
+ function json3(data, status = 200) {
557
+ return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } });
558
+ }
559
+ __name(json3, "json");
560
+ async function handleEpisodes3(anilistId, url) {
561
+ const series = await resolveSeries(anilistId);
562
+ const [reanimeEps, anizip] = await Promise.all([
563
+ fetchEpisodesList(series.animeId),
564
+ fetchAnizip(anilistId)
565
+ ]);
566
+ if (!reanimeEps.length) return json3({ error: `No reanime episodes found for AniList ID ${anilistId} (slug ${series.animeId})` }, 404);
567
+ const episodes = reanimeEps.map((ep) => {
568
+ const meta = anizip?.episodes?.[String(ep.episode_number)] ?? null;
569
+ return mergeEpisode(anilistId, ep, meta, "sub");
570
+ }).sort((a, b) => a.number - b.number);
571
+ return json3({
572
+ anime: series.title,
573
+ anilistId: Number(anilistId),
574
+ malId: series.malId,
575
+ animeId: series.animeId,
576
+ episodes,
577
+ pagination: { currentPage: 1, lastPage: 1, hasNextPage: false }
578
+ });
579
+ }
580
+ __name(handleEpisodes3, "handleEpisodes");
581
+ async function resolveStream3(anilistId, audio, ep) {
582
+ const series = await resolveSeries(anilistId);
583
+ const title2 = series.title;
584
+ const slug = series.animeId;
585
+ const order = { "HD-2": 0, "HD-1": 1 };
586
+ const byPrio = (arr) => arr.slice().sort((a, b) => (order[a.serverName] ?? 9) - (order[b.serverName] ?? 9));
587
+ const [watchRes, flixRes] = await Promise.allSettled([
588
+ fetch(`${BASE}/api/watch/${slug}/${ep}`, { headers: H }).then(async (r) => {
589
+ const _raw = await r.text();
590
+ if (!r.ok) { const _e = new Error(`watch ${r.status}`); _e.rawBody = _raw; throw _e; }
591
+ try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; }
592
+ }),
593
+ fetch(`${BASE}/api/flix/${anilistId}/${ep}`, { headers: H }).then(async (r) => {
594
+ const _raw = await r.text();
595
+ if (!r.ok) { const _e = new Error(`flix ${r.status}`); _e.rawBody = _raw; throw _e; }
596
+ try { return JSON.parse(_raw); } catch (_pe) { _pe.rawBody = _raw; throw _pe; }
597
+ })
598
+ ]);
599
+ const watchData = watchRes.status === "fulfilled" ? watchRes.value : null;
600
+ const flixData = flixRes.status === "fulfilled" ? flixRes.value : null;
601
+ const links = [...watchData?.episode_links ?? []];
602
+ if (flixData?.success && flixData?.servers) {
603
+ const seen = new Set(links.map((s) => s["$id"]));
604
+ for (const s of flixData.servers) {
605
+ if (!seen.has(s["$id"])) links.push(s);
606
+ }
607
+ }
608
+ const audioTypes = audio === "sub" ? ["sub", "s-sub"] : ["dub", "s-dub"];
609
+ const servers = byPrio(links.filter((s) => audioTypes.includes(s.dataType)));
610
+ if (!servers.length) throw Object.assign(new Error(`No ${audio} servers for "${title2}" ep ${ep}`), { status: 404 });
611
+ const embedRes = await fetch(servers[0].dataLink, { headers: { ...H, Referer: `${BASE}/` } });
612
+ if (!embedRes.ok) throw Object.assign(new Error(`Embed fetch failed: ${embedRes.status}`), { status: 502 });
613
+ const stream = await decryptEmbed(await embedRes.text());
614
+ return { title: title2, slug, watchData, stream, server: servers[0].serverName, servers };
615
+ }
616
+ __name(resolveStream3, "resolveStream");
617
+ async function handleWatch3(anilistId, audio, epNum, origin) {
618
+ if (audio !== "sub" && audio !== "dub") return json3({ error: "audio must be sub or dub" }, 400);
619
+ const ep = parseInt(epNum);
620
+ if (isNaN(ep)) return json3({ error: `Invalid episode: ${epNum}` }, 400);
621
+ let resolved;
622
+ try {
623
+ resolved = await resolveStream3(anilistId, audio, ep);
624
+ } catch (e) {
625
+ return json3({ error: e.message, "Raw-ERROR": e.rawBody ?? null, stack: e.stack }, e.status ?? 500);
626
+ }
627
+ const { title: title2, slug, watchData, stream, server, servers } = resolved;
628
+ const redirectUrl = `${origin}/stream/reanime/${anilistId}/${audio}/${ep}`;
629
+ return json3({
630
+ anime: title2,
631
+ slug,
632
+ ep,
633
+ audio,
634
+ server,
635
+ stream_url: stream.url,
636
+ redirect_url: redirectUrl,
637
+ streams: [
638
+ { url: stream.url, type: "hls" },
639
+ { url: redirectUrl, type: "hls-redirect" },
640
+ ...servers.map((s) => ({ url: s.dataLink, type: "embed", server: s.serverName }))
641
+ ],
642
+ subtitles: stream.subtitles,
643
+ thumbnails_vtt: stream.thumbnails_vtt,
644
+ video_title: stream.video_title,
645
+ intro: stream.intro_chapter,
646
+ outro: stream.outro_chapter,
647
+ intro_start: watchData?.intro_start ?? null,
648
+ intro_end: watchData?.intro_end ?? null,
649
+ outro_start: watchData?.outro_start ?? null,
650
+ outro_end: watchData?.outro_end ?? null,
651
+ allServers: servers.map((s) => ({ name: s.serverName, type: s.dataType, embed: s.dataLink }))
652
+ });
653
+ }
654
+ __name(handleWatch3, "handleWatch");
655
+ async function handleStream3(anilistId, audio, epNum) {
656
+ if (audio !== "sub" && audio !== "dub") return json3({ error: "audio must be sub or dub" }, 400);
657
+ const ep = parseInt(epNum);
658
+ if (isNaN(ep)) return json3({ error: `Invalid episode: ${epNum}` }, 400);
659
+ let resolved;
660
+ try {
661
+ resolved = await resolveStream3(anilistId, audio, ep);
662
+ } catch (e) {
663
+ return json3({ error: e.message, "Raw-ERROR": e.rawBody ?? null, stack: e.stack }, e.status ?? 500);
664
+ }
665
+ return new Response(null, {
666
+ status: 302,
667
+ headers: {
668
+ "Location": resolved.stream.url,
669
+ "Access-Control-Allow-Origin": "*",
670
+ "Cache-Control": "no-store"
671
+ }
672
+ });
673
+ }
674
+ __name(handleStream3, "handleStream");
675
+ async function handleProxy3(url) {
676
+ const target = url.searchParams.get("url");
677
+ const referer = url.searchParams.get("referer") ?? `${FLIX}/`;
678
+ if (!target) return json3({ error: "Missing required ?url= param" }, 400);
679
+ let targetUrl;
680
+ try {
681
+ targetUrl = new URL(target);
682
+ } catch {
683
+ return json3({ error: "Invalid url param" }, 400);
684
+ }
685
+ const upstream = await fetch(target, {
686
+ headers: {
687
+ "User-Agent": UA5,
688
+ "Accept": "*/*",
689
+ "Accept-Language": "en-US,en;q=0.9",
690
+ "Referer": referer,
691
+ "Sec-Fetch-Dest": "empty",
692
+ "Sec-Fetch-Mode": "cors",
693
+ "Sec-Fetch-Site": "cross-site"
694
+ }
695
+ });
696
+ const ct = upstream.headers.get("Content-Type") ?? "";
697
+ const isM3U8 = ct.includes("mpegurl") || ct.includes("x-mpegurl") || targetUrl.pathname.endsWith(".m3u8") || targetUrl.pathname.endsWith(".m3u");
698
+ const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" };
699
+ if (!upstream.ok) {
700
+ return new Response(await upstream.text(), { status: upstream.status, headers: { "Content-Type": ct || "text/plain", ...corsHeaders } });
701
+ }
702
+ if (isM3U8) {
703
+ const text = await upstream.text();
704
+ const rewritten = rewriteM3U8(text, target, url.origin);
705
+ return new Response(rewritten, { status: 200, headers: { "Content-Type": "application/vnd.apple.mpegurl", ...corsHeaders } });
706
+ }
707
+ return new Response(upstream.body, { status: upstream.status, headers: { "Content-Type": ct || "application/octet-stream", ...corsHeaders } });
708
+ }
709
+ __name(handleProxy3, "handleProxy");
710
+ var reanime_default = {
711
+ async fetch(request) {
712
+ const url = new URL(request.url);
713
+ const path = url.pathname;
714
+ if (request.method === "OPTIONS") {
715
+ return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "*" } });
716
+ }
717
+ try {
718
+ let m;
719
+ if (path === "/healthz") return json3({ status: "ok", provider: "reanime" });
720
+ if (path === "/proxy") return await handleProxy3(url);
721
+ m = path.match(/^\/episodes\/(\d+)$/);
722
+ if (m) return await handleEpisodes3(m[1], url);
723
+ m = path.match(/^\/watch\/(\d+)\/(sub|dub)\/(\d+)$/);
724
+ if (m) return await handleWatch3(m[1], m[2], m[3], url.origin);
725
+ m = path.match(/^\/stream\/(\d+)\/(sub|dub)\/(\d+)$/);
726
+ if (m) return await handleStream3(m[1], m[2], m[3]);
727
+ 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);
728
+ } catch (err) {
729
+ return json3({ error: err.message, "Raw-ERROR": err.rawBody ?? null, ...err.debug ? { debug: err.debug } : {}, stack: err.stack }, 500);
730
+ }
731
+ }
732
+ };
733
+ async function getEpisodes3(anilistId, ctx = {}) {
734
+ const series = await resolveSeries(anilistId, ctx);
735
+ const anizip = ctx.anizip !== void 0 ? ctx.anizip : await fetchAnizip(anilistId);
736
+ const reanimeEps = await fetchEpisodesList(series.animeId);
737
+ if (!reanimeEps.length) throw new Error(`No reanime episodes found for AniList ${anilistId} (slug ${series.animeId})`);
738
+
739
+ const hasSub = series.subbed == null || series.subbed > 0;
740
+ const dubCount = series.dubbed ?? 0;
741
+ const sub = [], dub = [];
742
+ for (const ep of reanimeEps) {
743
+ const meta = anizip?.episodes?.[String(ep.episode_number)] ?? null;
744
+ if (hasSub) sub.push(mergeEpisode(anilistId, ep, meta, "sub"));
745
+ if (dubCount > 0 && ep.episode_number <= dubCount) dub.push(mergeEpisode(anilistId, ep, meta, "dub"));
746
+ }
747
+ sub.sort((a, b) => a.number - b.number);
748
+ dub.sort((a, b) => a.number - b.number);
749
+ return {
750
+ meta: { title: series.title, malId: series.malId, animeId: series.animeId },
751
+ episodes: { sub, dub }
752
+ };
753
+ }
754
+ __name(getEpisodes3, "getEpisodes");
755
+ export default reanime_default;
756
+ export { getEpisodes3 as getEpisodes };
anivexa-api/providers/senshi.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { json, episodeMeta } from "../core/new-provider-utils.js";
2
+ import { getMedia } from "../core/anilist.js";
3
+ import { get as cacheGet, set as cacheSet, isFresh,
4
+ SHOW_IDENTITY_TTL } from "../core/smartcache.js";
5
+
6
+ const BASE = "https://senshi.live";
7
+ const UA = "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0";
8
+ const H = { "User-Agent": UA, "Referer": `${BASE}/` };
9
+
10
+ async function fetchEpisodeList(malId) {
11
+ const res = await fetch(`${BASE}/episodes/${malId}`, { headers: H });
12
+ if (!res.ok) throw new Error(`Senshi episodes ${res.status} (MAL ${malId})`);
13
+ const data = await res.json();
14
+ return Array.isArray(data) ? data : [];
15
+ }
16
+
17
+ async function fetchEmbeds(malId, epNum) {
18
+ const res = await fetch(`${BASE}/episode-embeds/${malId}/${epNum}`, { headers: H });
19
+ if (!res.ok) throw new Error(`Senshi embeds ${res.status} (MAL ${malId} ep ${epNum})`);
20
+ const data = await res.json();
21
+ return Array.isArray(data) ? data : [];
22
+ }
23
+
24
+ async function resolveMalId(anilistId) {
25
+ const cacheKey = `np:senshi:${anilistId}`;
26
+ const cached = cacheGet(cacheKey);
27
+ if (isFresh(cached)) return cached.data;
28
+
29
+ const media = await getMedia(anilistId);
30
+ if (!media?.idMal) throw new Error(`Senshi: no MAL ID found for AniList ${anilistId}`);
31
+
32
+ cacheSet(cacheKey, media.idMal, SHOW_IDENTITY_TTL);
33
+ return media.idMal;
34
+ }
35
+
36
+ function isDub(status) {
37
+ return (status ?? "").toLowerCase() === "dub";
38
+ }
39
+
40
+ export async function getEpisodes(anilistId, ctx = {}) {
41
+ const malId = await resolveMalId(anilistId);
42
+ const items = await fetchEpisodeList(malId);
43
+
44
+ if (!items.length) {
45
+ throw new Error(`Senshi: no episodes for AniList ${anilistId} (MAL ${malId})`);
46
+ }
47
+
48
+ let hasDub = false;
49
+ try {
50
+ const probe = await fetchEmbeds(malId, 1);
51
+ hasDub = probe.some(e => isDub(e.status));
52
+ } catch { /* ignore */ }
53
+
54
+ const sub = [];
55
+ const dub = [];
56
+
57
+ for (const item of items) {
58
+ const num = item.ep_id;
59
+ const meta = episodeMeta(num, ctx);
60
+ const title = item.ep_title || meta.title || `Episode ${num}`;
61
+ const duration = meta.duration;
62
+ const filler = item.ep_filler || meta.filler || false;
63
+ const recap = item.ep_recap || false;
64
+ const description = meta.description;
65
+ const image = meta.image;
66
+ const airDate = meta.airDate;
67
+
68
+ sub.push({
69
+ id: `watch/senshi/${anilistId}/sub/senshi-${num}`,
70
+ number: num,
71
+ title,
72
+ duration,
73
+ audio: "sub",
74
+ filler,
75
+ recap,
76
+ uncensored: false,
77
+ description,
78
+ image,
79
+ airDate
80
+ });
81
+
82
+ if (hasDub) {
83
+ dub.push({
84
+ id: `watch/senshi/${anilistId}/dub/senshi-${num}`,
85
+ number: num,
86
+ title,
87
+ duration,
88
+ audio: "dub",
89
+ filler,
90
+ recap,
91
+ uncensored: false,
92
+ description,
93
+ image,
94
+ airDate
95
+ });
96
+ }
97
+ }
98
+
99
+ sub.sort((a, b) => a.number - b.number);
100
+ dub.sort((a, b) => a.number - b.number);
101
+
102
+ return {
103
+ meta: {
104
+ title: ctx.media?.title?.english ?? ctx.media?.title?.romaji ?? null,
105
+ malId,
106
+ source: "senshi",
107
+ },
108
+ episodes: { sub, dub },
109
+ };
110
+ }
111
+
112
+ async function handleWatch(anilistId, audio, epNum) {
113
+ const malId = await resolveMalId(anilistId);
114
+ const embeds = await fetchEmbeds(malId, epNum);
115
+
116
+ if (!embeds.length) {
117
+ return json({ error: `Senshi: no sources for episode ${epNum}` }, 404);
118
+ }
119
+
120
+ const wantDub = audio === "dub";
121
+ const source = embeds.find(e => wantDub ? isDub(e.status) : !isDub(e.status));
122
+
123
+ if (!source) {
124
+ return json({ error: `Senshi: no ${audio} source for episode ${epNum}` }, 404);
125
+ }
126
+
127
+ const list = await fetchEpisodeList(malId).catch(() => []);
128
+ const epItem = list.find(item => Number(item.ep_id) === Number(epNum));
129
+
130
+ const intro = {
131
+ start: epItem?.intro_start ?? 0,
132
+ end: epItem?.intro_end ?? 0,
133
+ };
134
+ const outro = {
135
+ start: epItem?.outro_start ?? 0,
136
+ end: epItem?.outro_end ?? 0,
137
+ };
138
+
139
+ const streams = [];
140
+ const downloads = [];
141
+
142
+ if (source.url) {
143
+ streams.push({
144
+ url: source.url,
145
+ type: "hls",
146
+ server: "Senshi",
147
+ referer: `${BASE}/`,
148
+ priority: 5,
149
+ isActive: true,
150
+ });
151
+ }
152
+
153
+ if (source.server2) {
154
+ streams.push({
155
+ url: source.server2,
156
+ type: "embed",
157
+ server: "StreamNin",
158
+ referer: `${BASE}/`,
159
+ priority: 3,
160
+ isActive: false,
161
+ });
162
+ }
163
+
164
+ if (source.serverFM) {
165
+ streams.push({
166
+ url: source.serverFM,
167
+ type: "embed",
168
+ server: "FileMoon",
169
+ referer: `${BASE}/`,
170
+ priority: 2,
171
+ isActive: false,
172
+ });
173
+ }
174
+
175
+ if (source.download) {
176
+ downloads.push({ url: source.download, label: "Download" });
177
+ }
178
+
179
+ return json({
180
+ anilistId: Number(anilistId),
181
+ malId,
182
+ episode: Number(epNum),
183
+ audio,
184
+ intro,
185
+ outro,
186
+ streams,
187
+ downloads,
188
+ headers: H,
189
+ });
190
+ }
191
+
192
+ export default {
193
+ async fetch(request) {
194
+ if (request.method === "OPTIONS") {
195
+ return new Response(null, {
196
+ status: 204,
197
+ headers: {
198
+ "Access-Control-Allow-Origin": "*",
199
+ "Access-Control-Allow-Methods": "GET,OPTIONS",
200
+ "Access-Control-Allow-Headers": "*",
201
+ },
202
+ });
203
+ }
204
+ const url = new URL(request.url);
205
+ try {
206
+ const m = url.pathname.match(/^\/watch\/senshi\/(\d+)\/(sub|dub)\/senshi-(\d+)\/?$/);
207
+ if (m) return await handleWatch(m[1], m[2], m[3]);
208
+ return json({ error: "Not found" }, 404);
209
+ } catch (err) {
210
+ return json({ error: err.message, stack: err.stack }, 500);
211
+ }
212
+ },
213
+ };
anivexa-api/proxy/worker.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ async fetch(request) {
3
+ const url = new URL(request.url);
4
+ const target = url.searchParams.get("url");
5
+ const ref = url.searchParams.get("ref") ?? "https://anidb.app/";
6
+
7
+ if (!target) {
8
+ return new Response(JSON.stringify({ error: "Missing ?url= param" }), {
9
+ status: 400,
10
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
11
+ });
12
+ }
13
+
14
+ let targetUrl;
15
+ try { targetUrl = new URL(target); } catch {
16
+ return new Response(JSON.stringify({ error: "Invalid URL" }), {
17
+ status: 400,
18
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
19
+ });
20
+ }
21
+
22
+ if (!targetUrl.hostname.endsWith("anidb.app")) {
23
+ return new Response(JSON.stringify({ error: "Only anidb.app requests allowed" }), {
24
+ status: 403,
25
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
26
+ });
27
+ }
28
+
29
+ const res = await fetch(target, {
30
+ headers: {
31
+ "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",
32
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,application/json,*/*;q=0.8",
33
+ "Accept-Language": "en-US,en;q=0.9",
34
+ "Referer": ref,
35
+ "X-Requested-With": request.headers.get("X-Requested-With") ?? "",
36
+ },
37
+ }).catch((e) => null);
38
+
39
+ if (!res) {
40
+ return new Response(JSON.stringify({ error: "Fetch failed" }), {
41
+ status: 502,
42
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
43
+ });
44
+ }
45
+
46
+ const body = await res.arrayBuffer();
47
+ const headers = new Headers();
48
+ headers.set("Access-Control-Allow-Origin", "*");
49
+ headers.set("Content-Type", res.headers.get("Content-Type") ?? "text/plain");
50
+
51
+ return new Response(body, { status: res.status, headers });
52
+ },
53
+ };
anivexa-api/proxy/wrangler.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ name = "anidb-proxy"
2
+ main = "worker.js"
3
+ compatibility_date = "2024-01-01"
anivexa-api/run.bat ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Anivexa-API sidecar (Node, zero deps) — runs on http://127.0.0.1:8002
3
+ REM (anidoom backend expects it there; see ANIVEXA_URL in backend/.env)
4
+ cd /d "%~dp0"
5
+ where node >nul 2>nul || (echo Node.js is required. Install from https://nodejs.org & exit /b 1)
6
+ set PORT=8002
7
+ node server.js
anivexa-api/run.sh ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Anivexa-API sidecar (Node, zero deps) — runs on http://127.0.0.1:8002
3
+ # (anidoom backend expects it there; see ANIVEXA_URL in backend/.env)
4
+ set -e
5
+ cd "$(dirname "$0")"
6
+ command -v node >/dev/null || { echo "Node.js is required."; exit 1; }
7
+ export PORT=8002
8
+ exec node server.js
anivexa-api/server.js ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from "node:http";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import worker from "./index.js";
6
+
7
+ const PORT = process.env.PORT ?? 4000;
8
+ const BASE = process.env.BASE_PATH ?? "";
9
+ const __dir = dirname(fileURLToPath(import.meta.url));
10
+
11
+ const STATIC = {
12
+ "/": { file: "docs/landing.html", mime: "text/html" },
13
+ "/docs": { file: "docs/index.html", mime: "text/html" },
14
+ "/style.css": { file: "docs/style.css", mime: "text/css" },
15
+ "/logo.svg": { file: "docs/logo.svg", mime: "image/svg+xml" },
16
+ };
17
+
18
+ function serveStatic(res, entry) {
19
+ try {
20
+ const body = readFileSync(join(__dir, entry.file));
21
+ res.writeHead(200, {
22
+ "Content-Type": entry.mime + "; charset=utf-8",
23
+ "Cache-Control": "no-cache",
24
+ });
25
+ res.end(body);
26
+ } catch {
27
+ res.writeHead(404);
28
+ res.end("Not found");
29
+ }
30
+ }
31
+
32
+ async function nodeToRequest(req) {
33
+ const host = req.headers["host"] ?? `localhost:${PORT}`;
34
+ const stripped = BASE && req.url.startsWith(BASE) ? req.url.slice(BASE.length) || "/" : req.url;
35
+ const url = `http://${host}${stripped}`;
36
+
37
+ const chunks = [];
38
+ for await (const chunk of req) chunks.push(chunk);
39
+ const body = chunks.length ? Buffer.concat(chunks) : null;
40
+
41
+ return new Request(url, {
42
+ method: req.method,
43
+ headers: req.headers,
44
+ body: body?.length ? body : undefined,
45
+ duplex: "half",
46
+ });
47
+ }
48
+
49
+ const server = http.createServer(async (req, res) => {
50
+ console.log(`→ ${req.method} ${req.url}`);
51
+
52
+ const pathname = req.url.split("?")[0];
53
+ const staticEntry = STATIC[pathname];
54
+
55
+ if (req.method === "GET" && staticEntry) {
56
+ return serveStatic(res, staticEntry);
57
+ }
58
+
59
+ try {
60
+ const request = await nodeToRequest(req);
61
+ const response = await worker.fetch(request, {});
62
+
63
+ res.statusCode = response.status;
64
+ for (const [k, v] of response.headers) res.setHeader(k, v);
65
+
66
+ const buf = await response.arrayBuffer();
67
+ res.end(Buffer.from(buf));
68
+ } catch (err) {
69
+ console.error("Unhandled error:", err);
70
+ res.statusCode = 500;
71
+ res.setHeader("Content-Type", "application/json");
72
+ res.end(JSON.stringify({ error: err.message }));
73
+ }
74
+ });
75
+
76
+ server.listen(PORT, () => {
77
+ console.log(`Anivexa dev server → http://localhost:${PORT}`);
78
+ });
anivexa-api/sidecar-8002.err.log ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
2
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
3
+ [ep:senshi] Senshi episodes 502 (MAL 61048)
4
+ [ep:kaa] KAA: low confidence match for AniList 186863 — best "neko-to-ryuu-89c7" score 0.500
5
+ [ep:2dhive] 2dhive: no player props for mal 61048 ep1
6
+ [ep:senshi] Senshi episodes 502 (MAL 21)
7
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
8
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
9
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
10
+ [ep:anineko] HTTP 500 fetching https://anineko.to/watch/that-time-i-got-reincarnated-as-a-slime
11
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
12
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
13
+ [ep:senshi] Senshi episodes 502 (MAL 21)
14
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
15
+ [ep:senshi] Senshi episodes 502 (MAL 21)
16
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
17
+ [ep:anizone] HTTP 502 fetching https://anizone.to/anime/zldcbsft
18
+ [ep:senshi] Senshi episodes 502 (MAL 21)
19
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
20
+ [ep:senshi] Senshi episodes 502 (MAL 21)
21
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
22
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
23
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
24
+ [ep:anibd] anibd: no episodes found for AniList 141953
25
+ [ep:senshi] Senshi episodes 502 (MAL 60568)
26
+ [ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568)
27
+ [ep:anineko] AniNeko match not found for AniList 141953
28
+ [ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8)
29
+ [ep:animegg] AnimeGG match not found for AniList 141953
30
+ [ep:anibd] anibd: no episodes found for AniList 141953
31
+ [ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568)
32
+ [ep:senshi] Senshi episodes 502 (MAL 60568)
33
+ [ep:animegg] AnimeGG match not found for AniList 141953
34
+ [ep:anineko] AniNeko match not found for AniList 141953
35
+ [ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8)
36
+ [ep:anizone] AniZone match not found for AniList 141953
37
+ [ep:anizone] AniZone match not found for AniList 141953
38
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
39
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
40
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
41
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
42
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
43
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
44
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
45
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
46
+ [ep:anizone] HTTP 502 fetching https://anizone.to/anime/q3n6aqt7
47
+ [ep:anizone] HTTP 502 fetching https://anizone.to/anime/q3n6aqt7
48
+ [ep:senshi] Senshi episodes 502 (MAL 21)
49
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
50
+ [ep:anibd] anibd: no episodes found for AniList 141953
51
+ [ep:senshi] Senshi episodes 502 (MAL 60568)
52
+ [ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568)
53
+ [ep:anineko] AniNeko match not found for AniList 141953
54
+ [ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8)
55
+ [ep:animegg] AnimeGG match not found for AniList 141953
56
+ [ep:anibd] anibd: no episodes found for AniList 141953
57
+ [ep:senshi] Senshi episodes 502 (MAL 60568)
58
+ [ep:2dhive] 2dhive: no episodes found for AniList 141953 (MAL 60568)
59
+ [ep:anineko] AniNeko match not found for AniList 141953
60
+ [ep:kaa] KAA: no episodes found for AniList 141953 (slug: false-memory-2-52d8)
61
+ [ep:animegg] AnimeGG match not found for AniList 141953
62
+ [ep:anizone] AniZone match not found for AniList 141953
63
+ [ep:anizone] AniZone match not found for AniList 141953
64
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
65
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
66
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
67
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
68
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
69
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
70
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
71
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
72
+ [ep:anizone] HTTP 502 fetching https://anizone.to/anime/m1zauh0z
73
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
74
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
75
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
76
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
77
+ [ep:anizone] HTTP 502 fetching https://anizone.to/anime/z6cxc5zy
78
+ [ep:senshi] Senshi episodes 502 (MAL 59970)
79
+ [ep:2dhive] 2dhive: no player props for mal 59970 ep1
80
+ [ep:senshi] Senshi episodes 502 (MAL 63537)
81
+ [ep:2dhive] 2dhive: no episodes found for AniList 208225 (MAL 63537)
82
+ [ep:senshi] Senshi episodes 502 (MAL 21)
83
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
84
+ [ep:senshi] Senshi episodes 502 (MAL 62001)
85
+ [ep:2dhive] 2dhive: no player props for mal 62001 ep1
86
+ [ep:2dhive] 2dhive: no MAL ID found for AniList 214863
87
+ [ep:senshi] Senshi: no MAL ID found for AniList 214863
88
+ [ep:animedunya] AnimeDunya: no MAL ID found
89
+ [ep:kaa] KAA: no search results for AniList 214863
90
+ [ep:anibd] anibd: no episodes found for AniList 214863
91
+ [ep:reanime] No confirmed reanime match for AniList 214863
92
+ [ep:anineko] AniNeko match not found for AniList 214863
93
+ [ep:animenosub] animenosub match not found for AniList 214863
94
+ [ep:allmanga] No AllAnime match for "Akuyaku no Ending wa Shi nomi"
95
+ [ep:anizone] AniZone match not found for AniList 214863
96
+ [ep:animegg] AnimeGG match not found for AniList 214863
97
+ [ep:anidbapp] AniDB.app match not found for AniList 214863
98
+ [ep:senshi] Senshi episodes 502 (MAL 21)
99
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
100
+ [ep:senshi] Senshi episodes 502 (MAL 62001)
101
+ [ep:2dhive] 2dhive: no player props for mal 62001 ep1
102
+ [ep:senshi] Senshi episodes 502 (MAL 30276)
103
+ [ep:2dhive] 2dhive: no player props for mal 30276 ep1
104
+ [ep:senshi] Senshi episodes 502 (MAL 16498)
105
+ [ep:animenosub] animenosub match not found for AniList 16498
106
+ [ep:2dhive] 2dhive: no player props for mal 16498 ep1
107
+ [ep:senshi] Senshi episodes 502 (MAL 21)
108
+ [ep:2dhive] 2dhive: no player props for mal 21 ep1
109
+ [ep:senshi] Senshi episodes 502 (MAL 62001)
110
+ [ep:2dhive] 2dhive: no player props for mal 62001 ep1
111
+ [ep:senshi] Senshi episodes 502 (MAL 40748)
112
+ [ep:2dhive] 2dhive: no player props for mal 40748 ep1
113
+ [ep:2dhive] 2dhive: no MAL ID found for AniList 201514
114
+ [ep:senshi] Senshi: no MAL ID found for AniList 201514
115
+ [ep:animedunya] AnimeDunya: no MAL ID found
116
+ [ep:senshi] Senshi episodes 502 (MAL 16498)
117
+ [ep:2dhive] 2dhive: no player props for mal 16498 ep1
118
+ [ep:animenosub] animenosub match not found for AniList 16498
119
+ [ep:senshi] Senshi episodes 502 (MAL 62001)
120
+ [ep:2dhive] 2dhive: no player props for mal 62001 ep1
121
+ [ep:senshi] Senshi episodes 502 (MAL 51553)
122
+ [ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500
123
+ [ep:2dhive] 2dhive: no player props for mal 51553 ep1
124
+ [ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500
125
+ [ep:senshi] Senshi episodes 502 (MAL 51553)
126
+ [ep:2dhive] 2dhive: no player props for mal 51553 ep1
127
+ [ep:senshi] Senshi episodes 502 (MAL 51553)
128
+ [ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500
129
+ [ep:2dhive] 2dhive: no player props for mal 51553 ep1
130
+ [ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500
131
+ [ep:senshi] Senshi episodes 502 (MAL 51553)
132
+ [ep:2dhive] 2dhive: no player props for mal 51553 ep1
133
+ [ep:2dhive] 2dhive: no player props for mal 51553 ep1
134
+ [ep:kaa] KAA: low confidence match for AniList 147105 — best "tongari-boushi-no-atelier-9824" score 0.500
135
+ [ep:senshi] Senshi episodes 502 (MAL 51553)
136
+ [ep:senshi] Senshi episodes 502 (MAL 62001)
137
+ [ep:2dhive] 2dhive: no player props for mal 62001 ep1
138
+ [ep:anibd] anibd: no episodes found for AniList 185874
139
+ [ep:kaa] KAA: no search results for AniList 185874
140
+ [ep:senshi] Senshi episodes 502 (MAL 60636)
141
+ [ep:2dhive] 2dhive: no episodes found for AniList 185874 (MAL 60636)
142
+ [ep:anibd] anibd: no episodes found for AniList 185874
143
+ [ep:kaa] KAA: no search results for AniList 185874
144
+ [ep:senshi] Senshi episodes 502 (MAL 60636)
145
+ [ep:2dhive] 2dhive: no episodes found for AniList 185874 (MAL 60636)
146
+ [ep:senshi] Senshi episodes 502 (MAL 269)
147
+ [ep:animenosub] animenosub match not found for AniList 269
148
+ [ep:2dhive] 2dhive: no player props for mal 269 ep1
149
+ [ep:senshi] Senshi episodes 502 (MAL 62001)
150
+ [ep:2dhive] 2dhive: no player props for mal 62001 ep1
151
+ [ep:2dhive] 2dhive: no MAL ID found for AniList 201514
152
+ [ep:senshi] Senshi: no MAL ID found for AniList 201514
153
+ [ep:animedunya] AnimeDunya: no MAL ID found
154
+ [ep:allmanga] Could not resolve titles for AniList ID: 136312
155
+ [ep:anibd] anibd: no episodes found for AniList 136312
156
+ [ep:anikoto] No data found for AniList ID 136312
157
+ [ep:animegg] No data found for AniList ID 136312
158
+ [ep:anineko] No data found for AniList ID 136312
159
+ [ep:anidbapp] No data found for AniList ID 136312
160
+ [ep:animenosub] No data found for AniList ID 136312
161
+ [ep:anizone] No data found for AniList ID 136312
162
+ [ep:kaa] No data found for AniList ID 136312
163
+ [ep:reanime] No data found for AniList ID 136312
164
+ [ep:2dhive] No data found for AniList ID 136312
165
+ [ep:senshi] No data found for AniList ID 136312
166
+ [ep:animedunya] No data found for AniList ID 136312
167
+ [ep:senshi] Senshi episodes 502 (MAL 61169)
168
+ [ep:2dhive] 2dhive: no player props for mal 61169 ep1
169
+ [ep:senshi] Senshi episodes 502 (MAL 62001)
170
+ [ep:2dhive] 2dhive: no player props for mal 62001 ep1
anivexa-api/sidecar-8002.log ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Anivexa dev server → http://localhost:8002
2
+ → GET /
3
+ → GET /watch/allmanga/182205/sub/allmanga-1
4
+ → GET /watch/anikoto/182205/sub/anikoto-1
5
+ → GET /watch/animedunya/182205/sub/animedunya-1
6
+ → GET /watch/anikoto/182205/sub/anikoto-1
7
+ → GET /episodes/182205
8
+ → GET /episodes/186863
9
+ → GET /watch/allmanga/186863/sub/allmanga-1
10
+ → GET /watch/allmanga/186863/sub/allmanga-1
11
+ → GET /watch/allmanga/182205/sub/allmanga-1
12
+ → GET /watch/allmanga/186863/sub/allmanga-1
13
+ → GET /watch/allmanga/182205/sub/allmanga-1
14
+ → GET /watch/reanime/186863/sub/reanime-1
15
+ → GET /watch/allmanga/182205/sub/allmanga-1
16
+ → GET /watch/reanime/182205/sub/reanime-1
17
+ → GET /watch/anikoto/186863/sub/anikoto-1
18
+ → GET /watch/reanime/182205/sub/reanime-1
19
+ → GET /watch/animedunya/182205/sub/animedunya-1
20
+ → GET /watch/reanime/182205/sub/reanime-1
21
+ → GET /watch/anikoto/182205/sub/anikoto-1
22
+ → GET /watch/anineko/182205/sub/anineko-1
23
+ → GET /watch/anizone/182205/sub/anizone-1
24
+ → GET /episodes/21
25
+ → GET /watch/anibd/182205/sub/anibd-1
26
+ → GET /watch/kaa/182205/sub/kaa-1
27
+ → GET /watch/animegg/182205/sub/animegg-1
28
+ → GET /watch/allmanga/21/sub/allmanga-1
29
+ → GET /watch/allmanga/21/sub/allmanga-1
30
+ → GET /watch/reanime/21/sub/reanime-1
31
+ → GET /watch/allmanga/21/sub/allmanga-1
32
+ → GET /watch/animedunya/182205/sub/animedunya-1
33
+ → GET /
34
+ → GET /watch/animedunya/182205/sub/animedunya-1
35
+ → GET /watch/allmanga/21/sub/allmanga-1
36
+ → GET /watch/allmanga/21/sub/allmanga-1
37
+ → GET /watch/allmanga/21/sub/allmanga-1
38
+ → GET /watch/reanime/21/sub/reanime-1
39
+ → GET /watch/animedunya/182205/sub/animedunya-1
40
+ → GET /watch/animedunya/182205/sub/animedunya-1
41
+ → GET /episodes/182205
42
+ → GET /episodes/182205
43
+ → GET /watch/animedunya/182205/sub/animedunya-1
44
+ → GET /watch/animedunya/182205/sub/animedunya-1
45
+ → GET /watch/anikoto/21/sub/anikoto-1
46
+ → GET /episodes/21
47
+ → GET /episodes/21
48
+ → GET /episodes/21
49
+ → GET /episodes/21
50
+ → GET /watch/allmanga/21/sub/allmanga-1
51
+ → GET /watch/allmanga/21/sub/allmanga-1
52
+ → GET /watch/allmanga/21/sub/allmanga-1
53
+ → GET /watch/reanime/21/sub/reanime-1
54
+ → GET /watch/allmanga/21/sub/allmanga-1
55
+ → GET /watch/allmanga/21/sub/allmanga-1
56
+ → GET /watch/anidbapp/21/sub/anidbapp-1
57
+ → GET /watch/allmanga/21/sub/allmanga-1
58
+ → GET /watch/anidbapp/21/sub/anidbapp-1
59
+ → GET /watch/animenosub/21/sub/animenosub-1
60
+ → GET /episodes/182205
61
+ → GET /watch/allmanga/182205/sub/allmanga-1
62
+ → GET /watch/allmanga/182205/sub/allmanga-1
63
+ → GET /watch/anizone/21/sub/anizone-1
64
+ → GET /watch/allmanga/182205/sub/allmanga-1
65
+ → GET /watch/reanime/182205/sub/reanime-1
66
+ → GET /watch/anikoto/182205/sub/anikoto-1
67
+ → GET /watch/anibd/21/sub/anibd-1
68
+ → GET /watch/anibd/21/sub/anibd-1
69
+ → GET /watch/allmanga/21/sub/allmanga-1
70
+ → GET /watch/allmanga/182205/sub/allmanga-1
71
+ → GET /watch/allmanga/21/sub/allmanga-1
72
+ → GET /watch/allmanga/182205/sub/allmanga-1
73
+ → GET /watch/allmanga/21/sub/allmanga-1
74
+ → GET /watch/allmanga/182205/sub/allmanga-1
75
+ → GET /watch/reanime/182205/sub/reanime-1
76
+ → GET /watch/reanime/21/sub/reanime-1
77
+ → GET /watch/allmanga/182205/sub/allmanga-1
78
+ → GET /watch/allmanga/182205/sub/allmanga-1
79
+ → GET /watch/allmanga/182205/sub/allmanga-1
80
+ → GET /watch/reanime/182205/sub/reanime-1
81
+ → GET /watch/anikoto/182205/sub/anikoto-1
82
+ → GET /watch/anikoto/21/sub/anikoto-1
83
+ → GET /watch/allmanga/21/sub/allmanga-1
84
+ → GET /watch/allmanga/21/sub/allmanga-1
85
+ → GET /watch/allmanga/21/sub/allmanga-1
86
+ → GET /watch/reanime/21/sub/reanime-1
87
+ → GET /episodes/141953
88
+ → GET /watch/anikoto/182205/sub/anikoto-1
89
+ → GET /watch/animegg/182205/dub/animegg-1
90
+ → GET /episodes/141953
91
+ → GET /watch/animegg/182205/sub/animegg-1
92
+ → GET /episodes/182205
93
+ → GET /watch/allmanga/141953/sub/allmanga-1
94
+ → GET /watch/allmanga/141953/sub/allmanga-1
95
+ → GET /watch/allmanga/141953/sub/allmanga-1
96
+ → GET /watch/reanime/141953/sub/reanime-1
97
+ → GET /watch/anikoto/141953/sub/anikoto-1
98
+ → GET /episodes/182205
99
+ → GET /episodes/182205
100
+ → GET /episodes/182205
101
+ → GET /episodes/21
102
+ → GET /watch/allmanga/21/sub/allmanga-1
103
+ → GET /watch/allmanga/21/sub/allmanga-1
104
+ → GET /watch/allmanga/21/sub/allmanga-1
105
+ → GET /watch/reanime/21/sub/reanime-1
106
+ → GET /watch/anikoto/21/sub/anikoto-1
107
+ → GET /watch/anikoto/21/dub/anikoto-1
108
+ → GET /watch/animegg/21/dub/animegg-1
109
+ → GET /episodes/141953
110
+ → GET /episodes/141953
111
+ → GET /watch/animegg/182205/sub/animegg-1
112
+ → GET /episodes/182205
113
+ → GET /episodes/182205
114
+ → GET /watch/allmanga/141953/sub/allmanga-1
115
+ → GET /watch/allmanga/141953/sub/allmanga-3
116
+ → GET /watch/allmanga/141953/sub/allmanga-1
117
+ → GET /watch/reanime/141953/sub/reanime-1
118
+ → GET /watch/allmanga/141953/sub/allmanga-1
119
+ → GET /watch/reanime/141953/sub/reanime-3
120
+ → GET /watch/anikoto/141953/sub/anikoto-1
121
+ → GET /watch/animedunya/182205/sub/animedunya-1
122
+ → GET /
123
+ → GET /episodes/182205
124
+ → GET /episodes/182205
125
+ → GET /watch/animedunya/182205/sub/animedunya-1
126
+ → GET /episodes/182205
127
+ → GET /episodes/182205
128
+ → GET /episodes/182205
129
+ → GET /watch/animedunya/182205/sub/animedunya-1
130
+ → GET /episodes/208225
131
+ → GET /watch/allmanga/208225/sub/allmanga-1
132
+ → GET /watch/allmanga/208225/sub/allmanga-1
133
+ → GET /watch/allmanga/208225/sub/allmanga-1
134
+ → GET /watch/reanime/208225/sub/reanime-1
135
+ → GET /watch/anikoto/208225/sub/anikoto-1
136
+ → GET /episodes/21
137
+ → GET /episodes/195600
138
+ → GET /watch/allmanga/195600/sub/allmanga-1
139
+ → GET /watch/allmanga/195600/sub/allmanga-1
140
+ → GET /watch/allmanga/195600/sub/allmanga-1
141
+ → GET /watch/reanime/195600/sub/reanime-1
142
+ → GET /watch/anikoto/195600/sub/anikoto-1
143
+ → GET /episodes/214863
144
+ → GET /episodes/21
145
+ → GET /episodes/195600
146
+ → GET /watch/allmanga/195600/sub/allmanga-1
147
+ → GET /watch/allmanga/195600/sub/allmanga-1
148
+ → GET /watch/allmanga/195600/sub/allmanga-1
149
+ → GET /watch/reanime/195600/sub/reanime-1
150
+ → GET /watch/anikoto/195600/sub/anikoto-1
151
+ → GET /episodes/21087
152
+ → GET /watch/allmanga/21087/sub/allmanga-1
153
+ → GET /watch/allmanga/21087/sub/allmanga-1
154
+ → GET /watch/allmanga/21087/sub/allmanga-1
155
+ → GET /watch/reanime/21087/sub/reanime-1
156
+ → GET /watch/animegg/21087/sub/animegg-1
157
+ → GET /watch/anineko/21087/sub/anineko-1
158
+ → GET /watch/anidbapp/21087/sub/anidbapp-1
159
+ → GET /watch/reanime/21087/sub/reanime-1
160
+ → GET /watch/anikoto/21087/sub/anikoto-1
161
+ → GET /watch/kaa/21087/sub/kaa-1
162
+ → GET /watch/anibd/21087/sub/anibd-1
163
+ → GET /watch/anizone/21087/sub/anizone-1
164
+ → GET /watch/animedunya/21087/sub/animedunya-1
165
+ → GET /episodes/16498
166
+ → GET /watch/allmanga/16498/sub/allmanga-0
167
+ → GET /watch/allmanga/16498/dub/allmanga-1
168
+ → GET /watch/allmanga/16498/sub/allmanga-0
169
+ → GET /watch/allmanga/16498/dub/allmanga-1
170
+ → GET /watch/reanime/16498/sub/reanime-1
171
+ → GET /watch/reanime/16498/dub/reanime-1
172
+ → GET /episodes/21
173
+ → GET /watch/reanime/21/sub/reanime-1
174
+ → GET /watch/reanime/21/dub/reanime-1
175
+ → GET /watch/kaa/21/sub/kaa-1
176
+ → GET /watch/kaa/21/dub/kaa-1
177
+ → GET /watch/anikoto/21/sub/anikoto-1
178
+ → GET /watch/anikoto/21/dub/anikoto-1
179
+ → GET /watch/animegg/21/sub/animegg-1
180
+ → GET /watch/animegg/21/dub/animegg-1
181
+ → GET /watch/anineko/21/sub/anineko-1
182
+ → GET /watch/anineko/21/dub/anineko-1
183
+ → GET /watch/reanime/21/sub/reanime-1
184
+ → GET /watch/reanime/21/dub/reanime-1
185
+ → GET /episodes/195600
186
+ → GET /watch/allmanga/195600/sub/allmanga-1
187
+ → GET /watch/allmanga/195600/sub/allmanga-1
188
+ → GET /watch/allmanga/195600/sub/allmanga-1
189
+ → GET /watch/reanime/195600/sub/reanime-1
190
+ → GET /watch/anikoto/195600/sub/anikoto-1
191
+ → GET /episodes/113415
192
+ → GET /watch/allmanga/113415/sub/allmanga-1
193
+ → GET /watch/allmanga/113415/sub/allmanga-1
194
+ → GET /watch/allmanga/113415/sub/allmanga-1
195
+ → GET /watch/reanime/113415/sub/reanime-1
196
+ → GET /watch/anikoto/113415/sub/anikoto-1
197
+ → GET /watch/anikoto/113415/dub/anikoto-1
198
+ → GET /episodes/201514
199
+ → GET /watch/allmanga/201514/sub/allmanga-1
200
+ → GET /watch/allmanga/201514/sub/allmanga-1
201
+ → GET /watch/allmanga/201514/sub/allmanga-1
202
+ → GET /watch/reanime/201514/sub/reanime-1
203
+ → GET /episodes/16498
204
+ → GET /watch/allmanga/16498/sub/allmanga-0
205
+ → GET /watch/allmanga/16498/sub/allmanga-0
206
+ → GET /watch/allmanga/16498/sub/allmanga-0
207
+ → GET /watch/reanime/16498/sub/reanime-1
208
+ → GET /watch/anikoto/16498/sub/anikoto-1
209
+ → GET /watch/anikoto/16498/sub/anikoto-2
210
+ → GET /episodes/195600
211
+ → GET /watch/allmanga/195600/sub/allmanga-1
212
+ → GET /watch/allmanga/195600/sub/allmanga-1
213
+ → GET /watch/allmanga/195600/sub/allmanga-1
214
+ → GET /watch/reanime/195600/sub/reanime-1
215
+ → GET /watch/anikoto/195600/sub/anikoto-1
216
+ → GET /watch/anikoto/195600/sub/anikoto-1
217
+ → GET /episodes/147105
218
+ → GET /watch/allmanga/147105/sub/allmanga-1
219
+ → GET /watch/allmanga/147105/sub/allmanga-1
220
+ → GET /watch/allmanga/147105/sub/allmanga-1
221
+ → GET /watch/reanime/147105/sub/reanime-1
222
+ → GET /watch/anikoto/147105/sub/anikoto-1
223
+ → GET /watch/anikoto/147105/sub/anikoto-1
224
+ → GET /episodes/147105
225
+ → GET /watch/animegg/147105/sub/animegg-1
226
+ → GET /watch/anineko/147105/sub/anineko-1
227
+ → GET /watch/anidbapp/147105/sub/anidbapp-1
228
+ → GET /watch/animenosub/147105/sub/animenosub-1
229
+ → GET /episodes/147105
230
+ → GET /watch/animenosub/147105/sub/animenosub-1
231
+ → GET /episodes/147105
232
+ → GET /watch/animenosub/147105/sub/animenosub-1
233
+ → GET /episodes/147105
234
+ → GET /episodes/195600
235
+ → GET /watch/allmanga/195600/sub/allmanga-1
236
+ → GET /watch/allmanga/195600/sub/allmanga-1
237
+ → GET /watch/allmanga/195600/sub/allmanga-1
238
+ → GET /watch/reanime/195600/sub/reanime-1
239
+ → GET /watch/anikoto/195600/sub/anikoto-1
240
+ → GET /watch/animegg/195600/sub/animegg-1
241
+ → GET /watch/anineko/195600/sub/anineko-1
242
+ → GET /watch/anidbapp/195600/sub/anidbapp-1
243
+ → GET /episodes/185874
244
+ → GET /episodes/185874
245
+ → GET /watch/allmanga/195600/sub/allmanga-1
246
+ → GET /watch/allmanga/195600/sub/allmanga-1
247
+ → GET /watch/allmanga/195600/sub/allmanga-1
248
+ → GET /watch/reanime/195600/sub/reanime-1
249
+ → GET /episodes/269
250
+ → GET /episodes/195600
251
+ → GET /episodes/201514
252
+ → GET /episodes/136312
253
+ → GET /episodes/187538
254
+ → GET /watch/allmanga/187538/sub/allmanga-1
255
+ → GET /watch/allmanga/187538/sub/allmanga-1
256
+ → GET /watch/allmanga/187538/sub/allmanga-1
257
+ → GET /watch/reanime/187538/sub/reanime-1
258
+ → GET /watch/anikoto/187538/sub/anikoto-1
259
+ → GET /watch/animegg/187538/sub/animegg-1
260
+ → GET /watch/anineko/187538/sub/anineko-1
261
+ → GET /watch/anidbapp/187538/sub/anidbapp-1
262
+ → GET /watch/animenosub/187538/sub/animenosub-1
263
+ → GET /episodes/195600
264
+ → GET /watch/allmanga/195600/sub/allmanga-1
265
+ → GET /watch/allmanga/195600/sub/allmanga-1
266
+ → GET /watch/allmanga/195600/sub/allmanga-1
267
+ → GET /watch/reanime/195600/sub/reanime-1
268
+ → GET /watch/anikoto/195600/sub/anikoto-1
269
+ → GET /watch/animegg/195600/sub/animegg-1
270
+ → GET /watch/anineko/195600/sub/anineko-1
271
+ → GET /watch/anidbapp/195600/sub/anidbapp-1
272
+ → GET /watch/animenosub/195600/sub/animenosub-1
273
+ → GET /watch/anizone/195600/sub/anizone-1
274
+ → GET /watch/anibd/195600/sub/anibd-1
275
+ → GET /watch/kaa/195600/sub/kaa-1
276
+ → GET /watch/animedunya/195600/sub/animedunya-1
docker-compose.yml ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ backend:
5
+ build: ./backend
6
+ container_name: anidoom-backend
7
+ ports:
8
+ - "8000:8000"
9
+ environment:
10
+ - PYTHONUNBUFFERED=1
11
+ volumes:
12
+ - ./backend:/app
13
+ restart: unless-stopped
14
+
15
+ manga-vault:
16
+ build: ./manga-vault
17
+ container_name: anidoom-manga-vault
18
+ ports:
19
+ - "8001:8001"
20
+ environment:
21
+ - PYTHONUNBUFFERED=1
22
+ volumes:
23
+ - ./manga-vault:/app
24
+ restart: unless-stopped
25
+
26
+ anivexa-api:
27
+ build: ./anivexa-api
28
+ container_name: anidoom-anivexa-api
29
+ ports:
30
+ - "8002:8002"
31
+ volumes:
32
+ - ./anivexa-api:/app
33
+ restart: unless-stopped
34
+
35
+ moviebox-api:
36
+ build: ./moviebox-api
37
+ container_name: anidoom-moviebox-api
38
+ ports:
39
+ - "8003:8003"
40
+ volumes:
41
+ - ./moviebox-api:/app
42
+ restart: unless-stopped
43
+
44
+ frontend:
45
+ build: ./frontend
46
+ container_name: anidoom-frontend
47
+ ports:
48
+ - "5173:5173"
49
+ volumes:
50
+ - ./frontend:/app
51
+ - /app/node_modules
52
+ environment:
53
+ - VITE_API_URL=http://localhost:8000
54
+ - VITE_STREAM_PROXY_URL=http://localhost:8787
55
+ restart: unless-stopped
56
+ depends_on:
57
+ - backend
docs/API.md ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API
2
+
3
+ Base URL: `http://localhost:8000` · Interactive docs at `/docs`.
4
+
5
+ All endpoints return JSON. Collection endpoints return:
6
+
7
+ ```json
8
+ { "page": 1, "perPage": 20, "total": 5000, "hasNextPage": true, "results": [ ... ] }
9
+ ```
10
+
11
+ ## Health & meta
12
+
13
+ | Method | Path | Description |
14
+ | ------ | ------------- | ------------------------------------ |
15
+ | GET | `/api/health` | `{"status":"ok"}` + provider health |
16
+ | GET | `/api/img` | Image proxy. `?url=` (host allow-list)|
17
+
18
+ ## Catalog (AniList + Jikan)
19
+
20
+ | Method | Path | Params | Description |
21
+ | ------ | --------------------------- | --------------------------------- | ----------- |
22
+ | GET | `/api/anime/search` | `q` (required), `page=1` | Full-text search, Jikan-enriched. |
23
+ | GET | `/api/anime/trending` | `page=1`, `perPage=20` | Trending now. |
24
+ | GET | `/api/anime/popular` | `page=1`, `perPage=20` | All-time most popular. |
25
+ | GET | `/api/anime/upcoming` | `page=1`, `perPage=20` | Not-yet-released, most anticipated. |
26
+ | GET | `/api/anime/recent` | `page=1`, `perPage=20` | Currently airing / this season. |
27
+ | GET | `/api/anime/schedule` | `page=1`, `perPage=20` | Airing schedule (with `airingAt`). |
28
+ | GET | `/api/anime/{id}` | — | Full anime details (rich AniList + Jikan fields). |
29
+ | GET | `/api/anime/{id}/mal` | — | Raw Jikan full details for the mapped MAL id (`idMal`). |
30
+
31
+ ### Media shape (catalog)
32
+
33
+ ```json
34
+ {
35
+ "id": 21,
36
+ "idMal": 20,
37
+ "title": { "romaji": "...", "english": "...", "native": "..." },
38
+ "synopsis": "…",
39
+ "coverImage": "https://s4.anilist.co/…/large.jpg",
40
+ "bannerImage": "https://s4.anilist.co/…/wide.jpg",
41
+ "format": "TV",
42
+ "season": "WINTER",
43
+ "seasonYear": 2026,
44
+ "episodes": 12,
45
+ "duration": 24,
46
+ "status": "RELEASING",
47
+ "score": 85,
48
+ "meanScore": 84,
49
+ "popularity": 9001,
50
+ "genres": ["Action", "Drama"],
51
+ "studios": [{ "name": "MAPPA", "isAnimationStudio": true }],
52
+ "nextAiringEpisode": { "episode": 3, "airingAt": 1735765200 },
53
+ "startDate": "2026-01-04",
54
+ "endDate": null,
55
+ "trailer": { "id": "…", "site": "youtube" },
56
+ "relations": [ ... ],
57
+ "characters": [ ... ],
58
+ "mal": { "score": 8.5, "members": 12345, "synopsis": "…", "url": "https://myanimelist.net/anime/20/…" }
59
+ }
60
+ ```
61
+
62
+ ## Streaming (Anivexa + Aniraku)
63
+
64
+ | Method | Path | Description |
65
+ | ------ | ----------------------------- | ----------- |
66
+ | GET | `/api/anime/{id}/episodes` | Episode lists per provider & audio type. |
67
+ | GET | `/api/watch/{episodeId:path}` | Resolve m3u8 sources for one episode. |
68
+
69
+ ### Episodes
70
+
71
+ ```
72
+ GET /api/anime/178005/episodes
73
+ ```
74
+
75
+ ```json
76
+ {
77
+ "anilistId": 178005,
78
+ "mappings": { "anilistId": 178005, "malId": 56885, "kitsuId": "..." },
79
+ "providers": [
80
+ { "name": "anikoto", "sub": [ ... ], "dub": [ ... ] },
81
+ { "name": "allmanga", "sub": [ ... ], "dub": [] },
82
+ { "name": "reanime", "sub": [ ... ], "dub": [] },
83
+ { "name": "aniraku", "sub": [ ... ], "dub": [] } // fallback, when Anivexa returns nothing
84
+ ]
85
+ }
86
+ ```
87
+
88
+ Providers come from the **Anivexa sidecar** (`anikoto`, `allmanga`, `reanime`,
89
+ `anizone`, … 13 total) — the primary source. `aniraku` is a synthetic fallback
90
+ provider added when Anivexa returns nothing. Miruro providers appear only when
91
+ `MIRURO_ENABLED=true` (disabled by default — upstream 403s without a fresh
92
+ `cf_clearance`). Episode ids keep the same
93
+ `watch/{provider}/{anilistId}/{sub|dub}/{ref}` shape — the frontend uses them
94
+ directly in the watch route.
95
+
96
+ ### Sources
97
+
98
+ ```
99
+ GET /api/watch/watch/kiwi/178005/sub/animepahe-1
100
+ GET /api/watch/watch/anikoto/178005/sub/anikoto-1
101
+ GET /api/watch/watch/aniraku/178005/sub/1
102
+ ```
103
+
104
+ ```json
105
+ {
106
+ "streams": [
107
+ { "url": "https://.../master.m3u8", "type": "hls", "quality": "1080p",
108
+ "server": "vidcloud", "referer": "https://anikototv.to/" }
109
+ ],
110
+ "subtitles": [ { "file": "https://.../en.vtt", "label": "English", "kind": "captions" } ],
111
+ "intro": { "start": 0, "end": 90 },
112
+ "outro": { "start": 1300, "end": 1420 },
113
+ "provider": "anikoto",
114
+ "headers": { "Referer": "https://anikototv.to/" }
115
+ }
116
+ ```
117
+
118
+ Stream resolution dispatches by provider: `aniraku` → Aniraku `/servers` +
119
+ `/stream`, Anivexa providers → Anivexa `/watch/...`, Miruro pipe → only when
120
+ `MIRURO_ENABLED=true`, anything else → `404`. `streams[].referer` (when
121
+ present) is forwarded to the HLS worker via `/hls?url=...&ref=...` so segments
122
+ fetch with the right Referer.
123
+
124
+ ## Movies & TV (vendored MovieBox-API sidecar)
125
+
126
+ The movies endpoints are served by a **vendored copy of
127
+ [`DavidCyril1/moviebox-api`](https://github.com/DavidCyril1/moviebox-api)**
128
+ (`moviebox-api/`, run on `MOVIEBOX_URL`, default `:8003`) — a Node/Express
129
+ server that talks to MovieBox.ph's mobile BFF (`wefeed-h5-bff`) with an
130
+ app-like session. The backend (`app/providers/moviebox.py`) proxies it and
131
+ normalizes the shapes: home sections, paged catalogs, search, deep details,
132
+ signed MP4 stream URLs and captions.
133
+
134
+ The sidecar was patched to add catalog/search/suggest/detail routes on
135
+ MovieBox's **web** BFF (`h5-api.aoneroom.com`), and its `/api/stream?url=`
136
+ proxy fetches signed MP4s with frontend-mirror Referer/Origin headers — this
137
+ bypasses the CDN rate-limit (429) that blocks bare browser requests and
138
+ Cloudflare egress, so movies actually play.
139
+
140
+ | Method | Path | Description |
141
+ | ------ | ---- | ----------- |
142
+ | GET | `/api/movies/home` | Homepage: banner + genre/subject rows. |
143
+ | GET | `/api/movies/catalog` | Paged catalog. `type=movie\|tv\|animation`, `page`, `sort=RECOMMEND\|HOT\|NEW\|RATING\|POPULAR\|LATEST`. |
144
+ | GET | `/api/movies/search?q=` | Full-text search across movies, series & animation. |
145
+ | GET | `/api/movies/suggest?q=` | Autocomplete suggestions (titles only — search to deep-link). |
146
+ | GET | `/api/movies/{slug}` | Full metadata for one title. |
147
+ | GET | `/api/movies/{slug}/stream` | Direct MP4/HLS sources + captions. `se`/`ep` for series episodes. |
148
+
149
+ ### Movie shapes
150
+
151
+ ```jsonc
152
+ // /api/movies/home → [{ key, title, items: [{ id, slug, title, cover, rating, year, badge }] }]
153
+ // /api/movies/catalog → { page, perPage, total, hasNextPage, results: [ …cards ] }
154
+ // /api/movies/{slug} →
155
+ {
156
+ "id": "9048868765454191080", "slug": "dune-WLVlz3JUrMa", "title": "Dune",
157
+ "description": "…", "cover": "https://pbcdnw.aoneroom.com/…", "banner": "…",
158
+ "genres": ["Action", "Adventure", "Sci-Fi"], "country": "USA",
159
+ "rating": "6.2", "imdbCount": null, "releaseDate": "…", "year": "2021",
160
+ "duration": 137, "type": 1, // 1 = movie, 2 = TV series (inferred from seasons)
161
+ "audioTracks": [ … ], "hasResource": false, "trailer": "",
162
+ "cast": [{ "name": "…", "character": "…", "avatar": "https://…" }],
163
+ "seasons": [{ "se": 1, "maxEp": 10 }, …] // empty for movies
164
+ }
165
+ // /api/movies/{slug}/stream →
166
+ {
167
+ "subjectId": "…", "se": 0, "ep": 0, "hasResource": true, "note": null,
168
+ "freeEpisodes": null, "limited": false,
169
+ "streams": [{ "url": "http://127.0.0.1:8003/api/stream?url=…", "resolution": "1080p",
170
+ "format": "MP4", "size": "…", "duration": null, "type": "mp4",
171
+ "direct": true }], // direct = already proxied by the sidecar; play as-is
172
+ "captions": [{ "lang": "English", "label": "English", "url": "https://cacdn.hakunaymatata.com/…srt" }]
173
+ }
174
+ ```
175
+
176
+ Stream URLs point at the **sidecar's own `/api/stream` proxy** (the player
177
+ plays them directly — no HLS-worker hop). Streams are signed with an expiry,
178
+ so the backend caches them briefly (`short_cache`, 60s). Captions arrive
179
+ inline with the stream response; the frontend converts them to WebVTT
180
+ client-side. Posters are proxied through `/api/img` (moviebox hosts are in
181
+ `IMG_PROXY_ALLOW`).
182
+
183
+ ## Manga (MangaVault sidecar)
184
+
185
+ The manga endpoints proxy the vendored MangaVault sidecar (`manga-vault/`,
186
+ run on `MANGA_VAULT_URL`, default `:8001`). All three sources are aggregated:
187
+ `nato` (Manganato), `atsu` (Atsumaru), `comix` (Comix).
188
+
189
+ | Method | Path | Description |
190
+ | ------ | ---- | ----------- |
191
+ | GET | `/api/manga/home` | Aggregated trending/latest sections from every enabled source. |
192
+ | GET | `/api/manga/search?q=` | Search across Atsumaru + Comix (Manganato has no search API). |
193
+ | GET | `/api/manga/{source}/{id}/details` | Metadata + chapter list for one manga. |
194
+ | GET | `/api/manga/chapter-images?path=` | Page image URLs for a chapter (`path` = chapter `imagePath`). |
195
+ | GET | `/api/manga/img?url=&src=` | Proxy chapter images with the source's Referer header. |
196
+
197
+ ### Manga shapes
198
+
199
+ ```jsonc
200
+ // /api/manga/home → [{ key, title, items: [{ source, id, title, cover, latest }] }]
201
+ // /api/manga/{source}/{id}/details →
202
+ {
203
+ "source": "nato", "id": "bitch-im-a-young-lady-with-hax",
204
+ "title": "…", "cover": "https://…", "description": "…",
205
+ "authors": "…", "status": "Ongoing", "genres": ["Action"],
206
+ "views": "…", "updated": "…",
207
+ "chapters": [
208
+ { "id": "chapter-313", "number": null,
209
+ "title": "Chapter 313",
210
+ "imagePath": "/nato/manga/bitch-…/chapter-313/images" }
211
+ ]
212
+ }
213
+ // /api/manga/chapter-images?path=… → ["https://cdn…/p1.jpg", …]
214
+ ```
215
+
216
+ `chapter.images` is a *relative* manga-vault path — pass it straight to
217
+ `/api/manga/chapter-images`; the reader loads each page via `/api/manga/img`.
218
+
219
+ ### Errors
220
+
221
+ | Status | Meaning |
222
+ | ------ | ------- |
223
+ | 400 | Bad request / malformed episode id |
224
+ | 401 | (Worker only) missing/invalid `x-stream-key` |
225
+ | 403 | Miruro Cloudflare block (only when `MIRURO_ENABLED=true`) — re-mint `cf_clearance` |
226
+ | 429 | Upstream rate-limited (AniList/Jikan) |
227
+ | 502 | Upstream provider (Anivexa/Aniraku) failed |
228
+ | 503 | Metadata provider error |
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ anidoom is split into three deployable pieces plus docs:
6
+
7
+ 1. **`backend/`** — FastAPI application.
8
+ - **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.
9
+ - **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`.)
10
+ 2. **`anivexa-proxy/`** — vendored [`walterwhite-69/Anivexa-Proxy`](https://github.com/walterwhite-69/Anivexa-Proxy), a zero-dependency **stream proxy** (Cloudflare Worker / Node).
11
+ - 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=<m3u8>&ref=<referer>`.
12
+ - Optional `STREAM_KEY` auth (anidoom patch) — see `docs/CLOUDFLARE.md`.
13
+ 3. **`frontend/`** — React SPA. Talks only to the backend REST API and the worker's `/hls` endpoint. Plays m3u8 with **hls.js**.
14
+
15
+ ```
16
+ Browser (React SPA)
17
+ │ fetch /api/*
18
+
19
+ FastAPI backend ──────▶ AniList GraphQL (graphql.anilist.co)
20
+ │ metadata Jikan (api.jikan.moe/v4)
21
+
22
+ │ plain HTTP
23
+
24
+ Anivexa-API sidecar (:8002, 13 providers) ──▶ episode list + m3u8 URLs
25
+ │ (fallback: Aniraku hosted backend — servers + /stream)
26
+ │ (Miruro pipe: disabled unless MIRURO_ENABLED=true)
27
+
28
+ Browser (hls.js) ────▶ Anivexa-Proxy /hls?url=<m3u8>&ref=<referer> ──▶ provider CDN
29
+ │ (rewrites m3u8/mpd → segments via proxy)
30
+
31
+ provider CDN (.ts/.m4s/.vtt/.key)
32
+ ```
33
+
34
+ ## Data flow: finding a stream
35
+
36
+ The backend exposes a 3-step flow:
37
+
38
+ ```
39
+ 1. GET /api/anime/{anilistId}/episodes
40
+ └─ Anivexa sidecar → providers: [ { name: "anikoto", sub: [...], dub: [...] }, { name: "allmanga", ... }, ... ]
41
+ Each episode has an id like "watch/anikoto/178005/sub/anikoto-1"
42
+ (aniraku fallback adds a synthetic provider when this list is empty)
43
+
44
+ 2. GET /api/watch/{episodeId} (episodeId is the full path, e.g. watch/anikoto/178005/sub/anikoto-1)
45
+ └─ Anivexa /watch (or Aniraku /servers+/stream) → { streams: [{ url, type, quality, referer }],
46
+ subtitles: [{ file, label, kind }], intro: { start, end }, outro: { start, end } }
47
+
48
+ 3. Frontend feeds streams[0].url (an m3u8) into hls.js through the proxy:
49
+ PLAYER_URL = https://anidoom-proxy.shawnmwask1234.workers.dev/hls?url=<encoded m3u8>[&ref=<referer>]
50
+ The proxy rewrites the playlist so every segment request also goes through it.
51
+ ```
52
+
53
+ ## Why curl_cffi + cf_clearance (not requests/httpx) for Miruro
54
+
55
+ > **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.
56
+
57
+ Miruro's pipe endpoint is protected by Cloudflare. Cloudflare checks, among other things:
58
+
59
+ - **TLS fingerprint (JA3/JA4)** — standard `requests`/`httpx` TLS stacks are instantly flagged.
60
+ - **`cf_clearance` cookie** — minted when a real browser solves the JS challenge; bound to your **IP + User-Agent + TLS fingerprint**.
61
+ - **IP reputation** — datacenter ranges (Vercel, Render, AWS Lambda, **Cloudflare Workers**) get hard `403`s.
62
+
63
+ The backend therefore:
64
+
65
+ - Uses `curl_cffi` with `impersonate="chrome110"` (browser-grade TLS fingerprint).
66
+ - Sends a full same-origin header set (`sec-ch-ua`, `sec-fetch-*`, matching `Referer`/`Origin`).
67
+ - 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`).
68
+ - Detects challenge responses (`cf-mitigated: challenge` header or `challenge-platform` in body) and surfaces a clear error telling you to re-mint.
69
+
70
+ > **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.
71
+
72
+ ## The Miruro pipe protocol (reverse-engineered)
73
+
74
+ Derived from the open-source [`walterwhite-69/Miruro-API`](https://github.com/walterwhite-69/Miruro-API) project (see `docs/DATA_SOURCES.md`):
75
+
76
+ - **Request**: `GET https://www.miruro.tv/api/secure/pipe?e=<payload>`
77
+ - `payload = base64url( json.dumps({ "path": ..., "method": "GET", "query": {...}, "body": null, "version": "0.1.0" }) )` with `=` padding stripped.
78
+ - `path` is `"episodes"` (query: `{"anilistId": <id>}`) or the episode id itself (e.g. `watch/kiwi/178005/sub/animepahe-1`) to resolve sources.
79
+ - **Response**: `gzip.decompress( base64url_decode(body) )` → JSON.
80
+ - Domain rotation across `www.miruro.tv` / `miruro.to` / `miruro.ru` / `miruro.bz` is supported via `MIRURO_DOMAINS`.
81
+
82
+ ## Frontend design notes
83
+
84
+ - React Router routes: `/`, `/search`, `/anime/:id`, `/watch/:episodeId`, `/manga`.
85
+ - `/watch/:episodeId` takes the full episode path (slashes included) as a single param; the router encodes it.
86
+ - 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.
87
+ - All image URLs are proxied by the backend (`/api/img?url=...`) to avoid referer/hotlink issues and let us swap posters between AniList/MAL.
88
+
89
+ ## Extending: adding a Malkan provider
90
+
91
+ The metadata layer is provider-agnostic. A provider module implements a small async interface (see `backend/app/providers/`):
92
+
93
+ ```python
94
+ class MetadataProvider(Protocol):
95
+ async def search(self, query: str, page: int = 1) -> list[dict]: ...
96
+ async def details(self, media_id: int) -> dict | None: ...
97
+ async def trending(self, page: int = 1) -> list[dict]: ...
98
+ ```
99
+
100
+ Register it in `catalog.py`'s provider list. When a real Malkan API becomes available, it slots in without touching routers or the frontend.
docs/CLOUDFLARE.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cloudflare: cf_clearance & the HLS proxy Worker
2
+
3
+ Two separate Cloudflare concerns:
4
+
5
+ 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.
6
+ 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/`.
7
+
8
+ ---
9
+
10
+ ## 1. Getting a `cf_clearance` cookie
11
+
12
+ > ⚠️ **Miruro is disabled by default.** Upstream currently returns **403 on
13
+ > every pipe call** even with a `cf_clearance` cookie present — the clearance
14
+ > expires within hours–days. If you want Miruro as an extra source: mint a
15
+ > fresh cookie (below), put it in `backend/.env`, **and** set
16
+ > `MIRURO_ENABLED=true` in `backend/.env`. Skip this entire section otherwise.
17
+
18
+ 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).
19
+
20
+ ### Option A — manual (fastest)
21
+
22
+ 1. Open `https://www.miruro.tv` in a normal Chrome/Edge browser **on the machine that will run the backend**.
23
+ 2. Solve any challenge if it appears.
24
+ 3. DevTools → Application → Cookies → `https://www.miruro.tv` → copy the `cf_clearance` value.
25
+ 4. Put it in `backend/.env`:
26
+ ```
27
+ CF_CLEARANCE=xxxxx.yyyyy.zzzzz
28
+ ```
29
+ (value only — no `cf_clearance=` prefix, no quotes)
30
+
31
+ ### Option B — automated minter script
32
+
33
+ `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`.
34
+
35
+ ```bash
36
+ pip install nodriver
37
+ python scripts/mint_cf_clearance.py
38
+ ```
39
+
40
+ 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).
41
+
42
+ ### Why it fails sometimes (pitfalls)
43
+
44
+ | Symptom | Cause / fix |
45
+ | ------- | ----------- |
46
+ | `403` from pipe, or `cf-mitigated: challenge` | Cookie expired (typically hours–days) → re-mint. |
47
+ | Re-challenge loop when reusing a cookie | You switched IP (VPN/proxy) or UA → re-mint from the *same* network & browser. |
48
+ | `403` on a VPS/cloud host | Datacenter IP blocked by WAF → use a residential IP or a VPS provider with clean ranges. |
49
+ | Works from browser, fails from code | `requests`/`httpx` TLS fingerprints are flagged → the backend already uses `curl_cffi` (chrome110). |
50
+
51
+ 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.
52
+
53
+ ---
54
+
55
+ ## 2. Anivexa-Proxy (stream proxy — replaces the old worker/)
56
+
57
+ 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.
58
+
59
+ ### How it works
60
+
61
+ - **`/hls?url=<encoded m3u8>&ref=<referer>`** (path is ignored — `/proxy` works too) — fetch the playlist upstream with browser headers, and rewrite:
62
+ - segment + variant lines (`foo/seg-1.ts`, `../master.m3u8`, absolute URLs) → `…/hls?url=<encoded>&ref=…`
63
+ - `#EXT-X-KEY:…URI="…"`, `#EXT-X-MAP:URI="…"` (fMP4 init) and `#EXT-X-I-FRAME-STREAM-INF URI=…` → rewritten through the proxy
64
+ - **DASH**: `.mpd` manifests get `<BaseURL>`, `initialization`, `media`, `sourceURL` rewritten.
65
+ - **MP4/segments** — stream the body through with correct `Content-Type`, forwarding the client's `Range` header (seeking, `206`) and propagating `Content-Range`.
66
+ - If no `ref` is given, it defaults to the target URL's origin.
67
+
68
+ ### Streaming (memory safety)
69
+
70
+ Only manifests (small) are read as text and rewritten; media bodies stream through without buffering.
71
+
72
+ ### Auth (anidoom patch)
73
+
74
+ Upstream Anivexa-Proxy is an **open proxy**. Our vendored copy adds optional `STREAM_KEY` auth:
75
+
76
+ ```bash
77
+ cd anivexa-proxy
78
+ npx wrangler secret put STREAM_KEY # then send: x-stream-key: <key> from the frontend
79
+ ```
80
+
81
+ Frontend: `VITE_STREAM_KEY=<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).
82
+
83
+ > ⚠️ **Safari native-HLS caveat:** if hls.js is unavailable (rare — hls.js works on Safari via MSE), `HlsPlayer` falls back to a native `<video src=...>`, and native video elements **cannot send the `x-stream-key` header**. On a keyed deployment that fallback gets `401`. Either keep the key unset, or accept that ancient-Safari users need hls.js support. `/health` and the bare root stay public either way.
84
+
85
+ ### Deploy
86
+
87
+ ```bash
88
+ cd anivexa-proxy
89
+ npm install
90
+ npx wrangler login
91
+ npm run deploy # wrangler.toml names it: anidoom-proxy
92
+ npm run secret:set # optional STREAM_KEY auth
93
+ ```
94
+
95
+ Production frontend `.env`: `VITE_STREAM_PROXY_URL=https://anidoom-proxy.shawnmwask1234.workers.dev/proxy` (+ `VITE_STREAM_KEY` if configured).
96
+
97
+ > ⚠️ Do **not** route Miruro's pipe through this proxy — Workers use Cloudflare datacenter IPs which Miruro's WAF hard-blocks. The proxy only forwards provider CDN content.
98
+
99
+ ---
100
+
101
+ ## Quick reference
102
+
103
+ | Piece | URL | Auth |
104
+ | ----- | --- | ---- |
105
+ | Miruro pipe | `https://www.miruro.tv/api/secure/pipe?e=…` | `cf_clearance` cookie + chrome TLS (only if `MIRURO_ENABLED=true`) |
106
+ | Provider CDN | varies (animepahe, etc.) | Referer/Origin headers only |
107
+ | Anivexa-Proxy | `https://anidoom-proxy.shawnmwask1234.workers.dev/proxy?url=…&ref=…` | optional `x-stream-key` |
docs/DATA_SOURCES.md ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data sources
2
+
3
+ Everything anidoom reads from upstream, plus how IDs map across services.
4
+
5
+ ## 1. AniList (primary metadata)
6
+
7
+ - **Endpoint**: `https://graphql.anilist.co` (POST, JSON `{ query, variables }`)
8
+ - **Auth**: none required for public reads
9
+ - **Rate limit**: ~90 req/min (see `X-RateLimit-*` headers; 429 with `Retry-After`)
10
+ - **Used for**: search, trending, popular, upcoming, seasonal, schedule, full details, characters, staff, relations, recommendations, trailers.
11
+
12
+ **Key fields** used by the backend:
13
+
14
+ | Field | Purpose |
15
+ | ----- | ------- |
16
+ | `id` | Primary key used across the whole app |
17
+ | `idMal` | **MAL cross-reference** — maps to Jikan `mal_id` |
18
+ | `title { romaji english native }` | Localized titles |
19
+ | `coverImage { large extraLarge color }` | Posters (`s4.anilist.co`) |
20
+ | `bannerImage` | Wide banners for hero/backdrops |
21
+ | `description` | Synopsis (HTML; backend strips tags) |
22
+ | `format / season / seasonYear / episodes / duration / status` | Browsing filters + badges |
23
+ | `averageScore / meanScore / popularity / favourites` | Scores & popularity |
24
+ | `nextAiringEpisode { episode airingAt timeUntilAiring }` | Schedule |
25
+ | `studios { nodes { name isAnimationStudio } }` | Studio credits |
26
+
27
+ Example:
28
+
29
+ ```graphql
30
+ query ($search: String!) {
31
+ Media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
32
+ id idMal title { romaji english native } coverImage { large extraLarge } episodes status
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## 2. MyAnimeList — via Jikan (secondary enrichment)
38
+
39
+ - **Endpoint**: `https://api.jikan.moe/v4` (REST, no auth)
40
+ - **Rate limits**: **3 req/s · 60 req/min** → backend throttles to ~2 req/s and caches aggressively
41
+ - **Used for**: MAL synopsis (often richer), MAL user score, members, MAL page URL, and MAL-hosted images as a poster fallback.
42
+
43
+ Endpoints used:
44
+
45
+ | Endpoint | Purpose |
46
+ | -------- | ------- |
47
+ | `GET /anime/{mal_id}/full` | Full MAL details for enrichment |
48
+ | `GET /anime?q=…` | Search fallback / cross-check |
49
+
50
+ **ID mapping**: AniList `idMal` ⟷ Jikan `mal_id`. They are the same integer. AniList is the source of truth for IDs; Jikan is looked up *by* the MAL id.
51
+
52
+ ## 3. Miruro (streaming — ⚠️ DISABLED by default)
53
+
54
+ > **Status: disabled.** Miruro is the **only** broken piece of the streaming stack: every `/api/secure/pipe` call currently returns **HTTP 403** even with a valid `cf_clearance` cookie (clearance expires within hours–days and Cloudflare re-binds it to IP/UA/TLS). Set `MIRURO_ENABLED=true` in `backend/.env` to re-enable — after re-minting `cf_clearance` per `docs/CLOUDFLARE.md`. While disabled, the episodes endpoint skips Miruro entirely (faster responses) and the `/watch` route rejects Miruro episode ids.
55
+
56
+ - **Site**: `https://miruro.tv` (mirrors: `miruro.to`, `miruro.ru`, `miruro.bz`)
57
+ - **Pipe**: `GET https://www.miruro.tv/api/secure/pipe?e=<payload>` behind Cloudflare (see `docs/CLOUDFLARE.md`)
58
+ - **Protocol** (reverse-engineered; credits to [`walterwhite-69/Miruro-API`](https://github.com/walterwhite-69/Miruro-API)):
59
+ - Request payload: `base64url(json)` of `{ "path": "episodes" | "<episode-id>", "method": "GET", "query": {...}, "body": null, "version": "0.1.0" }`, `=` stripped.
60
+ - Response: `gzip.decompress(base64url_decode(body))` → JSON.
61
+ - Episode ids look like `watch/kiwi/178005/sub/animepahe-1` and are passed straight back as the `path` to resolve sources.
62
+ - Sources response: `{ streams: [{url,type,quality}], subtitles: [{file,label,kind}], intro: {start,end}, outro: {start,end} }`.
63
+
64
+ **Important caveats**
65
+ - The pipe protocol may change; the backend isolates all pipe logic in `backend/app/providers/miruro.py`.
66
+ - Providers (kiwi/arc/zoro/hop/bee) come and go — the frontend lists whatever the pipe returns.
67
+ - Miruro blocks datacenter IPs. Run the backend from a residential/clean IP.
68
+
69
+ ## 6. Anivexa-API (streaming — vendored sidecar)
70
+
71
+ Vendored copy of the zero-dependency Node.js
72
+ [`walterwhite-69/Anivexa-API`](https://github.com/walterwhite-69/Anivexa-API)
73
+ (`anidoom/anivexa-api/`, run on `ANIVEXA_URL`, default `:8002` via `run.bat`).
74
+ Aggregates **13 providers** by AniList id:
75
+
76
+ `allmanga` · `reanime` · `anikoto` · `animegg` · `anineko` · `anidbapp` ·
77
+ `2dhive` · `animenosub` · `anizone` · `anibd` · `senshi` · `kaa` · `animedunya`
78
+
79
+ Used for: `/episodes/{anilistId}` (merged into our episodes response) and
80
+ `/watch/{provider}/{id}/sub|dub/{provider}-{ep}` (stream resolution). Episode
81
+ ids already match our canonical `watch/...` format. Watch responses carry
82
+ per-stream `referer`, `subtitles`, and `intro`/`outro`.
83
+
84
+ ## 7. Aniraku (streaming — hosted fallback)
85
+
86
+ [aniraku.tech](https://www.aniraku.tech) is a sibling project; its hosted
87
+ backend (`https://aniraku-backend-fhyy.onrender.com/api/v1`) resolves streams
88
+ server-side (it handles Miruro's Cloudflare on its side). We use it as a
89
+ **fallback**: when Anivexa returns no providers, a synthetic `aniraku` provider
90
+ is added with episodes from `/anime/{id}/episodes`; playing one calls
91
+ `/servers` + `/stream`. Enabled via `ANIRAKU_ENABLED` (default true).
92
+
93
+ ## 8. Malkan (⚠️ not found — pluggable stub)
94
+
95
+ The original plan called for "Malkan" as a third metadata source. **Extensive research found no public API, domain, or GitHub project named Malkan** (searched GitHub API, web, and domain lookups — nothing resolves). Per project decision, it's **skipped for now**.
96
+
97
+ The provider layer is designed so a real Malkan can be added later with zero frontend/router changes:
98
+
99
+ 1. Create `backend/app/providers/malkan.py` implementing the same async interface as `anilist.py` / `mal.py` (`search`, `details`, `trending`, …).
100
+ 2. Register it in `backend/app/services/catalog.py`'s provider chain.
101
+ 3. Add a `MALKAN_URL` env var + a row in this doc.
102
+
103
+ If "Malkan" was a typo for an existing service (Kitsu, AniDB, AniAPI, Annict…), the same slot applies.
104
+
105
+ ## 5. MangaVault (manga — vendored sidecar)
106
+
107
+ The manga section uses a **vendored copy** of the MIT-licensed
108
+ [`walterwhite-69/MangaVault`](https://github.com/walterwhite-69/MangaVault) repo
109
+ (`anidoom/manga-vault/`). It is a self-hosted FastAPI that aggregates three
110
+ manga sources:
111
+
112
+ | Source | Endpoints used | Status |
113
+ | ------ | -------------- | ------ |
114
+ | **Manganato** (`nato`) | `/nato/home`, `/nato/manga/{slug}/details`, `/nato/manga/{slug}/{chapter}/images` | ✅ works |
115
+ | **Atsumaru** (`atsu`) | `/atsu/home`, `/atsu/search`, `/atsu/manga/{id}/details`, `/atsu/manga/{id}/chapter/{cid}/images` | ✅ works |
116
+ | **Comix** (`comix`) | `/comix/home`, `/comix/search`, `/comix/manga/{id}/details`, `/comix/manga/{id}/chapters` | ⚠️ **broken upstream** (comix.to changed its API/HTML — search & chapters 404, home parses empty). Disabled by default via `MANGA_VAULT_SOURCES`; re-enable only after updating the vendored scraper. |
117
+
118
+ All responses are wrapped as `{"success", "took", "data"}`. Our backend
119
+ (`app/providers/manga_vault.py`) normalizes the per-source shapes into one
120
+ unified card/detail/chapter shape and exposes `/api/manga/*`.
121
+
122
+ **Running it**: `manga-vault/run.bat` (or `run.sh`) creates a venv and serves
123
+ on `http://127.0.0.1:8001` (anidoom's `MANGA_VAULT_URL` default). We added a
124
+ `requirements.txt` (the upstream repo ships without one) plus the run scripts.
125
+
126
+ **Image hotlink protection**: chapter page images live on the sources' CDNs and
127
+ may check `Referer`. The reader loads pages through `/api/manga/img?url=&src=`
128
+ which fetches with the matching Referer (`MANGA_REFERERS` in config).
129
+
130
+ ## 9. MovieBox (movies & TV — vendored DavidCyril1/moviebox-api sidecar)
131
+
132
+ The Movies & TV section is powered by a **vendored copy of
133
+ [`DavidCyril1/moviebox-api`](https://github.com/DavidCyril1/moviebox-api)**
134
+ (lives in `moviebox-api/`, run on `MOVIEBOX_URL`, default `:8003` via
135
+ `run.bat`/`run.sh`) — an Express (Node) server that talks to MovieBox.ph's
136
+ **mobile BFF** (`h5.aoneroom.com/wefeed-h5-bff`) with an app-like cookie
137
+ session (`okhttp/4.12.0` UA). The backend `app/providers/moviebox.py` proxies
138
+ it and normalizes the shapes.
139
+
140
+ **Sidecar endpoints** (all verified live):
141
+
142
+ - `/api/homepage` — home rows (banner + subject rows).
143
+ - `/api/info/:movieId` + `/api/sources/:movieId?season=&episode=` — full
144
+ metadata and download/stream sources with captions inline.
145
+ - Patched routes on MovieBox's **web** BFF (`h5-api.aoneroom.com`, cookie-free
146
+ client, `Host` pinned):
147
+ - `/api/catalog?type=movie|tv|animation&page=&sort=` — POST-only
148
+ `subject/filter` with a `{tabId, filter:{sort,…}, page, perPage}` body
149
+ (`tabId`: 2=movie, 5=tv-series, 8=animated-series).
150
+ - `/api/search/:query` — `subject/search` (the mobile BFF returns empty).
151
+ - `/api/suggest?q=` — `subject/search-suggest`.
152
+ - `/api/detail/:slug` — JSON `detail?detailPath={slug}` (subject + resource
153
+ seasons + stars).
154
+ - `/api/stream?url=` — **video proxy with Range support**: fetches the signed
155
+ MP4 from the CDN with frontend-mirror Referer/Origin headers, pipes bytes
156
+ back with `206`/`Accept-Ranges`. This is what makes playback work.
157
+
158
+ **Why the stream proxy, and why not a Cloudflare Worker?** MovieBox **rate
159
+ limits Cloudflare's egress IPs** (429 `RESOURCE_EXHAUSTED` on every endpoint,
160
+ verified live) and its CDN rejects bare browser requests — that's what caused
161
+ "codec not supported" errors before. A patched copy of
162
+ [`mdtahseen7/MovieBox-API`](https://github.com/mdtahseen7/MovieBox-API) v4
163
+ (lives in `moviebox-worker/`) was deployed but cannot serve from Cloudflare.
164
+ The sidecar runs on a residential IP, and its proxy adds the mirror referers
165
+ the CDN accepts, so streams play.
166
+
167
+ **Notes**
168
+ - Stream URLs are **signed with an expiry timestamp**; the sidecar rewrites
169
+ `streamUrl`/`downloadUrl` to its own `/api/stream`/`/api/download` proxies.
170
+ The backend marks them `direct` so the player plays them as-is, and caches
171
+ stream responses briefly (60s).
172
+ - Posters/stills come from `pbcdnw.aoneroom.com` / `pacdn.aoneroom.com` —
173
+ added to `IMG_PROXY_ALLOW` so they flow through `/api/img`.
174
+ - The catalog `totalCount` is unreliable (often a flat 1,000,000); the
175
+ sidecar reads the authoritative `pager.hasMore` flag.
176
+ - MovieBox content skews toward Hindi/regional + international titles;
177
+ some streams are `limited`/paid.
178
+ - The hidden API can change and rate-limit (429s) — the backend normalizes
179
+ everything in `app/providers/moviebox.py` and caches catalog/detail
180
+ responses for `CACHE_TTL`.
181
+
182
+ ## External links & credits
183
+
184
+ - AniList GraphQL docs: https://docs.anilist.co
185
+ - Jikan docs: https://jikan.moe/docs
186
+ - Miruro-API (pipe reverse-engineering reference): https://github.com/walterwhite-69/Miruro-API
187
+ - MangaVault (vendored manga sidecar, MIT): https://github.com/walterwhite-69/MangaVault
188
+ - Anivexa-API (vendored streaming sidecar): https://github.com/walterwhite-69/Anivexa-API
189
+ - MovieBox-API (vendored Movies & TV sidecar, MIT): https://github.com/DavidCyril1/moviebox-api
190
+ - MovieBox-API (reference Worker, vendored under moviebox-worker/ — not used; MovieBox 429s CF egress IPs): https://github.com/mdtahseen7/MovieBox-API
191
+ - Aniraku (sibling project / hosted fallback): https://www.aniraku.tech
docs/SETUP.md ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Setup
2
+
3
+ ## Prerequisites
4
+
5
+ - Python **3.11+**
6
+ - Node.js **18+** (frontend + worker)
7
+ - A machine with a **residential or clean non-datacenter IP** for the backend — only required if you re-enable the Miruro provider (disabled by default; its Cloudflare WAF blocks datacenter ranges). Anivexa + Aniraku need no special IP.
8
+
9
+ ## 1. Backend (FastAPI)
10
+
11
+ ```bash
12
+ cd backend
13
+ python -m venv .venv
14
+
15
+ # Windows (cmd/PowerShell):
16
+ .venv\Scripts\activate
17
+ # macOS / Linux:
18
+ source .venv/bin/activate
19
+
20
+ pip install -r requirements.txt
21
+ cp .env.example .env
22
+ ```
23
+
24
+ ### Environment variables
25
+
26
+ | Variable | Default | Description |
27
+ | ------------------- | ---------------------------------------- | ----------- |
28
+ | `MIRURO_ENABLED` | `false` | Miruro pipe is **disabled by default** (upstream 403s without a fresh `cf_clearance`). Set `true` to re-enable after re-minting. |
29
+ | `MIRURO_DOMAINS` | `www.miruro.tv,miruro.to,miruro.ru,miruro.bz` | Pipe hosts, tried in order (only when enabled). |
30
+ | `CF_CLEARANCE` | *(empty)* | cf_clearance cookie value (no prefix). See `docs/CLOUDFLARE.md`. Only needed if Miruro is enabled. |
31
+ | `CF_USER_AGENT` | Chrome 110 UA (matches impersonation) | Must match the UA your clearance was minted with. |
32
+ | `ANIVEXA_URL` | `http://127.0.0.1:8002` | Anivexa-API sidecar base (primary streaming source). |
33
+ | `ANIRAKU_ENABLED` | `true` | Hosted fallback when Anivexa returns no providers. |
34
+ | `MOVIEBOX_URL` | `http://127.0.0.1:8003` | MovieBox-API sidecar base (Movies & TV). |
35
+ | `ANILIST_URL` | `https://graphql.anilist.co` | AniList GraphQL endpoint. |
36
+ | `JIKAN_URL` | `https://api.jikan.moe/v4` | Jikan (MAL) REST base. |
37
+ | `CACHE_TTL` | `600` | In-memory cache TTL (seconds). |
38
+ | `CORS_ORIGINS` | `*` | Comma-separated allow-list. |
39
+ | `IMG_PROXY_ALLOW` | `s4.anilist.co,cdn.myanimelist.net,pbcdnw.aoneroom.com,pacdn.aoneroom.com` | Hosts the `/api/img` proxy will fetch (moviebox poster hosts included). |
40
+
41
+ ### Run
42
+
43
+ ```bash
44
+ uvicorn app.main:app --reload --port 8000
45
+ # interactive docs: http://localhost:8000/docs
46
+ ```
47
+
48
+ > **Windows note:** `curl_cffi` prints a harmless `Proactor event loop` warning. Ignore it.
49
+
50
+ ## 2. MangaVault sidecar (manga section)
51
+
52
+ The manga section is powered by a vendored copy of the MIT-licensed
53
+ [`walterwhite-69/MangaVault`](https://github.com/walterwhite-69/MangaVault) API
54
+ (lives in `manga-vault/`, aggregates Manganato / Atsumaru / Comix).
55
+
56
+ ```bash
57
+ cd manga-vault
58
+ ./run.bat # Windows — creates a venv, installs deps, runs on :8001
59
+ # or: bash run.sh
60
+ ```
61
+
62
+ Confirm it is up:
63
+
64
+ ```bash
65
+ curl http://127.0.0.1:8001/nato/home
66
+ ```
67
+
68
+ Set `MANGA_VAULT_URL` in `backend/.env` if you run it on a different port:
69
+
70
+ ```
71
+ MANGA_VAULT_URL=http://127.0.0.1:8001
72
+ ```
73
+
74
+ > The sidecar runs on **:8001** to avoid clashing with the anidoom API on :8000.
75
+
76
+ ## 3. Anivexa-API sidecar (extra streaming providers)
77
+
78
+ The streaming stack uses a vendored copy of the MIT-licensed
79
+ [`walterwhite-69/Anivexa-API`](https://github.com/walterwhite-69/Anivexa-API)
80
+ (lives in `anivexa-api/`) — a zero-dependency **Node.js** service aggregating
81
+ 13 anime providers (allmanga, reanime, anikoto, animegg, anineko, anidbapp,
82
+ 2dhive, animenosub, anizone, anibd, senshi, kaa, animedunya) by AniList id.
83
+ It is the **primary streaming source** (Miruro is disabled by default); its
84
+ providers need only plain HTTP.
85
+
86
+ ```bash
87
+ cd anivexa-api
88
+ ./run.bat # Windows — requires Node.js, runs on :8002
89
+ # or: bash run.sh
90
+ ```
91
+
92
+ Confirm it is up:
93
+
94
+ ```bash
95
+ curl http://127.0.0.1:8002/episodes/21 | head -c 200
96
+ ```
97
+
98
+ Set `ANIVEXA_URL` in `backend/.env` if you run it elsewhere.
99
+
100
+ > The **Aniraku fallback** (`ANIRAKU_ENABLED=true`) needs no setup — it calls
101
+ > aniraku.tech's hosted backend only when Anivexa returns no providers.
102
+
103
+ ## 4. Movies & TV — vendored MovieBox-API sidecar
104
+
105
+ The Movies & TV section is powered by a vendored copy of the MIT-licensed
106
+ [`DavidCyril1/moviebox-api`](https://github.com/DavidCyril1/moviebox-api)
107
+ (lives in `moviebox-api/`) — a Node/Express server that talks to MovieBox.ph's
108
+ mobile BFF with an app-like session.
109
+
110
+ ```bash
111
+ cd moviebox-api
112
+ ./run.bat # Windows — requires Node.js, installs deps, runs on :8003
113
+ # or: bash run.sh
114
+ ```
115
+
116
+ Confirm it is up:
117
+
118
+ ```bash
119
+ curl http://127.0.0.1:8003/api/homepage | head -c 200
120
+ ```
121
+
122
+ Set `MOVIEBOX_URL` in `backend/.env` if you run it on a different port:
123
+
124
+ ```
125
+ MOVIEBOX_URL=http://127.0.0.1:8003
126
+ ```
127
+
128
+ > **Patches applied to the vendored copy:** catalog (`/api/catalog?type=&page=&sort=`),
129
+ > search (`/api/search/:query`), autocomplete (`/api/suggest?q=`) and
130
+ > slug-detail (`/api/detail/:slug`) are routed to MovieBox's **web** BFF
131
+ > (`h5-api.aoneroom.com`) because the mobile BFF returns empty search results.
132
+ > Streams resolve through the sidecar's own **`/api/stream?url=` proxy**, which
133
+ > fetches the signed MP4 with frontend-mirror Referer/Origin headers and Range
134
+ > support — this bypasses the CDN rate-limit that blocks bare browser requests.
135
+
136
+ > **Why not a worker?** A Cloudflare Worker was tried (see `moviebox-worker/`,
137
+ > a patched copy of [`mdtahseen7/MovieBox-API`](https://github.com/mdtahseen7/MovieBox-API)
138
+ > v4) and deployed, but MovieBox **rate-limits Cloudflare's egress IPs** (429
139
+ > `RESOURCE_EXHAUSTED` on every endpoint), so it cannot serve. The sidecar,
140
+ > however, runs on a residential IP that upstream accepts — and its stream
141
+ > proxy makes playback work even though the CDN blocks bare requests.
142
+
143
+ > Streams are signed and expire — the backend caches them briefly (60s).
144
+ > Captions arrive inline with the stream response; the frontend converts them
145
+ > to WebVTT client-side. Streams are marked `direct` so the player plays the
146
+ > sidecar URL as-is (no Anivexa-Proxy hop).
147
+
148
+ ## 5. Anivexa-Proxy (HLS/DASH/MP4 stream proxy — replaces worker/)
149
+
150
+ The old hand-rolled `worker/` is **retired**. Streaming now goes through a
151
+ vendored copy of the MIT-licensed
152
+ [`walterwhite-69/Anivexa-Proxy`](https://github.com/walterwhite-69/Anivexa-Proxy)
153
+ (`anivexa-proxy/`) — a zero-dependency proxy that fetches provider-CDN streams
154
+ with the right `Referer`, rewrites m3u8/DASH playlists, and forwards `Range` for
155
+ MP4 seeking. It is path-agnostic, so the frontend's `/hls?url=&ref=` calls work
156
+ unchanged.
157
+
158
+ ```bash
159
+ cd anivexa-proxy
160
+ npm install # wrangler dev dependency (only needed for deploy)
161
+ npm run dev # node proxy.js on :8787 (Vite proxies /hls → :8787)
162
+ ```
163
+
164
+ Local smoke test (any path works — `/hls`, `/proxy`, `/`):
165
+
166
+ ```bash
167
+ curl "http://localhost:8787/hls?url=https%3A%2F%2Fexample.com%2Fmaster.m3u8"
168
+ curl http://localhost:8787/health
169
+ ```
170
+
171
+ Deploy to Cloudflare (one-time, needs your account):
172
+
173
+ ```bash
174
+ npx wrangler login # opens a browser to authenticate
175
+ npm run deploy # → https://anidoom-proxy.<account>.workers.dev/proxy
176
+ npm run secret:set # optional: STREAM_KEY auth (see below)
177
+ ```
178
+
179
+ > Deployed as **`anidoom-proxy`** (wrangler.toml `name`). Current live URL:
180
+ > `https://anidoom-proxy.shawnmwask1234.workers.dev/proxy`
181
+
182
+ Then point the production frontend at it in `frontend/.env`:
183
+
184
+ ```
185
+ VITE_STREAM_PROXY_URL=https://anidoom-proxy.shawnmwask1234.workers.dev/proxy
186
+ VITE_STREAM_KEY=<the-key-you-set> # only if you configured STREAM_KEY
187
+ ```
188
+
189
+ > **STREAM_KEY auth (anidoom patch):** upstream Anivexa-Proxy is an open proxy.
190
+ > Our vendored copy adds optional auth — when `STREAM_KEY` is set (Worker
191
+ > secret), every request needs `x-stream-key: <key>` or gets a `401`. The
192
+ > frontend already sends this header via `VITE_STREAM_KEY`. Local dev without a
193
+ > key stays open.
194
+
195
+ See `docs/CLOUDFLARE.md` for the full proxy notes.
196
+
197
+ ## 6. Frontend (React)
198
+
199
+ ```bash
200
+ cd frontend
201
+ npm install
202
+ cp .env.example .env
203
+ ```
204
+
205
+ | Variable | Default | Description |
206
+ | ---------------------- | ----------------------------- | ----------- |
207
+ | `VITE_API_URL` | `http://localhost:8000/api` | Backend base. |
208
+ | `VITE_STREAM_PROXY_URL`| `http://localhost:8787/hls` | Worker `/hls` endpoint. |
209
+
210
+ ```bash
211
+ npm run dev # http://localhost:5173
212
+ npm run build # production build → dist/
213
+ ```
214
+
215
+ During dev, Vite proxies `/api` → `:8000` and `/hls` → `:8787` (see `vite.config.js`), so you can also leave the env vars at defaults.
216
+
217
+ ## Smoke test checklist
218
+
219
+ 1. `GET /api/health` → `{"status":"ok"}` from the backend.
220
+ 2. `GET /api/anime/trending` returns a list with `title`, `coverImage`, `id`.
221
+ 3. `GET /api/anime/{id}/episodes` returns `providers` — expect a dozen+ names (anikoto, allmanga, reanime, …) from the Anivexa sidecar, and `aniraku` as fallback.
222
+ 4. `GET /api/watch/{episodeId}` returns `streams` with an m3u8 URL.
223
+ 5. Frontend home page renders hero + rows; clicking a card opens details; clicking Watch plays through the Anivexa-Proxy on :8787.
224
+ 6. `/manga` shows manga sections; opening a title lists chapters; reading loads pages through `/api/manga/img`.
225
+ 7. `/movies` shows movie/TV rows; a catalog tab loads a grid; opening a title shows details + Watch; the player page renders a `<video>` with quality buttons (streams play directly through the MovieBox sidecar's own `/api/stream` proxy on :8003).
op_eps.json ADDED
The diff for this file is too large to render. See raw diff
 
scripts/mint_cf_clearance.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Mint a cf_clearance cookie for miruro.tv and write it into backend/.env.
3
+
4
+ Usage:
5
+ pip install nodriver
6
+ python scripts/mint_cf_clearance.py [--domain www.miruro.tv] [--timeout 60]
7
+
8
+ The script opens a real (undetected) Chrome window, lets Cloudflare's JS
9
+ challenge complete, extracts the `cf_clearance` cookie, and writes/updates
10
+ `CF_CLEARANCE=` in backend/.env.
11
+
12
+ Notes:
13
+ * Mint on the SAME machine/network that runs the backend (the cookie is
14
+ bound to your IP + User-Agent).
15
+ * If Cloudflare shows an interactive CAPTCHA (Turnstile), solve it in the
16
+ opened window — the script waits for you.
17
+ * The cookie expires — re-run periodically.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import re
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ BACKEND_ENV = Path(__file__).resolve().parent.parent / "backend" / ".env"
28
+ COOKIE_NAME = "cf_clearance"
29
+
30
+
31
+ def find_cookie(cookies: list[dict]) -> str | None:
32
+ for c in cookies:
33
+ if c.get("name") == COOKIE_NAME:
34
+ return c.get("value")
35
+ return None
36
+
37
+
38
+ def write_env(domain: str, value: str) -> None:
39
+ BACKEND_ENV.parent.mkdir(parents=True, exist_ok=True)
40
+ text = BACKEND_ENV.read_text(encoding="utf-8") if BACKEND_ENV.exists() else ""
41
+ if not re.search(r"^\s*CF_CLEARANCE\s*=", text, flags=re.MULTILINE):
42
+ text = text.rstrip() + f"\nCF_CLEARANCE={value}\n"
43
+ else:
44
+ text = re.sub(
45
+ r"^\s*CF_CLEARANCE\s*=.*$",
46
+ f"CF_CLEARANCE={value}",
47
+ text,
48
+ flags=re.MULTILINE,
49
+ )
50
+ BACKEND_ENV.write_text(text, encoding="utf-8")
51
+ print(f"✔ Wrote CF_CLEARANCE to {BACKEND_ENV} ({len(value)} chars)")
52
+
53
+
54
+ def main() -> int:
55
+ parser = argparse.ArgumentParser(description="Mint a cf_clearance cookie for Miruro")
56
+ parser.add_argument("--domain", default="www.miruro.tv")
57
+ parser.add_argument("--timeout", type=int, default=60, help="max seconds to wait")
58
+ args = parser.parse_args()
59
+
60
+ try:
61
+ import nodriver as uc
62
+ except ImportError:
63
+ print("nodriver is required. Install it with: pip install nodriver")
64
+ return 1
65
+
66
+ print(f"Opening {args.domain} in an undetected Chrome window…")
67
+ print("If a CAPTCHA appears, solve it manually in the window.")
68
+
69
+ async def run():
70
+ browser = await uc.start()
71
+ try:
72
+ tab = await browser.get(f"https://{args.domain}/")
73
+ deadline = time.monotonic() + args.timeout
74
+ while time.monotonic() < deadline:
75
+ cookies = await tab.cookies.all()
76
+ value = find_cookie(cookies)
77
+ if value:
78
+ write_env(args.domain, value)
79
+ return 0
80
+ time.sleep(2)
81
+ print(f"✖ No {COOKIE_NAME} cookie within {args.timeout}s. "
82
+ "Try increasing --timeout or solving an interactive CAPTCHA.")
83
+ return 1
84
+ finally:
85
+ await browser.stop()
86
+
87
+ try:
88
+ import asyncio
89
+
90
+ return asyncio.run(run())
91
+ except KeyboardInterrupt:
92
+ print("\nAborted.")
93
+ return 1
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main())
tmp_watch.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"episodeId":"watch/anikoto/21/sub/anikoto-1","provider":"anikoto","streams":[{"url":"https://megap.akirax.buzz/f899139df5e1059396431415e770c6dd/61b87186ab260d05003427e16ccf5657/master.m3u8","type":"hls","quality":null,"server":"HD-1","referer":"https://megaplay.buzz/"},{"url":"https://megap.akirax.buzz/f899139df5e1059396431415e770c6dd/61b87186ab260d05003427e16ccf5657/master.m3u8","type":"hls","quality":null,"server":"Vidstream-2","referer":"https://megaplay.buzz/"},{"url":"https://vidtub.mikora.top/1b252d77b90e951c15c32d17e81e5882/master.m3u8","type":"hls","quality":null,"server":"VidPlay-1","referer":"https://vidtube.site/"}],"subtitles":[{"file":"https://1oe.lostproject.club/anime/f899139df5e1059396431415e770c6dd/61b87186ab260d05003427e16ccf5657/subtitles/eng-2.vtt","label":"English","kind":"captions"},{"file":"https://vidtub.mikora.top/1b252d77b90e951c15c32d17e81e5882/subtitles/English.vtt","label":"English","kind":"captions"}],"intro":{"start":31.0,"end":111.0},"outro":{"start":1376.0,"end":1447.0},"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","Referer":"https://megaplay.buzz/"}}