Spaces:
Build error
Build error
Upload 55 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +25 -0
- Dockerfile +28 -0
- README.md +112 -0
- README_HF.md +50 -0
- anivexa-api/.dockerignore +14 -0
- anivexa-api/Dockerfile +13 -0
- anivexa-api/README.md +109 -0
- anivexa-api/api/handler.js +5 -0
- anivexa-api/api/index.js +25 -0
- anivexa-api/core/anilist.js +152 -0
- anivexa-api/core/episode-cache.js +212 -0
- anivexa-api/core/episode-strategy.js +271 -0
- anivexa-api/core/mapper.js +144 -0
- anivexa-api/core/new-provider-utils.js +227 -0
- anivexa-api/core/smartcache.js +230 -0
- anivexa-api/docs/index.html +786 -0
- anivexa-api/docs/landing.html +345 -0
- anivexa-api/docs/logo.svg +14 -0
- anivexa-api/docs/style.css +757 -0
- anivexa-api/index.js +302 -0
- anivexa-api/package.json +9 -0
- anivexa-api/providers/2dhive.js +270 -0
- anivexa-api/providers/allmanga.js +757 -0
- anivexa-api/providers/anibd.js +175 -0
- anivexa-api/providers/anidbapp.js +358 -0
- anivexa-api/providers/anikoto.js +523 -0
- anivexa-api/providers/animedunya.js +187 -0
- anivexa-api/providers/animegg.js +229 -0
- anivexa-api/providers/animenosub.js +365 -0
- anivexa-api/providers/anineko.js +183 -0
- anivexa-api/providers/anizone.js +296 -0
- anivexa-api/providers/kickassanime.js +318 -0
- anivexa-api/providers/reanime.js +756 -0
- anivexa-api/providers/senshi.js +213 -0
- anivexa-api/proxy/worker.js +53 -0
- anivexa-api/proxy/wrangler.toml +3 -0
- anivexa-api/run.bat +7 -0
- anivexa-api/run.sh +8 -0
- anivexa-api/server.js +78 -0
- anivexa-api/sidecar-8002.err.log +170 -0
- anivexa-api/sidecar-8002.log +276 -0
- docker-compose.yml +57 -0
- docs/API.md +228 -0
- docs/ARCHITECTURE.md +100 -0
- docs/CLOUDFLARE.md +107 -0
- docs/DATA_SOURCES.md +191 -0
- docs/SETUP.md +225 -0
- op_eps.json +0 -0
- scripts/mint_cf_clearance.py +97 -0
- 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 |
+

|
| 12 |
+
[](https://discord.gg/MARQ9z9QSX)
|
| 13 |
+
[](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 |
+
[](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(/"/g, '"')
|
| 26 |
+
.replace(/'/g, "'")
|
| 27 |
+
.replace(/&/g, "&")
|
| 28 |
+
.replace(/</g, "<")
|
| 29 |
+
.replace(/>/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(/"/g, '"')
|
| 29 |
+
.replace(/&/g, "&")
|
| 30 |
+
.replace(/'/g, "'")
|
| 31 |
+
.replace(/</g, "<")
|
| 32 |
+
.replace(/>/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\\":\\"(https?:\/\/.*?)\\"/);
|
| 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/"}}
|