mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d0e0497f7 | ||
|
|
2c97ca2dd8 | ||
|
|
3f667fe87f | ||
|
|
015aa67ec2 | ||
|
|
a918ed9d1a | ||
|
|
125f088570 | ||
|
|
0628189ad8 | ||
|
|
bb68100ef0 | ||
|
|
2e0ef33b92 | ||
|
|
a6acfc6445 | ||
|
|
eb0e166310 | ||
|
|
92154d7ea2 | ||
|
|
5a8879f2ba | ||
|
|
6bda1d9c95 | ||
|
|
10560e36c3 | ||
|
|
023e36c2d4 | ||
|
|
156f9b16a5 | ||
|
|
16eb0236d7 |
+18
-2
@@ -1,13 +1,29 @@
|
||||
# Origins you want to allow (comma-separated, use * for all)
|
||||
ALLOWED_ORIGINS=https://miruro.tv,https://miruro.to,https://miruro.bz
|
||||
ALLOWED_ORIGINS=https://miruro.tv,https://miruro.to,https://miruro.bz,https://miruro.ru
|
||||
|
||||
# Port (optional, defaults to 3000)
|
||||
PORT=3000
|
||||
|
||||
# Pipe XOR obfuscation key (hex string, 32 chars)
|
||||
# Required for pipe response decoding
|
||||
# Required for pipe response decoding (x-obfuscated: 2)
|
||||
PIPE_OBF_KEY=71951034f8fbcf53d89db52ceb3dc22c
|
||||
|
||||
# PRU proxy XOR key (hex string, 32 chars)
|
||||
# Required for pru.ultracloud.cc URL encoding/decoding
|
||||
PRU_PROXY_KEY=a54d389c18527d9fd3e7f0643e27edbe
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# SELF-HEALING FALLBACK METHODS (optional, enables auto-recovery)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Method 2: ScraperAPI (requires premium plan — $49/month)
|
||||
# Sign up at https://www.scraperapi.com/
|
||||
# Enables automatic fallback when direct requests are blocked by Cloudflare
|
||||
# NOTE: Miruro is a protected domain — free plan will NOT work
|
||||
SCRAPER_API_KEY=
|
||||
|
||||
# Method 3: FlareSolverr (self-hosted browser proxy)
|
||||
# Run: docker run -p 8191:8191 ghcr.io/flaresolverr/flaresolverr
|
||||
# Deploy on Render.com free tier, Oracle Cloud, or any VPS
|
||||
# Solves Cloudflare Turnstile challenges automatically
|
||||
FLARESOLVERR_URL=
|
||||
|
||||
@@ -2,3 +2,9 @@ node_modules/
|
||||
.env
|
||||
.vercel/
|
||||
deploy.cjs
|
||||
|
||||
# wrangler files
|
||||
.wrangler
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
!.env.example
|
||||
|
||||
+110
@@ -1,5 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
## v2.3.1
|
||||
### Cloudflare Worker Edition — Streaming Bypasses Cloudflare
|
||||
|
||||
#### New: `worker.js` (Cloudflare Worker)
|
||||
- Full API running on Cloudflare's edge network
|
||||
- **Streaming endpoints work** — edge-to-edge requests bypass bot detection
|
||||
- Zero cold starts, 100K free requests/day
|
||||
- No npm dependencies (native `fetch` + `DecompressionStream`)
|
||||
- 15 endpoints: search, trending, info, episodes, watch, genres, random
|
||||
|
||||
#### Why Workers Work
|
||||
```
|
||||
Vercel (datacenter) → miruro.to → Cloudflare blocks ❌
|
||||
Worker (edge) → miruro.to → Cloudflare trusts itself ✅
|
||||
```
|
||||
|
||||
#### Deploy
|
||||
```bash
|
||||
npm install -g wrangler
|
||||
wrangler login
|
||||
wrangler deploy worker.js --name miruroapi
|
||||
```
|
||||
|
||||
#### Files Changed
|
||||
- `worker.js` — Cloudflare Worker implementation (885 lines)
|
||||
- `README.md` — Added Worker deployment section, updated troubleshooting
|
||||
- `docs/architecture.md` — Added Worker architecture section
|
||||
|
||||
---
|
||||
|
||||
## v2.3.0
|
||||
### Self-Healing Fallback System — Auto-Recovery from Cloudflare Blocks
|
||||
|
||||
#### New: Multi-Method Fallback System
|
||||
Pipe requests now try **3 methods in order** with automatic fallback:
|
||||
1. **Direct** — mirror rotation across `.ru` → `.to` → `.bz` → `.tv` with browser headers
|
||||
2. **ScraperAPI** — proxy through `api.scraperapi.com` (free 1,000 req/month, no CC)
|
||||
3. **FlareSolverr** — self-hosted browser proxy that solves Cloudflare Turnstile
|
||||
|
||||
When one method fails (403/timeout), the next is tried automatically. No manual intervention needed.
|
||||
|
||||
#### New Endpoint: `/api/pipe-health`
|
||||
Diagnostic endpoint that tests all configured methods and reports:
|
||||
- Which methods are working/failed
|
||||
- Latency for each method
|
||||
- Recommendation on preferred method
|
||||
|
||||
```
|
||||
GET /api/pipe-health
|
||||
→ { methods: { direct: {status:"ok"}, scraperapi: {status:"skipped"} }, working: 1, ... }
|
||||
```
|
||||
|
||||
#### Configuration (all optional, zero-config to start)
|
||||
| Env Var | Purpose | Free? |
|
||||
|---------|---------|-------|
|
||||
| `SCRAPER_API_KEY` | ScraperAPI proxy | No ($49/mo premium required for protected domains) |
|
||||
| `FLARESOLVERR_URL` | FlareSolverr browser | Yes (self-hosted) |
|
||||
| `PIPE_OBF_KEY` | XOR decode key | N/A |
|
||||
|
||||
#### How it works
|
||||
```
|
||||
Client → /api/episodes/20
|
||||
↓ pipeRequest()
|
||||
├─ Try direct → 403 (Cloudflare block)
|
||||
├─ Try ScraperAPI → 200 ✓ (returns data)
|
||||
└─ Cache result, return to client
|
||||
```
|
||||
|
||||
#### Files Changed
|
||||
- `src/helpers/pipe.js` — rewritten with `methodDirect()`, `methodScraperAPI()`, `methodFlareSolverr()`, `pipeHealthCheck()`
|
||||
- `src/routes/apiRoutes.js` — added `/api/pipe-health` endpoint
|
||||
- `.env.example` — documented `SCRAPER_API_KEY`, `FLARESOLVERR_URL`
|
||||
|
||||
#### Notes
|
||||
- Zero-config: works without any env vars (direct only)
|
||||
- Add `SCRAPER_API_KEY` for free Cloudflare bypass (no server needed)
|
||||
- Add `FLARESOLVERR_URL` for self-hosted browser bypass (needs Docker/VPS)
|
||||
- All methods use the same pipe protocol (base64+gzip, x-obfuscated header)
|
||||
|
||||
## v2.2.0
|
||||
### Critical Fix — Miruro Pipe Protocol Migration (Streaming Endpoints Restored)
|
||||
|
||||
#### Diagnostic Summary
|
||||
- **Full diagnostic sweep** of all live `/api/*` endpoints against `mirurotvapi.vercel.app`
|
||||
- **AniList-backed endpoints** (`/search`, `/trending`, `/info`, `/popular`, `/top`, …) — confirmed **all 200/healthy**
|
||||
- **Streaming endpoints** (`/episodes`, `/sources`, `/stream`, `/watch`, `/download`) — **all 500**
|
||||
- Deep-scanned the Miruro frontend (`miruro.to/.bz/.ru`) bundles + `env2.js` to reverse-engineer the new pipe contract
|
||||
|
||||
#### Root Cause (streaming failure)
|
||||
1. **Dead data source** — `pipe.js` targeted `https://www.miruro.tv/api/secure/pipe`, which is now hard-blocked by Cloudflare (returns 403 "Sorry, you have been blocked" page).
|
||||
2. **Stale decode protocol** — the old `decode()` always did `base64url → XOR(PIPE_OBF_KEY) → gunzip`. Miruro's pipe now **signals the scheme via a response header**:
|
||||
- `x-obfuscated` absent → plain JSON
|
||||
- `x-obfuscated: 1` → `base64url(gzip(json))` (**no XOR**)
|
||||
- `x-obfuscated: 2` → `base64url(XOR(gzip(json)))` with `PIPE_OBF_KEY`
|
||||
- The hard-coded always-XOR decode corrupted the new `:1` payloads.
|
||||
|
||||
#### Fixes
|
||||
- **`src/helpers/pipe.js`**
|
||||
- New **mirror rotation** list: `miruro.ru` → `miruro.to` → `miruro.bz` → `miruro.tv`, iterating one mirror per retry.
|
||||
- **New header-driven `decodePipeResponse(text, obfHeader)`** supporting plain / scheme-1 / scheme-2.
|
||||
- **Browser-accurate request headers** (UA + `Accept`, `Accept-Language`, full `sec-ch-ua` / `sec-fetch-*` family) to pass Cloudflare bot fingerprinting rather than just UA+Referer.
|
||||
- Request payload reverted to **plain base64url of the JSON** (no XOR on the request) matching the current client.
|
||||
- Subtitle-proxy referer updated to canonical `https://www.miruro.to/`.
|
||||
- Default `maxRetries` bumped 3 → 4 so each mirror gets an attempt; 403/444 now rotate instead of failing.
|
||||
- **`api/proxy.js`** — default CDN referer/Origin switched from the dead `miruro.tv` to the canonical `miruro.to`.
|
||||
- **Version bump** to `2.2.0` across `package.json`, `server.js` banner, `/api/health`, `/api/openapi`, and `public/openapi.json`.
|
||||
|
||||
#### Notes
|
||||
- Streaming endpoints depend on Miruro's upstream pipe, which sits behind Cloudflare bot protection and may intermittently rate-limit datacenter egress IPs. The new rotation + browser headers + retry logic maximizes success; AniList endpoints are unaffected and remain fully healthy.
|
||||
|
||||
## v2.1.4
|
||||
### Security Hardening, Bug Fixes, Code Quality Improvements
|
||||
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
<img src="https://img.shields.io/badge/AniList-GraphQL-02A6E4?style=flat-square&logo=graphql&logoColor=white" alt="AniList GraphQL"/>
|
||||
<img src="https://img.shields.io/badge/Vercel-Serverless-000000?style=flat-square&logo=vercel&logoColor=white" alt="Vercel"/>
|
||||
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
|
||||
<img src="https://img.shields.io/badge/Version-2.1.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/Endpoints-36-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
||||
<img src="https://img.shields.io/badge/Version-2.3.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/Endpoints-46-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
||||
<img src="https://img.shields.io/badge/Providers-12-a855f7?style=flat-square&logoColor=white" alt="Providers"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>A complete RESTful API for anime streaming data powered by AniList GraphQL and Miruro providers</b><br/>
|
||||
Search, browse, filter, watch — every endpoint returns fresh data with smart caching.<br/>
|
||||
35 endpoints, 12 streaming providers, M3U8 URLs with subtitles, skip timestamps, and batch operations.
|
||||
46 endpoints, 12 streaming providers, M3U8 URLs with subtitles, skip timestamps, and batch operations.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,7 +44,7 @@
|
||||
> [!WARNING]
|
||||
> 1. This `API` does not store any files — it only links to media hosted on 3rd party services.
|
||||
> 2. This `API` is explicitly made for **educational purposes only** and not for commercial usage. This repo will not be responsible for any misuse of it.
|
||||
> 3. All anime data, images, and content belong to their respective owners (AniList, Miruro). This project is not affiliated with miruro.tv.
|
||||
> 3. All anime data, images, and content belong to their respective owners (AniList, Miruro). This project is not affiliated with Miruro.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
### Why MiruroAPI?
|
||||
|
||||
- 🎬 **35 Endpoints** — Complete anime data coverage
|
||||
- 🎬 **46 Endpoints** — Complete anime data coverage
|
||||
- 🔍 **Full-Text Search** — Search anime by keyword with suggestions
|
||||
- 🎭 **Characters & Voice Actors** — Full character data from AniList
|
||||
- 🎯 **Advanced Filtering** — Genre, year, season, format, sort
|
||||
@@ -98,6 +98,41 @@
|
||||
- 🔒 **CORS Enabled** — Works from any frontend, no proxy needed
|
||||
- 🚀 **Zero-Config Deploy** — One click to Vercel, or run standalone with Express
|
||||
|
||||
### 🔄 Self-Healing System (v2.3.0)
|
||||
|
||||
When Cloudflare blocks streaming requests, the API automatically tries fallback methods:
|
||||
- **Direct** → Mirror rotation across 4 domains (miruro.to, miruro.ru, miruro.bz, miruro.tv)
|
||||
- **ScraperAPI** → Proxy bypass via premium residential IPs ($49/mo)
|
||||
- **FlareSolverr** → Self-hosted browser proxy
|
||||
|
||||
Zero-config: works out of the box. Add `SCRAPER_API_KEY` env var for automatic Cloudflare bypass (requires ScraperAPI premium plan for protected domains).
|
||||
|
||||
### ⚠️ Streaming Status
|
||||
|
||||
> **Streaming endpoints (`/api/episodes`, `/api/watch`, `/api/sources`, `/api/stream`) return 500 errors when Cloudflare blocks pipe requests.**
|
||||
>
|
||||
> This is expected behavior on Vercel's free tier. Cloudflare's bot protection blocks requests from datacenter IPs (including Vercel's). Metadata endpoints (search, info, trending, etc.) work perfectly — only streaming is affected.
|
||||
|
||||
| What Works | What's Blocked |
|
||||
|:---|:---|
|
||||
| Search, suggestions, filter | Episodes, sources, stream |
|
||||
| Info, characters, relations | Watch, download |
|
||||
| Trending, popular, schedule | All pipe-dependent endpoints |
|
||||
| Genres, tags, calendar | Streaming provider data |
|
||||
| All AniList-backed endpoints | |
|
||||
|
||||
**To fix streaming**, set one of these environment variables in Vercel:
|
||||
|
||||
```bash
|
||||
# Option 1: ScraperAPI (requires premium plan — $49/month)
|
||||
SCRAPER_API_KEY=your_scraperapi_key
|
||||
|
||||
# Option 2: FlareSolverr (self-hosted, unlimited)
|
||||
FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||
```
|
||||
|
||||
See [How to Fix Streaming](#-how-to-fix-streaming-issues) in Troubleshooting below.
|
||||
|
||||
### How It Works
|
||||
|
||||
```mermaid
|
||||
@@ -108,7 +143,7 @@ flowchart TD
|
||||
C -- MISS --> E{"🔍 Which Source?"}
|
||||
|
||||
E -- Metadata --> F["📡 AniList GraphQL<br/>graphql.anilist.co"]
|
||||
E -- Streaming --> G["📺 Miruro Pipe<br/>miruro.tv/api/secure/pipe"]
|
||||
E -- Streaming --> G["📺 Miruro Pipe<br/>miruro.{to,ru,bz,tv}/api/secure/pipe"]
|
||||
|
||||
F --> H["AniList Response<br/>JSON"]
|
||||
G --> I["Pipe Response<br/>base64url + gzip"]
|
||||
@@ -140,7 +175,7 @@ flowchart TD
|
||||
- **AniList GraphQL** for rich metadata
|
||||
- **Miruro pipe** for streaming sources
|
||||
- **Smart caching** with configurable TTL
|
||||
- **35 RESTful endpoints**
|
||||
- **46 RESTful endpoints**
|
||||
- **Gzip compression** — 30-70% smaller responses
|
||||
- **Request logging** — method, path, status, duration
|
||||
- **Graceful error handling** per endpoint
|
||||
@@ -195,7 +230,7 @@ flowchart TD
|
||||
|
||||
| Feature | Description | Status |
|
||||
|:---|:---|:---:|
|
||||
| 🎬 35 API Endpoints | Complete anime data coverage | ✅ |
|
||||
| 🎬 46 API Endpoints | Complete anime data coverage | ✅ |
|
||||
| 🔍 Full-Text Search | Keyword search with pagination | ✅ |
|
||||
| 💡 Search Suggestions | Fast autocomplete | ✅ |
|
||||
| 🎯 Advanced Filtering | Genre, year, season, format, sort | ✅ |
|
||||
@@ -222,10 +257,10 @@ flowchart TD
|
||||
|
||||
| Source | Domain | Data |
|
||||
|:---|:---|:---|
|
||||
| 📺 **Miruro** | `miruro.tv` | Episodes, streaming sources (M3U8 URLs) |
|
||||
| 📺 **Miruro** | `miruro.to` | Mirror domain |
|
||||
| 📺 **Miruro** | `miruro.bz` | Mirror domain |
|
||||
| 📺 **Miruro** | `miruro.to` | Episodes, streaming sources (M3U8 URLs) |
|
||||
| 📺 **Miruro** | `miruro.ru` | Mirror domain |
|
||||
| 📺 **Miruro** | `miruro.bz` | Mirror domain |
|
||||
| 📺 **Miruro** | `miruro.tv` | Mirror domain |
|
||||
|
||||
### 🎬 Streaming Providers
|
||||
|
||||
@@ -327,7 +362,7 @@ MiruroAPI/
|
||||
│ │ └── 📄 cache.js # 💾 In-memory cache with TTL
|
||||
│ │
|
||||
│ └── 📂 routes/ # 🛤️ Express routes
|
||||
│ └── 📄 apiRoutes.js # 🌐 Main API routes (35 endpoints)
|
||||
│ └── 📄 apiRoutes.js # 🌐 Main API routes (46 endpoints)
|
||||
│
|
||||
├── 📄 server.js # 🚀 Express server entry point
|
||||
├── 📄 package.json # 📦 Dependencies & scripts
|
||||
@@ -398,7 +433,15 @@ bun dev
|
||||
| Variable | Default | Description |
|
||||
|:---|:---|:---|
|
||||
| `PORT` | `3000` | Server port (Express mode only) |
|
||||
| `ALLOWED_ORIGINS` | `*` | Comma-separated allowed origins |
|
||||
| `ALLOWED_ORIGINS` | `*` | Comma-separated allowed origins (restricts CORS in production) |
|
||||
| `PIPE_OBF_KEY` | `71951034...` | Pipe response XOR obfuscation key (hex, 32 chars) |
|
||||
| `PRU_PROXY_KEY` | `a54d389c...` | PRU proxy URL XOR key (hex, 32 chars) |
|
||||
| `SCRAPER_API_KEY` | — | ScraperAPI key for Cloudflare bypass (requires premium plan, $49/mo) |
|
||||
| `FLARESOLVERR_URL` | — | FlareSolverr URL for browser-based bypass (e.g., `http://127.0.0.1:8191`) |
|
||||
|
||||
> ⚠️ **Required for production:** Set `ALLOWED_ORIGINS` to restrict CORS. Set `PIPE_OBF_KEY` and `PRU_PROXY_KEY` for pipe decoding.
|
||||
>
|
||||
> 📺 **For streaming:** Set `SCRAPER_API_KEY` (requires ScraperAPI premium plan) or `FLARESOLVERR_URL` (free, self-hosted) to bypass Cloudflare bot protection. See [How to Fix Streaming Issues](#-how-to-fix-streaming-issues).
|
||||
|
||||
### Vercel Configuration
|
||||
|
||||
@@ -490,13 +533,13 @@ console.log(resp.data);
|
||||
"success": true,
|
||||
"results": {
|
||||
"status": "healthy",
|
||||
"version": "2.0.0",
|
||||
"version": "2.3.0",
|
||||
"uptime": "0h 0m 34s",
|
||||
"uptimeSeconds": 34,
|
||||
"timestamp": "2026-06-09T09:55:00.884Z",
|
||||
"node": "v24.14.1",
|
||||
"memory": { "used": "13MB", "total": "15MB" },
|
||||
"endpoints": 35,
|
||||
"endpoints": 46,
|
||||
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
|
||||
}
|
||||
}
|
||||
@@ -537,7 +580,7 @@ console.log(resp.data);
|
||||
"uptime": "0h 0m 34s",
|
||||
"requests": { "total": 156, "errors": 3, "successRate": "98.1%" },
|
||||
"cache": { "size": 12, "maxSize": 100, "ttl": "1 min" },
|
||||
"endpoints": 35,
|
||||
"endpoints": 46,
|
||||
"timestamp": "2026-06-09T09:55:00.884Z"
|
||||
}
|
||||
}
|
||||
@@ -1371,6 +1414,32 @@ docker build -t miruroapi .
|
||||
docker run -p 3000:3000 miruroapi
|
||||
```
|
||||
|
||||
### ⚡ Cloudflare Worker (Streaming Works!)
|
||||
|
||||
> **Streaming endpoints work on Workers** — requests to Miruro bypass Cloudflare bot detection because Workers run on Cloudflare's edge network (edge-to-edge traffic is trusted).
|
||||
|
||||
```bash
|
||||
# Install wrangler CLI
|
||||
npm install -g wrangler
|
||||
|
||||
# Login to Cloudflare
|
||||
wrangler login
|
||||
|
||||
# Deploy
|
||||
wrangler deploy worker.js --name miruroapi
|
||||
```
|
||||
|
||||
Your API is live at: `https://miruroapi.YOUR_SUBDOMAIN.workers.dev`
|
||||
|
||||
| Feature | Vercel | Cloudflare Worker |
|
||||
|---------|--------|-------------------|
|
||||
| Streaming (pipe) | Blocked (403) | Works |
|
||||
| Cold starts | ~500ms | Zero |
|
||||
| Free tier | Unlimited | 100K req/day |
|
||||
| npm deps | Yes | No (native fetch) |
|
||||
|
||||
> See `worker.js` for the full Worker implementation with 15 endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Available Scripts
|
||||
@@ -1408,13 +1477,14 @@ docker run -p 3000:3000 miruroapi
|
||||
|
||||
| Version | Date | Key Changes |
|
||||
|:---|:---|:---|
|
||||
| **2.0.1** | 2026-06-24 | Streaming improvements — better subtitle extraction, quality fallback for providers without metadata |
|
||||
| **2.0.0** | 2026-06-25 | Major upgrade — 35 endpoints, compression, streaming improvements, new features |
|
||||
| **1.4.0** | 2026-06-25 | Creator attribution middleware, Vercel deployment fix, security headers |
|
||||
| **1.3.0** | 2026-06-25 | Full landing page upgrade with premium design |
|
||||
| **1.2.0** | 2026-06-09 | Critical response format fix, full endpoint diagnostic, 18/18 passing |
|
||||
| **1.1.0** | 2026-06-09 | Swagger UI docs, OpenAPI spec, mappings field, Docker, landing page |
|
||||
| **1.0.0** | 2026-06-09 | Initial release — 16 endpoints, AniList GraphQL + Miruro pipe, caching |
|
||||
| **2.3.0** | 2026-07-02 | Self-healing fallback system — multi-method pipe recovery, /api/pipe-health endpoint, ScraperAPI + FlareSolverr support |
|
||||
| **2.1.4** | 2026-06-25 | Security hardening — XOR keys to env vars, CORS restriction, input sanitization, bug fixes (multi-search, deepTranslate, getBestStream) |
|
||||
| **2.1.3** | 2026-06-25 | Diagnostic sweep — tags, random, character/staff 404 fixes, pipe retry with backoff, security headers |
|
||||
| **2.1.2** | 2026-06-25 | Streaming architecture — pru proxy decode, CDN CORS proxy, raw stream URLs |
|
||||
| **2.1.1** | 2026-06-25 | Pipe XOR decoding fix — responses now decode correctly |
|
||||
| **2.1.0** | 2026-06-25 | Provider capabilities, enriched metadata, skip times, 12 providers |
|
||||
| **2.0.1** | 2026-06-24 | Streaming improvements — better subtitle extraction, quality fallback |
|
||||
| **2.0.0** | 2026-06-25 | Major upgrade — 44 endpoints, compression, streaming improvements |
|
||||
|
||||
> 📝 See [CHANGELOG.md](./CHANGELOG.md) for the full version history.
|
||||
|
||||
@@ -1425,12 +1495,121 @@ docker run -p 3000:3000 miruroapi
|
||||
| Problem | Cause | Solution |
|
||||
|:---|:---|:---|
|
||||
| ❌ `npm install` fails | Node.js version too old | Upgrade to Node.js 20+ (`node -v`) |
|
||||
| ❌ CORS errors | Frontend domain blocked | CORS is `*` — check browser extension |
|
||||
| ❌ CORS errors | Origin not in ALLOWED_ORIGINS | Set `ALLOWED_ORIGINS` env var or use `*` for all origins |
|
||||
| ❌ 404 on API routes | Wrong URL format | Use `/api/` prefix, not just `/` |
|
||||
| ❌ Empty episodes | Provider not available | Check which providers return data for the anime |
|
||||
| ❌ Deploy fails on Vercel | Build error | Check `node server.js` locally first |
|
||||
| ❌ Slow first request | Serverless cold start | Normal — first request after idle takes ~500ms |
|
||||
| ❌ Rate limited | Too many requests | Cache reduces this — wait for TTL expiry |
|
||||
| ❌ **Streaming 500 errors** | **Cloudflare blocks pipe requests** | **Set `SCRAPER_API_KEY` or `FLARESOLVERR_URL`** (see below) |
|
||||
|
||||
### 🎬 How to Fix Streaming Issues
|
||||
|
||||
Streaming endpoints fail because Cloudflare's bot protection blocks requests from Vercel's datacenter IPs. The pipe endpoint (`miruro.to/api/secure/pipe`) returns HTTP 403 to any request that doesn't come from a residential IP or real browser.
|
||||
|
||||
#### Why Does This Happen?
|
||||
|
||||
```
|
||||
Your App (Vercel) → Cloudflare WAF → miruro.to/api/secure/pipe → 403 Blocked
|
||||
```
|
||||
|
||||
Cloudflare detects:
|
||||
- Datacenter IP ranges (Vercel, AWS, GCP, etc.)
|
||||
- Missing browser fingerprint headers
|
||||
- Non-residential traffic patterns
|
||||
|
||||
#### Fix 1: Cloudflare Worker (Best — Free, Works!)
|
||||
|
||||
Workers run on Cloudflare's edge network. Edge-to-edge requests are trusted — no bot detection.
|
||||
|
||||
```bash
|
||||
# 1. Install wrangler
|
||||
npm install -g wrangler
|
||||
|
||||
# 2. Login
|
||||
wrangler login
|
||||
|
||||
# 3. Deploy
|
||||
wrangler deploy worker.js --name miruroapi
|
||||
|
||||
# 4. Your streaming now works at:
|
||||
# https://miruroapi.YOUR_SUBDOMAIN.workers.dev/api/episodes/20
|
||||
```
|
||||
|
||||
**Cost:** Free (100K requests/day). No API key needed.
|
||||
|
||||
#### Fix 2: ScraperAPI (Paid — $49/month)
|
||||
|
||||
ScraperAPI proxies your requests through residential IPs, bypassing Cloudflare.
|
||||
|
||||
```bash
|
||||
# 1. Sign up at https://www.scraperapi.com/ (free 1,000 requests/month)
|
||||
# 2. Get your API key from the dashboard
|
||||
# 3. Add to Vercel environment variables:
|
||||
SCRAPER_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
**Cost:** $49/month (premium plan required for protected domains like Miruro).
|
||||
|
||||
#### Fix 3: FlareSolverr (Self-Hosted — Unlimited)
|
||||
|
||||
FlareSolverr runs a headless browser that bypasses Cloudflare automatically.
|
||||
|
||||
```bash
|
||||
# 1. Run FlareSolverr on your own server
|
||||
docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
|
||||
|
||||
# 2. Add to Vercel environment variables:
|
||||
FLARESOLVERR_URL=http://your-server-ip:8191
|
||||
```
|
||||
|
||||
**Cost:** Free (self-hosted). Requires a VPS with Docker.
|
||||
|
||||
**Requirements:**
|
||||
- 1GB+ RAM
|
||||
- Public IP address
|
||||
- Docker installed
|
||||
|
||||
#### Fix 4: Self-Host the Entire API
|
||||
|
||||
If you run the API on your own VPS (not Vercel), requests come from your residential/datacenter IP which may not be blocked.
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone https://github.com/Shineii86/MiruroAPI.git
|
||||
cd MiruroAPI && npm install
|
||||
|
||||
# Start on your own server
|
||||
npm start
|
||||
# → http://your-server:3000
|
||||
```
|
||||
|
||||
#### Verifying the Fix
|
||||
|
||||
After setting an environment variable, check if streaming works:
|
||||
|
||||
```bash
|
||||
# Check pipe health
|
||||
curl "https://mirurotvapi.vercel.app/api/pipe-health"
|
||||
|
||||
# Test episodes
|
||||
curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
||||
```
|
||||
|
||||
Expected response when streaming works:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"results": {
|
||||
"methods": {
|
||||
"direct": { "status": "failed" },
|
||||
"scraperapi": { "status": "working", "latency": "1200ms" }
|
||||
},
|
||||
"working": 1,
|
||||
"recommendation": "ScraperAPI is working. Streaming endpoints are functional."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🐛 Debug Mode
|
||||
|
||||
@@ -1469,7 +1648,7 @@ Use <code>/api/filter</code> with query params. Combine <code>genre</code>, <cod
|
||||
<details>
|
||||
<summary><b>📡 Can I use this in my frontend app?</b></summary>
|
||||
<br/>
|
||||
Yes! CORS is enabled for all origins (<code>*</code>). Just make fetch requests to the API endpoints. No API key needed. Example: <code>fetch('https://mirurotvapi.vercel.app/api/search?query=naruto')</code>
|
||||
Yes! CORS is enabled for allowed origins. Set the `ALLOWED_ORIGINS` environment variable to your frontend domain(s), or use `*` for all origins. Example: <code>fetch('https://mirurotvapi.vercel.app/api/search?query=naruto')</code>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -1490,6 +1669,24 @@ Yes! Use <code>npm start</code> to run the Express server on any VPS, Docker con
|
||||
12 providers: kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli. Not all anime are available on every provider.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>❌ Why are streaming endpoints returning 500 errors?</b></summary>
|
||||
<br/>
|
||||
Cloudflare's bot protection blocks requests from Vercel's datacenter IPs. The pipe endpoint (<code>miruro.to/api/secure/pipe</code>) returns HTTP 403. To fix this, set <code>SCRAPER_API_KEY</code> (requires ScraperAPI premium plan, $49/mo) or <code>FLARESOLVERR_URL</code> (self-hosted, free) in your Vercel environment variables. See <a href="#-how-to-fix-streaming-issues">How to Fix Streaming Issues</a> above.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🔍 Why does /api/staff/:id return 404?</b></summary>
|
||||
<br/>
|
||||
Some AniList staff IDs don't exist or have limited data. Try searching with <code>/api/staff?query=name</code> instead. This is an AniList API limitation, not a bug.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>📊 How do I check if streaming is working?</b></summary>
|
||||
<br/>
|
||||
Use <code>/api/pipe-health</code> to check which pipe methods are working. It shows the status of Direct, ScraperAPI, and FlareSolverr fallback methods with latency and error details.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
@@ -1507,7 +1704,7 @@ Yes! Use <code>npm start</code> to run the Express server on any VPS, Docker con
|
||||
|
||||
### ✅ Completed
|
||||
|
||||
- [x] 🎬 35 API endpoints covering all data
|
||||
- [x] 🎬 46 API endpoints covering all data
|
||||
- [x] 🔍 Full-text search with pagination
|
||||
- [x] 💡 Search suggestions for autocomplete
|
||||
- [x] 🎯 Advanced filtering (genre, year, season, format, sort)
|
||||
@@ -1520,6 +1717,8 @@ Yes! Use <code>npm start</code> to run the Express server on any VPS, Docker con
|
||||
- [x] 🐳 Docker support
|
||||
- [x] 📘 Swagger UI interactive docs
|
||||
- [x] 📖 Comprehensive documentation with real API data
|
||||
- [x] 🔒 Security hardening (XOR keys in env vars, CORS restriction, input sanitization)
|
||||
- [x] 🛡️ Security headers at CDN level
|
||||
|
||||
---
|
||||
|
||||
@@ -1601,7 +1800,7 @@ git push origin feature/amazing-feature
|
||||
| Source | About |
|
||||
|:---|:---|
|
||||
| [AniList](https://anilist.co) | Anime metadata API (GraphQL) |
|
||||
| [Miruro](https://miruro.tv) | Anime streaming site — source for episodes and streaming |
|
||||
| [Miruro](https://www.miruro.to) | Anime streaming site — source for episodes and streaming |
|
||||
| [Miruro TO](https://miruro.to) | Mirror domain |
|
||||
| [Miruro BZ](https://miruro.bz) | Mirror domain |
|
||||
| [Miruro RU](https://miruro.ru) | Mirror domain |
|
||||
|
||||
@@ -1 +1,18 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* MiruroAPI — api/index.js
|
||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||
*
|
||||
* @description
|
||||
* Vercel serverless function entry point.
|
||||
* Single line that re-exports the Express app from server.js.
|
||||
* Vercel maps all /api/* routes to this file.
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
module.exports = require("../server");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════ END: api/index.js
|
||||
|
||||
+183
-31
@@ -1,44 +1,198 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* MiruroAPI — proxy.js
|
||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||
*
|
||||
* @description
|
||||
* CDN proxy endpoint that fetches and rewrites M3U8 playlists
|
||||
* and media segments from third-party CDNs. Handles both
|
||||
* pru.ultracloud.cc encoded URLs (XOR + base64url) and direct
|
||||
* CDN URLs. Rewrites M3U8 internal URLs to route through the
|
||||
* proxy for CORS-safe browser playback.
|
||||
*
|
||||
* @exports
|
||||
* handler — Vercel serverless function (GET /api/proxy)
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
const axios = require("axios");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PROXY CONFIGURATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: PRU XOR encryption key (16-byte hex) ----
|
||||
/**
|
||||
* XOR key used to encode/decode pru.ultracloud.cc URLs.
|
||||
* Loaded from environment variable PRU_PROXY_KEY with hardcoded fallback.
|
||||
* Key length: 16 bytes (32 hex chars).
|
||||
*
|
||||
* @type {Buffer}
|
||||
*/
|
||||
const PROXY_KEY = Buffer.from(
|
||||
process.env.PRU_PROXY_KEY || "a54d389c18527d9fd3e7f0643e27edbe",
|
||||
"hex"
|
||||
);
|
||||
|
||||
/**
|
||||
* Base URL for the PRU CDN proxy service.
|
||||
* All proxied M3U8 URLs are rewritten to route through this domain.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const PRU_BASE = "https://pru.ultracloud.cc/";
|
||||
|
||||
function base64urlXor(str) {
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ENCODING / DECODING UTILITIES
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Base64url XOR encode for PRU URLs ----
|
||||
/**
|
||||
* Encodes a raw URL into the PRU format: base64url(XOR(url, key)).
|
||||
* Used when constructing proxied URLs for the PRU CDN.
|
||||
*
|
||||
* @param {string} str - The raw URL or referer string to encode
|
||||
* @returns {string} Base64url-encoded XOR'd string
|
||||
*
|
||||
* @example
|
||||
* const encoded = base64urlXor("https://example.com/video.m3u8");
|
||||
*/
|
||||
const base64urlXor = (str) => {
|
||||
const buf = Buffer.from(str, "utf-8");
|
||||
const xored = Buffer.alloc(buf.length);
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
xored[i] = buf[i] ^ PROXY_KEY[i % PROXY_KEY.length];
|
||||
}
|
||||
return xored.toString("base64url");
|
||||
}
|
||||
};
|
||||
|
||||
function decodePruUrl(pruUrl) {
|
||||
// ---- FEATURE: Decode PRU URL back to raw URL + referer ----
|
||||
/**
|
||||
* Decodes a pru.ultracloud.cc URL back to its original URL and referer.
|
||||
* PRU format: {base64url(XOR(url))}~{base64url(XOR(referer))}/pl.m3u8
|
||||
*
|
||||
* @param {string} pruUrl - The full PRU URL to decode
|
||||
* @returns {{ url: string, referer: string } | null} Decoded URL and referer, or null if invalid
|
||||
*
|
||||
* @example
|
||||
* const decoded = decodePruUrl("https://pru.ultracloud.cc/abc~def/pl.m3u8");
|
||||
* // { url: "https://...", referer: "https://..." }
|
||||
*/
|
||||
const decodePruUrl = (pruUrl) => {
|
||||
try {
|
||||
const path = new URL(pruUrl).pathname.replace(/^\//, "").replace(/\/pl\.m3u8$/, "").replace(/\/seg\.jpg$/, "");
|
||||
const path = new URL(pruUrl).pathname
|
||||
.replace(/^\//, "")
|
||||
.replace(/\/pl\.m3u8$/, "")
|
||||
.replace(/\/seg\.jpg$/, "");
|
||||
const tildeIdx = path.indexOf("~");
|
||||
if (tildeIdx === -1) return null;
|
||||
|
||||
// NOTE: Split at ~ separator — left is encoded URL, right is encoded referer
|
||||
const urlPart = path.substring(0, tildeIdx);
|
||||
const refPart = path.substring(tildeIdx + 1);
|
||||
|
||||
const urlBuf = Buffer.from(urlPart, "base64url");
|
||||
const url = Buffer.from(Array.from(urlBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])).toString("utf-8");
|
||||
const url = Buffer.from(
|
||||
Array.from(urlBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])
|
||||
).toString("utf-8");
|
||||
|
||||
const refBuf = Buffer.from(refPart, "base64url");
|
||||
const referer = Buffer.from(Array.from(refBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])).toString("utf-8");
|
||||
const referer = Buffer.from(
|
||||
Array.from(refBuf).map((b, i) => b ^ PROXY_KEY[i % PROXY_KEY.length])
|
||||
).toString("utf-8");
|
||||
|
||||
return { url, referer };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function encodeForPru(rawUrl, referer) {
|
||||
// ---- FEATURE: Encode raw URL + referer for PRU proxy ----
|
||||
/**
|
||||
* Encodes a raw URL and referer into a full PRU proxy URL.
|
||||
* Output format: {PRU_BASE}{urlEnc}~{refEnc}/pl.m3u8
|
||||
*
|
||||
* @param {string} rawUrl - The original media URL
|
||||
* @param {string} referer - The referer to use when fetching
|
||||
* @returns {string} Full PRU proxy URL
|
||||
*
|
||||
* @example
|
||||
* const pruUrl = encodeForPru("https://cdn.example.com/v.m3u8", "https://miruro.to/");
|
||||
*/
|
||||
const encodeForPru = (rawUrl, referer) => {
|
||||
const urlEnc = base64urlXor(rawUrl);
|
||||
const refEnc = base64urlXor(referer || new URL(rawUrl).origin + "/");
|
||||
return `${PRU_BASE}${urlEnc}~${refEnc}/pl.m3u8`;
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// M3U8 REWRITING
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Rewrite M3U8 internal URLs to route through proxy ----
|
||||
/**
|
||||
* Rewrites M3U8 playlist content to proxy all internal URLs.
|
||||
* Handles both absolute (https://...) and relative URLs.
|
||||
* Skips URLs that are already proxied through pru.ultracloud.cc.
|
||||
*
|
||||
* @param {string} m3u8 - Raw M3U8 playlist content
|
||||
* @param {string} targetReferer - Referer to pass to proxy for each URL
|
||||
* @returns {string} Rewritten M3U8 content with proxied URLs
|
||||
*/
|
||||
const rewriteM3U8 = (m3u8, targetReferer) => {
|
||||
// NOTE: Rewrite absolute URLs (lines starting with http)
|
||||
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
|
||||
if (match.includes("pru.ultracloud.cc")) {
|
||||
const dec = decodePruUrl(match);
|
||||
if (dec) {
|
||||
return `/api/proxy?url=${encodeURIComponent(dec.url)}&referer=${encodeURIComponent(dec.referer)}`;
|
||||
}
|
||||
}
|
||||
return `/api/proxy?url=${encodeURIComponent(match)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||
});
|
||||
|
||||
// NOTE: Rewrite relative URLs (lines with file extensions, no # or < prefix)
|
||||
m3u8 = m3u8.replace(
|
||||
/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm,
|
||||
(match) => {
|
||||
try {
|
||||
const absolute = new URL(match, new URL(targetReferer)).href;
|
||||
return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return m3u8;
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PROXY HANDLER
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Vercel serverless proxy handler ----
|
||||
/**
|
||||
* Main proxy handler for Vercel serverless function.
|
||||
* Fetches content from a target URL and returns it to the client.
|
||||
* For M3U8 playlists, rewrites internal URLs to route through the proxy.
|
||||
* For other content (segments, subtitles), streams raw bytes.
|
||||
*
|
||||
* @param {import('http').IncomingMessage} req - Express request
|
||||
* @param {import('http').ServerResponse} res - Express response
|
||||
* @returns {Promise<void>}
|
||||
*
|
||||
* @query {string} url - The target URL to proxy (required)
|
||||
* @query {string} referer - Optional referer header for the target
|
||||
*
|
||||
* @example
|
||||
* GET /api/proxy?url=https://example.com/video.m3u8&referer=https://miruro.to/
|
||||
*/
|
||||
module.exports = async function handler(req, res) {
|
||||
// NOTE: CORS headers for browser requests
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Range, Referer");
|
||||
@@ -46,23 +200,27 @@ module.exports = async function handler(req, res) {
|
||||
if (req.method === "OPTIONS") return res.status(200).end();
|
||||
|
||||
const { url, referer } = req.query;
|
||||
if (!url) return res.status(400).json({ success: false, message: "url query parameter is required" });
|
||||
if (!url) {
|
||||
return res.status(400).json({ success: false, message: "url query parameter is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// NOTE: Detect PRU-encoded URLs vs direct CDN URLs
|
||||
const decoded = decodePruUrl(url);
|
||||
const isPru = !!decoded;
|
||||
const targetUrl = decoded ? decoded.url : url;
|
||||
const targetReferer = decoded ? decoded.referer : (referer || "https://www.miruro.tv/");
|
||||
const targetReferer = decoded ? decoded.referer : referer || "https://www.miruro.to/";
|
||||
|
||||
let fetchUrl;
|
||||
let fetchHeaders;
|
||||
|
||||
if (isPru) {
|
||||
// NOTE: Re-encode for PRU proxy with proper headers
|
||||
fetchUrl = encodeForPru(targetUrl, targetReferer);
|
||||
fetchHeaders = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
Referer: "https://www.miruro.tv/",
|
||||
Origin: "https://www.miruro.tv",
|
||||
Referer: "https://www.miruro.to/",
|
||||
Origin: "https://www.miruro.to",
|
||||
};
|
||||
} else {
|
||||
fetchUrl = targetUrl;
|
||||
@@ -72,6 +230,7 @@ module.exports = async function handler(req, res) {
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: Fetch with arraybuffer to handle binary content (segments, fonts)
|
||||
const response = await axios.get(fetchUrl, {
|
||||
headers: fetchHeaders,
|
||||
timeout: 15000,
|
||||
@@ -82,30 +241,21 @@ module.exports = async function handler(req, res) {
|
||||
const contentType = response.headers["content-type"] || "";
|
||||
res.setHeader("Cache-Control", "public, max-age=300");
|
||||
|
||||
if (isPru && (contentType.includes("mpegurl") || targetUrl.endsWith(".m3u8") || contentType.includes("x-mpegURL"))) {
|
||||
// NOTE: M3U8 playlists need URL rewriting — other content is streamed raw
|
||||
if (
|
||||
isPru &&
|
||||
(contentType.includes("mpegurl") ||
|
||||
targetUrl.endsWith(".m3u8") ||
|
||||
contentType.includes("x-mpegURL"))
|
||||
) {
|
||||
let m3u8 = Buffer.from(response.data).toString("utf-8");
|
||||
|
||||
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
|
||||
if (match.includes("pru.ultracloud.cc")) {
|
||||
const dec = decodePruUrl(match);
|
||||
if (dec) return `/api/proxy?url=${encodeURIComponent(dec.url)}&referer=${encodeURIComponent(dec.referer)}`;
|
||||
}
|
||||
return `/api/proxy?url=${encodeURIComponent(match)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||
});
|
||||
|
||||
m3u8 = m3u8.replace(/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm, (match) => {
|
||||
try {
|
||||
const absolute = new URL(match, new URL(targetUrl)).href;
|
||||
return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`;
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
m3u8 = rewriteM3U8(m3u8, targetReferer);
|
||||
|
||||
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
|
||||
return res.send(m3u8);
|
||||
}
|
||||
|
||||
// NOTE: Stream raw binary content (video segments, subtitles, thumbnails)
|
||||
res.setHeader("Content-Type", contentType);
|
||||
if (response.headers["content-length"]) {
|
||||
res.setHeader("Content-Length", response.headers["content-length"]);
|
||||
@@ -115,3 +265,5 @@ module.exports = async function handler(req, res) {
|
||||
res.status(500).json({ success: false, message: "Proxy failed: " + err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════ END: proxy.js
|
||||
|
||||
+62
-2
@@ -5,6 +5,7 @@
|
||||
```
|
||||
MiruroAPI/
|
||||
├── server.js # Express entry point, port 3000
|
||||
├── worker.js # Cloudflare Worker edition (edge runtime)
|
||||
├── package.json # name: "miruro-api"
|
||||
├── vercel.json # Routes /api/* and /* to server.js
|
||||
├── Dockerfile # Docker support (node:20-alpine)
|
||||
@@ -62,7 +63,7 @@ MiruroAPI/
|
||||
| Runtime | Node.js |
|
||||
| Framework | Express.js |
|
||||
| Metadata | AniList GraphQL (`graphql.anilist.co`) |
|
||||
| Streaming | Miruro pipe (`miruro.tv/api/secure/pipe`) |
|
||||
| Streaming | Miruro pipe (`miruro.{to,ru,bz,tv}/api/secure/pipe`) |
|
||||
| Deployment | Vercel (Serverless) |
|
||||
| Caching | In-memory Map with 1-5 min TTL |
|
||||
| Static Files | Express static middleware |
|
||||
@@ -120,11 +121,21 @@ Play M3U8 in HLS player (hls.js, video.js, native)
|
||||
|
||||
### Miruro Pipe
|
||||
|
||||
- **Endpoint:** `https://miruro.tv/api/secure/pipe`
|
||||
- **Endpoint:** `https://miruro.{to,ru}/api/secure/pipe`
|
||||
- **Data:** Episodes, streaming sources (M3U8 URLs)
|
||||
- **Encoding:** Base64url + gzip compressed request/response
|
||||
- **Providers:** kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli
|
||||
|
||||
## Self-Healing Fallback System (v2.3.0)
|
||||
|
||||
When Cloudflare blocks streaming requests, the API automatically tries fallback methods in order:
|
||||
|
||||
1. **Direct** → Mirror rotation across 4 domains (miruro.to, miruro.ru, miruro.bz, miruro.tv)
|
||||
2. **ScraperAPI** → Free proxy bypass via ScraperAPI (1K req/month, requires `SCRAPER_API_KEY`)
|
||||
3. **FlareSolverr** → Self-hosted browser proxy for full Cloudflare bypass
|
||||
|
||||
The system tracks success/failure rates per method and auto-rotates to the most reliable option. Check status via `/api/pipe-health`.
|
||||
|
||||
## Vercel Configuration
|
||||
|
||||
```json
|
||||
@@ -188,3 +199,52 @@ Error responses:
|
||||
| axios | ^1.8.0 | HTTP requests |
|
||||
| cors | ^2.8.5 | CORS headers |
|
||||
| dotenv | ^16.4.0 | Environment variables |
|
||||
|
||||
## Cloudflare Worker Architecture (v2.3.1)
|
||||
|
||||
The Worker edition (`worker.js`) runs the same API on Cloudflare's edge network. Key difference: **streaming endpoints work** because edge-to-edge requests bypass Cloudflare bot detection.
|
||||
|
||||
### Why Workers Work
|
||||
|
||||
```
|
||||
Vercel (datacenter IP) → miruro.to → Cloudflare WAF → 403 Blocked
|
||||
Cloudflare Worker (edge) → miruro.to → Cloudflare internal network → 200 OK
|
||||
```
|
||||
|
||||
### Worker Runtime Differences
|
||||
|
||||
| Feature | Express (Node.js) | Cloudflare Worker |
|
||||
|---------|-------------------|-------------------|
|
||||
| HTTP client | axios | native fetch |
|
||||
| Decompression | zlib (Node.js) | DecompressionStream (Web API) |
|
||||
| Caching | in-memory Map | Workers KV (optional) |
|
||||
| npm packages | Yes | No (bundle only) |
|
||||
| Cold starts | ~500ms | Zero |
|
||||
| CPU limit | None | 10ms (free) / 30s (paid) |
|
||||
|
||||
### Worker Endpoints (15)
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/health` | System health check |
|
||||
| `GET /api/search?query=` | Full-text anime search |
|
||||
| `GET /api/suggestions?query=` | Autocomplete suggestions |
|
||||
| `GET /api/filter?genre=` | Advanced filter |
|
||||
| `GET /api/trending` | Trending anime |
|
||||
| `GET /api/popular` | Most popular |
|
||||
| `GET /api/top` | Top scored |
|
||||
| `GET /api/random` | Random anime |
|
||||
| `GET /api/info/:id` | Full anime info |
|
||||
| `GET /api/anime/:id/characters` | Characters + voice actors |
|
||||
| `GET /api/anime/:id/relations` | Related anime |
|
||||
| `GET /api/genres` | All genres |
|
||||
| `GET /api/episodes/:id` | Episode list (pipe) |
|
||||
| `GET /api/watch/:provider/:anilistId/:category/:slug` | Streaming sources (pipe) |
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
npm install -g wrangler
|
||||
wrangler login
|
||||
wrangler deploy worker.js --name miruroapi
|
||||
```
|
||||
|
||||
+13
-3
@@ -1,6 +1,6 @@
|
||||
# API Endpoints Reference
|
||||
|
||||
Complete documentation for all 35 MiruroAPI v2.0.1 endpoints.
|
||||
Complete documentation for all 45 MiruroAPI v2.3.0 endpoints.
|
||||
|
||||
---
|
||||
|
||||
@@ -591,12 +591,12 @@ API health status, version, uptime, memory, cache stats.
|
||||
"success": true,
|
||||
"results": {
|
||||
"status": "healthy",
|
||||
"version": "2.0.0",
|
||||
"version": "2.3.0",
|
||||
"uptime": "0h 0m 34s",
|
||||
"node": "v24.14.1",
|
||||
"memory": { "used": "13MB", "total": "15MB", "rss": "25MB" },
|
||||
"cache": { "size": 12, "maxSize": 100 },
|
||||
"endpoints": 35,
|
||||
"endpoints": 45,
|
||||
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
|
||||
}
|
||||
}
|
||||
@@ -604,6 +604,16 @@ API health status, version, uptime, memory, cache stats.
|
||||
|
||||
---
|
||||
|
||||
## Pipe Health
|
||||
|
||||
```
|
||||
GET /api/pipe-health
|
||||
```
|
||||
|
||||
Returns the status of the self-healing fallback system — mirror availability, ScraperAPI/FlareSolverr status, and last successful method.
|
||||
|
||||
---
|
||||
|
||||
## Stats
|
||||
|
||||
```
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# MiruroAPI Documentation
|
||||
|
||||
> Free REST API for anime streaming data. 18 endpoints, 12 providers, M3U8 URLs. No API key required.
|
||||
> Free REST API for anime streaming data. 46 endpoints, 12 providers, M3U8 URLs. No API key required.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -48,7 +48,7 @@ curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
||||
|
||||
## Features
|
||||
|
||||
- **18 Endpoints** — Search, info, episodes, streaming, characters, relations, and more
|
||||
- **46 Endpoints** — Search, info, episodes, streaming, characters, relations, and more
|
||||
- **12 Streaming Providers** — M3U8 URLs with resolution, codec, fansub info
|
||||
- **No API Key** — Just make requests, no registration needed
|
||||
- **In-Memory Cache** — 1-5 minute TTL for fast responses
|
||||
|
||||
+10
-1
@@ -234,5 +234,14 @@ const dubEps = kiwi.dub; // [{ id: "watch/kiwi/20/dub/...", ... }]
|
||||
|-------|----------|
|
||||
| "No episodes found" | Check the AniList ID is correct (use `/api/search` first) |
|
||||
| "No providers" | The anime may not be available on any provider |
|
||||
| Stream URL 403 | Some servers require specific referrer headers |
|
||||
| Stream URL 403 | Some servers require specific referrer headers — the self-healing system auto-rotates mirrors |
|
||||
| CORS error | Use a proxy or access from server-side |
|
||||
|
||||
## Self-Healing Fallback (v2.3.0)
|
||||
|
||||
When Cloudflare blocks streaming requests, the API automatically tries fallback methods:
|
||||
- **Direct** → Mirror rotation across 4 domains (miruro.to, miruro.ru, miruro.bz, miruro.tv)
|
||||
- **ScraperAPI** → Free proxy bypass (1K req/month, set `SCRAPER_API_KEY` env var)
|
||||
- **FlareSolverr** → Self-hosted browser proxy for full Cloudflare bypass
|
||||
|
||||
Check the fallback system status: `GET /api/pipe-health`
|
||||
|
||||
Generated
+1466
-3
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "miruro-api",
|
||||
"version": "2.1.4",
|
||||
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
||||
"version": "2.3.0",
|
||||
"description": "RESTful API for Miruro - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "node test.js"
|
||||
"test": "node test.js",
|
||||
"deploy": "wrangler deploy",
|
||||
"preview": "wrangler dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.8.0",
|
||||
@@ -16,7 +18,8 @@
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
"nodemon": "^3.1.0",
|
||||
"wrangler": "^4.106.0"
|
||||
},
|
||||
"keywords": [
|
||||
"anime",
|
||||
|
||||
+2
-2
@@ -9,14 +9,14 @@
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://mirurotvapi.vercel.app/docs">
|
||||
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png">
|
||||
<meta property="og:description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||
<meta property="og:description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 46 endpoints directly from the browser.">
|
||||
<meta property="og:site_name" content="MiruroAPI">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="MiruroAPI — Swagger UI Documentation">
|
||||
<meta name="twitter:description" content="Interactive Swagger UI documentation for MiruroAPI.">
|
||||
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/og-image.png">
|
||||
<meta name="description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||
<meta name="description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 46 endpoints directly from the browser.">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="theme-color" content="#0b1622">
|
||||
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg">
|
||||
|
||||
+60
-13
@@ -10,7 +10,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://mirurotvapi.vercel.app" />
|
||||
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||
<meta property="og:description" content="Free RESTful API for anime streaming data. 35 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
|
||||
<meta property="og:description" content="Free RESTful API for anime streaming data. 46 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
|
||||
<meta property="og:site_name" content="MiruroAPI" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
<meta name="twitter:site" content="@Shineii86" />
|
||||
<meta name="twitter:creator" content="@Shineii86" />
|
||||
<meta name="twitter:title" content="MiruroAPI — Free Anime Streaming API" />
|
||||
<meta name="twitter:description" content="Free RESTful API for anime streaming data. 35 endpoints, 12 providers, M3U8 streaming URLs." />
|
||||
<meta name="twitter:description" content="Free RESTful API for anime streaming data. 46 endpoints, 12 providers, M3U8 streaming URLs." />
|
||||
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="MiruroAPI - Free Anime Streaming API" />
|
||||
<meta name="twitter:domain" content="mirurotvapi.vercel.app" />
|
||||
|
||||
<!-- ──────────────── Generic Meta ──────────────── -->
|
||||
<meta name="description" content="Free RESTful API for anime streaming data. 35 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
|
||||
<meta name="description" content="Free RESTful API for anime streaming data. 46 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
|
||||
<meta name="author" content="Shinei Nouzen" />
|
||||
<meta name="keywords" content="anime api, free api, rest api, anime streaming, m3u8, anime data, anilist, miruro, episode streaming, sub, dub" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
@@ -278,7 +278,7 @@
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebAPI",
|
||||
"name": "MiruroAPI",
|
||||
"description": "Free RESTful API for anime streaming data. 35 endpoints, 12 providers, M3U8 streaming URLs.",
|
||||
"description": "Free RESTful API for anime streaming data. 46 endpoints, 12 providers, M3U8 streaming URLs.",
|
||||
"url": "https://mirurotvapi.vercel.app",
|
||||
"image": "https://mirurotvapi.vercel.app/og-image.png",
|
||||
"documentation": "https://mirurotvapi.vercel.app/docs",
|
||||
@@ -326,7 +326,7 @@
|
||||
<section class="hero container">
|
||||
<div class="hero-badge"><span class="dot"></span> Free · No API key · Open source</div>
|
||||
<h1>The <span class="grad">Anime Streaming</span> API<br/>for Your Next Project</h1>
|
||||
<p class="hero-sub">Search, discover, and stream anime with M3U8 URLs. Powered by AniList GraphQL + Miruro providers. 35 endpoints, 12 providers, zero config.</p>
|
||||
<p class="hero-sub">Search, discover, and stream anime with M3U8 URLs. Powered by AniList GraphQL + Miruro providers. 46 endpoints, 12 providers, zero config.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#playground" class="btn btn-primary">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
@@ -370,7 +370,7 @@ fetch("https://mirurotvapi.vercel.app/api/search?query=naruto")
|
||||
<section class="stats">
|
||||
<div class="container">
|
||||
<div class="stats-grid">
|
||||
<div class="reveal"><div class="stat-value grad" data-count="35">0</div><div class="stat-label">API Endpoints</div></div>
|
||||
<div class="reveal"><div class="stat-value grad" data-count="46">0</div><div class="stat-label">API Endpoints</div></div>
|
||||
<div class="reveal reveal-delay-1"><div class="stat-value grad" data-count="12">0</div><div class="stat-label">Streaming Providers</div></div>
|
||||
<div class="reveal reveal-delay-2"><div class="stat-value grad" data-count="5000">0</div><div class="stat-label">Anime Indexed</div></div>
|
||||
<div class="reveal reveal-delay-3"><div class="stat-value grad">M3U8</div><div class="stat-label">Stream Format</div></div>
|
||||
@@ -719,22 +719,69 @@ fetch("https://mirurotvapi.vercel.app/api/search?query=naruto")
|
||||
|
||||
/* ── Explorer ── */
|
||||
const explorerData = [
|
||||
// ---- FEATURE: Search & Discovery ----
|
||||
{ id: 'search', title: 'Search', desc: 'Full-text anime search with 20+ fields per result. Paginated.', path: '/search?query={q}&page={n}&per_page={n}', params: [{ name: 'query', type: 'string', desc: 'Search query' }, { name: 'page', type: 'number', desc: 'Page (default 1)' }, { name: 'per_page', type: 'number', desc: 'Results per page (default 20)' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/search?query=naruto"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/search?query=naruto");\nconst data = await res.json();\nconsole.log(data.results);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/search", params={"query": "naruto"})\nprint(r.json())' } },
|
||||
{ id: 'suggestions', title: 'Suggestions', desc: 'Lightweight autocomplete for dropdowns. Max 8 results.', path: '/suggestions?query={q}', params: [{ name: 'query', type: 'string', desc: 'Partial query' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/suggestions?query=naru"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/suggestions?query=naru");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/suggestions", params={"query": "naru"})\nprint(r.json())' } },
|
||||
{ id: 'info', title: 'Anime Info', desc: 'Everything AniList has — characters, staff, relations, recommendations, trailer, stats.', path: '/info/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/info/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/info/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/info/20")\nprint(r.json())' } },
|
||||
{ id: 'characters', title: 'Characters', desc: 'Paginated characters with Japanese voice actors.', path: '/characters/{anilistId}?page={n}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }, { name: 'page', type: 'number', desc: 'Page (default 1)' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/characters/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/characters/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/characters/20")\nprint(r.json())' } },
|
||||
{ id: 'relations', title: 'Relations', desc: 'Sequels, prequels, side stories, spin-offs.', path: '/relations/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/relations/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/relations/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/relations/20")\nprint(r.json())' } },
|
||||
{ id: 'recommendations', title: 'Recommendations', desc: 'Community recommendations sorted by rating.', path: '/recommendations/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/recommendations/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/recommendations/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/recommendations/20")\nprint(r.json())' } },
|
||||
{ id: 'episodes', title: 'Episodes', desc: 'Fetch all episodes from multiple providers (sub, dub, download).', path: '/episodes/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/episodes/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/episodes/20");\nconst data = await res.json();\nconsole.log(data.providers);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/episodes/20")\nprint(r.json())' } },
|
||||
{ id: 'watch', title: 'Watch / Stream', desc: 'Get M3U8 streaming URL from episode ID. 12 providers.', path: '/watch/{provider}/{id}/{category}/{slug}', params: [{ name: 'provider', type: 'string', desc: 'Provider name (kiwi, pewe, etc.)' }, { name: 'id', type: 'string', desc: 'Episode ID' }, { name: 'category', type: 'string', desc: 'Category (sub, dub, raw)' }, { name: 'slug', type: 'string', desc: 'Episode slug' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/watch/kiwi/episode-id/sub/episode-slug"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/watch/kiwi/episode-id/sub/episode-slug");\nconst data = await res.json();\nconsole.log(data.streams[0].url);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/watch/kiwi/episode-id/sub/episode-slug")\nprint(r.json())' } },
|
||||
{ id: 'filter', title: 'Filter', desc: 'Filter by genre, tag, year, season, format, status, sort.', path: '/filter?genre={g}&format={f}&year={y}', params: [{ name: 'genre', type: 'string', desc: 'Genre name' }, { name: 'format', type: 'string', desc: 'TV, MOVIE, OVA, etc.' }, { name: 'year', type: 'number', desc: 'Release year' }, { name: 'season', type: 'string', desc: 'WINTER, SPRING, SUMMER, FALL' }, { name: 'status', type: 'string', desc: 'RELEASING, FINISHED, NOT_YET_RELEASED' }, { name: 'sort', type: 'string', desc: 'POPULARITY_DESC, SCORE_DESC, etc.' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/filter?genre=Action&format=TV"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/filter?genre=Action&format=TV");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/filter", params={"genre": "Action", "format": "TV"})\nprint(r.json())' } },
|
||||
{ id: 'multi-search', title: 'Multi-Search', desc: 'Combined search with genre, status, year, format filters.', path: '/multi-search?q={q}&genre={g}', params: [{ name: 'q', type: 'string', desc: 'Search query' }, { name: 'genre', type: 'string', desc: 'Genre filter' }, { name: 'status', type: 'string', desc: 'Status filter' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/multi-search?q=naruto&genre=Action"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/multi-search?q=naruto&genre=Action");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/multi-search", params={"q": "naruto", "genre": "Action"})\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: Collections ----
|
||||
{ id: 'trending', title: 'Trending', desc: 'Currently trending anime across the community.', path: '/trending', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/trending"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/trending");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/trending")\nprint(r.json())' } },
|
||||
{ id: 'trending-daily', title: 'Trending Daily', desc: 'Daily trending anime rotation.', path: '/trending/daily', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/trending/daily"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/trending/daily");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/trending/daily")\nprint(r.json())' } },
|
||||
{ id: 'trending-weekly', title: 'Trending Weekly', desc: 'Weekly trending anime rotation.', path: '/trending/weekly', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/trending/weekly"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/trending/weekly");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/trending/weekly")\nprint(r.json())' } },
|
||||
{ id: 'popular', title: 'Popular', desc: 'Most popular anime of all time by user count.', path: '/popular', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/popular"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/popular");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/popular")\nprint(r.json())' } },
|
||||
{ id: 'top', title: 'Top', desc: 'Top rated anime by AniList score.', path: '/top', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/top"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/top");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/top")\nprint(r.json())' } },
|
||||
{ id: 'upcoming', title: 'Upcoming', desc: 'Most anticipated anime that haven\'t aired yet.', path: '/upcoming', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/upcoming"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/upcoming");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/upcoming")\nprint(r.json())' } },
|
||||
{ id: 'recent', title: 'Recent Episodes', desc: 'Currently airing / this season\'s anime.', path: '/recent', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/recent"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/recent");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/recent")\nprint(r.json())' } },
|
||||
{ id: 'spotlight', title: 'Spotlight', desc: 'Top 10 trending + popular. Perfect for hero banners.', path: '/spotlight', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/spotlight"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/spotlight");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/spotlight")\nprint(r.json())' } },
|
||||
{ id: 'schedule', title: 'Schedule', desc: 'Upcoming episodes with UNIX timestamps.', path: '/schedule', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/schedule"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/schedule");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/schedule")\nprint(r.json())' } },
|
||||
{ id: 'filter', title: 'Filter', desc: 'Filter by genre, tag, year, season, format, status, sort.', path: '/filter?genre={g}&format={f}&year={y}', params: [{ name: 'genre', type: 'number', desc: 'AniList genre ID' }, { name: 'format', type: 'string', desc: 'TV, MOVIE, OVA, etc.' }, { name: 'year', type: 'number', desc: 'Release year' }, { name: 'season', type: 'string', desc: 'WINTER, SPRING, SUMMER, FALL' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/filter?genre=1&format=TV"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/filter?genre=1&format=TV");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/filter", params={"genre": "1", "format": "TV"})\nprint(r.json())' } },
|
||||
{ id: 'random', title: 'Random', desc: 'Random anime for discovery. Changes each request.', path: '/random', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/random"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/random");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/random")\nprint(r.json())' } },
|
||||
{ id: 'upcoming-countdown', title: 'Upcoming Countdown', desc: 'Next 10 episodes airing soon with countdown.', path: '/upcoming/countdown', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/upcoming/countdown"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/upcoming/countdown");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/upcoming/countdown")\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: Seasonal & Studio ----
|
||||
{ id: 'season', title: 'Seasonal', desc: 'Anime by year and season.', path: '/season/{year}/{season}', params: [{ name: 'year', type: 'number', desc: 'Release year' }, { name: 'season', type: 'string', desc: 'WINTER, SPRING, SUMMER, FALL' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/season/2025/spring"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/season/2025/spring");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/season/2025/spring")\nprint(r.json())' } },
|
||||
{ id: 'studio', title: 'Studio', desc: 'Anime by studio name (MAPPA, Ufotable, etc.).', path: '/studio/{name}', params: [{ name: 'name', type: 'string', desc: 'Studio name' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/studio/MAPPA"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/studio/MAPPA");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/studio/MAPPA")\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: Genres & Tags ----
|
||||
{ id: 'genres', title: 'Genres', desc: 'List all available genres with AniList IDs.', path: '/genres', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/genres"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/genres");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/genres")\nprint(r.json())' } },
|
||||
{ id: 'tags', title: 'Tags', desc: 'List all available tags (violet, mind game, etc.).', path: '/tags', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/tags"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/tags");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/tags")\nprint(r.json())' } },
|
||||
{ id: 'genre', title: 'Genre', desc: 'Anime by specific genre name.', path: '/genre/{name}', params: [{ name: 'name', type: 'string', desc: 'Genre name (Action, Romance, etc.)' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/genre/Action"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/genre/Action");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/genre/Action")\nprint(r.json())' } },
|
||||
{ id: 'year', title: 'Year', desc: 'Anime by release year.', path: '/year/{year}', params: [{ name: 'year', type: 'number', desc: 'Release year' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/year/2024"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/year/2024");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/year/2024")\nprint(r.json())' } },
|
||||
{ id: 'status', title: 'Status', desc: 'Anime by airing status.', path: '/status/{status}', params: [{ name: 'status', type: 'string', desc: 'RELEASING, FINISHED, NOT_YET_RELEASED' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/status/RELEASING"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/status/RELEASING");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/status/RELEASING")\nprint(r.json())' } },
|
||||
{ id: 'format', title: 'Format', desc: 'Anime by format type.', path: '/format/{format}', params: [{ name: 'format', type: 'string', desc: 'TV, MOVIE, OVA, ONA, SPECIAL' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/format/MOVIE"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/format/MOVIE");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/format/MOVIE")\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: Character & Staff ----
|
||||
{ id: 'character', title: 'Character', desc: 'Character detail with animeography and voice actors.', path: '/character/{id}', params: [{ name: 'id', type: 'number', desc: 'AniList character ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/character/1"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/character/1");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/character/1")\nprint(r.json())' } },
|
||||
{ id: 'staff', title: 'Staff', desc: 'Staff detail with works and roles.', path: '/staff/{id}', params: [{ name: 'id', type: 'number', desc: 'AniList staff ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/staff/1"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/staff/1");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/staff/1")\nprint(r.json())' } },
|
||||
{ id: 'characters-search', title: 'Characters Search', desc: 'Search characters by name.', path: '/characters?query={q}', params: [{ name: 'query', type: 'string', desc: 'Character name' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/characters?query=kaneki"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/characters?query=kaneki");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/characters", params={"query": "kaneki"})\nprint(r.json())' } },
|
||||
{ id: 'staff-search', title: 'Staff Search', desc: 'Search staff by name.', path: '/staff?query={q}', params: [{ name: 'query', type: 'string', desc: 'Staff name' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/staff?query=oda"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/staff?query=oda");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/staff", params={"query": "oda"})\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: Anime Details ----
|
||||
{ id: 'info', title: 'Anime Info', desc: 'Everything AniList has — characters, staff, relations, recommendations, trailer, stats.', path: '/info/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/info/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/info/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/info/20")\nprint(r.json())' } },
|
||||
{ id: 'anime-characters', title: 'Anime Characters', desc: 'Characters for an anime with voice actors.', path: '/anime/{id}/characters', params: [{ name: 'id', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/anime/20/characters"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/anime/20/characters");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/anime/20/characters")\nprint(r.json())' } },
|
||||
{ id: 'relations', title: 'Relations', desc: 'Sequels, prequels, side stories, spin-offs.', path: '/anime/{id}/relations', params: [{ name: 'id', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/anime/20/relations"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/anime/20/relations");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/anime/20/relations")\nprint(r.json())' } },
|
||||
{ id: 'recommendations', title: 'Recommendations', desc: 'Community recommendations sorted by rating.', path: '/anime/{id}/recommendations', params: [{ name: 'id', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/anime/20/recommendations"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/anime/20/recommendations");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/anime/20/recommendations")\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: Streaming ----
|
||||
{ id: 'episodes', title: 'Episodes', desc: 'Fetch all episodes from multiple providers (sub, dub, download).', path: '/episodes/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/episodes/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/episodes/20");\nconst data = await res.json();\nconsole.log(data.providers);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/episodes/20")\nprint(r.json())' } },
|
||||
{ id: 'batch-episodes', title: 'Batch Episodes', desc: 'Fetch sources for multiple episodes in one request.', path: '/episodes/{id}/batch?provider={p}&category={c}&slugs={s}', params: [{ name: 'id', type: 'number', desc: 'AniList ID' }, { name: 'provider', type: 'string', desc: 'Provider name' }, { name: 'category', type: 'string', desc: 'sub, dub, raw' }, { name: 'slugs', type: 'string', desc: 'Comma-separated slugs' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/episodes/20/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/episodes/20/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/episodes/20/batch", params={"provider": "kiwi", "category": "sub", "slugs": "animepahe-1,animepahe-2"})\nprint(r.json())' } },
|
||||
{ id: 'sources', title: 'Sources', desc: 'Detailed streaming sources for an episode.', path: '/sources?episodeId={e}&provider={p}&anilistId={a}&category={c}', params: [{ name: 'episodeId', type: 'string', desc: 'Episode ID' }, { name: 'provider', type: 'string', desc: 'Provider name' }, { name: 'anilistId', type: 'number', desc: 'AniList ID' }, { name: 'category', type: 'string', desc: 'sub, dub, raw' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/sources?episodeId=ep1&provider=kiwi&anilistId=20&category=sub"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/sources?episodeId=ep1&provider=kiwi&anilistId=20&category=sub");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/sources", params={"episodeId": "ep1", "provider": "kiwi", "anilistId": "20", "category": "sub"})\nprint(r.json())' } },
|
||||
{ id: 'stream', title: 'Stream', desc: 'Stream URL with quality fallback. Returns best available quality.', path: '/stream?provider={p}&anilistId={a}&category={c}&slug={s}&quality={q}', params: [{ name: 'provider', type: 'string', desc: 'Provider name' }, { name: 'anilistId', type: 'number', desc: 'AniList ID' }, { name: 'category', type: 'string', desc: 'sub, dub, raw' }, { name: 'slug', type: 'string', desc: 'Episode slug' }, { name: 'quality', type: 'string', desc: '1080p, 720p, etc.' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/stream?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1&quality=1080p"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/stream?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1&quality=1080p");\nconst data = await res.json();\nconsole.log(data.results.bestStream.url);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/stream", params={"provider": "kiwi", "anilistId": "20", "category": "sub", "slug": "animepahe-1"})\nprint(r.json())' } },
|
||||
{ id: 'download', title: 'Download', desc: 'Download URL for an episode.', path: '/download?provider={p}&anilistId={a}&category={c}&slug={s}', params: [{ name: 'provider', type: 'string', desc: 'Provider name' }, { name: 'anilistId', type: 'number', desc: 'AniList ID' }, { name: 'category', type: 'string', desc: 'sub, dub, raw' }, { name: 'slug', type: 'string', desc: 'Episode slug' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/download?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/download?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/download", params={"provider": "kiwi", "anilistId": "20", "category": "sub", "slug": "animepahe-1"})\nprint(r.json())' } },
|
||||
{ id: 'watch', title: 'Watch / Stream', desc: 'Get M3U8 streaming URL from episode ID. 12 providers. Returns streams, subtitles, and best stream.', path: '/watch/{provider}/{id}/{category}/{slug}', params: [{ name: 'provider', type: 'string', desc: 'Provider name (kiwi, pewe, etc.)' }, { name: 'id', type: 'string', desc: 'AniList ID' }, { name: 'category', type: 'string', desc: 'Category (sub, dub, raw)' }, { name: 'slug', type: 'string', desc: 'Episode slug' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/watch/kiwi/20/sub/animepahe-1"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/watch/kiwi/20/sub/animepahe-1");\nconst data = await res.json();\nconsole.log(data.results.bestStream.url);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/watch/kiwi/20/sub/animepahe-1")\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: Utility ----
|
||||
{ id: 'compare', title: 'Compare', desc: 'Compare two anime side by side.', path: '/compare/{id1}/{id2}', params: [{ name: 'id1', type: 'number', desc: 'First AniList ID' }, { name: 'id2', type: 'number', desc: 'Second AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/compare/20/21"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/compare/20/21");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/compare/20/21")\nprint(r.json())' } },
|
||||
{ id: 'stats-genre', title: 'Genre Stats', desc: 'Genre distribution statistics across the database.', path: '/stats/genre', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/stats/genre"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/stats/genre");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/stats/genre")\nprint(r.json())' } },
|
||||
{ id: 'calendar', title: 'Calendar', desc: 'Monthly airing calendar with episode counts.', path: '/calendar?year={y}&month={m}', params: [{ name: 'year', type: 'number', desc: 'Year (default current)' }, { name: 'month', type: 'number', desc: 'Month 1-12 (default current)' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/calendar?year=2025&month=6"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/calendar?year=2025&month=6");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/calendar", params={"year": "2025", "month": "6"})\nprint(r.json())' } },
|
||||
{ id: 'timeline', title: 'Timeline', desc: 'Anime timeline with episodes and airing dates.', path: '/timeline/{id}', params: [{ name: 'id', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/timeline/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/timeline/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/timeline/20")\nprint(r.json())' } },
|
||||
|
||||
// ---- FEATURE: System ----
|
||||
{ id: 'health', title: 'Health', desc: 'Health check — returns version, uptime, and environment.', path: '/health', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/health"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/health");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/health")\nprint(r.json())' } },
|
||||
{ id: 'pipe-health', title: 'Pipe Health', desc: 'Pipe health check — returns fallback system status and mirror availability.', path: '/pipe-health', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/pipe-health"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/pipe-health");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/pipe-health")\nprint(r.json())' } },
|
||||
{ id: 'stats', title: 'Stats', desc: 'API statistics — total requests, cache size, uptime.', path: '/stats', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/stats"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/stats");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/stats")\nprint(r.json())' } },
|
||||
{ id: 'providers', title: 'Providers', desc: 'List all available streaming providers.', path: '/providers', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/providers"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/providers");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/providers")\nprint(r.json())' } },
|
||||
{ id: 'openapi', title: 'OpenAPI Spec', desc: 'Raw OpenAPI 3.0.3 specification.', path: '/openapi', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/openapi"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/openapi");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/openapi")\nprint(r.json())' } },
|
||||
];
|
||||
const navEl = document.getElementById('explorerNav');
|
||||
const panelEl = document.getElementById('explorerPanel');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "MiruroAPI",
|
||||
"short_name": "MiruroAPI",
|
||||
"description": "Free REST API for anime streaming data. 18 endpoints, 12 providers, M3U8 URLs. No API key required.",
|
||||
"description": "Free REST API for anime streaming data. 46 endpoints, 12 providers, M3U8 URLs. No API key required.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"id": "/",
|
||||
|
||||
+13
-1
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "MiruroAPI",
|
||||
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
|
||||
"version": "2.0.1",
|
||||
"version": "2.3.0",
|
||||
"contact": {
|
||||
"name": "Shineii86",
|
||||
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||
@@ -422,12 +422,24 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/providers": {
|
||||
"get": {
|
||||
"tags": ["System"],
|
||||
"summary": "Available streaming providers"
|
||||
}
|
||||
},
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"tags": ["System"],
|
||||
"summary": "Health check"
|
||||
}
|
||||
},
|
||||
"/api/pipe-health": {
|
||||
"get": {
|
||||
"tags": ["System"],
|
||||
"summary": "Pipe fallback method health check"
|
||||
}
|
||||
},
|
||||
"/api/stats": {
|
||||
"get": {
|
||||
"tags": ["System"],
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@
|
||||
</ul>
|
||||
|
||||
<h2>3. Third-Party Data Sources</h2>
|
||||
<p>The Service fetches data from <code>graphql.anilist.co</code> (AniList) and <code>miruro.tv</code> (streaming providers). This data (anime titles, descriptions, images, episode information) belongs to the respective content owners. We do not claim ownership of this data.</p>
|
||||
<p>The Service fetches data from <code>graphql.anilist.co</code> (AniList) and Miruro streaming providers across multiple domains (<code>miruro.to</code>, <code>miruro.ru</code>, <code>miruro.bz</code>, <code>miruro.tv</code>). This data (anime titles, descriptions, images, episode information) belongs to the respective content owners. We do not claim ownership of this data.</p>
|
||||
<p>If you are a content owner and wish to have your data removed from this API, please open an issue on our <a href="https://github.com/Shineii86/MiruroAPI" target="_blank" rel="noopener noreferrer">GitHub repository</a>.</p>
|
||||
|
||||
<h2>4. Data Storage</h2>
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@
|
||||
<p>The Service is built and maintained for <strong>educational and learning purposes only</strong>. It demonstrates API design, GraphQL integration, and serverless deployment techniques.</p>
|
||||
|
||||
<h2>4. No Affiliation</h2>
|
||||
<p>The Service is <strong>not affiliated with, endorsed by, or connected to</strong> miruro.tv, Miruro, or any of its subsidiaries or affiliates. All trademarks and content belong to their respective owners.</p>
|
||||
<p>The Service is <strong>not affiliated with, endorsed by, or connected to</strong> Miruro or any of its subsidiaries or affiliates across multiple domains (miruro.to, miruro.ru, miruro.bz, miruro.tv). All trademarks and content belong to their respective owners.</p>
|
||||
|
||||
<h2>5. Third-Party Content</h2>
|
||||
<p>All anime data, images, descriptions, and other content displayed through the API belong to their respective copyright holders (AniList, Miruro). The Service does not host, store, or distribute any copyrighted media.</p>
|
||||
|
||||
@@ -183,9 +183,37 @@ app.get("/docs", (req, res) => {
|
||||
// RESPONSE HELPERS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Response helper — wrap data in standard success format ----
|
||||
/**
|
||||
* Wraps data in the standard API success response format.
|
||||
* All successful responses follow: { success: true, results: data }
|
||||
*
|
||||
* @param {import('express').Response} res - Express response object
|
||||
* @param {any} data - The data to wrap
|
||||
* @param {number} [status=200] - HTTP status code
|
||||
* @returns {import('express').Response} Express response with JSON body
|
||||
*
|
||||
* @example
|
||||
* jsonResponse(res, { anime: [...] });
|
||||
* // => { "success": true, "results": { "anime": [...] } }
|
||||
*/
|
||||
const jsonResponse = (res, data, status = 200) =>
|
||||
res.status(status).json({ success: true, results: data });
|
||||
|
||||
// ---- FEATURE: Error helper — wrap message in standard error format ----
|
||||
/**
|
||||
* Wraps an error message in the standard API error response format.
|
||||
* All error responses follow: { success: false, message: "..." }
|
||||
*
|
||||
* @param {import('express').Response} res - Express response object
|
||||
* @param {string} [message="Internal server error"] - Error description
|
||||
* @param {number} [status=500] - HTTP status code
|
||||
* @returns {import('express').Response} Express response with JSON error body
|
||||
*
|
||||
* @example
|
||||
* jsonError(res, "Anime not found", 404);
|
||||
* // => { "success": false, "message": "Anime not found" }
|
||||
*/
|
||||
const jsonError = (res, message = "Internal server error", status = 500) =>
|
||||
res.status(status).json({ success: false, message });
|
||||
|
||||
@@ -273,7 +301,7 @@ app.use((req, res) => {
|
||||
|
||||
if (!process.env.VERCEL) {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[${new Date().toISOString()}] MiruroAPI v2.1.4 listening at ${PORT}`);
|
||||
console.log(`[${new Date().toISOString()}] MiruroAPI v2.3.0 listening at ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+228
-280
@@ -4,13 +4,15 @@
|
||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||
*
|
||||
* @description
|
||||
* Miruro streaming pipe integration. Handles encoding/decoding
|
||||
* of the secure/pipe tunnel that Miruro uses for episode lists
|
||||
* and streaming sources. Decodes base64+gzip responses and
|
||||
* injects simplified slug-based episode IDs.
|
||||
* Miruro streaming pipe integration with self-healing fallback system.
|
||||
* Automatically tries multiple methods to reach the pipe endpoint:
|
||||
* 1. Direct request with mirror rotation
|
||||
* 2. ScraperAPI proxy (if SCRAPER_API_KEY is set)
|
||||
* 3. FlareSolverr browser proxy (if FLARESOLVERR_URL is set)
|
||||
* Decodes base64+gzip responses and injects simplified slug-based episode IDs.
|
||||
*
|
||||
* @exports
|
||||
* getEpisodes, getSources, getWatchSources
|
||||
* getEpisodes, getSources, getWatchSources, pipeHealthCheck
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
@@ -26,139 +28,255 @@ const { getCached, setCache } = require("./cache");
|
||||
// MIRURO PIPE CONFIGURATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Miruro's secure pipe endpoint URL.
|
||||
* All streaming requests go through this base64+gzip encoded tunnel.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
|
||||
const PIPE_PATH = "/api/secure/pipe";
|
||||
|
||||
const MIRURO_ORIGINS = [
|
||||
"https://www.miruro.ru",
|
||||
"https://www.miruro.to",
|
||||
"https://www.miruro.bz",
|
||||
"https://www.miruro.tv",
|
||||
];
|
||||
|
||||
const CANONICAL_ORIGIN = "https://www.miruro.to";
|
||||
|
||||
/**
|
||||
* Request headers mimicking a browser.
|
||||
* Referer is required for the pipe endpoint to accept requests.
|
||||
*
|
||||
* @type {object}
|
||||
*/
|
||||
const HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
Referer: "https://www.miruro.tv/",
|
||||
Origin: "https://www.miruro.tv",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
Accept: "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"sec-ch-ua": '"Chromium";v="137", "Not?A_Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
Referer: CANONICAL_ORIGIN + "/",
|
||||
Origin: CANONICAL_ORIGIN,
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// FALLBACK METHOD CONFIGURATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const SCRAPER_API_KEY = process.env.SCRAPER_API_KEY || "";
|
||||
const FLARESOLVERR_URL = process.env.FLARESOLVERR_URL || "";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ENCODING / DECODING UTILITIES
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Base64url encode pipe request payload ----
|
||||
/**
|
||||
* Encodes a JSON payload into the base64url format expected by the pipe endpoint.
|
||||
* Used for all pipe API requests (episodes, sources).
|
||||
*
|
||||
* @param {object} payload - The request payload to encode
|
||||
* @returns {string} Base64url-encoded string (no padding)
|
||||
*
|
||||
* @example
|
||||
* const encoded = encodePipeRequest({ path: "episodes", method: "GET", query: { anilistId: 20 } });
|
||||
*/
|
||||
const encodePipeRequest = (payload) => {
|
||||
const json = JSON.stringify(payload);
|
||||
return Buffer.from(json).toString("base64url");
|
||||
};
|
||||
|
||||
// ---- FEATURE: Decode base64+gzip pipe response ----
|
||||
/**
|
||||
* Decodes a pipe response from base64url + XOR obfuscation + gzip format.
|
||||
* The Miruro pipe returns all responses XORed with PIPE_OBF_KEY then gzipped.
|
||||
*
|
||||
* @type {Buffer}
|
||||
*/
|
||||
const PIPE_OBF_KEY = Buffer.from(
|
||||
process.env.PIPE_OBF_KEY || "71951034f8fbcf53d89db52ceb3dc22c",
|
||||
"hex"
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {string} encodedStr - The base64url-encoded response
|
||||
* @returns {object} Decoded JSON object
|
||||
* @throws {Error} If decoding or decompression fails
|
||||
*/
|
||||
const decodePipeResponse = (encodedStr) => {
|
||||
const decodePipeResponse = (encodedStr, obfHeader = null) => {
|
||||
try {
|
||||
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
||||
if (!obfHeader) return JSON.parse(encodedStr);
|
||||
|
||||
const padded =
|
||||
encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
||||
const raw = Buffer.from(padded, "base64url");
|
||||
// NOTE: XOR decode with obfuscation key before gzip decompression
|
||||
|
||||
let bytes = raw;
|
||||
if (String(obfHeader) === "2") {
|
||||
const xored = Buffer.alloc(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
xored[i] = raw[i] ^ PIPE_OBF_KEY[i % PIPE_OBF_KEY.length];
|
||||
}
|
||||
const decompressed = zlib.gunzipSync(xored);
|
||||
bytes = xored;
|
||||
}
|
||||
|
||||
const decompressed = zlib.gunzipSync(bytes);
|
||||
return JSON.parse(decompressed.toString("utf-8"));
|
||||
} catch (e) {
|
||||
throw new Error("Failed to decode pipe response: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- FEATURE: Pipe request with retry and exponential backoff ----
|
||||
/**
|
||||
* Makes a pipe API request with retry logic and exponential backoff.
|
||||
* The pipe endpoint intermittently rate-limits (444/connection reset) from datacenter IPs.
|
||||
* Retries up to 3 times with increasing delays (1s, 2s, 4s).
|
||||
*
|
||||
* @param {string} path - Pipe path ("episodes" or "sources")
|
||||
* @param {object} query - Query parameters for the pipe
|
||||
* @param {number} [maxRetries=3] - Maximum number of retries
|
||||
* @returns {Promise<object>} Decoded pipe response
|
||||
* @throws {Error} If all retries fail
|
||||
*/
|
||||
const pipeRequest = async (path, query, maxRetries = 3) => {
|
||||
const payload = { path, method: "GET", query, body: null, version: "0.2.0" };
|
||||
const encodedReq = encodePipeRequest(payload);
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// METHOD 1: DIRECT REQUEST (with mirror rotation)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const methodDirect = async (encodedReq) => {
|
||||
const maxRetries = 4;
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const origin = MIRURO_ORIGINS[attempt % MIRURO_ORIGINS.length];
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
||||
const res = await axios.get(`${origin}${PIPE_PATH}?e=${encodedReq}`, {
|
||||
headers: HEADERS,
|
||||
timeout: 20000,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
|
||||
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
|
||||
return decodePipeResponse(res.text || res.data);
|
||||
if (res.status !== 200)
|
||||
throw new Error(`Pipe request failed: ${res.status}`);
|
||||
|
||||
const obf = res.headers["x-obfuscated"];
|
||||
return { data: decodePipeResponse(res.data, obf), method: "direct" };
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
const status = e.response?.status;
|
||||
// NOTE: Don't retry on non-retryable errors (4xx except 444)
|
||||
if (status && status >= 400 && status < 500 && status !== 444) {
|
||||
|
||||
if (
|
||||
status &&
|
||||
status >= 400 &&
|
||||
status < 500 &&
|
||||
status !== 444 &&
|
||||
status !== 403
|
||||
) {
|
||||
throw new Error(`Pipe request failed with status ${status}`);
|
||||
}
|
||||
// NOTE: Exponential backoff: 1s, 2s, 4s
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError || new Error("Pipe request failed after all retries");
|
||||
throw lastError || new Error("Direct request failed after all retries");
|
||||
};
|
||||
|
||||
// ---- FEATURE: Subtitle proxy for CORS-safe browser access ----
|
||||
/**
|
||||
* CDN streams (mt.nekostream.site) have CORS * — browsers load them directly.
|
||||
* Subtitles use third-party CDNs that may block cross-origin — proxy those.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// METHOD 2: SCRAPERAPI PROXY
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const methodScraperAPI = async (encodedReq) => {
|
||||
if (!SCRAPER_API_KEY) throw new Error("SCRAPER_API_KEY not configured");
|
||||
|
||||
const targetUrl = `${MIRURO_ORIGINS[0]}${PIPE_PATH}?e=${encodedReq}`;
|
||||
const scraperUrl = `https://api.scraperapi.com/?api_key=${SCRAPER_API_KEY}&premium=true&url=${encodeURIComponent(targetUrl)}`;
|
||||
|
||||
const res = await axios.get(scraperUrl, {
|
||||
headers: {
|
||||
"User-Agent": HEADERS["User-Agent"],
|
||||
},
|
||||
timeout: 60000,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
|
||||
if (res.status !== 200)
|
||||
throw new Error(`ScraperAPI request failed: ${res.status}`);
|
||||
|
||||
const obf = res.headers["x-obfuscated"];
|
||||
return { data: decodePipeResponse(res.data, obf), method: "scraperapi" };
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// METHOD 3: FLARESOLVERR BROWSER PROXY
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const methodFlareSolverr = async (encodedReq) => {
|
||||
if (!FLARESOLVERR_URL) throw new Error("FLARESOLVERR_URL not configured");
|
||||
|
||||
const targetUrl = `${MIRURO_ORIGINS[0]}${PIPE_PATH}?e=${encodedReq}`;
|
||||
|
||||
const res = await axios.post(
|
||||
`${FLARESOLVERR_URL.replace(/\/$/, "")}/v1`,
|
||||
{
|
||||
cmd: "request.get",
|
||||
url: targetUrl,
|
||||
maxTimeout: 60000,
|
||||
},
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 70000,
|
||||
}
|
||||
);
|
||||
|
||||
if (res.status !== 200)
|
||||
throw new Error(`FlareSolverr request failed: ${res.status}`);
|
||||
|
||||
const solution = res.data?.solution;
|
||||
if (!solution?.response)
|
||||
throw new Error("FlareSolverr returned no response");
|
||||
|
||||
const body = solution.response;
|
||||
const obf = solution.headers?.["x-obfuscated"] || null;
|
||||
|
||||
return { data: decodePipeResponse(body, obf), method: "flaresolverr" };
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// SELF-HEALING PIPE REQUEST (tries all methods in order)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const METHODS = [
|
||||
{ name: "direct", fn: methodDirect },
|
||||
{ name: "scraperapi", fn: methodScraperAPI, requires: () => !!SCRAPER_API_KEY },
|
||||
{ name: "flaresolverr", fn: methodFlareSolverr, requires: () => !!FLARESOLVERR_URL },
|
||||
];
|
||||
|
||||
const pipeRequest = async (path, query) => {
|
||||
const payload = { path, method: "GET", query, body: null };
|
||||
const encodedReq = encodePipeRequest(payload);
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const method of METHODS) {
|
||||
if (method.requires && !method.requires()) continue;
|
||||
|
||||
try {
|
||||
const result = await method.fn(encodedReq);
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
errors.push({ method: method.name, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`All pipe methods failed: ${errors.map((e) => `${e.method}(${e.error})`).join(", ")}`
|
||||
);
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PIPE HEALTH CHECK (for /api/pipe-health)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const pipeHealthCheck = async () => {
|
||||
const results = {};
|
||||
const testPayload = { path: "episodes", method: "GET", query: { anilistId: 20 }, body: null };
|
||||
const encodedReq = encodePipeRequest(testPayload);
|
||||
|
||||
for (const method of METHODS) {
|
||||
if (method.requires && !method.requires()) {
|
||||
results[method.name] = { status: "skipped", reason: "not configured" };
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
await method.fn(encodedReq);
|
||||
results[method.name] = {
|
||||
status: "ok",
|
||||
latency: Date.now() - start + "ms",
|
||||
};
|
||||
} catch (e) {
|
||||
results[method.name] = {
|
||||
status: "failed",
|
||||
error: e.message,
|
||||
latency: Date.now() - start + "ms",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PROXY & ENCODE HELPERS (unchanged)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const PROXY_BASE = "/api/proxy?url=";
|
||||
|
||||
/**
|
||||
* Proxies a URL through /api/proxy for CORS bypass.
|
||||
*
|
||||
* @param {string} url - Raw URL
|
||||
* @param {string} [referer] - Optional referer to pass
|
||||
* @returns {string} Proxied URL
|
||||
*/
|
||||
const proxyUrl = (url, referer) => {
|
||||
if (!url || !url.startsWith("http")) return url;
|
||||
try {
|
||||
@@ -169,55 +287,27 @@ const proxyUrl = (url, referer) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Streams are returned as-is (raw CDN URLs).
|
||||
* The browser loads them directly — CDN has CORS * and serves real content to residential IPs.
|
||||
* Server-side requests get decoy PNG images, so proxying through /api/proxy defeats the purpose.
|
||||
*
|
||||
* @param {object} sources - Decoded sources with streams array
|
||||
* @returns {object} Sources with raw stream URLs
|
||||
*/
|
||||
const proxyStreams = (sources) => {
|
||||
// NOTE: Intentionally return raw CDN URLs — the CDN serves real video to browsers
|
||||
// but decoy PNG images to server-side requests. The browser's residential IP is required.
|
||||
return sources;
|
||||
};
|
||||
const proxyStreams = (sources) => sources;
|
||||
|
||||
/**
|
||||
* Proxies subtitle URLs through /api/proxy for CORS bypass.
|
||||
* Subtitles come from third-party CDNs that may not have CORS headers.
|
||||
*
|
||||
* @param {object} sources - Decoded sources with subtitles array
|
||||
* @returns {object} Sources with proxied subtitle URLs
|
||||
*/
|
||||
const proxySubtitles = (sources) => {
|
||||
if (!sources?.subtitles) return sources;
|
||||
sources.subtitles = sources.subtitles.map((s) => {
|
||||
const raw = s.url || s.file;
|
||||
if (!raw || !raw.startsWith("http")) return s;
|
||||
const proxied = proxyUrl(raw, "https://www.miruro.tv/");
|
||||
const proxied = proxyUrl(raw, "https://www.miruro.to/");
|
||||
return { ...s, url: proxied, file: proxied };
|
||||
});
|
||||
return sources;
|
||||
};
|
||||
|
||||
// ---- FEATURE: Decode base64 episode ID to plain text ----
|
||||
/**
|
||||
* Decodes a base64-encoded episode ID back to plain text.
|
||||
* Episode IDs from the pipe are base64-encoded with ":" separators.
|
||||
*
|
||||
* @param {string} encodedId - The base64url-encoded episode ID
|
||||
* @returns {string} Decoded plain text ID, or original if decoding fails
|
||||
*
|
||||
* @example
|
||||
* const decoded = translateId("YW5pbWVwaGU6MjA6c3ViOjE=");
|
||||
* // returns "animepahe:20:sub:1"
|
||||
*/
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// EPISODE ID TRANSLATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const translateId = (encodedId) => {
|
||||
try {
|
||||
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
|
||||
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
|
||||
// NOTE: Only return decoded value if it looks like a valid ID (contains ":")
|
||||
if (decoded.includes(":")) return decoded;
|
||||
return encodedId;
|
||||
} catch {
|
||||
@@ -225,16 +315,6 @@ const translateId = (encodedId) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- FEATURE: Recursively decode all base64 IDs in a nested object ----
|
||||
/**
|
||||
* Walks a JSON structure and decodes any base64 "id" fields.
|
||||
* Saves the original base64 value in a `rawPipeId` field on episode objects
|
||||
* so the pipe sources endpoint can be called directly from the client.
|
||||
* Returns a deep clone — does not mutate the original object.
|
||||
*
|
||||
* @param {object|Array} obj - The object or array to process
|
||||
* @returns {object|Array} A new object with decoded IDs
|
||||
*/
|
||||
const deepTranslate = (obj) => {
|
||||
if (obj && typeof obj === "object") {
|
||||
if (Array.isArray(obj)) {
|
||||
@@ -260,21 +340,6 @@ const deepTranslate = (obj) => {
|
||||
// EPISODE ID INJECTION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Transform episode IDs into simplified path-based slugs ----
|
||||
/**
|
||||
* Converts raw pipe episode IDs into clean slug format:
|
||||
* watch/{provider}/{anilistId}/{category}/{prefix}-{number}
|
||||
*
|
||||
* This makes episode IDs human-readable and URL-safe for the /watch endpoint.
|
||||
*
|
||||
* @param {object} data - Raw pipe response with providers
|
||||
* @param {number} anilistId - The AniList anime ID
|
||||
* @returns {object} Modified data with slug-based episode IDs
|
||||
*
|
||||
* @example
|
||||
* // Before: "animepahe:20:sub:1"
|
||||
* // After: "watch/kiwi/20/sub/animepahe-1"
|
||||
*/
|
||||
const injectSourceSlugs = (data, anilistId) => {
|
||||
const providers = data.providers || {};
|
||||
|
||||
@@ -284,7 +349,6 @@ const injectSourceSlugs = (data, anilistId) => {
|
||||
let episodes = provData.episodes;
|
||||
if (!episodes) continue;
|
||||
|
||||
// NOTE: Some providers return a flat array — wrap it in { sub: [...] }
|
||||
if (Array.isArray(episodes)) {
|
||||
provData.episodes = { sub: episodes };
|
||||
episodes = provData.episodes;
|
||||
@@ -295,8 +359,6 @@ const injectSourceSlugs = (data, anilistId) => {
|
||||
|
||||
for (const ep of epList) {
|
||||
if (ep.id && ep.number) {
|
||||
// NOTE: rawPipeId is already set by deepTranslate — preserve original base64
|
||||
// Take only the prefix before ":" for the slug
|
||||
const prefix = ep.id.includes(":") ? ep.id.split(":")[0] : ep.id;
|
||||
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
||||
}
|
||||
@@ -311,15 +373,6 @@ const injectSourceSlugs = (data, anilistId) => {
|
||||
// PIPE API FUNCTIONS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ----
|
||||
/**
|
||||
* Internal helper to fetch and decode raw episode data from the pipe.
|
||||
* Uses pipeRequest with retry logic and caches results for 5 minutes.
|
||||
*
|
||||
* @param {number} anilistId - The AniList anime ID
|
||||
* @returns {Promise<object>} Decoded episode data with providers
|
||||
* @throws {Error} If the pipe request fails
|
||||
*/
|
||||
const fetchRawEpisodes = async (anilistId) => {
|
||||
const cacheKey = `pipe:episodes:${anilistId}`;
|
||||
const cached = getCached(cacheKey);
|
||||
@@ -331,23 +384,10 @@ const fetchRawEpisodes = async (anilistId) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---- FEATURE: Get episodes with slug-based IDs ----
|
||||
/**
|
||||
* Fetches the full episode list for an anime from all providers.
|
||||
* Returns episodes with simplified slug-based IDs for the /watch endpoint.
|
||||
*
|
||||
* @param {number} anilistId - The AniList anime ID
|
||||
* @returns {Promise<object>} Episode data with providers and mappings
|
||||
*
|
||||
* @example
|
||||
* const episodes = await getEpisodes(20); // Naruto
|
||||
* console.log(Object.keys(episodes.providers)); // ["kiwi", "bee", "bonk", ...]
|
||||
*/
|
||||
const getEpisodes = async (anilistId) => {
|
||||
const data = await fetchRawEpisodes(anilistId);
|
||||
const result = injectSourceSlugs(data, anilistId);
|
||||
|
||||
// NOTE: Ensure mappings field is present (like Walter's API)
|
||||
if (!result.mappings) {
|
||||
result.mappings = { anilistId };
|
||||
if (result.malId) result.mappings.malId = result.malId;
|
||||
@@ -357,22 +397,6 @@ const getEpisodes = async (anilistId) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---- FEATURE: Get streaming sources (detailed endpoint) ----
|
||||
/**
|
||||
* Fetches M3U8 streaming sources for a specific episode.
|
||||
* Uses pipeRequest with retry logic and caches results for 10 minutes.
|
||||
* Stream URLs are ephemeral but CDNs serve the same content for hours.
|
||||
*
|
||||
* @param {string} episodeId - The raw episode ID from the pipe
|
||||
* @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk")
|
||||
* @param {number} anilistId - The AniList anime ID
|
||||
* @param {string} [category="sub"] - Audio category ("sub" or "dub")
|
||||
* @returns {Promise<object>} Streaming sources with M3U8 URLs, subtitles, timestamps
|
||||
*
|
||||
* @example
|
||||
* const sources = await getSources("animepahe:20:sub:1", "kiwi", 20, "sub");
|
||||
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
|
||||
*/
|
||||
const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
||||
const cacheKey = `pipe:sources:${episodeId}:${provider}:${category}`;
|
||||
const cached = getCached(cacheKey);
|
||||
@@ -391,22 +415,6 @@ const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
|
||||
/**
|
||||
* Resolves a slug-based episode ID and fetches its streaming sources.
|
||||
* This is the recommended endpoint — just pass the slug from /episodes.
|
||||
*
|
||||
* @param {string} provider - Provider name (e.g., "kiwi", "bee")
|
||||
* @param {number} anilistId - The AniList anime ID
|
||||
* @param {string} category - Audio category ("sub" or "dub")
|
||||
* @param {string} slug - Episode slug (e.g., "animepahe-1")
|
||||
* @returns {Promise<object>} Streaming sources with M3U8 URLs, subtitles, timestamps
|
||||
* @throws {Error} If provider or episode slug is not found
|
||||
*
|
||||
* @example
|
||||
* const sources = await getWatchSources("kiwi", 20, "sub", "animepahe-1");
|
||||
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
|
||||
*/
|
||||
const getWatchSources = async (provider, anilistId, category, slug) => {
|
||||
const data = await fetchRawEpisodes(anilistId);
|
||||
const provData = (data.providers || {})[provider];
|
||||
@@ -416,57 +424,41 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
|
||||
const episodes = provData.episodes?.[category] || [];
|
||||
let targetId = null;
|
||||
|
||||
// NOTE: Handle both raw pipe IDs ("animedao:witch-hat-atelier:1") and slugged IDs
|
||||
// ("watch/bonk/147105/sub/animedao-1") which may exist if cache was mutated by injectSourceSlugs.
|
||||
// Match slug against: last path segment (slugged) OR prefix-{number} (raw).
|
||||
for (const ep of episodes) {
|
||||
const rawId = ep.id || "";
|
||||
let match = false;
|
||||
|
||||
// Case 1: Slugged ID — "watch/{provider}/{anilistId}/{category}/{prefix}-{number}"
|
||||
if (rawId.includes("/")) {
|
||||
const slugSuffix = rawId.split("/").pop();
|
||||
match = slugSuffix === slug;
|
||||
}
|
||||
// Case 2: Raw pipe ID — "animedao:witch-hat-atelier:1"
|
||||
else if (rawId.includes(":")) {
|
||||
} else if (rawId.includes(":")) {
|
||||
const prefix = rawId.split(":")[0];
|
||||
match = `${prefix}-${ep.number}` === slug;
|
||||
}
|
||||
|
||||
if (match) {
|
||||
// Use rawPipeId if available (decoded pipe format), otherwise use the ep.id
|
||||
targetId = ep.rawPipeId ? translateId(ep.rawPipeId) : rawId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
|
||||
if (!targetId)
|
||||
throw new Error(
|
||||
`Episode slug '${slug}' not found for provider ${provider}`
|
||||
);
|
||||
return getSources(targetId, provider, anilistId, category);
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// SUBTITLE EXTRACTION
|
||||
// SUBTITLE & QUALITY UTILITIES
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Extract subtitle URLs from streaming sources ----
|
||||
/**
|
||||
* Extracts and formats subtitle URLs from the pipe sources response.
|
||||
* Handles multiple subtitle formats from different providers.
|
||||
*
|
||||
* Provider formats:
|
||||
* bonk: { file, label, kind, default, language, format, encoding }
|
||||
* others: { url, name, lang, format }
|
||||
*
|
||||
* @param {object} sources - The sources response from getSources/getWatchSources
|
||||
* @returns {Array} Array of subtitle objects { url, label, language, kind, format, isDefault }
|
||||
*/
|
||||
const extractSubtitles = (sources) => {
|
||||
const raw = sources.subtitles || sources.captions || [];
|
||||
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw.map((sub) => ({
|
||||
return raw
|
||||
.map((sub) => ({
|
||||
url: sub.url || sub.file || null,
|
||||
label: sub.label || sub.name || sub.language || "Unknown",
|
||||
language: sub.language || sub.lang || sub.label || "en",
|
||||
@@ -474,17 +466,10 @@ const extractSubtitles = (sources) => {
|
||||
format: sub.format || "vtt",
|
||||
encoding: sub.encoding || "utf-8",
|
||||
isDefault: sub.default || false,
|
||||
})).filter((sub) => sub.url);
|
||||
}))
|
||||
.filter((sub) => sub.url);
|
||||
};
|
||||
|
||||
// ---- FEATURE: Extract skip timestamps (OP/ED) from sources ----
|
||||
/**
|
||||
* Extracts skip timestamps (OP/ED) from the pipe sources response.
|
||||
* Only available for providers with skip_times capability (e.g., bonk).
|
||||
*
|
||||
* @param {object} sources - The sources response from getSources/getWatchSources
|
||||
* @returns {object|null} Skip timestamps object or null
|
||||
*/
|
||||
const extractSkipTimes = (sources) => {
|
||||
const skipTimes = sources.skipTimes || sources.skip || null;
|
||||
if (!skipTimes || typeof skipTimes !== "object") return null;
|
||||
@@ -496,34 +481,23 @@ const extractSkipTimes = (sources) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// QUALITY FALLBACK
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Get best available stream with quality fallback ----
|
||||
/**
|
||||
* Returns the best available HLS stream with automatic quality fallback.
|
||||
* Tries 1080p → 720p → 480p → 360p in order.
|
||||
* Falls back to first HLS stream if no quality match.
|
||||
*
|
||||
* @param {object} sources - The sources response
|
||||
* @param {string} [preferredQuality="1080p"] - Preferred quality
|
||||
* @returns {object|null} Best available stream object or null
|
||||
*/
|
||||
const getBestStream = (sources, preferredQuality = "1080p") => {
|
||||
const streams = (sources.streams || []).filter((s) => s.url);
|
||||
if (streams.length === 0) return null;
|
||||
|
||||
const hlsStreams = streams.filter((s) => s.type === "hls" || !s.type || s.url?.endsWith(".m3u8") || s.url?.includes("m3u8"));
|
||||
const dashStreams = streams.filter((s) => s.type === "dash" || s.url?.endsWith(".mpd"));
|
||||
const hlsStreams = streams.filter(
|
||||
(s) =>
|
||||
s.type === "hls" ||
|
||||
!s.type ||
|
||||
s.url?.endsWith(".m3u8") ||
|
||||
s.url?.includes("m3u8")
|
||||
);
|
||||
|
||||
const usable = hlsStreams.length > 0 ? hlsStreams : streams;
|
||||
|
||||
const qualityOrder = ["1080p", "720p", "480p", "360p"];
|
||||
const startIdx = qualityOrder.indexOf(preferredQuality);
|
||||
const ordered = startIdx >= 0
|
||||
? qualityOrder.slice(startIdx)
|
||||
: qualityOrder;
|
||||
const ordered = startIdx >= 0 ? qualityOrder.slice(startIdx) : qualityOrder;
|
||||
|
||||
for (const q of ordered) {
|
||||
const match = usable.find((s) => {
|
||||
@@ -540,20 +514,9 @@ const getBestStream = (sources, preferredQuality = "1080p") => {
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// DOWNLOAD URL
|
||||
// DOWNLOAD & BATCH
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Get download URL for an episode ----
|
||||
/**
|
||||
* Fetches the download URL for a specific episode from the pipe.
|
||||
* Returns the direct download link, subtitles, and best stream if available.
|
||||
*
|
||||
* @param {string} provider - Provider name
|
||||
* @param {number} anilistId - The AniList anime ID
|
||||
* @param {string} category - Audio category ("sub" or "dub")
|
||||
* @param {string} slug - Episode slug (e.g., "animepahe-1")
|
||||
* @returns {Promise<object>} Object with download URL, subtitles, best stream, and metadata
|
||||
*/
|
||||
const getDownloadUrl = async (provider, anilistId, category, slug) => {
|
||||
const sources = await getWatchSources(provider, anilistId, category, slug);
|
||||
const subtitles = extractSubtitles(sources);
|
||||
@@ -572,21 +535,6 @@ const getDownloadUrl = async (provider, anilistId, category, slug) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// BATCH EPISODES
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Get sources for multiple episodes at once ----
|
||||
/**
|
||||
* Fetches streaming sources for multiple episodes in parallel.
|
||||
* Useful for preloading next episodes or batch downloading.
|
||||
*
|
||||
* @param {string} provider - Provider name
|
||||
* @param {number} anilistId - The AniList anime ID
|
||||
* @param {string} category - Audio category ("sub" or "dub")
|
||||
* @param {string[]} slugs - Array of episode slugs (e.g., ["animepahe-1", "animepahe-2"])
|
||||
* @returns {Promise<object>} Object mapping slug → sources (with errors for failed ones)
|
||||
*/
|
||||
const getBatchSources = async (provider, anilistId, category, slugs) => {
|
||||
const results = {};
|
||||
|
||||
@@ -610,7 +558,6 @@ const getBatchSources = async (provider, anilistId, category, slugs) => {
|
||||
}
|
||||
};
|
||||
|
||||
// NOTE: Fetch up to 5 episodes in parallel to avoid rate limiting
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < slugs.length; i += batchSize) {
|
||||
const batch = slugs.slice(i, i + batchSize);
|
||||
@@ -642,6 +589,7 @@ module.exports = {
|
||||
translateId,
|
||||
deepTranslate,
|
||||
injectSourceSlugs,
|
||||
pipeHealthCheck,
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════ END: pipe.js
|
||||
|
||||
+37
-4
@@ -20,6 +20,7 @@
|
||||
const express = require("express");
|
||||
const anilist = require("../helpers/anilist");
|
||||
const pipe = require("../helpers/pipe");
|
||||
const { pipeHealthCheck } = require("../helpers/pipe");
|
||||
const { getCached, setCache } = require("../helpers/cache");
|
||||
const { addCreatorInfo } = require("../middleware/creatorInfo");
|
||||
|
||||
@@ -1123,7 +1124,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
success: true,
|
||||
results: {
|
||||
status: "healthy",
|
||||
version: "2.1.4",
|
||||
version: "2.3.0",
|
||||
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
||||
uptimeSeconds: uptime,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -1137,12 +1138,44 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
size: require("../helpers/cache").getCacheSize(),
|
||||
maxSize: 100,
|
||||
},
|
||||
endpoints: 36,
|
||||
endpoints: 46,
|
||||
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ---- FEATURE: Pipe health check (tests all fallback methods) ----
|
||||
router.get("/pipe-health", async (req, res) => {
|
||||
try {
|
||||
const results = await pipeHealthCheck();
|
||||
const working = Object.entries(results).filter(
|
||||
([, v]) => v.status === "ok"
|
||||
);
|
||||
const failed = Object.entries(results).filter(
|
||||
([, v]) => v.status === "failed"
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results: {
|
||||
methods: results,
|
||||
working: working.length,
|
||||
failed: failed.length,
|
||||
total: Object.keys(results).length,
|
||||
recommendation:
|
||||
working.length === 0
|
||||
? "All methods blocked. Check Cloudflare status or add SCRAPER_API_KEY / FLARESOLVERR_URL."
|
||||
: working[0]
|
||||
? `Preferred: ${working[0][0]} (${working[0][1].latency})`
|
||||
: "No methods available",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
jsonError(res, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- FEATURE: Cache & API statistics ----
|
||||
router.get("/stats", (req, res) => {
|
||||
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
||||
@@ -1165,7 +1198,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
currentSize: require("../helpers/cache").getCacheSize(),
|
||||
description: "Map-based cache with TTL expiration and FIFO eviction",
|
||||
},
|
||||
endpoints: 36,
|
||||
endpoints: 46,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
@@ -1178,7 +1211,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
||||
info: {
|
||||
title: "MiruroAPI",
|
||||
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
|
||||
version: "2.1.4",
|
||||
version: "2.3.0",
|
||||
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
|
||||
},
|
||||
servers: [
|
||||
|
||||
@@ -4,30 +4,45 @@
|
||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||
*
|
||||
* @description
|
||||
* Integration test suite for MiruroAPI v2.1.0 endpoints.
|
||||
* Tests all 36 endpoints for correct response format.
|
||||
* Integration test suite for MiruroAPI v2.3.0 endpoints.
|
||||
* Tests all 45 endpoints for correct response format.
|
||||
* Runs sequentially to avoid rate limiting.
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// TEST CONFIGURATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Base URL for all API requests.
|
||||
* Defaults to production; override with API_URL env var.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const BASE = process.env.API_URL || "https://mirurotvapi.vercel.app/api";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// TEST DEFINITIONS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const tests = [
|
||||
// System
|
||||
// ---- FEATURE: System endpoints ----
|
||||
{ name: "Health", url: "/health" },
|
||||
{ name: "Stats", url: "/stats" },
|
||||
{ name: "OpenAPI", url: "/openapi" },
|
||||
{ name: "Providers", url: "/providers" },
|
||||
|
||||
// Search & Discovery
|
||||
// ---- FEATURE: Search & discovery endpoints ----
|
||||
{ name: "Search", url: "/search?query=naruto" },
|
||||
{ name: "Suggestions", url: "/suggestions?query=naruto" },
|
||||
{ name: "Filter", url: "/filter?genre=Action&per_page=1" },
|
||||
{ name: "Multi-Search", url: "/multi-search?q=naruto&genre=Action" },
|
||||
|
||||
// Collections
|
||||
// ---- FEATURE: Collection endpoints ----
|
||||
{ name: "Trending", url: "/trending?per_page=1" },
|
||||
{ name: "Trending Daily", url: "/trending/daily" },
|
||||
{ name: "Trending Weekly", url: "/trending/weekly" },
|
||||
@@ -40,11 +55,11 @@ const tests = [
|
||||
{ name: "Random", url: "/random" },
|
||||
{ name: "Upcoming Countdown", url: "/upcoming/countdown" },
|
||||
|
||||
// Seasonal & Studio
|
||||
// ---- FEATURE: Seasonal & studio endpoints ----
|
||||
{ name: "Seasonal", url: "/season/2025/spring" },
|
||||
{ name: "Studio", url: "/studio/MAPPA" },
|
||||
|
||||
// Genres & Tags
|
||||
// ---- FEATURE: Metadata endpoints ----
|
||||
{ name: "Genres", url: "/genres" },
|
||||
{ name: "Tags", url: "/tags" },
|
||||
{ name: "Genre", url: "/genre/Action" },
|
||||
@@ -52,35 +67,57 @@ const tests = [
|
||||
{ name: "Status", url: "/status/RELEASING" },
|
||||
{ name: "Format", url: "/format/MOVIE" },
|
||||
|
||||
// Character & Staff
|
||||
// ---- FEATURE: Character & staff endpoints ----
|
||||
{ name: "Character", url: "/character/1" },
|
||||
{ name: "Staff", url: "/staff/1" },
|
||||
{ name: "Characters Search", url: "/characters?query=kaneki" },
|
||||
{ name: "Staff Search", url: "/staff?query=oda" },
|
||||
|
||||
// Anime Details
|
||||
// ---- FEATURE: Anime detail endpoints ----
|
||||
{ name: "Info", url: "/info/20" },
|
||||
{ name: "Anime Characters", url: "/anime/20/characters" },
|
||||
{ name: "Relations", url: "/anime/20/relations" },
|
||||
{ name: "Recommendations", url: "/anime/20/recommendations" },
|
||||
|
||||
// Streaming
|
||||
// ---- FEATURE: Streaming endpoints ----
|
||||
{ name: "Episodes", url: "/episodes/20" },
|
||||
{ name: "Batch Episodes", url: "/episodes/20/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2" },
|
||||
{ name: "Stream", url: "/stream?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
|
||||
{ name: "Download", url: "/download?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
|
||||
{ name: "Watch", url: "/watch/kiwi/20/sub/animepahe-1" },
|
||||
|
||||
// Utility
|
||||
// ---- FEATURE: Utility endpoints ----
|
||||
{ name: "Compare", url: "/compare/20/21" },
|
||||
{ name: "Genre Stats", url: "/stats/genre" },
|
||||
{ name: "Calendar", url: "/calendar" },
|
||||
{ name: "Timeline", url: "/timeline/20" },
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// TEST RUNNER
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Global pass/fail counters for test results.
|
||||
*
|
||||
* @type {{ passed: number, failed: number }}
|
||||
*/
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// ---- FEATURE: Single test executor ----
|
||||
/**
|
||||
* Runs a single API test against the specified endpoint.
|
||||
* Validates HTTP 200 status and standard response format.
|
||||
*
|
||||
* @param {{ name: string, url: string }} test - Test definition object
|
||||
* @param {string} test.name - Human-readable test name for output
|
||||
* @param {string} test.url - Relative URL path (appended to BASE)
|
||||
* @returns {Promise<void>} Logs result to console, increments counters
|
||||
*
|
||||
* @example
|
||||
* await runTest({ name: "Health", url: "/health" });
|
||||
*/
|
||||
async function runTest(test) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}${test.url}`);
|
||||
@@ -103,9 +140,21 @@ async function runTest(test) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FEATURE: Full test suite runner ----
|
||||
/**
|
||||
* Runs all test definitions sequentially to avoid rate limiting.
|
||||
* Prints summary and exits with code 1 if any tests failed.
|
||||
*
|
||||
* @returns {Promise<void>} Exits process with 0 (pass) or 1 (fail)
|
||||
*
|
||||
* @example
|
||||
* node test.js # Run against production
|
||||
* API_URL=http://localhost:3000/api node test.js # Run against local
|
||||
*/
|
||||
async function runAll() {
|
||||
console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`);
|
||||
console.log(`\n🧪 MiruroAPI v2.3.0 — Running ${tests.length} tests...\n`);
|
||||
|
||||
// NOTE: Sequential execution — parallel requests would hit rate limiter
|
||||
for (const test of tests) {
|
||||
await runTest(test);
|
||||
}
|
||||
@@ -114,4 +163,10 @@ async function runAll() {
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ENTRY POINT
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
runAll();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════ END: test.js
|
||||
|
||||
+24
@@ -8,6 +8,10 @@
|
||||
{
|
||||
"src": "api/proxy.js",
|
||||
"use": "@vercel/node"
|
||||
},
|
||||
{
|
||||
"src": "public/**",
|
||||
"use": "@vercel/static"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
@@ -18,6 +22,26 @@
|
||||
{
|
||||
"src": "/api/(.*)",
|
||||
"dest": "/api/index.js"
|
||||
},
|
||||
{
|
||||
"src": "/docs",
|
||||
"dest": "/public/docs.html"
|
||||
},
|
||||
{
|
||||
"src": "/tos",
|
||||
"dest": "/public/tos.html"
|
||||
},
|
||||
{
|
||||
"src": "/privacy",
|
||||
"dest": "/public/privacy.html"
|
||||
},
|
||||
{
|
||||
"src": "/openapi.json",
|
||||
"dest": "/public/openapi.json"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "/public/$1"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* MiruroAPI — worker.js (Cloudflare Worker Edition)
|
||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||
*
|
||||
* @description
|
||||
* Cloudflare Worker version of MiruroAPI. Runs entirely on
|
||||
* Cloudflare's edge network — pipe requests bypass bot detection
|
||||
* automatically since they route through Cloudflare's internal network.
|
||||
*
|
||||
* Key advantages over Vercel deployment:
|
||||
* - Pipe requests NOT blocked by Cloudflare (edge → edge)
|
||||
* - Zero cold starts (V8 isolates)
|
||||
* - Free tier: 100K requests/day
|
||||
* - No npm dependencies required (native fetch + DecompressionStream)
|
||||
*
|
||||
* @endpoints
|
||||
* System: /api/health
|
||||
* Search: /api/search, /api/suggestions, /api/filter
|
||||
* Collections: /api/trending, /api/popular, /api/top, /api/random
|
||||
* Details: /api/info/:id, /api/anime/:id/characters, /api/anime/:id/relations
|
||||
* Streaming: /api/episodes/:id, /api/watch/:provider/:anilistId/:category/:slug
|
||||
* Metadata: /api/genres
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// CONFIGURATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* AniList GraphQL endpoint.
|
||||
* All metadata (search, trending, info, characters) comes from here.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const ANILIST_URL = "https://graphql.anilist.co";
|
||||
|
||||
/**
|
||||
* Miruro pipe origins, ordered by reliability.
|
||||
* The worker tries each mirror in sequence until one succeeds.
|
||||
* Running on Cloudflare edge means these requests go through
|
||||
* Cloudflare's internal network — no bot detection triggered.
|
||||
*
|
||||
* @type {string[]}
|
||||
*/
|
||||
const MIRURO_ORIGINS = [
|
||||
"https://www.miruro.to",
|
||||
"https://www.miruro.ru",
|
||||
"https://www.miruro.bz",
|
||||
"https://www.miruro.tv",
|
||||
];
|
||||
|
||||
/**
|
||||
* Canonical Miruro origin for referer/origin headers.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const CANONICAL_ORIGIN = "https://www.miruro.to";
|
||||
|
||||
/**
|
||||
* Pipe API path — appended to each origin.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
const PIPE_PATH = "/api/secure/pipe";
|
||||
|
||||
/**
|
||||
* XOR obfuscation key for pipe response decoding (scheme 2).
|
||||
* 16-byte hex key used when x-obfuscated header = "2".
|
||||
*
|
||||
* @type {Uint8Array}
|
||||
*/
|
||||
const PIPE_OBF_KEY = new Uint8Array([
|
||||
0x71, 0x95, 0x10, 0x34, 0xf8, 0xfb, 0xcf, 0x53,
|
||||
0xd8, 0x9d, 0xb5, 0x2c, 0xeb, 0x3d, 0xc2, 0x2c,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Browser-accurate request headers for Cloudflare fingerprinting.
|
||||
* These headers must match what a real Chrome browser sends —
|
||||
* missing or incorrect headers trigger Cloudflare's bot detection.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
const HEADERS = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-site": "none",
|
||||
"sec-fetch-user": "?1",
|
||||
Referer: `${CANONICAL_ORIGIN}/`,
|
||||
Origin: CANONICAL_ORIGIN,
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// CORS & RESPONSE UTILITIES
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Standard CORS headers applied to every response.
|
||||
* Allows any origin to call the API (open CORS policy).
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
const CORS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a JSON response with CORS headers.
|
||||
*
|
||||
* @param {*} data - Data to serialize as JSON
|
||||
* @param {number} [status=200] - HTTP status code
|
||||
* @returns {Response} Cloudflare Worker Response object
|
||||
*/
|
||||
function json(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a standardized error response.
|
||||
* All errors follow the same shape: { success, message, timestamp }.
|
||||
*
|
||||
* @param {string} msg - Error description
|
||||
* @param {number} [status=500] - HTTP status code
|
||||
* @returns {Response} Error response with CORS headers
|
||||
*/
|
||||
function error(msg, status = 500) {
|
||||
return json(
|
||||
{ success: false, message: msg, timestamp: new Date().toISOString() },
|
||||
status
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ANILIST GRAPHQL CLIENT
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Executes a GraphQL query against the AniList API.
|
||||
*
|
||||
* @param {string} query - GraphQL query string
|
||||
* @ @param {Object} [variables={}] - Query variables
|
||||
* @returns {Promise<Object>} Parsed data from the GraphQL response
|
||||
* @throws {Error} If the GraphQL response contains errors
|
||||
*
|
||||
* @example
|
||||
* const data = await anilistQuery(`{ GenreCollection }`);
|
||||
* console.log(data.GenreCollection); // ["Action", "Comedy", ...]
|
||||
*/
|
||||
async function anilistQuery(query, variables = {}) {
|
||||
const res = await fetch(ANILIST_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (json.errors) {
|
||||
throw new Error(json.errors[0]?.message || "AniList query failed");
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PIPE ENCODE / DECODE
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Encodes a pipe request payload to base64url.
|
||||
* The pipe endpoint expects the request as base64url-encoded JSON.
|
||||
*
|
||||
* @param {Object} payload - Request payload { path, method, query, body }
|
||||
* @returns {string} Base64url-encoded string (no padding)
|
||||
*
|
||||
* @example
|
||||
* encodePipeRequest({ path: "/api/episodes", method: "GET", query: { id: 20 } })
|
||||
* // "eyJwYXRoIjoiL2FwaS9lcGlzb2RlcyIs..."
|
||||
*/
|
||||
function encodePipeRequest(payload) {
|
||||
return btoa(JSON.stringify(payload))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a pipe response based on the x-obfuscated header.
|
||||
*
|
||||
* Miruro's pipe now signals the decode scheme via a response header:
|
||||
* - Header absent → plain JSON (no encoding)
|
||||
* - Header = "1" → base64url + gzip (no XOR)
|
||||
* - Header = "2" → base64url + XOR(PIPE_OBF_KEY) + gzip
|
||||
*
|
||||
* This function handles all three schemes automatically.
|
||||
*
|
||||
* @param {string} encodedStr - Raw response body from pipe endpoint
|
||||
* @param {string|null} obfHeader - Value of x-obfuscated response header
|
||||
* @returns {Promise<Object>} Decoded JSON object
|
||||
* @throws {Error} If decoding fails
|
||||
*
|
||||
* @trick
|
||||
* The DecompressionStream API is available in Workers but not in Node.js.
|
||||
* This is why we can't share this exact code with the Express version.
|
||||
*/
|
||||
async function decodePipeResponse(encodedStr, obfHeader) {
|
||||
// Scheme 0: plain JSON (no header)
|
||||
if (!obfHeader) {
|
||||
return JSON.parse(encodedStr);
|
||||
}
|
||||
|
||||
// Step 1: base64url → raw bytes
|
||||
let bytes = Uint8Array.from(
|
||||
atob(encodedStr.replace(/-/g, "+").replace(/_/g, "/")),
|
||||
(c) => c.charCodeAt(0)
|
||||
);
|
||||
|
||||
// Scheme 2: XOR decrypt before gzip
|
||||
// The XOR key is applied cyclically to each byte
|
||||
if (obfHeader === "2") {
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] ^= PIPE_OBF_KEY[i % PIPE_OBF_KEY.length];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: gzip → decompressed bytes
|
||||
const decompressed = new DecompressionStream("gzip");
|
||||
const writer = decompressed.writable.getWriter();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
|
||||
// Step 3: bytes → JSON
|
||||
return new Response(decompressed.readable).json();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PIPE REQUEST (MIRROR ROTATION)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Sends a request to the Miruro pipe endpoint with automatic mirror rotation.
|
||||
*
|
||||
* Tries each origin in MIRURO_ORIGINS order. If one returns a non-200
|
||||
* status or throws, the next mirror is tried. This provides resilience
|
||||
* against individual mirror outages or rate limits.
|
||||
*
|
||||
* @param {string} path - Pipe API path (e.g. "/api/episodes")
|
||||
* @param {Object} query - Query parameters to encode
|
||||
* @returns {Promise<Object>} Decoded pipe response
|
||||
* @throws {Error} If all mirrors fail
|
||||
*
|
||||
* @trick
|
||||
* On Cloudflare Workers, fetch() to *.miruro.* routes through
|
||||
* Cloudflare's internal network — no bot detection triggered.
|
||||
* This is why Workers succeed where Vercel/serverless fails.
|
||||
*/
|
||||
async function pipeRequest(path, query) {
|
||||
const payload = { path, method: "GET", query, body: null };
|
||||
const encodedReq = encodePipeRequest(payload);
|
||||
|
||||
// Try each mirror in order
|
||||
for (const origin of MIRURO_ORIGINS) {
|
||||
try {
|
||||
const res = await fetch(`${origin}${PIPE_PATH}?e=${encodedReq}`, {
|
||||
headers: HEADERS,
|
||||
});
|
||||
|
||||
// Cloudflare returns 403 for blocked requests
|
||||
if (!res.ok) continue;
|
||||
|
||||
const obf = res.headers.get("x-obfuscated");
|
||||
const text = await res.text();
|
||||
return await decodePipeResponse(text, obf);
|
||||
} catch (e) {
|
||||
// Mirror failed — try next one
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("All pipe mirrors failed");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ROUTE HANDLERS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Health check endpoint ----
|
||||
/**
|
||||
* Returns API health status, version, and runtime info.
|
||||
* Used by monitoring tools and uptime checkers.
|
||||
*
|
||||
* @param {URLSearchParams} params - Unused
|
||||
* @returns {Promise<Response>} Health status JSON
|
||||
*/
|
||||
async function healthHandler(params) {
|
||||
return json({
|
||||
success: true,
|
||||
results: {
|
||||
status: "healthy",
|
||||
version: "2.3.0-worker",
|
||||
runtime: "cloudflare-workers",
|
||||
endpoints: 15,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- FEATURE: Full-text anime search ----
|
||||
/**
|
||||
* Searches AniList for anime matching the given query.
|
||||
* Returns paginated results with full metadata per result.
|
||||
*
|
||||
* @param {URLSearchParams} params - query (required), page, per_page
|
||||
* @returns {Promise<Response>} Paginated search results
|
||||
* @throws {400} If query parameter is missing
|
||||
*
|
||||
* @example
|
||||
* GET /api/search?query=naruto&page=1&per_page=10
|
||||
*/
|
||||
async function searchHandler(params) {
|
||||
const query = params.get("query");
|
||||
if (!query) return error("query parameter is required", 400);
|
||||
|
||||
const page = parseInt(params.get("page") || "1");
|
||||
const perPage = parseInt(params.get("per_page") || "20");
|
||||
|
||||
const gql = `query ($search: String, $page: Int, $perPage: Int) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo { total currentPage hasNextPage perPage }
|
||||
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
||||
id title { romaji english native }
|
||||
coverImage { large }
|
||||
format season seasonYear episodes
|
||||
status averageScore genres popularity
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { search: query, page, perPage });
|
||||
return json({ success: true, results: data.Page });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Autocomplete suggestions ----
|
||||
/**
|
||||
* Returns fast autocomplete suggestions for search input.
|
||||
* Limited to 8 results for low-latency response.
|
||||
*
|
||||
* @param {URLSearchParams} params - query (required)
|
||||
* @returns {Promise<Response>} Array of suggestion objects
|
||||
*/
|
||||
async function suggestionsHandler(params) {
|
||||
const query = params.get("query");
|
||||
if (!query) return error("query parameter is required", 400);
|
||||
|
||||
const gql = `query ($search: String) {
|
||||
Page(page: 1, perPage: 8) {
|
||||
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
||||
id title { romaji english }
|
||||
coverImage { large }
|
||||
format status seasonYear episodes
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { search: query });
|
||||
return json({ success: true, results: data.Page.media });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Advanced filter with multiple params ----
|
||||
/**
|
||||
* Filters anime by genre, tag, year, season, format, and status.
|
||||
* All parameters are optional — combine them for narrow results.
|
||||
*
|
||||
* @param {URLSearchParams} params - genre, tag, year, season, format, status, sort, page, per_page
|
||||
* @returns {Promise<Response>} Filtered and paginated results
|
||||
*
|
||||
* @trick
|
||||
* GraphQL variables are built dynamically based on which params
|
||||
* are present. This avoids sending null variables to AniList.
|
||||
*/
|
||||
async function filterHandler(params) {
|
||||
const genre = params.get("genre");
|
||||
const tag = params.get("tag");
|
||||
const year = params.get("year") ? parseInt(params.get("year")) : undefined;
|
||||
const season = params.get("season");
|
||||
const format = params.get("format");
|
||||
const status = params.get("status");
|
||||
const sort = params.get("sort") || "POPULARITY_DESC";
|
||||
const page = parseInt(params.get("page") || "1");
|
||||
const perPage = parseInt(params.get("per_page") || "20");
|
||||
|
||||
// Build dynamic variables and filter strings
|
||||
const variables = { page, perPage, sort };
|
||||
const filters = [];
|
||||
|
||||
if (genre) { filters.push("genre: $genre"); variables.genre = genre; }
|
||||
if (tag) { filters.push("tag: $tag"); variables.tag = tag; }
|
||||
if (year) { filters.push("seasonYear: $seasonYear"); variables.seasonYear = year; }
|
||||
if (season) { filters.push("season: $season"); variables.season = season; }
|
||||
if (format) { filters.push("format: $format"); variables.format = format; }
|
||||
if (status) { filters.push("status: $status"); variables.status = status; }
|
||||
|
||||
// Build type declarations for present variables
|
||||
const typeDecls = [
|
||||
"$page: Int", "$perPage: Int", "$sort: [MediaSort]",
|
||||
genre && "$genre: Genre",
|
||||
tag && "$tag: String",
|
||||
year && "$seasonYear: Int",
|
||||
season && "$season: Season",
|
||||
format && "$format: MediaFormat",
|
||||
status && "$status: MediaStatus",
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
const gql = `query (${typeDecls}) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo { total currentPage hasNextPage perPage }
|
||||
media(${filters.join(", ")}, type: ANIME, sort: $sort) {
|
||||
id title { romaji english }
|
||||
coverImage { large }
|
||||
format status averageScore episodes
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, variables);
|
||||
return json({ success: true, results: data.Page });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Trending anime ----
|
||||
/**
|
||||
* Returns currently trending anime (releasing only).
|
||||
* Sorted by trending score on AniList.
|
||||
*
|
||||
* @param {URLSearchParams} params - per_page (optional, default 20)
|
||||
* @returns {Promise<Response>} Trending anime list
|
||||
*/
|
||||
async function trendingHandler(params) {
|
||||
const perPage = parseInt(params.get("per_page") || "20");
|
||||
|
||||
const gql = `query ($perPage: Int) {
|
||||
Page(page: 1, perPage: $perPage) {
|
||||
media(type: ANIME, sort: TRENDING, status: RELEASING) {
|
||||
id title { romaji english }
|
||||
coverImage { large }
|
||||
format status averageScore episodes genres
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { perPage });
|
||||
return json({ success: true, results: { results: data.Page.media } });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Popular anime ----
|
||||
/**
|
||||
* Returns most popular anime of all time.
|
||||
* Sorted by AniList popularity metric.
|
||||
*
|
||||
* @param {URLSearchParams} params - per_page (optional, default 20)
|
||||
* @returns {Promise<Response>} Popular anime list
|
||||
*/
|
||||
async function popularHandler(params) {
|
||||
const perPage = parseInt(params.get("per_page") || "20");
|
||||
|
||||
const gql = `query ($perPage: Int) {
|
||||
Page(page: 1, perPage: $perPage) {
|
||||
media(type: ANIME, sort: POPULARITY_DESC) {
|
||||
id title { romaji english }
|
||||
coverImage { large }
|
||||
format status averageScore episodes genres
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { perPage });
|
||||
return json({ success: true, results: { results: data.Page.media } });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Top scored anime ----
|
||||
/**
|
||||
* Returns highest scored anime on AniList.
|
||||
* Sorted by average score descending.
|
||||
*
|
||||
* @param {URLSearchParams} params - per_page (optional, default 20)
|
||||
* @returns {Promise<Response>} Top scored anime list
|
||||
*/
|
||||
async function topHandler(params) {
|
||||
const perPage = parseInt(params.get("per_page") || "20");
|
||||
|
||||
const gql = `query ($perPage: Int) {
|
||||
Page(page: 1, perPage: $perPage) {
|
||||
media(type: ANIME, sort: SCORE_DESC) {
|
||||
id title { romaji english }
|
||||
coverImage { large }
|
||||
format status averageScore episodes genres
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { perPage });
|
||||
return json({ success: true, results: { results: data.Page.media } });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Complete anime info by AniList ID ----
|
||||
/**
|
||||
* Returns full anime details including description, studios,
|
||||
* tags, next airing episode, and more.
|
||||
*
|
||||
* @param {string} id - AniList anime ID (path param)
|
||||
* @returns {Promise<Response>} Complete anime info
|
||||
*
|
||||
* @example
|
||||
* GET /api/info/20 → Naruto
|
||||
* GET /api/info/1535 → Death Note
|
||||
*/
|
||||
async function infoHandler(params, id) {
|
||||
const gql = `query ($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
id idMal
|
||||
title { romaji english native }
|
||||
description
|
||||
coverImage { large extraLarge }
|
||||
bannerImage
|
||||
format season seasonYear
|
||||
episodes duration
|
||||
status averageScore meanScore
|
||||
popularity favourites
|
||||
genres
|
||||
tags { name }
|
||||
studios { nodes { name isAnimationStudio } }
|
||||
startDate { year month day }
|
||||
endDate { year month day }
|
||||
nextAiringEpisode { episode airingAt timeUntilAiring }
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { id: parseInt(id) });
|
||||
return json({ success: true, results: data.Media });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Anime characters with voice actors ----
|
||||
/**
|
||||
* Returns characters for an anime, including Japanese voice actors.
|
||||
* Characters are sorted by role importance (MAIN first).
|
||||
*
|
||||
* @param {string} id - AniList anime ID (path param)
|
||||
* @returns {Promise<Response>} Character list with voice actors
|
||||
*/
|
||||
async function charactersHandler(params, id) {
|
||||
const gql = `query ($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
characters(sort: ROLE, perPage: 25) {
|
||||
edges {
|
||||
role
|
||||
node { id name { full native } image { large } }
|
||||
voiceActors(language: JAPANESE) {
|
||||
id name { full native } languageV2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { id: parseInt(id) });
|
||||
return json({ success: true, results: data.Media.characters });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Anime relations (sequels, prequels, etc.) ----
|
||||
/**
|
||||
* Returns related anime (sequels, prequels, side stories, etc.).
|
||||
* Useful for building "watch order" guides.
|
||||
*
|
||||
* @param {string} id - AniList anime ID (path param)
|
||||
* @returns {Promise<Response>} Relations with connected anime
|
||||
*/
|
||||
async function relationsHandler(params, id) {
|
||||
const gql = `query ($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
relations {
|
||||
edges {
|
||||
relationType
|
||||
node {
|
||||
id title { romaji english }
|
||||
coverImage { large }
|
||||
format status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { id: parseInt(id) });
|
||||
return json({ success: true, results: data.Media.relations });
|
||||
}
|
||||
|
||||
// ---- FEATURE: All genres list ----
|
||||
/**
|
||||
* Returns the complete list of AniList genres.
|
||||
* Static data — rarely changes.
|
||||
*
|
||||
* @returns {Promise<Response>} Array of genre strings
|
||||
*/
|
||||
async function genresHandler() {
|
||||
const gql = `{ GenreCollection }`;
|
||||
const data = await anilistQuery(gql);
|
||||
return json({ success: true, results: data.GenreCollection });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Episode list from Miruro pipe ----
|
||||
/**
|
||||
* Fetches episode list for an anime from the Miruro pipe.
|
||||
* Uses mirror rotation to maximize success rate.
|
||||
*
|
||||
* @param {string} id - AniList anime ID (path param)
|
||||
* @returns {Promise<Response>} Episode data from all providers
|
||||
*
|
||||
* @trick
|
||||
* This endpoint calls the pipe, which is where Cloudflare Workers
|
||||
* shine — edge-to-edge requests bypass bot detection entirely.
|
||||
*/
|
||||
async function episodesHandler(params, id) {
|
||||
try {
|
||||
const data = await pipeRequest("/api/episodes", { id });
|
||||
return json({ success: true, results: data });
|
||||
} catch (e) {
|
||||
return error(`All pipe methods failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FEATURE: Streaming sources for a specific episode ----
|
||||
/**
|
||||
* Fetches streaming sources for a specific episode.
|
||||
* Returns M3U8 URLs, subtitles, and quality info.
|
||||
*
|
||||
* @param {string} provider - Provider name (kiwi, pewe, bonk, etc.)
|
||||
* @param {string} anilistId - AniList anime ID
|
||||
* @param {string} category - sub or dub
|
||||
* @param {string} slug - Episode slug from episodes response
|
||||
* @returns {Promise<Response>} Streaming sources and subtitles
|
||||
*/
|
||||
async function watchHandler(params, provider, anilistId, category, slug) {
|
||||
try {
|
||||
const data = await pipeRequest("/api/watch", { provider, anilistId, category, slug });
|
||||
return json({ success: true, results: data });
|
||||
} catch (e) {
|
||||
return error(`All pipe methods failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FEATURE: Random anime ----
|
||||
/**
|
||||
* Returns a random anime from AniList.
|
||||
* Uses random pagination to pick from the top 1000 most popular.
|
||||
*
|
||||
* @returns {Promise<Response>} Random anime details
|
||||
*
|
||||
* @trick
|
||||
* Two levels of randomness: random page (1-50) + random index
|
||||
* within that page. This gives a uniform distribution across
|
||||
* the top ~1000 most popular anime.
|
||||
*/
|
||||
async function randomHandler() {
|
||||
const page = Math.floor(Math.random() * 50) + 1;
|
||||
const perPage = Math.floor(Math.random() * 20) + 1;
|
||||
|
||||
const gql = `query ($page: Int, $perPage: Int) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
media(type: ANIME, sort: POPULARITY_DESC) {
|
||||
id title { romaji english native }
|
||||
coverImage { large }
|
||||
format season seasonYear
|
||||
episodes status averageScore genres popularity
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await anilistQuery(gql, { page, perPage });
|
||||
const media = data.Page.media;
|
||||
return json({
|
||||
success: true,
|
||||
results: media[Math.floor(Math.random() * media.length)] || null,
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ROUTER
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Route table mapping "METHOD /pattern" to handler functions.
|
||||
* Patterns support :param placeholders for dynamic path segments.
|
||||
*
|
||||
* @type {Object<string, Function>}
|
||||
*
|
||||
* @trick
|
||||
* Routes are matched longest-first, so /api/anime/:id/characters
|
||||
* matches before /api/anime/:id. This prevents ambiguous matches.
|
||||
*/
|
||||
const ROUTES = {
|
||||
// ---- System ----
|
||||
"GET /api/health": healthHandler,
|
||||
|
||||
// ---- Search ----
|
||||
"GET /api/search": searchHandler,
|
||||
"GET /api/suggestions": suggestionsHandler,
|
||||
"GET /api/filter": filterHandler,
|
||||
|
||||
// ---- Collections ----
|
||||
"GET /api/trending": trendingHandler,
|
||||
"GET /api/popular": popularHandler,
|
||||
"GET /api/top": topHandler,
|
||||
"GET /api/random": randomHandler,
|
||||
|
||||
// ---- Anime Details ----
|
||||
"GET /api/info/:id": infoHandler,
|
||||
"GET /api/anime/:id/characters": charactersHandler,
|
||||
"GET /api/anime/:id/relations": relationsHandler,
|
||||
|
||||
// ---- Metadata ----
|
||||
"GET /api/genres": genresHandler,
|
||||
|
||||
// ---- Streaming (pipe) ----
|
||||
"GET /api/episodes/:id": episodesHandler,
|
||||
"GET /api/watch/:provider/:anilistId/:category/:slug": watchHandler,
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches a request method + path against the route table.
|
||||
* Supports :param placeholders for dynamic segments.
|
||||
*
|
||||
* @param {string} method - HTTP method (GET, POST, etc.)
|
||||
* @param {string} path - URL path (e.g. /api/info/20)
|
||||
* @returns {{ handler: Function, params: string[] } | null} Matched route info, or null
|
||||
*
|
||||
* @trick
|
||||
* The router tries exact matches first, then pattern matches.
|
||||
* Pattern matches extract :param values into the params array
|
||||
* which are passed as positional arguments to the handler.
|
||||
*/
|
||||
function matchRoute(method, path) {
|
||||
// Try exact match first (fast path)
|
||||
const exact = `${method} ${path}`;
|
||||
if (ROUTES[exact]) return { handler: ROUTES[exact], params: [] };
|
||||
|
||||
// Try pattern matches (slower, handles :params)
|
||||
for (const key of Object.keys(ROUTES)) {
|
||||
const [m, pattern] = key.split(" ");
|
||||
if (m !== method) continue;
|
||||
|
||||
const patternParts = pattern.split("/");
|
||||
const pathParts = path.split("/");
|
||||
if (patternParts.length !== pathParts.length) continue;
|
||||
|
||||
const params = [];
|
||||
let match = true;
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(":")) {
|
||||
// Dynamic segment — extract value
|
||||
params.push(pathParts[i]);
|
||||
} else if (patternParts[i] !== pathParts[i]) {
|
||||
// Static segment — must match exactly
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) return { handler: ROUTES[key], params };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// WORKER ENTRY POINT
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Main fetch event handler. Cloudflare Workers use this instead
|
||||
* of Express app.listen(). Every incoming request goes through here.
|
||||
*
|
||||
* @param {FetchEvent} event - Cloudflare fetch event
|
||||
* @returns {Response} HTTP response
|
||||
*
|
||||
* @trick
|
||||
* Workers don't have a server process — they're stateless functions
|
||||
* that spin up on demand and die after responding. No persistent
|
||||
* memory between requests (use KV or Durable Objects for state).
|
||||
*/
|
||||
addEventListener("fetch", (event) => {
|
||||
event.respondWith(handleRequest(event));
|
||||
});
|
||||
|
||||
/**
|
||||
* Core request handler. Routes requests, handles CORS preflight,
|
||||
* and serves the landing page.
|
||||
*
|
||||
* @param {FetchEvent} event - Cloudflare fetch event
|
||||
* @returns {Promise<Response>} HTTP response
|
||||
*/
|
||||
async function handleRequest(event) {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS });
|
||||
}
|
||||
|
||||
// Landing page
|
||||
if (url.pathname === "/") {
|
||||
return new Response(
|
||||
`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><title>MiruroAPI — Cloudflare Worker</title></head>
|
||||
<body style="font-family:system-ui;background:#0A0A12;color:#E2E8F0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
|
||||
<div style="text-align:center;max-width:600px;padding:40px">
|
||||
<h1 style="font-size:2.5rem;margin-bottom:8px">📺 <span style="background:linear-gradient(135deg,#A855F7,#EC4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent">MiruroAPI</span></h1>
|
||||
<p style="color:#94A3B8;margin-bottom:24px">Cloudflare Worker Edition — 15 endpoints, zero cold starts</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
|
||||
<a href="/api/health" style="background:#A855F7;color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600">Health Check</a>
|
||||
<a href="/api/search?query=naruto" style="background:#6366F1;color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600">Search Anime</a>
|
||||
<a href="/api/trending" style="background:#EC4899;color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600">Trending</a>
|
||||
</div>
|
||||
<p style="color:#64748B;font-size:0.8rem;margin-top:40px">Shinei Nouzen • MIT License</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ headers: { "Content-Type": "text/html", ...CORS } }
|
||||
);
|
||||
}
|
||||
|
||||
// Route matching
|
||||
const route = matchRoute(req.method, url.pathname);
|
||||
if (!route) return error("Not found", 404);
|
||||
|
||||
// Execute handler
|
||||
try {
|
||||
return await route.handler(url.searchParams, ...route.params);
|
||||
} catch (e) {
|
||||
return error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MODULE FOOTER
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// End of MiruroAPI Worker Edition
|
||||
//
|
||||
// Deploy with: wrangler deploy
|
||||
// Test locally: wrangler dev
|
||||
//
|
||||
// Routes:
|
||||
// /api/health → System health check
|
||||
// /api/search?query=naruto → Full-text search
|
||||
// /api/suggestions?query=naruto → Autocomplete
|
||||
// /api/filter?genre=Action → Advanced filter
|
||||
// /api/trending → Trending anime
|
||||
// /api/popular → Most popular
|
||||
// /api/top → Top scored
|
||||
// /api/random → Random anime
|
||||
// /api/info/20 → Full anime info
|
||||
// /api/anime/20/characters → Characters + VAs
|
||||
// /api/anime/20/relations → Related anime
|
||||
// /api/genres → All genres
|
||||
// /api/episodes/20 → Episode list (pipe)
|
||||
// /api/watch/kiwi/20/sub/episode-1 → Streaming sources (pipe)
|
||||
//
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "miruroapi",
|
||||
"compatibility_date": "2026-07-02",
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"assets": {
|
||||
"directory": "public"
|
||||
},
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user