mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 18:25:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b020e53b0 | ||
|
|
a2ba1df831 | ||
|
|
d08bb86931 | ||
|
|
4912b5d6eb | ||
|
|
5d2534eff2 | ||
|
|
45b436d752 | ||
|
|
4293683ae4 | ||
|
|
2c97ca2dd8 | ||
|
|
3f667fe87f | ||
|
|
015aa67ec2 | ||
|
|
a918ed9d1a | ||
|
|
125f088570 | ||
|
|
0628189ad8 | ||
|
|
bb68100ef0 | ||
|
|
2e0ef33b92 | ||
|
|
a6acfc6445 | ||
|
|
eb0e166310 | ||
|
|
92154d7ea2 | ||
|
|
5a8879f2ba | ||
|
|
6bda1d9c95 | ||
|
|
10560e36c3 | ||
|
|
023e36c2d4 | ||
|
|
156f9b16a5 | ||
|
|
16eb0236d7 | ||
|
|
73b510589e | ||
|
|
8297d8f27d | ||
|
|
1d0ec95f19 | ||
|
|
94421af2dd | ||
|
|
7394a1c94c | ||
|
|
b59ca7e1ec | ||
|
|
5acf8a2e1e | ||
|
|
00275adb42 | ||
|
|
90cdd26241 | ||
|
|
4509c0add3 | ||
|
|
d1dec20788 | ||
|
|
bff4c5b7c6 | ||
|
|
84d7e75372 | ||
|
|
e35d934190 | ||
|
|
398db2b4f0 | ||
|
|
94b59b3bc9 | ||
|
|
6cd2b9a19e | ||
|
|
76c47368a7 | ||
|
|
f208becd27 | ||
|
|
74f3dcd7c3 | ||
|
|
90b0999d7d | ||
|
|
f7b3e32cb7 | ||
|
|
a65138e575 | ||
|
|
399f9b1917 | ||
|
|
4ec1ff69f4 | ||
|
|
56e3500d3e | ||
|
|
9b1ac11ff0 | ||
|
|
3ab0bae18d | ||
|
|
33e5e8e136 | ||
|
|
3675bbba77 | ||
|
|
9ada6e1016 | ||
|
|
3a28c4d182 | ||
|
|
68f7ea7cf4 | ||
|
|
37e70c3bda | ||
|
|
af08c91624 |
+31
-2
@@ -1,5 +1,34 @@
|
|||||||
# Origins you want to allow
|
# Origins you want to allow (comma-separated, use * for all)
|
||||||
ALLOWED_ORIGINS=https://site1.com,https://site2.com
|
ALLOWED_ORIGINS=https://miruro.tv,https://miruro.to,https://miruro.bz,https://miruro.ru
|
||||||
|
|
||||||
# Port (optional, defaults to 3000)
|
# Port (optional, defaults to 3000)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Pipe XOR obfuscation key (hex string, 32 chars)
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# Method 4: Scrape.do (1,000 free credits/month)
|
||||||
|
# Sign up at https://scrape.do/ (no credit card required)
|
||||||
|
# Handles Cloudflare, proxy rotation, CAPTCHA solving
|
||||||
|
SCRAPE_DO_TOKEN=
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.vercel/
|
.vercel/
|
||||||
|
deploy.cjs
|
||||||
|
|||||||
+378
-6
@@ -1,5 +1,377 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v2.3.2
|
||||||
|
### Cloudflare Worker Implementation — Issue #2 Fix
|
||||||
|
|
||||||
|
#### New: `worker.js` (Cloudflare Worker Edge Runtime)
|
||||||
|
- **Full Cloudflare Worker implementation** created at project root
|
||||||
|
- Edge-to-edge requests bypass Cloudflare bot detection (Worker → miruro.to → Cloudflare trusts itself)
|
||||||
|
- Zero cold starts, 100K free requests/day on Cloudflare free tier
|
||||||
|
- No npm dependencies — uses native `fetch`, `DecompressionStream`, `TextEncoder`/`TextDecoder`
|
||||||
|
- 15 API endpoints: search, suggestions, filter, trending, trending/daily, trending/weekly, popular, top, upcoming, recent, random, info, episodes, watch, stream, genres, tags, providers, health
|
||||||
|
|
||||||
|
#### New: `wrangler.toml`
|
||||||
|
- Cloudflare Workers deployment configuration
|
||||||
|
- Compatible with `wrangler deploy` CLI
|
||||||
|
- Node.js compatibility flag enabled for buffer/text encoding support
|
||||||
|
- CPU time limit: 30 seconds
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
```
|
||||||
|
Browser → Cloudflare Worker (edge) → miruro.to pipe → Cloudflare trusts itself ✅
|
||||||
|
Vercel (datacenter) → miruro.to → Cloudflare blocks ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Deploy
|
||||||
|
```bash
|
||||||
|
npm install -g wrangler
|
||||||
|
wrangler login
|
||||||
|
wrangler deploy worker.js --name miruroapi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Files Changed
|
||||||
|
- `worker.js` — Cloudflare Worker implementation (complete rewrite for edge runtime)
|
||||||
|
- `wrangler.toml` — Cloudflare Workers deployment configuration
|
||||||
|
- `CHANGELOG.md` — This entry
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
- This fixes Issue #2: Cloudflare Worker/Pages deployment support
|
||||||
|
- Streaming endpoints (`/episodes`, `/watch`, `/stream`) now work via edge-to-edge requests
|
||||||
|
- Environment variables `SCRAPER_API_KEY` and `FLARESOLVERR_URL` are optional for fallback methods
|
||||||
|
- The worker runs on Cloudflare's edge network with zero cold starts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
- **XOR keys moved to env vars** — `PRU_PROXY_KEY` and `PIPE_OBF_KEY` no longer hardcoded in source (proxy.js, pipe.js)
|
||||||
|
- **CORS restricted in production** — returns 403 when `ALLOWED_ORIGINS` is set and origin doesn't match
|
||||||
|
- **Input sanitization** — new `sanitizeId()` and `sanitizeString()` helpers applied to all route params (character, staff, studio, genre, year, status, format, season)
|
||||||
|
- **Security headers in vercel.json** — added at CDN level for faster response
|
||||||
|
|
||||||
|
#### Bug Fixes
|
||||||
|
- **multi-search** — now actually combines query + filters (was ignoring `q` when filters present)
|
||||||
|
- **`filterAnime`** — added `search` parameter and `SEARCH_MATCH` sort option
|
||||||
|
- **`getTrendingWeekly`** — now returns 40 results (was identical to daily with 20)
|
||||||
|
- **`calendar`** — fetches 200 results instead of 50 (was missing late-month shows)
|
||||||
|
- **`getBestStream`** — improved quality matching (checks quality/label fields), supports HLS+DASH, better fallback
|
||||||
|
- **`deepTranslate`** — now returns deep clone instead of mutating in-place (prevents cache corruption)
|
||||||
|
- **Version strings** — updated to 2.1.3 in server.js and test.js
|
||||||
|
|
||||||
|
#### Code Quality
|
||||||
|
- **Deleted `server.py`** — dead Flask code, not used on Vercel
|
||||||
|
- **Rate limiter** — aggressive cleanup on serverless (prunes empty entries every request)
|
||||||
|
- **`stats/genre`** — uses `Promise.allSettled` for parallel execution (faster, safer)
|
||||||
|
|
||||||
|
#### Config
|
||||||
|
- **vercel.json** — added security headers at CDN level
|
||||||
|
- **.env.example** — added `PRU_PROXY_KEY`, `PIPE_OBF_KEY` documentation
|
||||||
|
|
||||||
|
## v2.1.3
|
||||||
|
### Diagnostic Sweep — Bug fixes, security, AniList query corrections
|
||||||
|
|
||||||
|
#### Full Diagnostic Sweep Results
|
||||||
|
- Tested all 44 endpoints across 10 sections
|
||||||
|
- Found and fixed 5 bugs
|
||||||
|
- Added missing security headers
|
||||||
|
|
||||||
|
#### Bug Fixes
|
||||||
|
- **Tags endpoint**: `data.TagCollection` → `data.MediaTagCollection` (was returning empty array)
|
||||||
|
- **Random endpoint**: Added `Cache-Control: no-store` headers + `Date.now() % 499` for true randomness (CDN was caching rapid requests)
|
||||||
|
- **Character/Staff 404**: Now returns HTTP 404 instead of 500 on not-found
|
||||||
|
- **Character query**: Removed `dateOfDeath` (not in AniList schema), `role` → `characterRole`
|
||||||
|
- **Staff query**: Removed `dateOfDeath` and invalid `role` field
|
||||||
|
- **Studio query**: Removed `type: ANIME` from `Studio.media` (not supported by AniList)
|
||||||
|
- **Tags query**: `TagCollection` → `MediaTagCollection`, removed `rank` field
|
||||||
|
- **Trending**: Removed invalid `trending: 7/30` args (not in AniList schema)
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
- Added `X-XSS-Protection: 1; mode=block` header
|
||||||
|
|
||||||
|
#### Pipe Resilience
|
||||||
|
- Added `pipeRequest()` helper with retry logic and exponential backoff (3 attempts: 1s, 2s, 4s delays)
|
||||||
|
- Cached episodes (5 min) and sources (10 min) responses
|
||||||
|
- Confirmed kiwi/bonk CDNs serve real content, bee/ally serve decoys
|
||||||
|
- Fixed `getWatchSources` slug matching for both raw and slugged episode IDs
|
||||||
|
|
||||||
|
#### Performance
|
||||||
|
- Fastest: `/providers` ~0.8s (static data)
|
||||||
|
- Average: Most endpoints ~1.0-2.0s
|
||||||
|
- Slowest: `/stats/genre` ~5.7s (10 AniList queries)
|
||||||
|
|
||||||
|
## v2.1.2
|
||||||
|
### Streaming Architecture — pru proxy + raw CDN URLs
|
||||||
|
|
||||||
|
#### Streaming Pipeline Discovery
|
||||||
|
- Decoded pru.ultracloud.cc URL format: `base64url(XOR(url, key))~base64url(XOR(referer, key))/pl.m3u8`
|
||||||
|
- Key: `a54d389c18527d9fd3e7f0643e27edbe` (16-byte XOR)
|
||||||
|
- pru rewrites ALL M3U8 URLs (master, variant, segments) to proxy through itself
|
||||||
|
- CDN at mt.nekostream.site serves decoy PNG images to all server-side requests
|
||||||
|
|
||||||
|
#### Proxy Changes
|
||||||
|
- `/api/proxy` now handles both pru-encoded URLs (decodes + re-encodes) and direct CDN URLs (for subtitles)
|
||||||
|
- Stream URLs returned as raw CDN URLs (browser loads directly — CDN has CORS *)
|
||||||
|
- Subtitle URLs proxied through `/api/proxy` (third-party CDNs may lack CORS)
|
||||||
|
- Added `Origin: https://www.miruro.tv` header to pipe requests
|
||||||
|
|
||||||
|
#### Known Limitations
|
||||||
|
- Pipe sources endpoint returns 444 from Cloudflare for datacenter IPs
|
||||||
|
- Client-side pipe blocked by CORS (only allows miruro.tv origin)
|
||||||
|
- CDN segments serve PNG decoys to server IPs
|
||||||
|
- Video playback requires further investigation — likely needs miruro.tv domain or alternative streaming source
|
||||||
|
|
||||||
|
## v2.1.1
|
||||||
|
### Bug Fix — Pipe response XOR decoding
|
||||||
|
|
||||||
|
#### Fix
|
||||||
|
- `decodePipeResponse` now XOR-decodes with obfuscation key before gzip decompression — pipe was returning XOR+gzip, not plain gzip
|
||||||
|
- Updated pipe version from `0.1.0` to `0.2.0` to match miruro.tv
|
||||||
|
- `getWatchSources` now properly returns streams (was always failing before this fix)
|
||||||
|
|
||||||
|
## v2.1.0
|
||||||
|
### Major Feature Update — Provider capabilities, enriched metadata, skip times
|
||||||
|
|
||||||
|
#### New Endpoints
|
||||||
|
- GET /api/providers — Provider capabilities and configuration (12 providers with full metadata)
|
||||||
|
|
||||||
|
#### Anime Info Enrichment
|
||||||
|
- Added `alternateName` — multiple language titles from synonyms
|
||||||
|
- Added `aggregateRating` — rating with count, mean score
|
||||||
|
- Added `sameAs` — external links (AniList, MAL, official sites)
|
||||||
|
- Added `externalLinks` — structured external links with site/type
|
||||||
|
- Added `productionCompany` — non-animation studios
|
||||||
|
- Added `animationStudio` — animation studios
|
||||||
|
- Added `trailer` — anime trailer info
|
||||||
|
- Added `nextAiringEpisode` — next episode air time
|
||||||
|
- Added `tags` — simplified tag names array
|
||||||
|
|
||||||
|
#### Streaming Improvements
|
||||||
|
- Added `extractSkipTimes` — extract OP/ED skip timestamps from sources
|
||||||
|
- Added `skipTimes` field to /stream and /watch responses
|
||||||
|
- Episode thumbnails already included in pipe response
|
||||||
|
|
||||||
|
#### Provider Capabilities (from miruro.tv __SSR_CONFIG__)
|
||||||
|
- kiwi: sub, download
|
||||||
|
- pewe: sub
|
||||||
|
- bonk: sub, ssub, download, skip_times
|
||||||
|
- bee: ssub
|
||||||
|
- ally: sub, download, thumbnails
|
||||||
|
- moo: sub, download
|
||||||
|
- hop: ssub, thumbnails
|
||||||
|
- nun: sub (embed of ally)
|
||||||
|
- bun: ssub (embed of bee)
|
||||||
|
- twin: sub, ssub (embed of bonk)
|
||||||
|
- cog: sub (embed of moo)
|
||||||
|
- telli: sub (embed of kiwi, hidden)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.0.1
|
||||||
|
### Streaming Improvements — Better subtitle extraction, quality fallback
|
||||||
|
|
||||||
|
#### Streaming Enhancements
|
||||||
|
- Improved `extractSubtitles` — now handles multiple provider formats (bonk: file/label/kind/language, others: url/name/lang)
|
||||||
|
- Updated `getBestStream` — falls back to first active HLS stream when quality metadata is missing
|
||||||
|
- Updated `getDownloadUrl` — now returns subtitles and bestStream in response
|
||||||
|
- Added subtitle fields: url, label, language, kind, format, encoding, isDefault
|
||||||
|
|
||||||
|
#### Provider Improvements
|
||||||
|
- Bonk provider confirmed to return subtitles (English, VTT format, captions kind)
|
||||||
|
- Better handling of providers without quality metadata (bonk, ally, bee return undefined quality)
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
- Subtitle extraction working: bonk provider returns 1 English subtitle per episode
|
||||||
|
- Download URL working: bonk provider returns direct download link
|
||||||
|
- Batch sources: subtitles included in batch responses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.0.0
|
||||||
|
### Major Release — 35 endpoints, streaming improvements, new features
|
||||||
|
|
||||||
|
#### Technical Foundation
|
||||||
|
- Added gzip compression middleware (30-70% response size reduction)
|
||||||
|
- Added request logging with method, path, status, duration, IP
|
||||||
|
- Added Cache-Control headers for API responses (varies by endpoint type)
|
||||||
|
- Improved CORS with preflight caching (24h max-age)
|
||||||
|
- Health endpoint now includes memory stats (heap, RSS), cache size, node version
|
||||||
|
|
||||||
|
#### New AniList Helpers (11 new functions)
|
||||||
|
- getAllGenres — complete genre list from AniList
|
||||||
|
- getAllTags — complete tag list with metadata
|
||||||
|
- getTopAnime — top-rated anime of all time (SCORE_DESC)
|
||||||
|
- getTrendingDaily — trending anime (last 7 days)
|
||||||
|
- getTrendingWeekly — trending anime (last 30 days)
|
||||||
|
- getSeasonalAnime — anime by year + season (e.g., 2025/Spring)
|
||||||
|
- getAnimeByStudio — anime produced by a specific studio
|
||||||
|
- getCharacterInfo — character detail with anime list
|
||||||
|
- getStaffInfo — staff detail with anime list
|
||||||
|
- searchCharacters — search characters by name
|
||||||
|
- searchStaff — search staff by name
|
||||||
|
|
||||||
|
#### New Streaming & Download Features
|
||||||
|
- extractSubtitles — extract subtitle URLs from sources
|
||||||
|
- getBestStream — automatic quality fallback (1080p → 720p → 360p)
|
||||||
|
- getDownloadUrl — dedicated download URL endpoint
|
||||||
|
- getBatchSources — fetch multiple episode sources in parallel
|
||||||
|
|
||||||
|
#### New API Endpoints (17 new)
|
||||||
|
- GET /api/trending/daily — daily trending anime
|
||||||
|
- GET /api/trending/weekly — weekly trending anime
|
||||||
|
- GET /api/top — top anime by score
|
||||||
|
- GET /api/random — random anime of the day
|
||||||
|
- GET /api/multi-search — combined query + filters
|
||||||
|
- GET /api/season/:year/:season — seasonal anime
|
||||||
|
- GET /api/studio/:name — anime by studio
|
||||||
|
- GET /api/genres — all genres list
|
||||||
|
- GET /api/tags — all tags list
|
||||||
|
- GET /api/genre/:name — anime by genre
|
||||||
|
- GET /api/year/:year — anime by year
|
||||||
|
- GET /api/status/:status — anime by status
|
||||||
|
- GET /api/format/:format — anime by format
|
||||||
|
- GET /api/character/:id — character detail
|
||||||
|
- GET /api/staff/:id — staff detail
|
||||||
|
- GET /api/characters — search characters
|
||||||
|
- GET /api/staff — search staff
|
||||||
|
- GET /api/episodes/:id/batch — batch episode sources
|
||||||
|
- GET /api/stream — streaming with quality fallback
|
||||||
|
- GET /api/download — download URL
|
||||||
|
- GET /api/compare/:id1/:id2 — compare two anime
|
||||||
|
- GET /api/stats/genre — genre statistics
|
||||||
|
- GET /api/calendar — monthly airing calendar
|
||||||
|
- GET /api/timeline/:id — anime release timeline
|
||||||
|
|
||||||
|
#### Streaming Improvements
|
||||||
|
- Subtitle URL extraction from pipe responses
|
||||||
|
- Quality fallback: tries 1080p → 720p → 480p → 360p automatically
|
||||||
|
- Batch episode fetching (up to 5 parallel requests)
|
||||||
|
- Dedicated download endpoint with provider metadata
|
||||||
|
- Cache headers: episodes/sources (2min), info (5min), genres/tags (24h)
|
||||||
|
|
||||||
|
#### Version synced to 2.0.0 across all files
|
||||||
|
|
||||||
|
## v1.4.0
|
||||||
|
- Added creator attribution middleware — injects author info into every response
|
||||||
|
- Fixed Vercel deployment: replaced legacy `builds`/`routes` config with modern `functions` config
|
||||||
|
- Added `api/index.js` serverless entry point
|
||||||
|
- Security headers: removed deprecated `X-XSS-Protection`, added `Strict-Transport-Security` (HSTS)
|
||||||
|
- Rate limiter: uses `x-forwarded-for` header for correct IP detection on Vercel
|
||||||
|
- Rate limiter: added periodic IP entry pruning when >200 IPs tracked
|
||||||
|
- Cache: added periodic expired entry cleanup when cache reaches 80% capacity
|
||||||
|
- Version synced to 1.4.0
|
||||||
|
|
||||||
## v1.3.0
|
## v1.3.0
|
||||||
- Full landing page upgrade to match AniKotoAPI style
|
- Full landing page upgrade to match AniKotoAPI style
|
||||||
- Added canvas particle effects with cyan-colored floating particles
|
- Added canvas particle effects with cyan-colored floating particles
|
||||||
@@ -7,18 +379,18 @@
|
|||||||
- Added animated stat counters that count up on scroll
|
- Added animated stat counters that count up on scroll
|
||||||
- Added infinite scrolling marquee with 8 feature badges
|
- Added infinite scrolling marquee with 8 feature badges
|
||||||
- Added live interactive API console with 5 preset endpoint buttons
|
- Added live interactive API console with 5 preset endpoint buttons
|
||||||
- Added sidebar endpoint explorer with 16 endpoints and multi-language code tabs (cURL, JavaScript, Python)
|
- Added sidebar endpoint explorer with 16 endpoints and multi-language code tabs
|
||||||
- Added 4 interactive playground cards (Search, Info, Episodes, Trending)
|
- Added 4 interactive playground cards (Search, Info, Episodes, Trending)
|
||||||
- Added custom scrollbar styling
|
- Added custom scrollbar styling
|
||||||
- Added JetBrains Mono font for code blocks
|
- Added JetBrains Mono font for code blocks
|
||||||
- Added 4-column grid footer with API, Resources, Legal sections
|
- Added 4-column grid footer
|
||||||
- Added fixed header with scroll-aware blur/glass effect
|
- Added fixed header with scroll-aware blur/glass effect
|
||||||
- Added ambient orb gradient animations
|
- Added ambient orb gradient animations
|
||||||
- Added CTA section with gradient overlay
|
- Added CTA section with gradient overlay
|
||||||
- Color scheme updated to match MiruroAPI brand (cyan #38bdf8 + indigo #818cf8)
|
- Color scheme updated to match MiruroAPI brand (cyan #38bdf8 + indigo #818cf8)
|
||||||
- All branding updated: AniKotoAPI references → MiruroAPI
|
- All branding updated: AniKotoAPI references → MiruroAPI
|
||||||
- OG/Twitter meta tags updated for mirurotvapi.vercel.app
|
- OG/Twitter meta tags updated for mirurotvapi.vercel.app
|
||||||
- JSON-LD structured data updated with image, category, programmingModel
|
- JSON-LD structured data updated
|
||||||
- Real Miruro SVG favicons used in header and footer
|
- Real Miruro SVG favicons used in header and footer
|
||||||
|
|
||||||
## v1.2.0
|
## v1.2.0
|
||||||
@@ -26,14 +398,14 @@
|
|||||||
- Root cause: createApiRoutes() had (app, jsonResponse, jsonError) but was called with (jsonResponse, jsonError), shifting all params
|
- Root cause: createApiRoutes() had (app, jsonResponse, jsonError) but was called with (jsonResponse, jsonError), shifting all params
|
||||||
- Every jsonResponse() call was actually calling jsonError(), returning {success: false, message: data}
|
- Every jsonResponse() call was actually calling jsonError(), returning {success: false, message: data}
|
||||||
- Health endpoint worked because it used res.json() directly, bypassing the wrapper
|
- Health endpoint worked because it used res.json() directly, bypassing the wrapper
|
||||||
- All 18 endpoints verified working: health, stats, search, suggestions, filter, trending, popular, upcoming, recent, spotlight, schedule, info, characters, relations, recommendations, episodes, watch
|
- All 18 endpoints verified working
|
||||||
|
|
||||||
## v1.1.0
|
## v1.1.0
|
||||||
- Added Swagger UI interactive docs at /docs
|
- Added Swagger UI interactive docs at /docs
|
||||||
- Added OpenAPI 3.0 spec at /openapi.json
|
- Added OpenAPI 3.0 spec at /openapi.json
|
||||||
- Added mappings field in episodes response (anilistId, malId, kitsuId)
|
- Added mappings field in episodes response
|
||||||
- Added Docker support (Dockerfile + .dockerignore)
|
- Added Docker support (Dockerfile + .dockerignore)
|
||||||
- Improved landing page with premium design (Walter-style)
|
- Improved landing page with premium design
|
||||||
- Added "Try it" buttons on all endpoints
|
- Added "Try it" buttons on all endpoints
|
||||||
- Health endpoint now includes version, providers list, and endpoint count
|
- Health endpoint now includes version, providers list, and endpoint count
|
||||||
- Version bump to 1.1.0
|
- Version bump to 1.1.0
|
||||||
|
|||||||
@@ -50,6 +50,37 @@ MiruroAPI/
|
|||||||
- Follow existing patterns in the codebase
|
- Follow existing patterns in the codebase
|
||||||
- Keep helpers focused on one task
|
- Keep helpers focused on one task
|
||||||
|
|
||||||
|
### File Headers
|
||||||
|
|
||||||
|
```js
|
||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — filename.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* What this module does.
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Section Markers
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SECTION NAME
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Markers
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ---- FEATURE: Name ----
|
||||||
|
```
|
||||||
|
|
||||||
## Commit Messages
|
## Commit Messages
|
||||||
|
|
||||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
@@ -75,6 +106,32 @@ Use [Conventional Commits](https://www.conventionalcommits.org/):
|
|||||||
- Include expected vs actual behavior
|
- Include expected vs actual behavior
|
||||||
- Include your Node.js version
|
- Include your Node.js version
|
||||||
|
|
||||||
|
## Contributing Bypass Solutions (Priority)
|
||||||
|
|
||||||
|
Streaming endpoints are blocked on cloud platforms. This is the most impactful contribution you can make.
|
||||||
|
|
||||||
|
### What to Build
|
||||||
|
|
||||||
|
Add a new fallback method in `src/helpers/pipe.js`. The file already has 3 methods (direct, ScraperAPI, FlareSolverr) — add yours as a 4th.
|
||||||
|
|
||||||
|
### Free/Open Source Options
|
||||||
|
|
||||||
|
| Tool | Language | Install |
|
||||||
|
|------|----------|---------|
|
||||||
|
| [nodriver](https://github.com/ultrafunkamsterdam/nodriver) | Python | `pip install nodriver` |
|
||||||
|
| [Camoufox](https://github.com/nicandris/camoufox) | Python | `pip install camoufox` |
|
||||||
|
| [curl_cffi](https://github.com/nicandris/curl_cffi) | Python | `pip install curl_cffi` |
|
||||||
|
| [patchright](https://github.com/nicandris/patchright) | Python | `pip install patchright` |
|
||||||
|
| [Scrape.do](https://scrape.do) | HTTP API | 1K free credits/mo |
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Fork and clone
|
||||||
|
2. Add your method in `src/helpers/pipe.js`
|
||||||
|
3. Add your API key/URL to `.env.example`
|
||||||
|
4. Test: `curl http://localhost:3000/api/episodes/20`
|
||||||
|
5. Submit PR
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
> [!NOTE]
|
||||||
|
> **API MAYBE Temporarily Paused** due to suspiciously too many requests. My hosted version of this API is only for testing purposes. You MUST host your own instance to use the API.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="https://capsule-render.vercel.app/api?type=waving&height=300&color=gradient&text=MiruroAPI&fontAlignY=30&fontSize=90&desc=Free%20Anime%20Streaming%20API&descSize=30" />
|
<img src="https://capsule-render.vercel.app/api?type=waving&height=300&color=gradient&text=𝙈𝙞𝙧𝙪𝙧𝙤𝘼𝙋𝙄&fontAlignY=30&fontSize=100&desc=𝖠%20𝖢𝗈𝗆𝗉𝗅𝖾𝗍𝖾%20𝖱𝖤𝖲𝖳𝖿𝗎𝗅%20𝖠𝖯𝖨%20𝖥𝗈𝗋%20𝖠𝗇𝗂𝗆𝖾%20𝖲𝗍𝗋𝖾𝖺𝗆𝗂𝗇𝗀&descSize=25" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -19,15 +22,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/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/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/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
|
||||||
<img src="https://img.shields.io/badge/Version-1.2.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
<img src="https://img.shields.io/badge/Version-2.3.2-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
||||||
<img src="https://img.shields.io/badge/Endpoints-18-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
<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"/>
|
<img src="https://img.shields.io/badge/Providers-12-a855f7?style=flat-square&logoColor=white" alt="Providers"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>A complete RESTful API for anime streaming data powered by AniList GraphQL and Miruro providers</b><br/>
|
<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/>
|
Search, browse, filter, watch — every endpoint returns fresh data with smart caching.<br/>
|
||||||
18 endpoints, 12 streaming providers, M3U8 URLs with subtitles and skip timestamps.
|
46 endpoints, 12 streaming providers, M3U8 URLs with subtitles, skip timestamps, and batch operations.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -41,11 +44,10 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ## ⚠️ Disclaimer
|
> [!WARNING]
|
||||||
>
|
|
||||||
> 1. This `API` does not store any files — it only links to media hosted on 3rd party services.
|
> 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.
|
> 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@
|
|||||||
|
|
||||||
### Why MiruroAPI?
|
### Why MiruroAPI?
|
||||||
|
|
||||||
- 🎬 **18 Endpoints** — Complete anime data coverage
|
- 🎬 **46 Endpoints** — Complete anime data coverage
|
||||||
- 🔍 **Full-Text Search** — Search anime by keyword with suggestions
|
- 🔍 **Full-Text Search** — Search anime by keyword with suggestions
|
||||||
- 🎭 **Characters & Voice Actors** — Full character data from AniList
|
- 🎭 **Characters & Voice Actors** — Full character data from AniList
|
||||||
- 🎯 **Advanced Filtering** — Genre, year, season, format, sort
|
- 🎯 **Advanced Filtering** — Genre, year, season, format, sort
|
||||||
@@ -93,10 +95,50 @@
|
|||||||
- 📅 **Airing Schedule** — See what's airing on any date
|
- 📅 **Airing Schedule** — See what's airing on any date
|
||||||
- 📡 **12 Streaming Providers** — M3U8 streaming sources
|
- 📡 **12 Streaming Providers** — M3U8 streaming sources
|
||||||
- ⏭️ **Skip Timestamps** — OP/ED skip data
|
- ⏭️ **Skip Timestamps** — OP/ED skip data
|
||||||
|
- 🎭 **Character & Staff Search** — Search characters and staff by name
|
||||||
|
- 🎬 **Batch Episodes** — Fetch multiple episode sources in parallel
|
||||||
- ⚡ **Smart Caching** — In-memory Map with configurable TTL
|
- ⚡ **Smart Caching** — In-memory Map with configurable TTL
|
||||||
- 🔒 **CORS Enabled** — Works from any frontend, no proxy needed
|
- 🔒 **CORS Enabled** — Works from any frontend, no proxy needed
|
||||||
- 🚀 **Zero-Config Deploy** — One click to Vercel, or run standalone with Express
|
- 🚀 **Zero-Config Deploy** — One click to Vercel, or run standalone with Express
|
||||||
|
|
||||||
|
### 🔄 Self-Healing System (v2.3.2)
|
||||||
|
|
||||||
|
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 work on Cloudflare Workers** — deploy `worker.js` for full streaming support.
|
||||||
|
>
|
||||||
|
> On Vercel, streaming endpoints (`/api/episodes`, `/api/watch`, `/api/sources`, `/api/stream`) return 500 errors when Cloudflare blocks pipe requests. Metadata endpoints (search, info, trending, etc.) work perfectly on all platforms.
|
||||||
|
|
||||||
|
| What Works (All Platforms) | What's Blocked (Vercel Only) |
|
||||||
|
|:---|:---|
|
||||||
|
| 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 | **✅ Works on Cloudflare Worker** |
|
||||||
|
|
||||||
|
**To fix streaming on Vercel**, set one of these environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Deploy as Cloudflare Worker (recommended — free, bypasses Cloudflare)
|
||||||
|
wrangler deploy worker.js --name miruroapi
|
||||||
|
|
||||||
|
# Option 2: ScraperAPI (requires premium plan — $49/month)
|
||||||
|
SCRAPER_API_KEY=your_scraperapi_key
|
||||||
|
|
||||||
|
# Option 3: 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
|
### How It Works
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -107,7 +149,7 @@ flowchart TD
|
|||||||
C -- MISS --> E{"🔍 Which Source?"}
|
C -- MISS --> E{"🔍 Which Source?"}
|
||||||
|
|
||||||
E -- Metadata --> F["📡 AniList GraphQL<br/>graphql.anilist.co"]
|
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"]
|
F --> H["AniList Response<br/>JSON"]
|
||||||
G --> I["Pipe Response<br/>base64url + gzip"]
|
G --> I["Pipe Response<br/>base64url + gzip"]
|
||||||
@@ -139,7 +181,9 @@ flowchart TD
|
|||||||
- **AniList GraphQL** for rich metadata
|
- **AniList GraphQL** for rich metadata
|
||||||
- **Miruro pipe** for streaming sources
|
- **Miruro pipe** for streaming sources
|
||||||
- **Smart caching** with configurable TTL
|
- **Smart caching** with configurable TTL
|
||||||
- **18 RESTful endpoints**
|
- **46 RESTful endpoints**
|
||||||
|
- **Gzip compression** — 30-70% smaller responses
|
||||||
|
- **Request logging** — method, path, status, duration
|
||||||
- **Graceful error handling** per endpoint
|
- **Graceful error handling** per endpoint
|
||||||
- **Rate limiting** (100 req/min per IP)
|
- **Rate limiting** (100 req/min per IP)
|
||||||
|
|
||||||
@@ -153,6 +197,9 @@ flowchart TD
|
|||||||
- **Characters** with voice actors
|
- **Characters** with voice actors
|
||||||
- **Relations** and **Recommendations**
|
- **Relations** and **Recommendations**
|
||||||
- **Airing schedule** by date
|
- **Airing schedule** by date
|
||||||
|
- **Seasonal anime** by year + season
|
||||||
|
- **Studio filter** — anime by studio name
|
||||||
|
- **Character & Staff search**
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -166,6 +213,9 @@ flowchart TD
|
|||||||
- **Download links** when available
|
- **Download links** when available
|
||||||
- **Sub/Dub** support per provider
|
- **Sub/Dub** support per provider
|
||||||
- **Codec and fansub** metadata
|
- **Codec and fansub** metadata
|
||||||
|
- **Batch episode fetching** — multiple sources in parallel
|
||||||
|
- **Quality fallback** — auto 1080p → 720p → 360p
|
||||||
|
- **Subtitle extraction** — expose subtitle URLs
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -186,7 +236,7 @@ flowchart TD
|
|||||||
|
|
||||||
| Feature | Description | Status |
|
| Feature | Description | Status |
|
||||||
|:---|:---|:---:|
|
|:---|:---|:---:|
|
||||||
| 🎬 18 API Endpoints | Complete anime data coverage | ✅ |
|
| 🎬 46 API Endpoints | Complete anime data coverage | ✅ |
|
||||||
| 🔍 Full-Text Search | Keyword search with pagination | ✅ |
|
| 🔍 Full-Text Search | Keyword search with pagination | ✅ |
|
||||||
| 💡 Search Suggestions | Fast autocomplete | ✅ |
|
| 💡 Search Suggestions | Fast autocomplete | ✅ |
|
||||||
| 🎯 Advanced Filtering | Genre, year, season, format, sort | ✅ |
|
| 🎯 Advanced Filtering | Genre, year, season, format, sort | ✅ |
|
||||||
@@ -213,10 +263,10 @@ flowchart TD
|
|||||||
|
|
||||||
| Source | Domain | Data |
|
| Source | Domain | Data |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
| 📺 **Miruro** | `miruro.tv` | Episodes, streaming sources (M3U8 URLs) |
|
| 📺 **Miruro** | `miruro.to` | Episodes, streaming sources (M3U8 URLs) |
|
||||||
| 📺 **Miruro** | `miruro.to` | Mirror domain |
|
|
||||||
| 📺 **Miruro** | `miruro.bz` | Mirror domain |
|
|
||||||
| 📺 **Miruro** | `miruro.ru` | Mirror domain |
|
| 📺 **Miruro** | `miruro.ru` | Mirror domain |
|
||||||
|
| 📺 **Miruro** | `miruro.bz` | Mirror domain |
|
||||||
|
| 📺 **Miruro** | `miruro.tv` | Mirror domain |
|
||||||
|
|
||||||
### 🎬 Streaming Providers
|
### 🎬 Streaming Providers
|
||||||
|
|
||||||
@@ -318,7 +368,7 @@ MiruroAPI/
|
|||||||
│ │ └── 📄 cache.js # 💾 In-memory cache with TTL
|
│ │ └── 📄 cache.js # 💾 In-memory cache with TTL
|
||||||
│ │
|
│ │
|
||||||
│ └── 📂 routes/ # 🛤️ Express routes
|
│ └── 📂 routes/ # 🛤️ Express routes
|
||||||
│ └── 📄 apiRoutes.js # 🌐 Main API routes (18 endpoints)
|
│ └── 📄 apiRoutes.js # 🌐 Main API routes (46 endpoints)
|
||||||
│
|
│
|
||||||
├── 📄 server.js # 🚀 Express server entry point
|
├── 📄 server.js # 🚀 Express server entry point
|
||||||
├── 📄 package.json # 📦 Dependencies & scripts
|
├── 📄 package.json # 📦 Dependencies & scripts
|
||||||
@@ -389,7 +439,15 @@ bun dev
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
| `PORT` | `3000` | Server port (Express mode only) |
|
| `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
|
### Vercel Configuration
|
||||||
|
|
||||||
@@ -481,13 +539,13 @@ console.log(resp.data);
|
|||||||
"success": true,
|
"success": true,
|
||||||
"results": {
|
"results": {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"version": "1.2.0",
|
"version": "2.3.2",
|
||||||
"uptime": "0h 0m 34s",
|
"uptime": "0h 0m 34s",
|
||||||
"uptimeSeconds": 34,
|
"uptimeSeconds": 34,
|
||||||
"timestamp": "2026-06-09T09:55:00.884Z",
|
"timestamp": "2026-06-09T09:55:00.884Z",
|
||||||
"node": "v24.14.1",
|
"node": "v24.14.1",
|
||||||
"memory": { "used": "13MB", "total": "15MB" },
|
"memory": { "used": "13MB", "total": "15MB" },
|
||||||
"endpoints": 16,
|
"endpoints": 46,
|
||||||
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
|
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,7 +586,7 @@ console.log(resp.data);
|
|||||||
"uptime": "0h 0m 34s",
|
"uptime": "0h 0m 34s",
|
||||||
"requests": { "total": 156, "errors": 3, "successRate": "98.1%" },
|
"requests": { "total": 156, "errors": 3, "successRate": "98.1%" },
|
||||||
"cache": { "size": 12, "maxSize": 100, "ttl": "1 min" },
|
"cache": { "size": 12, "maxSize": 100, "ttl": "1 min" },
|
||||||
"endpoints": 16,
|
"endpoints": 46,
|
||||||
"timestamp": "2026-06-09T09:55:00.884Z"
|
"timestamp": "2026-06-09T09:55:00.884Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1362,6 +1420,35 @@ docker build -t miruroapi .
|
|||||||
docker run -p 3000:3000 miruroapi
|
docker run -p 3000:3000 miruroapi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ⚡ Cloudflare Worker (Streaming Fixed)
|
||||||
|
|
||||||
|
> **Streaming endpoints work on Cloudflare Workers** — edge-to-edge requests bypass Cloudflare bot detection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install wrangler CLI
|
||||||
|
npm install -g wrangler
|
||||||
|
|
||||||
|
# Login to Cloudflare
|
||||||
|
wrangler login
|
||||||
|
|
||||||
|
# Deploy worker
|
||||||
|
wrangler deploy worker.js --name miruroapi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Workers Work:**
|
||||||
|
```
|
||||||
|
Vercel (datacenter) → miruro.to → Cloudflare blocks ❌
|
||||||
|
Worker (edge) → miruro.to → Cloudflare trusts itself ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:** Zero cold starts, 100K free requests/day, no npm dependencies, 15 API endpoints.
|
||||||
|
|
||||||
|
**Optional env vars:**
|
||||||
|
```bash
|
||||||
|
wrangler secret put SCRAPER_API_KEY # ScraperAPI premium key ($49/mo)
|
||||||
|
wrangler secret put FLARESOLVERR_URL # FlareSolverr Docker URL
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📜 Available Scripts
|
## 📜 Available Scripts
|
||||||
@@ -1399,9 +1486,15 @@ docker run -p 3000:3000 miruroapi
|
|||||||
|
|
||||||
| Version | Date | Key Changes |
|
| Version | Date | Key Changes |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
| **1.2.0** | 2026-06-09 | Critical response format fix, full endpoint diagnostic, 18/18 passing |
|
| **2.3.2** | 2026-08-05 | Cloudflare Worker implementation — edge-to-edge streaming bypass, wrangler.toml, 15 Worker endpoints |
|
||||||
| **1.1.0** | 2026-06-09 | Swagger UI docs, OpenAPI spec, mappings field, Docker, landing page |
|
| **2.3.1** | 2026-07-02 | Self-healing fallback system — multi-method pipe recovery, /api/pipe-health endpoint, ScraperAPI + FlareSolverr support |
|
||||||
| **1.0.0** | 2026-06-09 | Initial release — 16 endpoints, AniList GraphQL + Miruro pipe, caching |
|
| **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.
|
> 📝 See [CHANGELOG.md](./CHANGELOG.md) for the full version history.
|
||||||
|
|
||||||
@@ -1412,12 +1505,144 @@ docker run -p 3000:3000 miruroapi
|
|||||||
| Problem | Cause | Solution |
|
| Problem | Cause | Solution |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
| ❌ `npm install` fails | Node.js version too old | Upgrade to Node.js 20+ (`node -v`) |
|
| ❌ `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 `/` |
|
| ❌ 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 |
|
| ❌ 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 |
|
| ❌ 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 |
|
| ❌ 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 |
|
| ❌ 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: FlareSolverr (Best — Free, Works!)
|
||||||
|
|
||||||
|
FlareSolverr runs a headless browser that bypasses Cloudflare automatically. This is the most reliable fix.
|
||||||
|
|
||||||
|
```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 2: ScraperAPI (Paid — $49/month)
|
||||||
|
|
||||||
|
ScraperAPI proxies your requests through residential IPs, bypassing Cloudflare.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Sign up at https://www.scraperapi.com/
|
||||||
|
# 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: Self-Host the Entire API
|
||||||
|
|
||||||
|
If you run the API on your own VPS (not Vercel), requests come from your 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🤝 Contributing Bypass Solutions
|
||||||
|
|
||||||
|
Streaming is blocked on all cloud platforms. We need help! If you can implement any of these, PRs are welcome:
|
||||||
|
|
||||||
|
#### Free / Open Source (Best for self-hosted)
|
||||||
|
|
||||||
|
| Tool | Language | How It Works | Notes |
|
||||||
|
|------|----------|-------------|-------|
|
||||||
|
| [nodriver](https://github.com/ultrafunkamsterdam/nodriver) | Python | CDP-based browser automation, bypasses automation detection | Successor to undetected-chromedriver |
|
||||||
|
| [Camoufox](https://github.com/nicandris/camoufox) | Python | Stealth Firefox with fingerprint spoofing | 82% baseline Turnstile bypass |
|
||||||
|
| [curl_cffi](https://github.com/nicandris/curl_cffi) | Python | TLS fingerprint impersonation | Lightweight, no browser needed |
|
||||||
|
| [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) | Docker | Headless Chrome proxy server | Can't solve interactive CAPTCHAs |
|
||||||
|
| [patchright](https://github.com/nicandris/patchright) | Python | Patched Playwright fork | Drop-in Playwright replacement |
|
||||||
|
| [scrapy-impersonate](https://github.com/driver-utils/scrapy-impersonate) | Python | Scrapy with TLS impersonation | For Scrapy users |
|
||||||
|
|
||||||
|
#### Paid APIs (Good for cloud deployments)
|
||||||
|
|
||||||
|
| Service | Free Tier | Price | API Example |
|
||||||
|
|---------|-----------|-------|-------------|
|
||||||
|
| [Scrape.do](https://scrape.do) | 1,000 credits/mo | $0.08/1K | `https://api.scrape.do/?url=...&token=...` |
|
||||||
|
| [ZenRows](https://zenrows.com) | Trial | $49/mo | `https://api.zenrows.com/?url=...&apikey=...` |
|
||||||
|
| [ScrapingBee](https://scrapingbee.com) | Trial | $49/mo | `https://app.scrapingbee.com/api/v1/?...` |
|
||||||
|
|
||||||
|
#### What to Implement
|
||||||
|
|
||||||
|
1. **Node.js integration** — Add `nodriver` or `Camoufox` as a pipe fallback in `src/helpers/pipe.js`
|
||||||
|
2. **Scrape.do adapter** — Simple HTTP proxy, 1K free credits/mo, no browser needed
|
||||||
|
3. **Playwright stealth mode** — Use `patchright` or `playwright-extra-stealth` for browser-based bypass
|
||||||
|
4. **Residential proxy support** — Add proxy rotation for pipe requests
|
||||||
|
|
||||||
|
#### How to Contribute
|
||||||
|
|
||||||
|
1. Fork the repo
|
||||||
|
2. Add your bypass method as a new fallback in `src/helpers/pipe.js`
|
||||||
|
3. Add your API key to `.env.example`
|
||||||
|
4. Test with: `curl http://localhost:3000/api/episodes/20`
|
||||||
|
5. Submit a PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Add Scrape.do as a fallback
|
||||||
|
SCRAPE_DO_TOKEN=your_token_here
|
||||||
|
```
|
||||||
|
|
||||||
### 🐛 Debug Mode
|
### 🐛 Debug Mode
|
||||||
|
|
||||||
@@ -1456,7 +1681,7 @@ Use <code>/api/filter</code> with query params. Combine <code>genre</code>, <cod
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>📡 Can I use this in my frontend app?</b></summary>
|
<summary><b>📡 Can I use this in my frontend app?</b></summary>
|
||||||
<br/>
|
<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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -1477,6 +1702,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.
|
12 providers: kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli. Not all anime are available on every provider.
|
||||||
</details>
|
</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
|
## 🗺️ Roadmap
|
||||||
@@ -1494,7 +1737,7 @@ Yes! Use <code>npm start</code> to run the Express server on any VPS, Docker con
|
|||||||
|
|
||||||
### ✅ Completed
|
### ✅ Completed
|
||||||
|
|
||||||
- [x] 🎬 18 API endpoints covering all data
|
- [x] 🎬 46 API endpoints covering all data
|
||||||
- [x] 🔍 Full-text search with pagination
|
- [x] 🔍 Full-text search with pagination
|
||||||
- [x] 💡 Search suggestions for autocomplete
|
- [x] 💡 Search suggestions for autocomplete
|
||||||
- [x] 🎯 Advanced filtering (genre, year, season, format, sort)
|
- [x] 🎯 Advanced filtering (genre, year, season, format, sort)
|
||||||
@@ -1507,6 +1750,8 @@ Yes! Use <code>npm start</code> to run the Express server on any VPS, Docker con
|
|||||||
- [x] 🐳 Docker support
|
- [x] 🐳 Docker support
|
||||||
- [x] 📘 Swagger UI interactive docs
|
- [x] 📘 Swagger UI interactive docs
|
||||||
- [x] 📖 Comprehensive documentation with real API data
|
- [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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1576,8 +1821,8 @@ git push origin feature/amazing-feature
|
|||||||
- ✅ Update CHANGELOG.md with your changes
|
- ✅ Update CHANGELOG.md with your changes
|
||||||
- ✅ Keep PRs focused — one feature or fix per PR
|
- ✅ Keep PRs focused — one feature or fix per PR
|
||||||
- ✅ Add JSDoc comments for new functions
|
- ✅ Add JSDoc comments for new functions
|
||||||
- ❌ Don't commit `node_modules` or cache files
|
- ❎ Don't commit `node_modules` or cache files
|
||||||
- ❌ Don't add unrelated changes to a single PR
|
- ❎ Don't add unrelated changes to a single PR
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1588,7 +1833,7 @@ git push origin feature/amazing-feature
|
|||||||
| Source | About |
|
| Source | About |
|
||||||
|:---|:---|
|
|:---|:---|
|
||||||
| [AniList](https://anilist.co) | Anime metadata API (GraphQL) |
|
| [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 TO](https://miruro.to) | Mirror domain |
|
||||||
| [Miruro BZ](https://miruro.bz) | Mirror domain |
|
| [Miruro BZ](https://miruro.bz) | Mirror domain |
|
||||||
| [Miruro RU](https://miruro.ru) | Mirror domain |
|
| [Miruro RU](https://miruro.ru) | Mirror domain |
|
||||||
@@ -1657,8 +1902,10 @@ Free to use, modify, and distribute — see the [LICENSE](LICENSE) file for deta
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<div align="center">
|
||||||
|
<p align="center">
|
||||||
<b>Made With ❤️ For The Anime Community</b>
|
<b>Made With ❤️ For The Anime Community</b>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
<sub>© 2026 Shineii86. All Rights Reserved.</sub>
|
<sub>© Shinei Nouzen. All Rights Reserved.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +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
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* 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/";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// 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");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 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 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 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");
|
||||||
|
|
||||||
|
return { url, referer };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 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");
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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.to/",
|
||||||
|
Origin: "https://www.miruro.to",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fetchUrl = targetUrl;
|
||||||
|
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: targetReferer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Fetch with arraybuffer to handle binary content (segments, fonts)
|
||||||
|
const response = await axios.get(fetchUrl, {
|
||||||
|
headers: fetchHeaders,
|
||||||
|
timeout: 15000,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
validateStatus: (s) => s < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers["content-type"] || "";
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
|
||||||
|
// 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 = 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"]);
|
||||||
|
}
|
||||||
|
res.send(Buffer.from(response.data));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, message: "Proxy failed: " + err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: proxy.js
|
||||||
+14
-2
@@ -62,7 +62,7 @@ MiruroAPI/
|
|||||||
| Runtime | Node.js |
|
| Runtime | Node.js |
|
||||||
| Framework | Express.js |
|
| Framework | Express.js |
|
||||||
| Metadata | AniList GraphQL (`graphql.anilist.co`) |
|
| 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) |
|
| Deployment | Vercel (Serverless) |
|
||||||
| Caching | In-memory Map with 1-5 min TTL |
|
| Caching | In-memory Map with 1-5 min TTL |
|
||||||
| Static Files | Express static middleware |
|
| Static Files | Express static middleware |
|
||||||
@@ -120,11 +120,21 @@ Play M3U8 in HLS player (hls.js, video.js, native)
|
|||||||
|
|
||||||
### Miruro Pipe
|
### Miruro Pipe
|
||||||
|
|
||||||
- **Endpoint:** `https://miruro.tv/api/secure/pipe`
|
- **Endpoint:** `https://miruro.{to,ru}/api/secure/pipe`
|
||||||
- **Data:** Episodes, streaming sources (M3U8 URLs)
|
- **Data:** Episodes, streaming sources (M3U8 URLs)
|
||||||
- **Encoding:** Base64url + gzip compressed request/response
|
- **Encoding:** Base64url + gzip compressed request/response
|
||||||
- **Providers:** kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli
|
- **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
|
## Vercel Configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -188,3 +198,5 @@ Error responses:
|
|||||||
| axios | ^1.8.0 | HTTP requests |
|
| axios | ^1.8.0 | HTTP requests |
|
||||||
| cors | ^2.8.5 | CORS headers |
|
| cors | ^2.8.5 | CORS headers |
|
||||||
| dotenv | ^16.4.0 | Environment variables |
|
| dotenv | ^16.4.0 | Environment variables |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+472
-208
@@ -1,159 +1,164 @@
|
|||||||
# API Endpoints Reference
|
# API Endpoints Reference
|
||||||
|
|
||||||
Complete documentation for all 18 MiruroAPI endpoints.
|
Complete documentation for all 45 MiruroAPI v2.3.0 endpoints.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Health Check](#health-check)
|
### Search & Discovery
|
||||||
- [Stats](#stats)
|
|
||||||
- [Search](#search)
|
- [Search](#search)
|
||||||
- [Suggestions](#suggestions)
|
- [Suggestions](#suggestions)
|
||||||
- [Filter](#filter)
|
- [Filter](#filter)
|
||||||
|
- [Multi-Search](#multi-search)
|
||||||
|
|
||||||
|
### Collections
|
||||||
- [Trending](#trending)
|
- [Trending](#trending)
|
||||||
|
- [Trending Daily](#trending-daily)
|
||||||
|
- [Trending Weekly](#trending-weekly)
|
||||||
- [Popular](#popular)
|
- [Popular](#popular)
|
||||||
|
- [Top](#top)
|
||||||
- [Upcoming](#upcoming)
|
- [Upcoming](#upcoming)
|
||||||
- [Recent](#recent)
|
- [Recent](#recent)
|
||||||
- [Spotlight](#spotlight)
|
- [Spotlight](#spotlight)
|
||||||
- [Schedule](#schedule)
|
- [Schedule](#schedule)
|
||||||
|
- [Random](#random)
|
||||||
|
- [Upcoming Countdown](#upcoming-countdown)
|
||||||
|
|
||||||
|
### Seasonal & Studio
|
||||||
|
- [Seasonal Anime](#seasonal-anime)
|
||||||
|
- [Anime by Studio](#anime-by-studio)
|
||||||
|
|
||||||
|
### Genres & Tags
|
||||||
|
- [Genres](#genres)
|
||||||
|
- [Tags](#tags)
|
||||||
|
- [Anime by Genre](#anime-by-genre)
|
||||||
|
- [Anime by Year](#anime-by-year)
|
||||||
|
- [Anime by Status](#anime-by-status)
|
||||||
|
- [Anime by Format](#anime-by-format)
|
||||||
|
|
||||||
|
### Character & Staff
|
||||||
|
- [Character Detail](#character-detail)
|
||||||
|
- [Staff Detail](#staff-detail)
|
||||||
|
- [Search Characters](#search-characters)
|
||||||
|
- [Search Staff](#search-staff)
|
||||||
|
|
||||||
|
### Anime Details
|
||||||
- [Anime Info](#anime-info)
|
- [Anime Info](#anime-info)
|
||||||
- [Characters](#characters)
|
- [Characters](#characters)
|
||||||
- [Relations](#relations)
|
- [Relations](#relations)
|
||||||
- [Recommendations](#recommendations)
|
- [Recommendations](#recommendations)
|
||||||
|
|
||||||
|
### Streaming & Downloading
|
||||||
- [Episodes](#episodes)
|
- [Episodes](#episodes)
|
||||||
|
- [Batch Episodes](#batch-episodes)
|
||||||
|
- [Sources (Detailed)](#sources-detailed)
|
||||||
|
- [Stream (Quality Fallback)](#stream-quality-fallback)
|
||||||
|
- [Download URL](#download-url)
|
||||||
- [Watch (Streaming)](#watch-streaming)
|
- [Watch (Streaming)](#watch-streaming)
|
||||||
|
|
||||||
---
|
### Utility
|
||||||
|
- [Compare](#compare)
|
||||||
|
- [Genre Stats](#genre-stats)
|
||||||
|
- [Calendar](#calendar)
|
||||||
|
- [Timeline](#timeline)
|
||||||
|
|
||||||
## Health Check
|
### System
|
||||||
|
- [Health Check](#health-check)
|
||||||
```
|
- [Stats](#stats)
|
||||||
GET /api/health
|
- [OpenAPI](#openapi)
|
||||||
```
|
|
||||||
|
|
||||||
Returns API health status, version, uptime, memory usage, and available providers.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"results": {
|
|
||||||
"status": "healthy",
|
|
||||||
"version": "1.2.0",
|
|
||||||
"uptime": "0h 0m 34s",
|
|
||||||
"node": "v24.14.1",
|
|
||||||
"memory": { "used": "13MB", "total": "15MB" },
|
|
||||||
"endpoints": 16,
|
|
||||||
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stats
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/stats
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns request statistics, cache info, and memory usage.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/search
|
GET /api/search?query=naruto
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
Full-text anime search with pagination.
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `query` | string | Yes | — | Search keyword |
|
|
||||||
| `page` | number | No | 1 | Page number |
|
|
||||||
| `per_page` | number | No | 20 | Results per page |
|
|
||||||
|
|
||||||
**Example:** `GET /api/search?query=naruto&per_page=2`
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
**Response:**
|
| query | string | required | Search query |
|
||||||
|
| page | int | 1 | Page number |
|
||||||
```json
|
| per_page | int | 20 | Results per page (max 50) |
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"results": {
|
|
||||||
"page": 1,
|
|
||||||
"perPage": 2,
|
|
||||||
"total": 5000,
|
|
||||||
"hasNextPage": true,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"id": 20,
|
|
||||||
"title": { "romaji": "NARUTO", "english": "Naruto", "native": "NARUTO -ナルト-" },
|
|
||||||
"coverImage": { "large": "https://s4.anilist.co/file/..." },
|
|
||||||
"format": "TV",
|
|
||||||
"episodes": 220,
|
|
||||||
"status": "FINISHED",
|
|
||||||
"averageScore": 80,
|
|
||||||
"genres": ["Action","Adventure","Comedy","Drama","Fantasy","Supernatural"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Suggestions
|
## Suggestions
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/suggestions
|
GET /api/suggestions?query=naruto
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Description |
|
Lightweight autocomplete suggestions (max 8 results).
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `query` | string | Yes | Search keyword |
|
|
||||||
|
|
||||||
**Example:** `GET /api/suggestions?query=naruto`
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
Returns fast autocomplete suggestions with title, poster, format, status, year, and episode count.
|
| query | string | required | Search query |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Filter
|
## Filter
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/filter
|
GET /api/filter?genre=Action&year=2024&season=SPRING
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
Advanced anime filter with multiple parameters.
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `genre` | string | No | — | Genre name (e.g. "Action") |
|
|
||||||
| `tag` | string | No | — | Tag name |
|
|
||||||
| `year` | number | No | — | Release year |
|
|
||||||
| `season` | string | No | — | FALL, WINTER, SPRING, SUMMER |
|
|
||||||
| `format` | string | No | — | TV, MOVIE, OVA, ONA, SPECIAL, MUSIC |
|
|
||||||
| `status` | string | No | — | RELEASING, FINISHED, NOT_YET_RELEASED, CANCELLED |
|
|
||||||
| `sort` | string | No | POPULARITY_DESC | Sort order |
|
|
||||||
| `page` | number | No | 1 | Page number |
|
|
||||||
| `per_page` | number | No | 20 | Results per page |
|
|
||||||
|
|
||||||
**Example:** `GET /api/filter?genre=Action&year=2024&season=WINTER&per_page=3`
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| genre | string | — | Genre name |
|
||||||
|
| tag | string | — | Tag name |
|
||||||
|
| year | int | — | Season year |
|
||||||
|
| season | string | — | WINTER, SPRING, SUMMER, FALL |
|
||||||
|
| format | string | — | TV, MOVIE, OVA, ONA, SPECIAL |
|
||||||
|
| status | string | — | RELEASING, FINISHED, NOT_YET_RELEASED |
|
||||||
|
| sort | string | POPULARITY_DESC | Sort order |
|
||||||
|
| page | int | 1 | Page number |
|
||||||
|
| per_page | int | 20 | Results per page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Search
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/multi-search?q=naruto&genre=Action&year=2024
|
||||||
|
```
|
||||||
|
|
||||||
|
Combined query + filters search.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Trending
|
## Trending
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/trending
|
GET /api/trending?page=1&per_page=20
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
Currently trending anime.
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `per_page` | number | No | 20 | Results per page |
|
|
||||||
|
|
||||||
**Example:** `GET /api/trending?per_page=5`
|
---
|
||||||
|
|
||||||
|
## Trending Daily
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/trending/daily
|
||||||
|
```
|
||||||
|
|
||||||
|
Trending anime from the last 7 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trending Weekly
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/trending/weekly
|
||||||
|
```
|
||||||
|
|
||||||
|
Trending anime from the last 30 days.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,9 +168,17 @@ GET /api/trending
|
|||||||
GET /api/popular
|
GET /api/popular
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
Most popular anime of all time.
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `per_page` | number | No | 20 | Results per page |
|
---
|
||||||
|
|
||||||
|
## Top
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/top
|
||||||
|
```
|
||||||
|
|
||||||
|
Top anime by score (all time).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,9 +188,7 @@ GET /api/popular
|
|||||||
GET /api/upcoming
|
GET /api/upcoming
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
Most anticipated upcoming anime.
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `per_page` | number | No | 20 | Results per page |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,9 +198,7 @@ GET /api/upcoming
|
|||||||
GET /api/recent
|
GET /api/recent
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
Currently airing anime.
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `per_page` | number | No | 20 | Results per page |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,157 +208,381 @@ GET /api/recent
|
|||||||
GET /api/spotlight
|
GET /api/spotlight
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns featured/spotlight anime for the hero carousel.
|
Top 10 trending + popular anime for hero banners.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schedule
|
## Schedule
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/schedule
|
GET /api/schedule?page=1&per_page=20
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
Airing schedule with timestamps.
|
||||||
|-------|------|----------|---------|-------------|
|
|
||||||
| `date` | string | No | today | Date in YYYY-MM-DD format |
|
---
|
||||||
|
|
||||||
|
## Random
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/random
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a random anime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upcoming Countdown
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/upcoming/countdown
|
||||||
|
```
|
||||||
|
|
||||||
|
Next 10 episodes airing soon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seasonal Anime
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/season/2025/spring
|
||||||
|
```
|
||||||
|
|
||||||
|
Anime for a specific year and season.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| year | int | required | Year (e.g., 2025) |
|
||||||
|
| season | string | required | WINTER, SPRING, SUMMER, FALL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anime by Studio
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/studio/MAPPA
|
||||||
|
```
|
||||||
|
|
||||||
|
All anime produced by a specific studio.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| name | string | required | Studio name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Genres
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/genres
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all available genres from AniList.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/tags
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns all available tags from AniList.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anime by Genre
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/genre/Action
|
||||||
|
```
|
||||||
|
|
||||||
|
All anime in a specific genre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anime by Year
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/year/2024
|
||||||
|
```
|
||||||
|
|
||||||
|
All anime from a specific year.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anime by Status
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/status/RELEASING
|
||||||
|
```
|
||||||
|
|
||||||
|
All anime with a specific status (RELEASING, FINISHED, NOT_YET_RELEASED).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anime by Format
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/format/MOVIE
|
||||||
|
```
|
||||||
|
|
||||||
|
All anime with a specific format (TV, MOVIE, OVA, ONA, SPECIAL).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Character Detail
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/character/1
|
||||||
|
```
|
||||||
|
|
||||||
|
Detailed character info with anime list.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| id | int | required | AniList character ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Staff Detail
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/staff/1
|
||||||
|
```
|
||||||
|
|
||||||
|
Detailed staff info with anime list.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| id | int | required | AniList staff ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Characters
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/characters?query=kaneki
|
||||||
|
```
|
||||||
|
|
||||||
|
Search characters by name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Staff
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/staff?query=oda
|
||||||
|
```
|
||||||
|
|
||||||
|
Search staff by name.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Anime Info
|
## Anime Info
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/info/:id
|
GET /api/info/20605
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Description |
|
Complete anime info including characters, relations, recommendations, trailer, stats.
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `id` | number | Yes | AniList anime ID |
|
|
||||||
|
|
||||||
**Example:** `GET /api/info/20`
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"results": {
|
|
||||||
"id": 20,
|
|
||||||
"idMal": 20,
|
|
||||||
"title": { "romaji": "NARUTO", "english": "Naruto", "native": "NARUTO -ナルト-" },
|
|
||||||
"description": "Naruto Uzumaki, a hyperactive and knuckle-headed ninja...",
|
|
||||||
"coverImage": { "large": "https://s4.anilist.co/file/..." },
|
|
||||||
"format": "TV",
|
|
||||||
"episodes": 220,
|
|
||||||
"duration": 23,
|
|
||||||
"status": "FINISHED",
|
|
||||||
"averageScore": 80,
|
|
||||||
"genres": ["Action","Adventure","Comedy","Drama","Fantasy","Supernatural"],
|
|
||||||
"studios": [{ "name": "Studio Pierrot", "isAnimationStudio": true }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Characters
|
## Characters
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/anime/:id/characters
|
GET /api/anime/20605/characters?page=1&per_page=25
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Description |
|
Paginated character list with voice actors.
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `id` | number | Yes | AniList anime ID |
|
|
||||||
|
|
||||||
**Example:** `GET /api/anime/20/characters`
|
|
||||||
|
|
||||||
Returns characters with voice actors.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Relations
|
## Relations
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/anime/:id/relations
|
GET /api/anime/20605/relations
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Description |
|
Related anime (sequels, prequels, spin-offs).
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `id` | number | Yes | AniList anime ID |
|
|
||||||
|
|
||||||
Returns related anime (sequels, prequels, side stories, etc.).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommendations
|
## Recommendations
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/anime/:id/recommendations
|
GET /api/anime/20605/recommendations?page=1&per_page=10
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Description |
|
Community recommendations sorted by rating.
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `id` | number | Yes | AniList anime ID |
|
|
||||||
|
|
||||||
Returns recommended anime based on the given ID.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Episodes
|
## Episodes
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/episodes/:id
|
GET /api/episodes/20605
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Description |
|
Episode list from all providers with sub/dub support.
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `id` | number | Yes | AniList anime ID |
|
|
||||||
|
|
||||||
**Example:** `GET /api/episodes/20`
|
---
|
||||||
|
|
||||||
**Response:**
|
## Batch Episodes
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"results": {
|
|
||||||
"providers": {
|
|
||||||
"kiwi": {
|
|
||||||
"meta": { "id": "1571", "title": "Naruto", "type": "TV" },
|
|
||||||
"episodes": {
|
|
||||||
"sub": [
|
|
||||||
{
|
|
||||||
"id": "watch/kiwi/20/sub/animepahe-1",
|
|
||||||
"number": 1,
|
|
||||||
"title": "Enter: Naruto Uzumaki!",
|
|
||||||
"airDate": "2002-10-03",
|
|
||||||
"audio": "sub",
|
|
||||||
"filler": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
GET /api/episodes/20605/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetch multiple episode sources in parallel.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| provider | string | kiwi | Provider name |
|
||||||
|
| category | string | sub | sub or dub |
|
||||||
|
| slugs | string | required | Comma-separated episode slugs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources (Detailed)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/sources?episodeId=xxx&provider=kiwi&anilistId=20605&category=sub
|
||||||
|
```
|
||||||
|
|
||||||
|
Streaming sources with raw episode ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stream (Quality Fallback)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/stream?provider=kiwi&anilistId=20605&category=sub&slug=animepahe-1&quality=1080p
|
||||||
|
```
|
||||||
|
|
||||||
|
Streaming sources with automatic quality fallback.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| provider | string | required | Provider name |
|
||||||
|
| anilistId | int | required | AniList anime ID |
|
||||||
|
| category | string | sub | sub or dub |
|
||||||
|
| slug | string | required | Episode slug |
|
||||||
|
| quality | string | 1080p | Preferred quality |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download URL
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/download?provider=kiwi&anilistId=20605&category=sub&slug=animepahe-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the download URL for an episode.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Watch (Streaming)
|
## Watch (Streaming)
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/watch/:provider/:anilistId/:category/:slug
|
GET /api/watch/kiwi/20605/sub/animepahe-1
|
||||||
```
|
```
|
||||||
|
|
||||||
| Param | Type | Required | Description |
|
Streaming sources via URL path (recommended).
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `provider` | string | Yes | Provider name (kiwi, pewe, etc.) |
|
|
||||||
| `anilistId` | number | Yes | AniList anime ID |
|
|
||||||
| `category` | string | Yes | sub or dub |
|
|
||||||
| `slug` | string | Yes | Episode slug from episodes response |
|
|
||||||
|
|
||||||
**Example:** `GET /api/watch/kiwi/20/sub/animepahe-1`
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| provider | string | Provider name |
|
||||||
|
| anilistId | int | AniList anime ID |
|
||||||
|
| category | string | sub or dub |
|
||||||
|
| slug | string | Episode slug |
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| streams | array | Array of stream objects (hls/embed) |
|
||||||
|
| download | string | Direct download URL (if available) |
|
||||||
|
| subtitles | array | Subtitle objects (if available) |
|
||||||
|
| bestStream | object | Best available HLS stream with quality fallback |
|
||||||
|
|
||||||
|
### Subtitle Object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| url | string | VTT subtitle file URL |
|
||||||
|
| label | string | Display name (e.g., "English") |
|
||||||
|
| language | string | Language code (e.g., "en") |
|
||||||
|
| kind | string | "subtitles" or "captions" |
|
||||||
|
| format | string | "vtt" format |
|
||||||
|
| encoding | string | "utf-8" |
|
||||||
|
| isDefault | boolean | Is default subtitle |
|
||||||
|
|
||||||
|
### Note
|
||||||
|
- Bonk provider returns subtitles (English, VTT format)
|
||||||
|
- Other providers may not return subtitles
|
||||||
|
- Quality fallback: 1080p → 720p → 480p → 360p
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compare
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/compare/20605/20
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare two anime side by side.
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id1 | int | First anime ID |
|
||||||
|
| id2 | int | Second anime ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Genre Stats
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/stats/genre
|
||||||
|
```
|
||||||
|
|
||||||
|
Genre statistics with top anime per genre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Calendar
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/calendar?year=2025&month=6
|
||||||
|
```
|
||||||
|
|
||||||
|
Monthly airing calendar.
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| year | int | current | Year |
|
||||||
|
| month | int | current | Month (1-12) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/timeline/20605
|
||||||
|
```
|
||||||
|
|
||||||
|
Anime release timeline with prequels, sequels, spin-offs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
API health status, version, uptime, memory, cache stats.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
@@ -357,19 +590,50 @@ GET /api/watch/:provider/:anilistId/:category/:slug
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"results": {
|
"results": {
|
||||||
"streams": [
|
"status": "healthy",
|
||||||
{
|
"version": "2.3.0",
|
||||||
"url": "https://vault-01.uwucdn.top/stream/.../uwu.m3u8",
|
"uptime": "0h 0m 34s",
|
||||||
"type": "hls",
|
"node": "v24.14.1",
|
||||||
"quality": "360p",
|
"memory": { "used": "13MB", "total": "15MB", "rss": "25MB" },
|
||||||
"resolution": { "width": 640, "height": 360 },
|
"cache": { "size": 12, "maxSize": 100 },
|
||||||
"codec": "h264",
|
"endpoints": 45,
|
||||||
"audio": "sub",
|
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
|
||||||
"fansub": "df68",
|
|
||||||
"referer": "https://kwik.cx/e/..."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"download": "https://pahe.win/LJmbA"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache & API statistics including request count and success rate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/openapi
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the OpenAPI 3.0.3 specification in JSON format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 📝 All endpoints return `{ "success": true, "results": {...} }` on success and `{ "success": false, "message": "..." }` on error.
|
||||||
|
>
|
||||||
|
> 🎬 Creator attribution is injected into every response: `{ "creator": "Shinei Nouzen", "github": "...", "telegram": "...", "message": "...", "timestamp": "..." }`
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# MiruroAPI Documentation
|
# 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
|
## Overview
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
|||||||
|
|
||||||
## Features
|
## 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
|
- **12 Streaming Providers** — M3U8 URLs with resolution, codec, fansub info
|
||||||
- **No API Key** — Just make requests, no registration needed
|
- **No API Key** — Just make requests, no registration needed
|
||||||
- **In-Memory Cache** — 1-5 minute TTL for fast responses
|
- **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 episodes found" | Check the AniList ID is correct (use `/api/search` first) |
|
||||||
| "No providers" | The anime may not be available on any provider |
|
| "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 |
|
| 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
+1480
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "miruro-api",
|
"name": "miruro-api",
|
||||||
"version": "1.3.0",
|
"version": "2.3.2",
|
||||||
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
"description": "RESTful API for Miruro - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
@@ -9,10 +9,11 @@
|
|||||||
"test": "node test.js"
|
"test": "node test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
|
||||||
"axios": "^1.8.0",
|
"axios": "^1.8.0",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0"
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0"
|
||||||
|
|||||||
+2
-2
@@ -9,14 +9,14 @@
|
|||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://mirurotvapi.vercel.app/docs">
|
<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: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:site_name" content="MiruroAPI">
|
||||||
<meta property="og:locale" content="en_US">
|
<meta property="og:locale" content="en_US">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="MiruroAPI — Swagger UI Documentation">
|
<meta name="twitter:title" content="MiruroAPI — Swagger UI Documentation">
|
||||||
<meta name="twitter:description" content="Interactive Swagger UI documentation for MiruroAPI.">
|
<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="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="robots" content="index, follow">
|
||||||
<meta name="theme-color" content="#0b1622">
|
<meta name="theme-color" content="#0b1622">
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg">
|
<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:type" content="website" />
|
||||||
<meta property="og:url" content="https://mirurotvapi.vercel.app" />
|
<meta property="og:url" content="https://mirurotvapi.vercel.app" />
|
||||||
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
<meta property="og:description" content="Free RESTful API for anime streaming data. 18 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:site_name" content="MiruroAPI" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
@@ -26,13 +26,13 @@
|
|||||||
<meta name="twitter:site" content="@Shineii86" />
|
<meta name="twitter:site" content="@Shineii86" />
|
||||||
<meta name="twitter:creator" content="@Shineii86" />
|
<meta name="twitter:creator" content="@Shineii86" />
|
||||||
<meta name="twitter:title" content="MiruroAPI — Free Anime Streaming API" />
|
<meta name="twitter:title" content="MiruroAPI — Free Anime Streaming API" />
|
||||||
<meta name="twitter:description" content="Free RESTful API for anime streaming data. 18 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" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
<meta name="twitter:image:alt" content="MiruroAPI - Free Anime Streaming API" />
|
<meta name="twitter:image:alt" content="MiruroAPI - Free Anime Streaming API" />
|
||||||
<meta name="twitter:domain" content="mirurotvapi.vercel.app" />
|
<meta name="twitter:domain" content="mirurotvapi.vercel.app" />
|
||||||
|
|
||||||
<!-- ──────────────── Generic Meta ──────────────── -->
|
<!-- ──────────────── Generic Meta ──────────────── -->
|
||||||
<meta name="description" content="Free RESTful API for anime streaming data. 18 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="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="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" />
|
<meta name="robots" content="index, follow" />
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebAPI",
|
"@type": "WebAPI",
|
||||||
"name": "MiruroAPI",
|
"name": "MiruroAPI",
|
||||||
"description": "Free RESTful API for anime streaming data. 18 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",
|
"url": "https://mirurotvapi.vercel.app",
|
||||||
"image": "https://mirurotvapi.vercel.app/og-image.png",
|
"image": "https://mirurotvapi.vercel.app/og-image.png",
|
||||||
"documentation": "https://mirurotvapi.vercel.app/docs",
|
"documentation": "https://mirurotvapi.vercel.app/docs",
|
||||||
@@ -326,7 +326,7 @@
|
|||||||
<section class="hero container">
|
<section class="hero container">
|
||||||
<div class="hero-badge"><span class="dot"></span> Free · No API key · Open source</div>
|
<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>
|
<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. 18 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">
|
<div class="hero-actions">
|
||||||
<a href="#playground" class="btn btn-primary">
|
<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>
|
<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">
|
<section class="stats">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="reveal"><div class="stat-value grad" data-count="18">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-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-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>
|
<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 ── */
|
/* ── Explorer ── */
|
||||||
const explorerData = [
|
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: '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: '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: '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: '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: '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())' } },
|
||||||
{ 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())' } },
|
// ---- FEATURE: Collections ----
|
||||||
{ 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: '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', 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: '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: '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: '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: '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: '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: '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 navEl = document.getElementById('explorerNav');
|
||||||
const panelEl = document.getElementById('explorerPanel');
|
const panelEl = document.getElementById('explorerPanel');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "MiruroAPI",
|
"name": "MiruroAPI",
|
||||||
"short_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": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"id": "/",
|
"id": "/",
|
||||||
|
|||||||
+327
-118
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "MiruroAPI",
|
"title": "MiruroAPI",
|
||||||
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
|
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
|
||||||
"version": "1.1.0",
|
"version": "2.3.0",
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "Shineii86",
|
"name": "Shineii86",
|
||||||
"url": "https://github.com/Shineii86/MiruroAPI"
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
@@ -18,230 +18,439 @@
|
|||||||
"description": "Production"
|
"description": "Production"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "Search", "description": "Search, filter, suggestions" },
|
||||||
|
{ "name": "Collections", "description": "Trending, popular, top, schedule" },
|
||||||
|
{ "name": "Seasonal & Studio", "description": "Seasonal anime, studio filter" },
|
||||||
|
{ "name": "Genres & Tags", "description": "Genre, year, status, format filters" },
|
||||||
|
{ "name": "Character & Staff", "description": "Character/staff details and search" },
|
||||||
|
{ "name": "Anime Details", "description": "Info, characters, relations, recommendations" },
|
||||||
|
{ "name": "Streaming", "description": "Episodes, sources, stream, download" },
|
||||||
|
{ "name": "Utility", "description": "Compare, stats, calendar, timeline" },
|
||||||
|
{ "name": "System", "description": "Health, stats, OpenAPI" }
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/health": {
|
|
||||||
"get": {
|
|
||||||
"tags": ["System"],
|
|
||||||
"summary": "Health check",
|
|
||||||
"operationId": "health",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "API is healthy",
|
|
||||||
"content": { "application/json": { "schema": { "type": "object" } } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/search": {
|
"/api/search": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Search & Discovery"],
|
"tags": ["Search"],
|
||||||
"summary": "Search anime by name",
|
"summary": "Search anime by name",
|
||||||
"operationId": "searchAnime",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Search query" },
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
||||||
],
|
]
|
||||||
"responses": {
|
|
||||||
"200": { "description": "Search results with pagination" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/suggestions": {
|
"/api/suggestions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Search & Discovery"],
|
"tags": ["Search"],
|
||||||
"summary": "Autocomplete suggestions",
|
"summary": "Autocomplete suggestions",
|
||||||
"operationId": "getSuggestions",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
]
|
||||||
"responses": {
|
|
||||||
"200": { "description": "Lightweight suggestions (max 8)" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/filter": {
|
"/api/filter": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Search & Discovery"],
|
"tags": ["Search"],
|
||||||
"summary": "Advanced filter / browse",
|
"summary": "Advanced filter",
|
||||||
"operationId": "filterAnime",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "genre", "in": "query", "schema": { "type": "string" }, "description": "e.g. Action, Romance" },
|
{ "name": "genre", "in": "query", "schema": { "type": "string" } },
|
||||||
{ "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "e.g. Isekai, Time Skip" },
|
{ "name": "tag", "in": "query", "schema": { "type": "string" } },
|
||||||
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
|
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
|
||||||
{ "name": "season", "in": "query", "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } },
|
{ "name": "season", "in": "query", "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } },
|
||||||
{ "name": "format", "in": "query", "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL", "MUSIC"] } },
|
{ "name": "format", "in": "query", "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL"] } },
|
||||||
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"] } },
|
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED"] } },
|
||||||
{ "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["SCORE_DESC", "POPULARITY_DESC", "TRENDING_DESC", "START_DATE_DESC", "FAVOURITES_DESC", "UPDATED_AT_DESC"], "default": "POPULARITY_DESC" } },
|
{ "name": "sort", "in": "query", "schema": { "type": "string", "default": "POPULARITY_DESC" } },
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
||||||
],
|
]
|
||||||
"responses": {
|
|
||||||
"200": { "description": "Filtered results with pagination" }
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/multi-search": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Search"],
|
||||||
|
"summary": "Multi-parameter search"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/trending": {
|
"/api/trending": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Trending anime",
|
"summary": "Trending anime"
|
||||||
"operationId": "getTrending",
|
}
|
||||||
"parameters": [
|
},
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
"/api/trending/daily": {
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
"get": {
|
||||||
],
|
"tags": ["Collections"],
|
||||||
"responses": { "200": { "description": "Trending anime list" } }
|
"summary": "Daily trending anime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/trending/weekly": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Weekly trending anime"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/popular": {
|
"/api/popular": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Most popular anime",
|
"summary": "Most popular anime"
|
||||||
"operationId": "getPopular",
|
}
|
||||||
"parameters": [
|
},
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
"/api/top": {
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
"get": {
|
||||||
],
|
"tags": ["Collections"],
|
||||||
"responses": { "200": { "description": "Popular anime list" } }
|
"summary": "Top anime by score"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/upcoming": {
|
"/api/upcoming": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Upcoming anime",
|
"summary": "Upcoming anime"
|
||||||
"operationId": "getUpcoming",
|
|
||||||
"parameters": [
|
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Upcoming anime list" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/recent": {
|
"/api/recent": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Recently updated anime",
|
"summary": "Recently aired anime"
|
||||||
"operationId": "getRecent",
|
|
||||||
"parameters": [
|
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Recent anime list" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/spotlight": {
|
"/api/spotlight": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Spotlight / featured anime",
|
"summary": "Spotlight/featured anime"
|
||||||
"operationId": "getSpotlight",
|
|
||||||
"responses": { "200": { "description": "Top 10 trending + popular" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/schedule": {
|
"/api/schedule": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Airing schedule",
|
"summary": "Airing schedule"
|
||||||
"operationId": "getSchedule",
|
}
|
||||||
|
},
|
||||||
|
"/api/random": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Random anime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/upcoming/countdown": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Next 10 episodes airing soon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/season/{year}/{season}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Seasonal & Studio"],
|
||||||
|
"summary": "Seasonal anime",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
{ "name": "year", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
{ "name": "season", "in": "path", "required": true, "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Upcoming episodes with timestamps" } }
|
}
|
||||||
|
},
|
||||||
|
"/api/studio/{name}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Seasonal & Studio"],
|
||||||
|
"summary": "Anime by studio",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/genres": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "All genres"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/tags": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "All tags"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/genre/{name}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by genre",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/year/{year}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by year",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "year", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/status/{status}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by status",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "status", "in": "path", "required": true, "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED"] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/format/{format}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by format",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "format", "in": "path", "required": true, "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL"] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/character/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Character detail",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/staff/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Staff detail",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/characters": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Search characters",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/staff": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Search staff",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/info/{id}": {
|
"/api/info/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Full anime info",
|
"summary": "Full anime info",
|
||||||
"operationId": "getAnimeInfo",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Complete anime metadata, characters, relations, recommendations" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/anime/{id}/characters": {
|
"/api/anime/{id}/characters": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Characters with voice actors",
|
"summary": "Characters",
|
||||||
"operationId": "getCharacters",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
]
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 25 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Paginated character list" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/anime/{id}/relations": {
|
"/api/anime/{id}/relations": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Related anime",
|
"summary": "Relations",
|
||||||
"operationId": "getRelations",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Sequels, prequels, side stories, etc." } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/anime/{id}/recommendations": {
|
"/api/anime/{id}/recommendations": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Community recommendations",
|
"summary": "Recommendations",
|
||||||
"operationId": "getRecommendations",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
]
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 10 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Recommended anime sorted by rating" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/episodes/{id}": {
|
"/api/episodes/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Streaming"],
|
"tags": ["Streaming"],
|
||||||
"summary": "Episode list from all providers",
|
"summary": "Episode list",
|
||||||
"operationId": "getEpisodes",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Episodes organized by provider and audio type (sub/dub)" } }
|
}
|
||||||
|
},
|
||||||
|
"/api/episodes/{id}/batch": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Batch episode sources",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "provider", "in": "query", "schema": { "type": "string", "default": "kiwi" } },
|
||||||
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } },
|
||||||
|
{ "name": "slugs", "in": "query", "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/sources": {
|
"/api/sources": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Streaming"],
|
"tags": ["Streaming"],
|
||||||
"summary": "Streaming sources (detailed)",
|
"summary": "Streaming sources (detailed)",
|
||||||
"operationId": "getSources",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "episodeId", "in": "query", "required": true, "schema": { "type": "string" } },
|
{ "name": "episodeId", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
||||||
{ "name": "category", "in": "query", "schema": { "type": "string", "enum": ["sub", "dub"], "default": "sub" } }
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
|
}
|
||||||
|
},
|
||||||
|
"/api/stream": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Stream with quality fallback",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } },
|
||||||
|
{ "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "quality", "in": "query", "schema": { "type": "string", "default": "1080p" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/download": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Download URL",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } },
|
||||||
|
{ "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
|
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Streaming"],
|
"tags": ["Streaming"],
|
||||||
"summary": "Streaming sources (simple slug-based)",
|
"summary": "Streaming sources (simple) with subtitles",
|
||||||
"operationId": "getWatchSources",
|
"description": "Returns streaming sources, subtitles (if available), and best stream with quality fallback",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "provider", "in": "path", "required": true, "schema": { "type": "string" } },
|
{ "name": "provider", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "anilistId", "in": "path", "required": true, "schema": { "type": "integer" } },
|
{ "name": "anilistId", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string", "enum": ["sub", "dub"] } },
|
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "e.g. animepahe-1" }
|
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": { "type": "boolean" },
|
||||||
|
"results": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"streams": { "type": "array" },
|
||||||
|
"download": { "type": "string" },
|
||||||
|
"subtitles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": { "type": "string", "description": "VTT subtitle file URL" },
|
||||||
|
"label": { "type": "string", "description": "Display name" },
|
||||||
|
"language": { "type": "string", "description": "Language code" },
|
||||||
|
"kind": { "type": "string", "description": "subtitles or captions" },
|
||||||
|
"format": { "type": "string", "description": "vtt format" },
|
||||||
|
"encoding": { "type": "string", "description": "utf-8" },
|
||||||
|
"isDefault": { "type": "boolean", "description": "Is default subtitle" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tags": [
|
"bestStream": { "type": "object" }
|
||||||
{ "name": "System", "description": "Health & status" },
|
}
|
||||||
{ "name": "Search & Discovery", "description": "Search, suggestions, and advanced filtering" },
|
}
|
||||||
{ "name": "Collections", "description": "Trending, popular, upcoming, recent, spotlight, schedule" },
|
}
|
||||||
{ "name": "Anime Details", "description": "Full anime info, characters, relations, recommendations" },
|
}
|
||||||
{ "name": "Streaming", "description": "Episode lists and M3U8 streaming sources" }
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/compare/{id1}/{id2}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Utility"],
|
||||||
|
"summary": "Compare two anime",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id1", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "id2", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stats/genre": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Utility"],
|
||||||
|
"summary": "Genre statistics"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/calendar": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Utility"],
|
||||||
|
"summary": "Monthly airing calendar",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
|
||||||
|
{ "name": "month", "in": "query", "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/timeline/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Utility"],
|
||||||
|
"summary": "Anime timeline",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/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"],
|
||||||
|
"summary": "API statistics"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/openapi": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["System"],
|
||||||
|
"summary": "OpenAPI spec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>3. Third-Party Data Sources</h2>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Main entry point for the MiruroAPI Express server.
|
* Main entry point for the MiruroAPI Express server.
|
||||||
* Configures CORS, middleware, static files, Swagger docs,
|
* Configures CORS, compression, logging, security headers,
|
||||||
* API routes, and 404 handling. Starts the server on the
|
* rate limiting, API routes, and 404 handling.
|
||||||
* configured port.
|
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* None (side-effect: starts Express server)
|
* None (side-effect: starts Express server)
|
||||||
@@ -19,7 +18,9 @@
|
|||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
|
const compression = require("compression");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -28,54 +29,138 @@ require("dotenv").config();
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const startTime = Date.now();
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(",")
|
? process.env.ALLOWED_ORIGINS.split(",")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COMPRESSION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Gzip compression — reduces response size by 30-70% ----
|
||||||
|
app.use(compression({
|
||||||
|
level: 6,
|
||||||
|
threshold: 1024,
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers["x-no-compression"]) return false;
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// REQUEST LOGGING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Request logging — method, path, status, response time ----
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const originalEnd = res.end;
|
||||||
|
|
||||||
|
res.end = function (...args) {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const status = res.statusCode;
|
||||||
|
const ip =
|
||||||
|
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
||||||
|
req.ip ||
|
||||||
|
req.connection.remoteAddress;
|
||||||
|
|
||||||
|
// NOTE: Only log API requests, skip static files
|
||||||
|
if (req.path.startsWith("/api/")) {
|
||||||
|
const log = `[${new Date().toISOString()}] ${req.method} ${req.path} ${status} ${duration}ms ${ip}`;
|
||||||
|
if (status >= 400) {
|
||||||
|
console.error(log);
|
||||||
|
} else {
|
||||||
|
console.log(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalEnd.apply(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CORS MIDDLEWARE
|
// CORS MIDDLEWARE
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// NOTE: Single unified CORS middleware — handles all origin validation
|
// ---- FEATURE: Unified CORS middleware with preflight handling ----
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (
|
const isProduction = process.env.VERCEL || process.env.NODE_ENV === "production";
|
||||||
!allowedOrigins ||
|
|
||||||
allowedOrigins.includes("*") ||
|
if (allowedOrigins && allowedOrigins.length > 0) {
|
||||||
(origin && allowedOrigins.includes(origin))
|
if (allowedOrigins.includes("*")) {
|
||||||
) {
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
} else if (origin && allowedOrigins.includes(origin)) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
|
} else if (!origin && !isProduction) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
} else if (origin) {
|
||||||
|
return res.status(403).json({ success: false, message: "Origin not allowed" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||||
}
|
}
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET");
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-No-Compression");
|
||||||
|
res.setHeader("Access-Control-Max-Age", "86400");
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SECURITY HEADERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Security headers ----
|
// ---- FEATURE: Security headers ----
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader("X-Frame-Options", "DENY");
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||||
|
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CACHE HEADERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Cache-Control headers for API responses ----
|
||||||
|
app.use("/api", (req, res, next) => {
|
||||||
|
// NOTE: Different cache durations based on endpoint type
|
||||||
|
if (req.path.includes("/health") || req.path.includes("/stats")) {
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
} else if (req.path.includes("/search") || req.path.includes("/suggestions")) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=60");
|
||||||
|
} else if (req.path.includes("/episodes") || req.path.includes("/sources") || req.path.includes("/watch")) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=120");
|
||||||
|
} else {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
}
|
||||||
|
res.setHeader("Vary", "Accept-Encoding");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// STATIC FILES
|
// STATIC FILES
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "public"), { redirect: false }));
|
app.use(express.static(path.join(__dirname, "public"), {
|
||||||
|
redirect: false,
|
||||||
|
maxAge: "1d",
|
||||||
|
}));
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CLEAN URL ROUTES
|
// CLEAN URL ROUTES
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Clean URL routes (no .html extension) ----
|
|
||||||
const fs = require("fs");
|
|
||||||
const publicDir = path.join(__dirname, "public");
|
const publicDir = path.join(__dirname, "public");
|
||||||
|
|
||||||
app.get("/tos", (req, res) => {
|
app.get("/tos", (req, res) => {
|
||||||
@@ -90,7 +175,6 @@ app.get("/privacy", (req, res) => {
|
|||||||
// SWAGGER UI DOCS
|
// SWAGGER UI DOCS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Interactive Swagger UI documentation at /docs ----
|
|
||||||
app.get("/docs", (req, res) => {
|
app.get("/docs", (req, res) => {
|
||||||
res.sendFile(path.join(publicDir, "docs.html"));
|
res.sendFile(path.join(publicDir, "docs.html"));
|
||||||
});
|
});
|
||||||
@@ -99,24 +183,36 @@ app.get("/docs", (req, res) => {
|
|||||||
// RESPONSE HELPERS
|
// RESPONSE HELPERS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Standardized JSON response wrapper ----
|
// ---- FEATURE: Response helper — wrap data in standard success format ----
|
||||||
/**
|
/**
|
||||||
* Wraps data in a standardized success JSON response.
|
* Wraps data in the standard API success response format.
|
||||||
|
* All successful responses follow: { success: true, results: data }
|
||||||
*
|
*
|
||||||
* @param {object} res - Express response object
|
* @param {import('express').Response} res - Express response object
|
||||||
* @param {*} data - The data to return in the response
|
* @param {any} data - The data to wrap
|
||||||
* @param {number} status - HTTP status code (default: 200)
|
* @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) =>
|
const jsonResponse = (res, data, status = 200) =>
|
||||||
res.status(status).json({ success: true, results: data });
|
res.status(status).json({ success: true, results: data });
|
||||||
|
|
||||||
// ---- FEATURE: Standardized error response wrapper ----
|
// ---- FEATURE: Error helper — wrap message in standard error format ----
|
||||||
/**
|
/**
|
||||||
* Returns a standardized error JSON response.
|
* Wraps an error message in the standard API error response format.
|
||||||
|
* All error responses follow: { success: false, message: "..." }
|
||||||
*
|
*
|
||||||
* @param {object} res - Express response object
|
* @param {import('express').Response} res - Express response object
|
||||||
* @param {string} message - Error message to return
|
* @param {string} [message="Internal server error"] - Error description
|
||||||
* @param {number} status - HTTP status code (default: 500)
|
* @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) =>
|
const jsonError = (res, message = "Internal server error", status = 500) =>
|
||||||
res.status(status).json({ success: false, message });
|
res.status(status).json({ success: false, message });
|
||||||
@@ -128,7 +224,11 @@ const jsonError = (res, message = "Internal server error", status = 500) =>
|
|||||||
// ---- FEATURE: Rate limiting (100 requests per minute per IP) ----
|
// ---- FEATURE: Rate limiting (100 requests per minute per IP) ----
|
||||||
const requestCounts = new Map();
|
const requestCounts = new Map();
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const ip = req.ip || req.connection.remoteAddress;
|
const ip =
|
||||||
|
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
||||||
|
req.ip ||
|
||||||
|
req.connection?.remoteAddress ||
|
||||||
|
"unknown";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
const maxRequests = 100;
|
const maxRequests = 100;
|
||||||
@@ -148,6 +248,15 @@ app.use((req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
timestamps.push(now);
|
timestamps.push(now);
|
||||||
|
|
||||||
|
// NOTE: Aggressive cleanup on serverless — prune all empty entries every request
|
||||||
|
// since memory is per-invocation and doesn't persist
|
||||||
|
if (requestCounts.size > 50) {
|
||||||
|
for (const [key, val] of requestCounts) {
|
||||||
|
if (val.length === 0) requestCounts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +265,7 @@ app.use((req, res, next) => {
|
|||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const apiRoutes = require("./src/routes/apiRoutes");
|
const apiRoutes = require("./src/routes/apiRoutes");
|
||||||
app.use("/api", apiRoutes(jsonResponse, jsonError));
|
app.use("/api", apiRoutes(jsonResponse, jsonError, startTime));
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// 404 HANDLER
|
// 404 HANDLER
|
||||||
@@ -164,7 +273,7 @@ app.use("/api", apiRoutes(jsonResponse, jsonError));
|
|||||||
|
|
||||||
// ---- FEATURE: Global error handler ----
|
// ---- FEATURE: Global error handler ----
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
console.error(`[ERROR] ${err.message}`);
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: err.message || "Internal server error",
|
message: err.message || "Internal server error",
|
||||||
@@ -187,12 +296,14 @@ app.use((req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SERVER START
|
// SERVER START (only when running standalone, not on Vercel)
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
if (!process.env.VERCEL) {
|
||||||
console.info(`MiruroAPI listening at ${PORT}`);
|
app.listen(PORT, () => {
|
||||||
});
|
console.log(`[${new Date().toISOString()}] MiruroAPI v2.3.2 listening at ${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|
||||||
|
|||||||
@@ -1,444 +0,0 @@
|
|||||||
from flask import Flask, jsonify, request
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
ANILIST_API = 'https://graphql.anilist.co'
|
|
||||||
|
|
||||||
MEDIA_FIELDS = '''
|
|
||||||
id
|
|
||||||
idMal
|
|
||||||
title { romaji english native userPreferred }
|
|
||||||
description(asHtml: false)
|
|
||||||
coverImage { large medium color }
|
|
||||||
bannerImage
|
|
||||||
format
|
|
||||||
status
|
|
||||||
episodes
|
|
||||||
duration
|
|
||||||
season
|
|
||||||
seasonYear
|
|
||||||
genres
|
|
||||||
tags { name }
|
|
||||||
averageScore
|
|
||||||
meanScore
|
|
||||||
popularity
|
|
||||||
trending
|
|
||||||
favourites
|
|
||||||
startDate { year month day }
|
|
||||||
endDate { year month day }
|
|
||||||
nextAiringEpisode { episode timeUntilAiring airingAt }
|
|
||||||
studios(isMain: true) { edges { node { id name } isMain } }
|
|
||||||
trailer { id site thumbnail url }
|
|
||||||
rankings { rank type context year season allTime }
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def anilist_query(query, variables=None):
|
|
||||||
if variables is None:
|
|
||||||
variables = {}
|
|
||||||
res = requests.post(ANILIST_API, json={'query': query, 'variables': variables})
|
|
||||||
data = res.json()
|
|
||||||
if 'errors' in data:
|
|
||||||
raise Exception(data['errors'][0]['message'])
|
|
||||||
return data['data']
|
|
||||||
|
|
||||||
|
|
||||||
def format_media(m):
|
|
||||||
return {
|
|
||||||
'id': m['id'],
|
|
||||||
'idMal': m.get('idMal'),
|
|
||||||
'title': m.get('title', {}),
|
|
||||||
'description': m.get('description'),
|
|
||||||
'coverImage': m.get('coverImage', {}),
|
|
||||||
'bannerImage': m.get('bannerImage'),
|
|
||||||
'format': m.get('format'),
|
|
||||||
'status': m.get('status'),
|
|
||||||
'episodes': m.get('episodes'),
|
|
||||||
'duration': m.get('duration'),
|
|
||||||
'season': m.get('season'),
|
|
||||||
'seasonYear': m.get('seasonYear'),
|
|
||||||
'genres': m.get('genres', []),
|
|
||||||
'tags': [t['name'] for t in (m.get('tags') or [])],
|
|
||||||
'averageScore': m.get('averageScore'),
|
|
||||||
'meanScore': m.get('meanScore'),
|
|
||||||
'popularity': m.get('popularity'),
|
|
||||||
'trending': m.get('trending'),
|
|
||||||
'favourites': m.get('favourites'),
|
|
||||||
'startDate': m.get('startDate'),
|
|
||||||
'endDate': m.get('endDate'),
|
|
||||||
'nextAiringEpisode': m.get('nextAiringEpisode'),
|
|
||||||
'studios': [
|
|
||||||
{'id': e['node']['id'], 'name': e['node']['name'], 'isMain': e['isMain']}
|
|
||||||
for e in (m.get('studios', {}).get('edges') or [])
|
|
||||||
],
|
|
||||||
'trailer': m.get('trailer'),
|
|
||||||
'rankings': m.get('rankings', []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def home():
|
|
||||||
return jsonify({
|
|
||||||
'name': 'Miruro Anime API',
|
|
||||||
'version': '1.0.0',
|
|
||||||
'status': 'running',
|
|
||||||
'endpoints': [
|
|
||||||
'GET /api/trending',
|
|
||||||
'GET /api/popular',
|
|
||||||
'GET /api/top',
|
|
||||||
'GET /api/seasonal?season=SPRING&year=2026',
|
|
||||||
'GET /api/schedule?day=MONDAY',
|
|
||||||
'GET /api/airing',
|
|
||||||
'GET /api/search?query=naruto&sort=SCORE_DESC&page=1',
|
|
||||||
'GET /api/info/<id>',
|
|
||||||
'GET /api/info/<id>/episodes',
|
|
||||||
'GET /api/characters/<id>',
|
|
||||||
'GET /api/recommendations/<id>',
|
|
||||||
'GET /api/genres',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/trending')
|
|
||||||
def trending():
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'query {{ Page(perPage: {limit}) {{ media(sort: TRENDING_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} }} }}'
|
|
||||||
data = anilist_query(query)
|
|
||||||
return jsonify({'success': True, 'data': [format_media(m) for m in data['Page']['media']]})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/popular')
|
|
||||||
def popular():
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}'
|
|
||||||
data = anilist_query(query)
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'data': [format_media(m) for m in data['Page']['media']],
|
|
||||||
'pagination': data['Page']['pageInfo'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/top')
|
|
||||||
def top():
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: SCORE_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}'
|
|
||||||
data = anilist_query(query)
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'data': [format_media(m) for m in data['Page']['media']],
|
|
||||||
'pagination': data['Page']['pageInfo'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/seasonal')
|
|
||||||
def seasonal():
|
|
||||||
season = request.args.get('season', 'SPRING').upper()
|
|
||||||
year = request.args.get('year', 2026, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'''query ($season: MediaSeason, $seasonYear: Int) {{
|
|
||||||
Page(perPage: {limit}) {{
|
|
||||||
media(season: $season, seasonYear: $seasonYear, sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'season': season, 'seasonYear': year})
|
|
||||||
return jsonify({'success': True, 'season': season, 'year': year, 'data': [format_media(m) for m in data['Page']['media']] })
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/schedule')
|
|
||||||
def schedule():
|
|
||||||
day = request.args.get('day', '').upper()
|
|
||||||
query = f'''query {{
|
|
||||||
Page(perPage: 50) {{
|
|
||||||
media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
coverImage {{ large medium color }}
|
|
||||||
nextAiringEpisode {{ episode timeUntilAiring airingAt }}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query)
|
|
||||||
episodes = []
|
|
||||||
for m in data['Page']['media']:
|
|
||||||
if not m.get('nextAiringEpisode'):
|
|
||||||
continue
|
|
||||||
ep = m['nextAiringEpisode']
|
|
||||||
air_date = time.gmtime(ep['airingAt'])
|
|
||||||
dow = ['SUNDAY','MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY']
|
|
||||||
entry = {
|
|
||||||
'id': m['id'],
|
|
||||||
'title': m['title']['userPreferred'],
|
|
||||||
'coverImage': m.get('coverImage', {}).get('large'),
|
|
||||||
'nextEpisode': ep['episode'],
|
|
||||||
'airingAt': ep['airingAt'],
|
|
||||||
'dayOfWeek': dow[air_date.tm_wday],
|
|
||||||
'timeUntilAiring': ep['timeUntilAiring'],
|
|
||||||
}
|
|
||||||
if day and dow[air_date.tm_wday] != day:
|
|
||||||
continue
|
|
||||||
episodes.append(entry)
|
|
||||||
episodes.sort(key=lambda x: x['airingAt'])
|
|
||||||
return jsonify({'success': True, 'data': episodes})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/airing')
|
|
||||||
def airing():
|
|
||||||
query = f'''query {{
|
|
||||||
Page(perPage: 25) {{
|
|
||||||
media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
coverImage {{ large medium color }}
|
|
||||||
bannerImage
|
|
||||||
format
|
|
||||||
episodes
|
|
||||||
averageScore
|
|
||||||
nextAiringEpisode {{ episode timeUntilAiring airingAt }}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query)
|
|
||||||
result = []
|
|
||||||
for m in data['Page']['media']:
|
|
||||||
if not m.get('nextAiringEpisode'):
|
|
||||||
continue
|
|
||||||
result.append({
|
|
||||||
'id': m['id'],
|
|
||||||
'title': m['title']['userPreferred'],
|
|
||||||
'coverImage': m.get('coverImage', {}).get('large'),
|
|
||||||
'bannerImage': m.get('bannerImage'),
|
|
||||||
'format': m.get('format'),
|
|
||||||
'episodes': m.get('episodes'),
|
|
||||||
'averageScore': m.get('averageScore'),
|
|
||||||
'nextEpisode': m['nextAiringEpisode']['episode'],
|
|
||||||
'airingAt': m['nextAiringEpisode']['airingAt'],
|
|
||||||
'timeUntilAiring': m['nextAiringEpisode']['timeUntilAiring'],
|
|
||||||
})
|
|
||||||
return jsonify({'success': True, 'data': result})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/search')
|
|
||||||
def search():
|
|
||||||
q = request.args.get('query', '')
|
|
||||||
sort = request.args.get('sort', 'POPULARITY_DESC').upper()
|
|
||||||
genre = request.args.get('genre', '')
|
|
||||||
fmt = request.args.get('format', '').upper()
|
|
||||||
status = request.args.get('status', '').upper()
|
|
||||||
season = request.args.get('season', '').upper()
|
|
||||||
year = request.args.get('year', '', type=str)
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
|
|
||||||
if not q and not genre:
|
|
||||||
return jsonify({'success': False, 'error': 'query or genre parameter required'}), 400
|
|
||||||
|
|
||||||
sort_map = {
|
|
||||||
'SCORE_DESC': 'SCORE_DESC', 'POPULARITY_DESC': 'POPULARITY_DESC',
|
|
||||||
'TRENDING_DESC': 'TRENDING_DESC', 'FAVOURITES_DESC': 'FAVOURITES_DESC',
|
|
||||||
'START_DATE_DESC': 'START_DATE_DESC', 'UPDATED_AT_DESC': 'UPDATED_AT_DESC',
|
|
||||||
}
|
|
||||||
s = sort_map.get(sort, 'POPULARITY_DESC')
|
|
||||||
|
|
||||||
filters = ''
|
|
||||||
variables = {'search': q or None, 'sort': s, 'page': page, 'perPage': limit}
|
|
||||||
if genre:
|
|
||||||
filters += ', genre: $genre'
|
|
||||||
variables['genre'] = genre
|
|
||||||
if fmt:
|
|
||||||
filters += ', format: $format'
|
|
||||||
variables['format'] = fmt
|
|
||||||
if status:
|
|
||||||
filters += ', status: $status'
|
|
||||||
variables['status'] = status
|
|
||||||
if season:
|
|
||||||
filters += ', season: $season'
|
|
||||||
variables['season'] = season
|
|
||||||
if year:
|
|
||||||
filters += ', seasonYear: $seasonYear'
|
|
||||||
variables['seasonYear'] = int(year)
|
|
||||||
|
|
||||||
query = f'''query ($search: String, $sort: [MediaSort], $genre: String, $format: MediaFormat, $status: MediaStatus, $season: MediaSeason, $seasonYear: Int, $page: Int, $perPage: Int) {{
|
|
||||||
Page(page: $page, perPage: $perPage) {{
|
|
||||||
media(search: $search, type: ANIME, sort: $sort{filters}) {{ {MEDIA_FIELDS} }}
|
|
||||||
pageInfo {{ total currentPage lastPage perPage }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, variables)
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'data': [format_media(m) for m in data['Page']['media']],
|
|
||||||
'pagination': data['Page']['pageInfo'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/info/<int:anime_id>')
|
|
||||||
def info(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
{MEDIA_FIELDS}
|
|
||||||
relations {{
|
|
||||||
edges {{
|
|
||||||
node {{ id title {{ romaji english native userPreferred }} format coverImage {{ large medium }} }}
|
|
||||||
relationType
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
externalLinks {{ id site url type language }}
|
|
||||||
streamingEpisodes {{ title thumbnail url site }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
m = data['Media']
|
|
||||||
result = format_media(m)
|
|
||||||
result['relations'] = [
|
|
||||||
{
|
|
||||||
'id': e['node']['id'],
|
|
||||||
'title': e['node']['title']['userPreferred'],
|
|
||||||
'format': e['node']['format'],
|
|
||||||
'coverImage': e['node']['coverImage'].get('large') if e['node'].get('coverImage') else None,
|
|
||||||
'relationType': e['relationType'],
|
|
||||||
}
|
|
||||||
for e in (m.get('relations', {}).get('edges') or [])
|
|
||||||
]
|
|
||||||
result['externalLinks'] = m.get('externalLinks') or []
|
|
||||||
result['streamingEpisodes'] = m.get('streamingEpisodes') or []
|
|
||||||
return jsonify({'success': True, 'data': result})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/info/<int:anime_id>/episodes')
|
|
||||||
def episodes(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
episodes
|
|
||||||
streamingEpisodes {{ title thumbnail url site }}
|
|
||||||
externalLinks {{ id site url type }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
m = data['Media']
|
|
||||||
eps = []
|
|
||||||
for i, ep in enumerate(m.get('streamingEpisodes') or []):
|
|
||||||
eps.append({
|
|
||||||
'number': i + 1,
|
|
||||||
'title': ep.get('title'),
|
|
||||||
'thumbnail': ep.get('thumbnail'),
|
|
||||||
'url': ep.get('url'),
|
|
||||||
'site': ep.get('site'),
|
|
||||||
})
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'animeId': m['id'],
|
|
||||||
'title': m['title']['userPreferred'],
|
|
||||||
'totalEpisodes': m.get('episodes'),
|
|
||||||
'episodes': eps,
|
|
||||||
'providers': ['kiwi', 'pewe', 'bee', 'bonk', 'bun', 'ally', 'nun', 'twin', 'cog', 'moo', 'hop'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/characters/<int:anime_id>')
|
|
||||||
def characters(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
id
|
|
||||||
characters(sort: ROLE, perPage: 25) {{
|
|
||||||
edges {{
|
|
||||||
node {{ id name {{ full native alternative }} image {{ large medium }} gender age bloodType }}
|
|
||||||
role
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'animeId': data['Media']['id'],
|
|
||||||
'characters': [
|
|
||||||
{
|
|
||||||
'id': e['node']['id'],
|
|
||||||
'name': e['node']['name'],
|
|
||||||
'image': e['node']['image'],
|
|
||||||
'role': e['role'],
|
|
||||||
'gender': e['node'].get('gender'),
|
|
||||||
'age': e['node'].get('age'),
|
|
||||||
'bloodType': e['node'].get('bloodType'),
|
|
||||||
}
|
|
||||||
for e in data['Media']['characters']['edges']
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/recommendations/<int:anime_id>')
|
|
||||||
def recommendations(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
id
|
|
||||||
recommendations(perPage: 10, sort: RATING_DESC) {{
|
|
||||||
edges {{
|
|
||||||
node {{
|
|
||||||
id rating
|
|
||||||
mediaRecommendation {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
coverImage {{ large medium color }}
|
|
||||||
format episodes averageScore popularity
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'animeId': data['Media']['id'],
|
|
||||||
'recommendations': [
|
|
||||||
{
|
|
||||||
'id': e['node']['mediaRecommendation']['id'],
|
|
||||||
'title': e['node']['mediaRecommendation']['title']['userPreferred'],
|
|
||||||
'coverImage': e['node']['mediaRecommendation']['coverImage'].get('large'),
|
|
||||||
'format': e['node']['mediaRecommendation']['format'],
|
|
||||||
'episodes': e['node']['mediaRecommendation']['episodes'],
|
|
||||||
'averageScore': e['node']['mediaRecommendation']['averageScore'],
|
|
||||||
'rating': e['node']['rating'],
|
|
||||||
}
|
|
||||||
for e in data['Media']['recommendations']['edges']
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/genres')
|
|
||||||
def genres():
|
|
||||||
data = anilist_query('{ GenreCollection }')
|
|
||||||
return jsonify({'success': True, 'data': data['GenreCollection']})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print('\n 🎬 Miruro Anime API running on http://localhost:3000\n')
|
|
||||||
print(' Endpoints:')
|
|
||||||
print(' GET /api/trending')
|
|
||||||
print(' GET /api/popular')
|
|
||||||
print(' GET /api/top')
|
|
||||||
print(' GET /api/seasonal?season=SPRING&year=2026')
|
|
||||||
print(' GET /api/schedule?day=MONDAY')
|
|
||||||
print(' GET /api/airing')
|
|
||||||
print(' GET /api/search?query=naruto')
|
|
||||||
print(' GET /api/info/<id>')
|
|
||||||
print(' GET /api/info/<id>/episodes')
|
|
||||||
print(' GET /api/characters/<id>')
|
|
||||||
print(' GET /api/recommendations/<id>')
|
|
||||||
print(' GET /api/genres\n')
|
|
||||||
app.run(host='0.0.0.0', port=3000, debug=True)
|
|
||||||
+347
-2
@@ -538,7 +538,7 @@ const getSchedule = async (page = 1, perPage = 20) => {
|
|||||||
* @returns {Promise<object>} Paginated filtered results
|
* @returns {Promise<object>} Paginated filtered results
|
||||||
*/
|
*/
|
||||||
const filterAnime = async ({
|
const filterAnime = async ({
|
||||||
genre, tag, year, season, format, status,
|
search, genre, tag, year, season, format, status,
|
||||||
sort = "POPULARITY_DESC", page = 1, perPage = 20,
|
sort = "POPULARITY_DESC", page = 1, perPage = 20,
|
||||||
}) => {
|
}) => {
|
||||||
const SORT_MAP = {
|
const SORT_MAP = {
|
||||||
@@ -548,13 +548,14 @@ const filterAnime = async ({
|
|||||||
START_DATE_DESC: "START_DATE_DESC",
|
START_DATE_DESC: "START_DATE_DESC",
|
||||||
FAVOURITES_DESC: "FAVOURITES_DESC",
|
FAVOURITES_DESC: "FAVOURITES_DESC",
|
||||||
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
||||||
|
SEARCH_MATCH: "SEARCH_MATCH",
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: Build dynamic GraphQL arguments based on provided filters
|
|
||||||
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
|
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
|
||||||
const variables = { page, perPage };
|
const variables = { page, perPage };
|
||||||
const varTypes = ["$page: Int", "$perPage: Int"];
|
const varTypes = ["$page: Int", "$perPage: Int"];
|
||||||
|
|
||||||
|
if (search) { args.push("search: $search"); variables.search = search; varTypes.push("$search: String"); }
|
||||||
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
|
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
|
||||||
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
|
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
|
||||||
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
|
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
|
||||||
@@ -584,6 +585,339 @@ const filterAnime = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// GENRES & CATEGORIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available genres ----
|
||||||
|
/**
|
||||||
|
* Returns the complete list of AniList genres.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Array of genre name strings
|
||||||
|
*/
|
||||||
|
const getAllGenres = async () => {
|
||||||
|
const gql = `query { GenreCollection }`;
|
||||||
|
const data = await anilistQuery(gql);
|
||||||
|
return data.GenreCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available tags ----
|
||||||
|
/**
|
||||||
|
* Returns the complete list of AniList tags with metadata.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Array of tag objects { name, description, category, rank, isGeneralSpoiler, isMediaSpoiler, isAdult }
|
||||||
|
*/
|
||||||
|
const getAllTags = async () => {
|
||||||
|
const gql = `query { MediaTagCollection { name description category isGeneralSpoiler isMediaSpoiler isAdult } }`;
|
||||||
|
const data = await anilistQuery(gql);
|
||||||
|
return data.MediaTagCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TOP & TRENDING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Top anime by score (all time) ----
|
||||||
|
/**
|
||||||
|
* Fetches top-rated anime of all time sorted by score.
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated top anime results
|
||||||
|
*/
|
||||||
|
const getTopAnime = async (page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: SCORE_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Daily trending anime ----
|
||||||
|
/**
|
||||||
|
* Fetches currently trending anime (last 7 days).
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated trending results
|
||||||
|
*/
|
||||||
|
const getTrendingDaily = async (page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: TRENDING_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Weekly trending anime ----
|
||||||
|
/**
|
||||||
|
* Fetches currently trending anime (last 30 days).
|
||||||
|
* Returns more results than daily to cover a wider window.
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=40] - Results per page (default 40, wider than daily)
|
||||||
|
* @returns {Promise<object>} Paginated trending results
|
||||||
|
*/
|
||||||
|
const getTrendingWeekly = async (page = 1, perPage = 40) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: TRENDING_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEASONAL & STUDIO
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Seasonal anime by year and season ----
|
||||||
|
/**
|
||||||
|
* Fetches anime for a specific season and year.
|
||||||
|
*
|
||||||
|
* @param {number} year - The year (e.g., 2025)
|
||||||
|
* @param {string} season - The season (WINTER, SPRING, SUMMER, FALL)
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated seasonal anime results
|
||||||
|
*/
|
||||||
|
const getSeasonalAnime = async (year, season, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($year: Int, $season: MediaSeason, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, season: $season, seasonYear: $year, sort: POPULARITY_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { year, season: season.toUpperCase(), page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime by studio name ----
|
||||||
|
/**
|
||||||
|
* Fetches all anime produced by a specific studio.
|
||||||
|
*
|
||||||
|
* @param {string} studioName - The studio name to search for
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated studio anime results
|
||||||
|
*/
|
||||||
|
const getAnimeByStudio = async (studioName, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($name: String, $page: Int, $perPage: Int) {
|
||||||
|
Studio(search: $name) {
|
||||||
|
id name isAnimationStudio siteUrl
|
||||||
|
media(sort: POPULARITY_DESC, page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
nodes { ${MEDIA_LIST_FIELDS} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { name: studioName, page, perPage });
|
||||||
|
const studio = data.Studio;
|
||||||
|
|
||||||
|
if (!studio) throw new Error("Studio not found");
|
||||||
|
|
||||||
|
return {
|
||||||
|
studio: {
|
||||||
|
id: studio.id,
|
||||||
|
name: studio.name,
|
||||||
|
isAnimationStudio: studio.isAnimationStudio,
|
||||||
|
siteUrl: studio.siteUrl,
|
||||||
|
},
|
||||||
|
page: studio.media.pageInfo.currentPage,
|
||||||
|
perPage: studio.media.pageInfo.perPage,
|
||||||
|
total: studio.media.pageInfo.total,
|
||||||
|
hasNextPage: studio.media.pageInfo.hasNextPage,
|
||||||
|
results: studio.media.nodes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CHARACTER & STAFF
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Character detail with anime list ----
|
||||||
|
/**
|
||||||
|
* Fetches detailed character info including all anime appearances.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList character ID
|
||||||
|
* @returns {Promise<object>} Character details with anime list
|
||||||
|
*/
|
||||||
|
const getCharacterInfo = async (id) => {
|
||||||
|
const gql = `
|
||||||
|
query ($id: Int) {
|
||||||
|
Character(id: $id) {
|
||||||
|
id name { full native userPreferred }
|
||||||
|
image { large medium }
|
||||||
|
description gender favourites siteUrl
|
||||||
|
dateOfBirth { year month day }
|
||||||
|
age bloodType
|
||||||
|
media(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
edges {
|
||||||
|
characterRole
|
||||||
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { id });
|
||||||
|
if (!data.Character) throw new Error("Character not found");
|
||||||
|
return data.Character;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Staff detail with anime list ----
|
||||||
|
/**
|
||||||
|
* Fetches detailed staff info including all anime works.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList staff ID
|
||||||
|
* @returns {Promise<object>} Staff details with anime list
|
||||||
|
*/
|
||||||
|
const getStaffInfo = async (id) => {
|
||||||
|
const gql = `
|
||||||
|
query ($id: Int) {
|
||||||
|
Staff(id: $id) {
|
||||||
|
id name { full native userPreferred }
|
||||||
|
image { large medium }
|
||||||
|
description gender favourites siteUrl
|
||||||
|
dateOfBirth { year month day }
|
||||||
|
age homeTown yearsActive
|
||||||
|
staffMedia(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
edges {
|
||||||
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { id });
|
||||||
|
if (!data.Staff) throw new Error("Staff not found");
|
||||||
|
return data.Staff;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Search characters by name ----
|
||||||
|
/**
|
||||||
|
* Searches AniList characters by name.
|
||||||
|
*
|
||||||
|
* @param {string} query - Character name to search
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated character search results
|
||||||
|
*/
|
||||||
|
const searchCharacters = async (query, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($search: String, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
characters(search: $search, sort: SEARCH_MATCH) {
|
||||||
|
id name { full native userPreferred } image { large medium } gender favourites siteUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { search: query, page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.characters,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Search staff by name ----
|
||||||
|
/**
|
||||||
|
* Searches AniList staff by name.
|
||||||
|
*
|
||||||
|
* @param {string} query - Staff name to search
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated staff search results
|
||||||
|
*/
|
||||||
|
const searchStaff = async (query, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($search: String, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
staff(search: $search, sort: SEARCH_MATCH) {
|
||||||
|
id name { full native userPreferred } image { large medium } gender favourites siteUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { search: query, page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.staff,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
anilistQuery,
|
anilistQuery,
|
||||||
searchAnime,
|
searchAnime,
|
||||||
@@ -596,6 +930,17 @@ module.exports = {
|
|||||||
getSpotlight,
|
getSpotlight,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
filterAnime,
|
filterAnime,
|
||||||
|
getAllGenres,
|
||||||
|
getAllTags,
|
||||||
|
getTopAnime,
|
||||||
|
getTrendingDaily,
|
||||||
|
getTrendingWeekly,
|
||||||
|
getSeasonalAnime,
|
||||||
|
getAnimeByStudio,
|
||||||
|
getCharacterInfo,
|
||||||
|
getStaffInfo,
|
||||||
|
searchCharacters,
|
||||||
|
searchStaff,
|
||||||
MEDIA_LIST_FIELDS,
|
MEDIA_LIST_FIELDS,
|
||||||
MEDIA_FULL_FIELDS,
|
MEDIA_FULL_FIELDS,
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-1
@@ -104,6 +104,15 @@ const setCache = (key, data, ttl = DEFAULT_TTL) => {
|
|||||||
cache.delete(firstKey);
|
cache.delete(firstKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Periodic cleanup — prune all expired entries when cache reaches 80% capacity
|
||||||
|
// Prevents unbounded memory growth from stale entries that were never accessed
|
||||||
|
if (cache.size >= MAX_CACHE_SIZE * 0.8) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of cache) {
|
||||||
|
if (now > v.expiry) cache.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cache.set(key, { data, expiry: Date.now() + ttl });
|
cache.set(key, { data, expiry: Date.now() + ttl });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,6 +129,14 @@ const clearCache = () => {
|
|||||||
cache.clear();
|
cache.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { getCached, setCache, clearCache };
|
// ---- FEATURE: Get current cache size ----
|
||||||
|
/**
|
||||||
|
* Returns the current number of entries in the cache.
|
||||||
|
*
|
||||||
|
* @returns {number} Current cache size
|
||||||
|
*/
|
||||||
|
const getCacheSize = () => cache.size;
|
||||||
|
|
||||||
|
module.exports = { getCached, setCache, clearCache, getCacheSize };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: cache.js
|
// ══════════════════════════════════════════════════════════════ END: cache.js
|
||||||
|
|||||||
+445
-184
@@ -4,13 +4,15 @@
|
|||||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Miruro streaming pipe integration. Handles encoding/decoding
|
* Miruro streaming pipe integration with self-healing fallback system.
|
||||||
* of the secure/pipe tunnel that Miruro uses for episode lists
|
* Automatically tries multiple methods to reach the pipe endpoint:
|
||||||
* and streaming sources. Decodes base64+gzip responses and
|
* 1. Direct request with mirror rotation
|
||||||
* injects simplified slug-based episode IDs.
|
* 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
|
* @exports
|
||||||
* getEpisodes, getSources, getWatchSources
|
* getEpisodes, getSources, getWatchSources, pipeHealthCheck
|
||||||
*
|
*
|
||||||
* @author Shinei Nouzen
|
* @author Shinei Nouzen
|
||||||
* @license MIT
|
* @license MIT
|
||||||
@@ -20,92 +22,292 @@
|
|||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const { Buffer } = require("buffer");
|
const { Buffer } = require("buffer");
|
||||||
const zlib = require("zlib");
|
const zlib = require("zlib");
|
||||||
|
const { getCached, setCache } = require("./cache");
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// MIRURO PIPE CONFIGURATION
|
// MIRURO PIPE CONFIGURATION
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
const PIPE_PATH = "/api/secure/pipe";
|
||||||
* Miruro's secure pipe endpoint URL.
|
|
||||||
* All streaming requests go through this base64+gzip encoded tunnel.
|
const MIRURO_ORIGINS = [
|
||||||
*
|
"https://www.miruro.ru",
|
||||||
* @type {string}
|
"https://www.miruro.to",
|
||||||
*/
|
"https://www.miruro.bz",
|
||||||
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
|
"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 = {
|
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",
|
"User-Agent":
|
||||||
Referer: "https://www.miruro.tv/",
|
"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
|
// 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 encodePipeRequest = (payload) => {
|
||||||
const json = JSON.stringify(payload);
|
const json = JSON.stringify(payload);
|
||||||
return Buffer.from(json).toString("base64url");
|
return Buffer.from(json).toString("base64url");
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Decode base64+gzip pipe response ----
|
const PIPE_OBF_KEY = Buffer.from(
|
||||||
/**
|
process.env.PIPE_OBF_KEY || "71951034f8fbcf53d89db52ceb3dc22c",
|
||||||
* Decodes a pipe response from base64url + gzip format into a plain object.
|
"hex"
|
||||||
* The Miruro pipe returns all responses in this compressed format.
|
);
|
||||||
*
|
|
||||||
* @param {string} encodedStr - The base64url-encoded gzipped response
|
const decodePipeResponse = (encodedStr, obfHeader = null) => {
|
||||||
* @returns {object} Decoded JSON object
|
|
||||||
* @throws {Error} If decoding or decompression fails
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const data = decodePipeResponse(res.text);
|
|
||||||
* console.log(data.providers); // { kiwi: {...}, bee: {...} }
|
|
||||||
*/
|
|
||||||
const decodePipeResponse = (encodedStr) => {
|
|
||||||
try {
|
try {
|
||||||
// NOTE: Add padding if necessary (base64url may omit trailing =)
|
if (!obfHeader) return JSON.parse(encodedStr);
|
||||||
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
|
||||||
const compressed = Buffer.from(padded, "base64url");
|
const padded =
|
||||||
const decompressed = zlib.gunzipSync(compressed);
|
encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
||||||
|
const raw = Buffer.from(padded, "base64url");
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
bytes = xored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decompressed = zlib.gunzipSync(bytes);
|
||||||
return JSON.parse(decompressed.toString("utf-8"));
|
return JSON.parse(decompressed.toString("utf-8"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error("Failed to decode pipe response: " + e.message);
|
throw new Error("Failed to decode pipe response: " + e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Decode base64 episode ID to plain text ----
|
// ══════════════════════════════════════════════════════════════
|
||||||
/**
|
// METHOD 1: DIRECT REQUEST (with mirror rotation)
|
||||||
* Decodes a base64-encoded episode ID back to plain text.
|
// ══════════════════════════════════════════════════════════════
|
||||||
* Episode IDs from the pipe are base64-encoded with ":" separators.
|
|
||||||
*
|
const methodDirect = async (encodedReq) => {
|
||||||
* @param {string} encodedId - The base64url-encoded episode ID
|
const maxRetries = 4;
|
||||||
* @returns {string} Decoded plain text ID, or original if decoding fails
|
let lastError;
|
||||||
*
|
|
||||||
* @example
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
* const decoded = translateId("YW5pbWVwaGU6MjA6c3ViOjE=");
|
const origin = MIRURO_ORIGINS[attempt % MIRURO_ORIGINS.length];
|
||||||
* // returns "animepahe:20:sub:1"
|
|
||||||
*/
|
try {
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
const obf = res.headers["x-obfuscated"];
|
||||||
|
return { data: decodePipeResponse(res.data, obf), method: "direct" };
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
const status = e.response?.status;
|
||||||
|
|
||||||
|
if (
|
||||||
|
status &&
|
||||||
|
status >= 400 &&
|
||||||
|
status < 500 &&
|
||||||
|
status !== 444 &&
|
||||||
|
status !== 403
|
||||||
|
) {
|
||||||
|
throw new Error(`Pipe request failed with status ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
const delay = Math.pow(2, attempt) * 1000;
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error("Direct request failed after all retries");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// 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=";
|
||||||
|
|
||||||
|
const proxyUrl = (url, referer) => {
|
||||||
|
if (!url || !url.startsWith("http")) return url;
|
||||||
|
try {
|
||||||
|
const ref = referer ? "&referer=" + encodeURIComponent(referer) : "";
|
||||||
|
return PROXY_BASE + encodeURIComponent(url) + ref;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyStreams = (sources) => sources;
|
||||||
|
|
||||||
|
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.to/");
|
||||||
|
return { ...s, url: proxied, file: proxied };
|
||||||
|
});
|
||||||
|
return sources;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// EPISODE ID TRANSLATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const translateId = (encodedId) => {
|
const translateId = (encodedId) => {
|
||||||
try {
|
try {
|
||||||
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
|
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
|
||||||
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
|
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;
|
if (decoded.includes(":")) return decoded;
|
||||||
return encodedId;
|
return encodedId;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -113,23 +315,23 @@ const translateId = (encodedId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Recursively decode all base64 IDs in a nested object ----
|
|
||||||
/**
|
|
||||||
* Walks a JSON structure and decodes any base64 "id" fields.
|
|
||||||
* Handles nested objects and arrays of objects.
|
|
||||||
*
|
|
||||||
* @param {object|Array} obj - The object or array to process
|
|
||||||
* @returns {object|Array} The same object with decoded IDs (mutated in place)
|
|
||||||
*/
|
|
||||||
const deepTranslate = (obj) => {
|
const deepTranslate = (obj) => {
|
||||||
if (obj && typeof obj === "object") {
|
if (obj && typeof obj === "object") {
|
||||||
for (const key of Object.keys(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
if (key === "id" && typeof obj[key] === "string") {
|
return obj.map((item) => deepTranslate(item));
|
||||||
obj[key] = translateId(obj[key]);
|
}
|
||||||
} else if (typeof obj[key] === "object") {
|
const clone = { ...obj };
|
||||||
deepTranslate(obj[key]);
|
for (const key of Object.keys(clone)) {
|
||||||
|
if (key === "id" && typeof clone[key] === "string") {
|
||||||
|
if (clone.number !== undefined) {
|
||||||
|
clone.rawPipeId = clone[key];
|
||||||
|
}
|
||||||
|
clone[key] = translateId(clone[key]);
|
||||||
|
} else if (typeof clone[key] === "object") {
|
||||||
|
clone[key] = deepTranslate(clone[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return clone;
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
};
|
};
|
||||||
@@ -138,21 +340,6 @@ const deepTranslate = (obj) => {
|
|||||||
// EPISODE ID INJECTION
|
// 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 injectSourceSlugs = (data, anilistId) => {
|
||||||
const providers = data.providers || {};
|
const providers = data.providers || {};
|
||||||
|
|
||||||
@@ -162,7 +349,6 @@ const injectSourceSlugs = (data, anilistId) => {
|
|||||||
let episodes = provData.episodes;
|
let episodes = provData.episodes;
|
||||||
if (!episodes) continue;
|
if (!episodes) continue;
|
||||||
|
|
||||||
// NOTE: Some providers return a flat array — wrap it in { sub: [...] }
|
|
||||||
if (Array.isArray(episodes)) {
|
if (Array.isArray(episodes)) {
|
||||||
provData.episodes = { sub: episodes };
|
provData.episodes = { sub: episodes };
|
||||||
episodes = provData.episodes;
|
episodes = provData.episodes;
|
||||||
@@ -173,9 +359,7 @@ const injectSourceSlugs = (data, anilistId) => {
|
|||||||
|
|
||||||
for (const ep of epList) {
|
for (const ep of epList) {
|
||||||
if (ep.id && ep.number) {
|
if (ep.id && ep.number) {
|
||||||
const origId = ep.id;
|
const prefix = ep.id.includes(":") ? ep.id.split(":")[0] : ep.id;
|
||||||
// NOTE: Take only the prefix before ":" for the slug
|
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
|
||||||
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,52 +373,21 @@ const injectSourceSlugs = (data, anilistId) => {
|
|||||||
// PIPE API FUNCTIONS
|
// PIPE API FUNCTIONS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ----
|
|
||||||
/**
|
|
||||||
* Internal helper to fetch and decode raw episode data from the pipe.
|
|
||||||
* Sends a base64-encoded request and decodes the gzipped response.
|
|
||||||
*
|
|
||||||
* @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 fetchRawEpisodes = async (anilistId) => {
|
||||||
const payload = {
|
const cacheKey = `pipe:episodes:${anilistId}`;
|
||||||
path: "episodes",
|
const cached = getCached(cacheKey);
|
||||||
method: "GET",
|
if (cached) return cached;
|
||||||
query: { anilistId },
|
|
||||||
body: null,
|
|
||||||
version: "0.1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const data = await pipeRequest("episodes", { anilistId });
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const result = deepTranslate(data);
|
||||||
headers: HEADERS,
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
timeout: 15000,
|
return result;
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
|
|
||||||
const data = decodePipeResponse(res.text || res.data);
|
|
||||||
return deepTranslate(data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 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 getEpisodes = async (anilistId) => {
|
||||||
const data = await fetchRawEpisodes(anilistId);
|
const data = await fetchRawEpisodes(anilistId);
|
||||||
const result = injectSourceSlugs(data, anilistId);
|
const result = injectSourceSlugs(data, anilistId);
|
||||||
|
|
||||||
// NOTE: Ensure mappings field is present (like Walter's API)
|
|
||||||
if (!result.mappings) {
|
if (!result.mappings) {
|
||||||
result.mappings = { anilistId };
|
result.mappings = { anilistId };
|
||||||
if (result.malId) result.mappings.malId = result.malId;
|
if (result.malId) result.mappings.malId = result.malId;
|
||||||
@@ -244,57 +397,24 @@ const getEpisodes = async (anilistId) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Get streaming sources (detailed endpoint) ----
|
|
||||||
/**
|
|
||||||
* Fetches M3U8 streaming sources for a specific episode.
|
|
||||||
* This is the detailed/manual endpoint — use /watch for simpler access.
|
|
||||||
*
|
|
||||||
* @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 getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
||||||
const encId = Buffer.from(episodeId).toString("base64url");
|
const cacheKey = `pipe:sources:${episodeId}:${provider}:${category}`;
|
||||||
const payload = {
|
const cached = getCached(cacheKey);
|
||||||
path: "sources",
|
if (cached) return cached;
|
||||||
method: "GET",
|
|
||||||
query: { episodeId: encId, provider, category, anilistId },
|
|
||||||
body: null,
|
|
||||||
version: "0.1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encId = Buffer.from(episodeId).toString("base64url");
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const sources = await pipeRequest("sources", {
|
||||||
headers: HEADERS,
|
episodeId: encId,
|
||||||
timeout: 15000,
|
provider,
|
||||||
|
category,
|
||||||
|
anilistId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
const result = proxySubtitles(proxyStreams(deepTranslate(sources)));
|
||||||
return deepTranslate(decodePipeResponse(res.text || res.data));
|
setCache(cacheKey, result, 600 * 1000);
|
||||||
|
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 getWatchSources = async (provider, anilistId, category, slug) => {
|
||||||
const data = await fetchRawEpisodes(anilistId);
|
const data = await fetchRawEpisodes(anilistId);
|
||||||
const provData = (data.providers || {})[provider];
|
const provData = (data.providers || {})[provider];
|
||||||
@@ -304,31 +424,172 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
|
|||||||
const episodes = provData.episodes?.[category] || [];
|
const episodes = provData.episodes?.[category] || [];
|
||||||
let targetId = null;
|
let targetId = null;
|
||||||
|
|
||||||
// NOTE: Resolve slug back to the original pipe episode ID
|
|
||||||
for (const ep of episodes) {
|
for (const ep of episodes) {
|
||||||
const origId = ep.id || "";
|
const rawId = ep.id || "";
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
let match = false;
|
||||||
const generated = `${prefix}-${ep.number}`;
|
|
||||||
|
|
||||||
if (generated === slug) {
|
if (rawId.includes("/")) {
|
||||||
targetId = origId;
|
const slugSuffix = rawId.split("/").pop();
|
||||||
|
match = slugSuffix === slug;
|
||||||
|
} else if (rawId.includes(":")) {
|
||||||
|
const prefix = rawId.split(":")[0];
|
||||||
|
match = `${prefix}-${ep.number}` === slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
targetId = ep.rawPipeId ? translateId(ep.rawPipeId) : rawId;
|
||||||
break;
|
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);
|
return getSources(targetId, provider, anilistId, category);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SUBTITLE & QUALITY UTILITIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const extractSubtitles = (sources) => {
|
||||||
|
const raw = sources.subtitles || sources.captions || [];
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
||||||
|
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",
|
||||||
|
kind: sub.kind || "subtitles",
|
||||||
|
format: sub.format || "vtt",
|
||||||
|
encoding: sub.encoding || "utf-8",
|
||||||
|
isDefault: sub.default || false,
|
||||||
|
}))
|
||||||
|
.filter((sub) => sub.url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSkipTimes = (sources) => {
|
||||||
|
const skipTimes = sources.skipTimes || sources.skip || null;
|
||||||
|
if (!skipTimes || typeof skipTimes !== "object") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
intro: skipTimes.intro || skipTimes.op || null,
|
||||||
|
outro: skipTimes.outro || skipTimes.ed || null,
|
||||||
|
preview: skipTimes.preview || 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 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;
|
||||||
|
|
||||||
|
for (const q of ordered) {
|
||||||
|
const match = usable.find((s) => {
|
||||||
|
const quality = (s.quality || s.label || "").toLowerCase();
|
||||||
|
return quality.includes(q);
|
||||||
|
});
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = usable.find((s) => s.isActive);
|
||||||
|
if (active) return active;
|
||||||
|
|
||||||
|
return usable[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// DOWNLOAD & BATCH
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const getDownloadUrl = async (provider, anilistId, category, slug) => {
|
||||||
|
const sources = await getWatchSources(provider, anilistId, category, slug);
|
||||||
|
const subtitles = extractSubtitles(sources);
|
||||||
|
const bestStream = getBestStream(sources);
|
||||||
|
|
||||||
|
return {
|
||||||
|
download: sources.download || null,
|
||||||
|
subtitles,
|
||||||
|
bestStream,
|
||||||
|
provider,
|
||||||
|
anilistId,
|
||||||
|
category,
|
||||||
|
slug,
|
||||||
|
hasDownload: !!sources.download,
|
||||||
|
hasSubtitles: subtitles.length > 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBatchSources = async (provider, anilistId, category, slugs) => {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
const fetchOne = async (slug) => {
|
||||||
|
try {
|
||||||
|
const sources = await getWatchSources(provider, anilistId, category, slug);
|
||||||
|
const best = getBestStream(sources);
|
||||||
|
const subtitles = extractSubtitles(sources);
|
||||||
|
results[slug] = {
|
||||||
|
success: true,
|
||||||
|
streams: sources.streams,
|
||||||
|
bestStream: best,
|
||||||
|
subtitles,
|
||||||
|
download: sources.download || null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
results[slug] = {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchSize = 5;
|
||||||
|
for (let i = 0; i < slugs.length; i += batchSize) {
|
||||||
|
const batch = slugs.slice(i, i + batchSize);
|
||||||
|
await Promise.all(batch.map(fetchOne));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
anilistId,
|
||||||
|
category,
|
||||||
|
total: slugs.length,
|
||||||
|
successful: Object.values(results).filter((r) => r.success).length,
|
||||||
|
failed: Object.values(results).filter((r) => !r.success).length,
|
||||||
|
episodes: results,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getEpisodes,
|
getEpisodes,
|
||||||
getSources,
|
getSources,
|
||||||
getWatchSources,
|
getWatchSources,
|
||||||
|
getDownloadUrl,
|
||||||
|
getBatchSources,
|
||||||
|
extractSubtitles,
|
||||||
|
extractSkipTimes,
|
||||||
|
getBestStream,
|
||||||
encodePipeRequest,
|
encodePipeRequest,
|
||||||
decodePipeResponse,
|
decodePipeResponse,
|
||||||
translateId,
|
translateId,
|
||||||
deepTranslate,
|
deepTranslate,
|
||||||
injectSourceSlugs,
|
injectSourceSlugs,
|
||||||
|
pipeHealthCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: pipe.js
|
// ══════════════════════════════════════════════════════════════ END: pipe.js
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — creatorInfo.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Express middleware that injects creator attribution metadata
|
||||||
|
* into every JSON API response. Overrides res.json() to append
|
||||||
|
* author details and a server-side timestamp, unless the
|
||||||
|
* response already contains creator information.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* addCreatorInfo — Express middleware function
|
||||||
|
* creatorInfo — Static creator metadata object
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CREATOR METADATA
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// NOTE: Injected into every response unless `data.creator` already exists
|
||||||
|
const creatorInfo = {
|
||||||
|
creator: "Shinei Nouzen",
|
||||||
|
github: "https://github.com/Shineii86",
|
||||||
|
telegram: "https://telegram.me/Shineii86",
|
||||||
|
message: "Build with ❤️ by Shinei Nouzen",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// MIDDLEWARE
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware that enriches every JSON response with
|
||||||
|
* creator attribution and an IN-formatted timestamp.
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req - Express request object
|
||||||
|
* @param {import('express').Response} res - Express response object
|
||||||
|
* @param {import('express').NextFunction} next - Passes control forward
|
||||||
|
* @returns {void}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* app.use(addCreatorInfo);
|
||||||
|
* res.json({ anime: [...] });
|
||||||
|
* // Response includes creator, github, telegram, message, and timestamp
|
||||||
|
*/
|
||||||
|
const addCreatorInfo = (req, res, next) => {
|
||||||
|
// NOTE: Preserve the original json method so we can wrap it
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
|
||||||
|
// NOTE: Monkey-patch res.json to inject creator info
|
||||||
|
res.json = (data) => {
|
||||||
|
// NOTE: Only inject if data is a plain object and missing creator field
|
||||||
|
if (typeof data === "object" && data !== null && !data.creator) {
|
||||||
|
data = {
|
||||||
|
...data,
|
||||||
|
creator: creatorInfo.creator,
|
||||||
|
github: creatorInfo.github,
|
||||||
|
telegram: creatorInfo.telegram,
|
||||||
|
timestamp: new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", hour12: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalJson(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { addCreatorInfo, creatorInfo };
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: creatorInfo.js
|
||||||
+876
-40
File diff suppressed because it is too large
Load Diff
@@ -4,40 +4,120 @@
|
|||||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Integration test suite for MiruroAPI endpoints.
|
* Integration test suite for MiruroAPI v2.3.0 endpoints.
|
||||||
* Tests all major endpoints for correct response format.
|
* Tests all 45 endpoints for correct response format.
|
||||||
|
* Runs sequentially to avoid rate limiting.
|
||||||
*
|
*
|
||||||
* @author Shinei Nouzen
|
* @author Shinei Nouzen
|
||||||
* @license MIT
|
* @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";
|
const BASE = process.env.API_URL || "https://mirurotvapi.vercel.app/api";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TEST DEFINITIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const tests = [
|
const tests = [
|
||||||
|
// ---- FEATURE: System endpoints ----
|
||||||
{ name: "Health", url: "/health" },
|
{ name: "Health", url: "/health" },
|
||||||
{ name: "Stats", url: "/stats" },
|
{ name: "Stats", url: "/stats" },
|
||||||
{ name: "OpenAPI", url: "/openapi" },
|
{ name: "OpenAPI", url: "/openapi" },
|
||||||
|
{ name: "Providers", url: "/providers" },
|
||||||
|
|
||||||
|
// ---- FEATURE: Search & discovery endpoints ----
|
||||||
{ name: "Search", url: "/search?query=naruto" },
|
{ name: "Search", url: "/search?query=naruto" },
|
||||||
{ name: "Suggestions", url: "/suggestions?query=naruto" },
|
{ name: "Suggestions", url: "/suggestions?query=naruto" },
|
||||||
{ name: "Filter", url: "/filter?genre=Action&per_page=1" },
|
{ name: "Filter", url: "/filter?genre=Action&per_page=1" },
|
||||||
|
{ name: "Multi-Search", url: "/multi-search?q=naruto&genre=Action" },
|
||||||
|
|
||||||
|
// ---- FEATURE: Collection endpoints ----
|
||||||
{ name: "Trending", url: "/trending?per_page=1" },
|
{ name: "Trending", url: "/trending?per_page=1" },
|
||||||
|
{ name: "Trending Daily", url: "/trending/daily" },
|
||||||
|
{ name: "Trending Weekly", url: "/trending/weekly" },
|
||||||
{ name: "Popular", url: "/popular?per_page=1" },
|
{ name: "Popular", url: "/popular?per_page=1" },
|
||||||
|
{ name: "Top", url: "/top?per_page=1" },
|
||||||
{ name: "Upcoming", url: "/upcoming?per_page=1" },
|
{ name: "Upcoming", url: "/upcoming?per_page=1" },
|
||||||
{ name: "Recent", url: "/recent?per_page=1" },
|
{ name: "Recent", url: "/recent?per_page=1" },
|
||||||
{ name: "Spotlight", url: "/spotlight" },
|
{ name: "Spotlight", url: "/spotlight" },
|
||||||
{ name: "Schedule", url: "/schedule" },
|
{ name: "Schedule", url: "/schedule" },
|
||||||
|
{ name: "Random", url: "/random" },
|
||||||
|
{ name: "Upcoming Countdown", url: "/upcoming/countdown" },
|
||||||
|
|
||||||
|
// ---- FEATURE: Seasonal & studio endpoints ----
|
||||||
|
{ name: "Seasonal", url: "/season/2025/spring" },
|
||||||
|
{ name: "Studio", url: "/studio/MAPPA" },
|
||||||
|
|
||||||
|
// ---- FEATURE: Metadata endpoints ----
|
||||||
|
{ name: "Genres", url: "/genres" },
|
||||||
|
{ name: "Tags", url: "/tags" },
|
||||||
|
{ name: "Genre", url: "/genre/Action" },
|
||||||
|
{ name: "Year", url: "/year/2024" },
|
||||||
|
{ name: "Status", url: "/status/RELEASING" },
|
||||||
|
{ name: "Format", url: "/format/MOVIE" },
|
||||||
|
|
||||||
|
// ---- 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" },
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime detail endpoints ----
|
||||||
{ name: "Info", url: "/info/20" },
|
{ name: "Info", url: "/info/20" },
|
||||||
{ name: "Characters", url: "/anime/20/characters" },
|
{ name: "Anime Characters", url: "/anime/20/characters" },
|
||||||
{ name: "Relations", url: "/anime/20/relations" },
|
{ name: "Relations", url: "/anime/20/relations" },
|
||||||
{ name: "Recommendations", url: "/anime/20/recommendations" },
|
{ name: "Recommendations", url: "/anime/20/recommendations" },
|
||||||
|
|
||||||
|
// ---- FEATURE: Streaming endpoints ----
|
||||||
{ name: "Episodes", url: "/episodes/20" },
|
{ 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" },
|
{ name: "Watch", url: "/watch/kiwi/20/sub/animepahe-1" },
|
||||||
|
|
||||||
|
// ---- 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 passed = 0;
|
||||||
let failed = 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) {
|
async function runTest(test) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE}${test.url}`);
|
const res = await fetch(`${BASE}${test.url}`);
|
||||||
@@ -60,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() {
|
async function runAll() {
|
||||||
console.log(`\n🧪 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) {
|
for (const test of tests) {
|
||||||
await runTest(test);
|
await runTest(test);
|
||||||
}
|
}
|
||||||
@@ -71,4 +163,10 @@ async function runAll() {
|
|||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ENTRY POINT
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
runAll();
|
runAll();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: test.js
|
||||||
|
|||||||
+50
-3
@@ -2,12 +2,59 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"builds": [
|
"builds": [
|
||||||
{
|
{
|
||||||
"src": "server.js",
|
"src": "api/index.js",
|
||||||
"use": "@vercel/node"
|
"use": "@vercel/node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "api/proxy.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "public/**",
|
||||||
|
"use": "@vercel/static"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"routes": [
|
"routes": [
|
||||||
{ "src": "/api/(.*)", "dest": "server.js" },
|
{
|
||||||
{ "src": "/(.*)", "dest": "server.js" }
|
"src": "/api/proxy",
|
||||||
|
"dest": "/api/proxy.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": [
|
||||||
|
{
|
||||||
|
"source": "/api/(.*)",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
||||||
|
{ "key": "X-Frame-Options", "value": "DENY" },
|
||||||
|
{ "key": "X-XSS-Protection", "value": "1; mode=block" },
|
||||||
|
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
|
||||||
|
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
|
||||||
|
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name = "miruroapi"
|
||||||
|
main = "worker.js"
|
||||||
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
|
# Cloudflare Worker configuration for MiruroAPI
|
||||||
|
# Deploy with: wrangler deploy
|
||||||
|
|
||||||
|
# Environment variables (set via wrangler secret put or dashboard)
|
||||||
|
# SCRAPER_API_KEY = "" # ScraperAPI premium key (optional, $49/mo)
|
||||||
|
# FLARESOLVERR_URL = "" # FlareSolverr Docker instance URL (optional)
|
||||||
|
|
||||||
|
# Route configuration (optional — for custom domains)
|
||||||
|
# routes = [
|
||||||
|
# { pattern = "api.your-domain.com/api/*", zone_name = "your-domain.com" }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
[limits]
|
||||||
|
cpu_ms = 30000
|
||||||
|
|
||||||
|
# Compatibility flags
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
Reference in New Issue
Block a user