35 Commits
Author SHA1 Message Date
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 73b510589e Fix CORS — allow non-browser requests (no Origin header) 2026-06-25 10:42:09 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 8297d8f27d v2.1.4: Security hardening, bug fixes, code quality improvements
- Move XOR keys to env vars (PRU_PROXY_KEY, PIPE_OBF_KEY)
- Fix multi-search to combine query + filters
- Fix deepTranslate to return clone instead of mutating
- Improve getBestStream quality matching and fallback
- Add input sanitization for all route params
- CORS blocks unknown origins in production
- Security headers in vercel.json at CDN level
- stats/genre uses Promise.allSettled for parallel execution
- Delete dead server.py, fix version strings
2026-06-25 10:34:12 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 1d0ec95f19 Update CHANGELOG with full diagnostic sweep results 2026-06-25 10:03:53 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 94421af2dd Add no-cache headers to /random to prevent CDN caching 2026-06-25 10:03:22 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 7394a1c94c Improve random entropy with multi-source seed 2026-06-25 10:00:28 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ b59ca7e1ec Fix random (Date.now+hrtime seed) + character/staff 404 detection 2026-06-25 09:58:44 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 5acf8a2e1e Fix diagnostic sweep bugs — tags, random, 404s, security headers
- Tags: data.TagCollection→data.MediaTagCollection (was returning empty array)
- Random: Math.random()*50→crypto.randomInt(1,500) for true randomness
- Character/Staff: return 404 instead of 500 on not-found
- Security: added X-XSS-Protection header
- All 44 endpoints tested in full diagnostic sweep
2026-06-25 09:56:58 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 00275adb42 Fix AniList queries — trending args, studio type arg, tags field name
- Trending: removed invalid trending:7/30 args (not in AniList schema)
- Studio: removed type:ANIME from Studio.media (not supported)
- Tags: TagCollection→MediaTagCollection, removed rank field
2026-06-25 09:26:18 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 90cdd26241 Fix tags query — TagCollection→MediaTagCollection, remove rank field 2026-06-25 09:21:27 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 4509c0add3 Fix character/staff queries — remove dateOfDeath, fix MediaEdge role field
- Character: removed dateOfDeath (not in AniList schema), changed role→characterRole
- Staff: removed dateOfDeath, removed invalid role field from staffMedia edges
- Fixes 400 errors on /api/character/:id and /api/staff/:id
2026-06-25 09:18:45 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ d1dec20788 Update CHANGELOG v2.1.3 with working endpoints and bug fixes 2026-06-25 09:02:24 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ bff4c5b7c6 Fix getWatchSources to handle both raw and slugged episode IDs
- Handle raw pipe IDs (animedao:witch-hat-atelier:1) with colon separator
- Handle slugged IDs (watch/bonk/147105/sub/animedao-1) with slash separator
- Use rawPipeId for decoded ID recovery in both cases
2026-06-25 08:59:58 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 84d7e75372 Fix getWatchSources slug matching after injectSourceSlugs mutation
- Match slug against last segment of ep.id (after injectSourceSlugs)
- Use rawPipeId + translateId to recover original decoded ID
- Fixes 'Episode slug not found' error for all providers
2026-06-25 08:57:05 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ e35d934190 v2.1.3: Pipe resilience — retry logic, caching, optimized streaming
- Added pipeRequest() with retry + exponential backoff (3 attempts)
- Cached episodes (5min) and sources (10min) responses
- Confirmed kiwi/bonk CDNs serve real content, bee/ally serve decoys
- Updated version to 2.1.3 across package.json, health, OpenAPI
2026-06-25 08:53:02 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 398db2b4f0 docs: v2.1.2 changelog — pru proxy discovery and streaming architecture 2026-06-25 08:34:58 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 94b59b3bc9 fix: add Origin header to pipe requests to bypass Cloudflare 444 2026-06-25 08:29:04 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 6cd2b9a19e feat: return raw CDN URLs for streams (browser loads directly via CORS *); keep subtitle proxy for third-party CDNs 2026-06-25 08:22:34 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 76c47368a7 feat: proxy through pru.ultracloud.cc for real video content 2026-06-25 08:10:11 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ f208becd27 fix: rewrite relative M3U8 URLs (variant playlists like 313625720.m3u8) 2026-06-25 08:06:39 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 74f3dcd7c3 feat: pipe XOR decode + CDN CORS proxy for streaming
- decodePipeResponse now XOR-decodes with obfuscation key before gzip
- Updated pipe version to 0.2.0 to match miruro.tv
- Added /api/proxy endpoint for CORS-safe M3U8/VTT streaming
- Proxied stream/subtitle URLs through /api/proxy for browser playback
- M3U8 content rewritten to proxy variant playlists and segments
2026-06-25 08:04:13 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 90b0999d7d fix: store original base64 rawPipeId in deepTranslate + use decoded id in getWatchSources 2026-06-25 04:10:34 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ f7b3e32cb7 fix: pipe stream resolution - rawPipeId + creatorInfo 2026-06-25 03:55:01 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ a65138e575 fix: preserve rawPipeId for stream resolution + fix creatorInfo message overwrite + match slug against tail of episode ID 2026-06-25 03:51:13 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 399f9b1917 fix: Vercel deployment - skip app.listen() on Vercel, fix vercel.json routes 2026-06-25 02:37:27 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 4ec1ff69f4 feat: provider capabilities, enriched metadata, skip times (v2.1.0) 2026-06-25 02:02:47 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 56e3500d3e fix: extractSubtitles returns empty array when subtitles undefined (dub) 2026-06-25 01:48:10 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 9b1ac11ff0 feat: improve subtitle extraction, quality fallback, and documentation 2026-06-25 01:43:11 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 3ab0bae18d v2.0.0: Update index.html — 35 endpoints 2026-06-25 01:19:52 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 33e5e8e136 v2.0.0: Sync all docs — README, endpoints, OpenAPI, test suite 2026-06-25 01:18:28 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 3675bbba77 v2.0.0: Major upgrade — 35 endpoints, compression, streaming improvements, new features 2026-06-25 01:09:35 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 9ada6e1016 v1.4.0: Add creator attribution middleware 2026-06-25 00:54:18 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 3a28c4d182 v1.4.0: Fix Vercel deployment, security headers, rate limiter, cache cleanup 2026-06-25 00:52:26 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 68f7ea7cf4 Update README.md 2026-06-10 22:11:26 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ 37e70c3bda Update README.md 2026-06-10 22:06:57 +05:30
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ af08c91624 Update README.md 2026-06-10 22:05:04 +05:30
20 changed files with 4611 additions and 1060 deletions
+10 -2
View File
@@ -1,5 +1,13 @@
# Origins you want to allow
ALLOWED_ORIGINS=https://site1.com,https://site2.com
# Origins you want to allow (comma-separated, use * for all)
ALLOWED_ORIGINS=https://miruro.tv,https://miruro.to,https://miruro.bz
# Port (optional, defaults to 3000)
PORT=3000
# Pipe XOR obfuscation key (hex string, 32 chars)
# Required for pipe response decoding
PIPE_OBF_KEY=71951034f8fbcf53d89db52ceb3dc22c
# PRU proxy XOR key (hex string, 32 chars)
# Required for pru.ultracloud.cc URL encoding/decoding
PRU_PROXY_KEY=a54d389c18527d9fd3e7f0643e27edbe
+1
View File
@@ -1,3 +1,4 @@
node_modules/
.env
.vercel/
deploy.cjs
+226 -6
View File
@@ -1,5 +1,225 @@
# Changelog
## 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
- Full landing page upgrade to match AniKotoAPI style
- Added canvas particle effects with cyan-colored floating particles
@@ -7,18 +227,18 @@
- Added animated stat counters that count up on scroll
- Added infinite scrolling marquee with 8 feature badges
- 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 custom scrollbar styling
- 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 ambient orb gradient animations
- Added CTA section with gradient overlay
- Color scheme updated to match MiruroAPI brand (cyan #38bdf8 + indigo #818cf8)
- All branding updated: AniKotoAPI references → MiruroAPI
- 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
## v1.2.0
@@ -26,14 +246,14 @@
- 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}
- 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
- Added Swagger UI interactive docs at /docs
- 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)
- Improved landing page with premium design (Walter-style)
- Improved landing page with premium design
- Added "Try it" buttons on all endpoints
- Health endpoint now includes version, providers list, and endpoint count
- Version bump to 1.1.0
+32 -17
View File
@@ -1,6 +1,6 @@
<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>
@@ -19,15 +19,15 @@
<img src="https://img.shields.io/badge/AniList-GraphQL-02A6E4?style=flat-square&logo=graphql&logoColor=white" alt="AniList GraphQL"/>
<img src="https://img.shields.io/badge/Vercel-Serverless-000000?style=flat-square&logo=vercel&logoColor=white" alt="Vercel"/>
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
<img src="https://img.shields.io/badge/Version-1.2.0-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/Version-2.1.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
<img src="https://img.shields.io/badge/Endpoints-36-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
<img src="https://img.shields.io/badge/Providers-12-a855f7?style=flat-square&logoColor=white" alt="Providers"/>
</p>
<p align="center">
<b>A complete RESTful API for anime streaming data powered by AniList GraphQL and Miruro providers</b><br/>
Search, browse, filter, watch — every endpoint returns fresh data with smart caching.<br/>
18 endpoints, 12 streaming providers, M3U8 URLs with subtitles and skip timestamps.
35 endpoints, 12 streaming providers, M3U8 URLs with subtitles, skip timestamps, and batch operations.
</p>
<p align="center">
@@ -41,8 +41,7 @@
---
> ## ⚠️ Disclaimer
>
> [!WARNING]
> 1. This `API` does not store any files — it only links to media hosted on 3rd party services.
> 2. This `API` is explicitly made for **educational purposes only** and not for commercial usage. This repo will not be responsible for any misuse of it.
> 3. All anime data, images, and content belong to their respective owners (AniList, Miruro). This project is not affiliated with miruro.tv.
@@ -85,7 +84,7 @@
### Why MiruroAPI?
- 🎬 **18 Endpoints** — Complete anime data coverage
- 🎬 **35 Endpoints** — Complete anime data coverage
- 🔍 **Full-Text Search** — Search anime by keyword with suggestions
- 🎭 **Characters & Voice Actors** — Full character data from AniList
- 🎯 **Advanced Filtering** — Genre, year, season, format, sort
@@ -93,6 +92,8 @@
- 📅 **Airing Schedule** — See what's airing on any date
- 📡 **12 Streaming Providers** — M3U8 streaming sources
- ⏭️ **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
- 🔒 **CORS Enabled** — Works from any frontend, no proxy needed
- 🚀 **Zero-Config Deploy** — One click to Vercel, or run standalone with Express
@@ -139,7 +140,9 @@ flowchart TD
- **AniList GraphQL** for rich metadata
- **Miruro pipe** for streaming sources
- **Smart caching** with configurable TTL
- **18 RESTful endpoints**
- **35 RESTful endpoints**
- **Gzip compression** — 30-70% smaller responses
- **Request logging** — method, path, status, duration
- **Graceful error handling** per endpoint
- **Rate limiting** (100 req/min per IP)
@@ -153,6 +156,9 @@ flowchart TD
- **Characters** with voice actors
- **Relations** and **Recommendations**
- **Airing schedule** by date
- **Seasonal anime** by year + season
- **Studio filter** — anime by studio name
- **Character & Staff search**
</td>
</tr>
@@ -166,6 +172,9 @@ flowchart TD
- **Download links** when available
- **Sub/Dub** support per provider
- **Codec and fansub** metadata
- **Batch episode fetching** — multiple sources in parallel
- **Quality fallback** — auto 1080p → 720p → 360p
- **Subtitle extraction** — expose subtitle URLs
</td>
<td>
@@ -186,7 +195,7 @@ flowchart TD
| Feature | Description | Status |
|:---|:---|:---:|
| 🎬 18 API Endpoints | Complete anime data coverage | ✅ |
| 🎬 35 API Endpoints | Complete anime data coverage | ✅ |
| 🔍 Full-Text Search | Keyword search with pagination | ✅ |
| 💡 Search Suggestions | Fast autocomplete | ✅ |
| 🎯 Advanced Filtering | Genre, year, season, format, sort | ✅ |
@@ -318,7 +327,7 @@ MiruroAPI/
│ │ └── 📄 cache.js # 💾 In-memory cache with TTL
│ │
│ └── 📂 routes/ # 🛤️ Express routes
│ └── 📄 apiRoutes.js # 🌐 Main API routes (18 endpoints)
│ └── 📄 apiRoutes.js # 🌐 Main API routes (35 endpoints)
│
├── 📄 server.js # 🚀 Express server entry point
├── 📄 package.json # 📦 Dependencies & scripts
@@ -481,13 +490,13 @@ console.log(resp.data);
"success": true,
"results": {
"status": "healthy",
"version": "1.2.0",
"version": "2.0.0",
"uptime": "0h 0m 34s",
"uptimeSeconds": 34,
"timestamp": "2026-06-09T09:55:00.884Z",
"node": "v24.14.1",
"memory": { "used": "13MB", "total": "15MB" },
"endpoints": 16,
"endpoints": 35,
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
}
}
@@ -528,7 +537,7 @@ console.log(resp.data);
"uptime": "0h 0m 34s",
"requests": { "total": 156, "errors": 3, "successRate": "98.1%" },
"cache": { "size": 12, "maxSize": 100, "ttl": "1 min" },
"endpoints": 16,
"endpoints": 35,
"timestamp": "2026-06-09T09:55:00.884Z"
}
}
@@ -1399,6 +1408,10 @@ docker run -p 3000:3000 miruroapi
| Version | Date | Key Changes |
|:---|:---|:---|
| **2.0.1** | 2026-06-24 | Streaming improvements — better subtitle extraction, quality fallback for providers without metadata |
| **2.0.0** | 2026-06-25 | Major upgrade — 35 endpoints, compression, streaming improvements, new features |
| **1.4.0** | 2026-06-25 | Creator attribution middleware, Vercel deployment fix, security headers |
| **1.3.0** | 2026-06-25 | Full landing page upgrade with premium design |
| **1.2.0** | 2026-06-09 | Critical response format fix, full endpoint diagnostic, 18/18 passing |
| **1.1.0** | 2026-06-09 | Swagger UI docs, OpenAPI spec, mappings field, Docker, landing page |
| **1.0.0** | 2026-06-09 | Initial release — 16 endpoints, AniList GraphQL + Miruro pipe, caching |
@@ -1494,7 +1507,7 @@ Yes! Use <code>npm start</code> to run the Express server on any VPS, Docker con
### ✅ Completed
- [x] 🎬 18 API endpoints covering all data
- [x] 🎬 35 API endpoints covering all data
- [x] 🔍 Full-text search with pagination
- [x] 💡 Search suggestions for autocomplete
- [x] 🎯 Advanced filtering (genre, year, season, format, sort)
@@ -1576,8 +1589,8 @@ git push origin feature/amazing-feature
- ✅ Update CHANGELOG.md with your changes
- ✅ Keep PRs focused — one feature or fix per PR
- ✅ Add JSDoc comments for new functions
- ❌ Don't commit `node_modules` or cache files
- ❌ Don't add unrelated changes to a single PR
- ❎ Don't commit `node_modules` or cache files
- ❎ Don't add unrelated changes to a single PR
---
@@ -1657,8 +1670,10 @@ Free to use, modify, and distribute — see the [LICENSE](LICENSE) file for deta
---
<div align="center">
<p align="center">
<b>Made With ❤️ For The Anime Community</b>
<br/><br/>
<sub>© 2026 Shineii86. All Rights Reserved.</sub>
<sub>© Shinei Nouzen. All Rights Reserved.</sub>
</p>
</div>
+1
View File
@@ -0,0 +1 @@
module.exports = require("../server");
+117
View File
@@ -0,0 +1,117 @@
const axios = require("axios");
const PROXY_KEY = Buffer.from(
process.env.PRU_PROXY_KEY || "a54d389c18527d9fd3e7f0643e27edbe",
"hex"
);
const PRU_BASE = "https://pru.ultracloud.cc/";
function base64urlXor(str) {
const buf = Buffer.from(str, "utf-8");
const xored = Buffer.alloc(buf.length);
for (let i = 0; i < buf.length; i++) {
xored[i] = buf[i] ^ PROXY_KEY[i % PROXY_KEY.length];
}
return xored.toString("base64url");
}
function decodePruUrl(pruUrl) {
try {
const path = new URL(pruUrl).pathname.replace(/^\//, "").replace(/\/pl\.m3u8$/, "").replace(/\/seg\.jpg$/, "");
const tildeIdx = path.indexOf("~");
if (tildeIdx === -1) return null;
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;
}
}
function encodeForPru(rawUrl, referer) {
const urlEnc = base64urlXor(rawUrl);
const refEnc = base64urlXor(referer || new URL(rawUrl).origin + "/");
return `${PRU_BASE}${urlEnc}~${refEnc}/pl.m3u8`;
}
module.exports = async function handler(req, res) {
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 {
const decoded = decodePruUrl(url);
const isPru = !!decoded;
const targetUrl = decoded ? decoded.url : url;
const targetReferer = decoded ? decoded.referer : (referer || "https://www.miruro.tv/");
let fetchUrl;
let fetchHeaders;
if (isPru) {
fetchUrl = encodeForPru(targetUrl, targetReferer);
fetchHeaders = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
Referer: "https://www.miruro.tv/",
Origin: "https://www.miruro.tv",
};
} 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,
};
}
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");
if (isPru && (contentType.includes("mpegurl") || targetUrl.endsWith(".m3u8") || contentType.includes("x-mpegURL"))) {
let m3u8 = Buffer.from(response.data).toString("utf-8");
m3u8 = m3u8.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
if (match.includes("pru.ultracloud.cc")) {
const dec = decodePruUrl(match);
if (dec) return `/api/proxy?url=${encodeURIComponent(dec.url)}&referer=${encodeURIComponent(dec.referer)}`;
}
return `/api/proxy?url=${encodeURIComponent(match)}&referer=${encodeURIComponent(targetReferer)}`;
});
m3u8 = m3u8.replace(/^([^\s#<][^\s]*\.(m3u8|ts|m4s|m4v|mp4|cmfv|cmfa|vtt|srt|ass|json|aac|mp3))(?:\?[^\s]*)?$/gm, (match) => {
try {
const absolute = new URL(match, new URL(targetUrl)).href;
return `/api/proxy?url=${encodeURIComponent(absolute)}&referer=${encodeURIComponent(targetReferer)}`;
} catch {
return match;
}
});
res.setHeader("Content-Type", "application/vnd.apple.mpegurl");
return res.send(m3u8);
}
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 });
}
};
+565 -311
View File
@@ -1,29 +1,579 @@
# API Endpoints Reference
Complete documentation for all 18 MiruroAPI endpoints.
Complete documentation for all 35 MiruroAPI v2.0.1 endpoints.
---
## Table of Contents
- [Health Check](#health-check)
- [Stats](#stats)
### Search & Discovery
- [Search](#search)
- [Suggestions](#suggestions)
- [Filter](#filter)
- [Multi-Search](#multi-search)
### Collections
- [Trending](#trending)
- [Trending Daily](#trending-daily)
- [Trending Weekly](#trending-weekly)
- [Popular](#popular)
- [Top](#top)
- [Upcoming](#upcoming)
- [Recent](#recent)
- [Spotlight](#spotlight)
- [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)
- [Characters](#characters)
- [Relations](#relations)
- [Recommendations](#recommendations)
### Streaming & Downloading
- [Episodes](#episodes)
- [Batch Episodes](#batch-episodes)
- [Sources (Detailed)](#sources-detailed)
- [Stream (Quality Fallback)](#stream-quality-fallback)
- [Download URL](#download-url)
- [Watch (Streaming)](#watch-streaming)
### Utility
- [Compare](#compare)
- [Genre Stats](#genre-stats)
- [Calendar](#calendar)
- [Timeline](#timeline)
### System
- [Health Check](#health-check)
- [Stats](#stats)
- [OpenAPI](#openapi)
---
## Search
```
GET /api/search?query=naruto
```
Full-text anime search with pagination.
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| query | string | required | Search query |
| page | int | 1 | Page number |
| per_page | int | 20 | Results per page (max 50) |
---
## Suggestions
```
GET /api/suggestions?query=naruto
```
Lightweight autocomplete suggestions (max 8 results).
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| query | string | required | Search query |
---
## Filter
```
GET /api/filter?genre=Action&year=2024&season=SPRING
```
Advanced anime filter with multiple parameters.
| 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
```
GET /api/trending?page=1&per_page=20
```
Currently trending anime.
---
## 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.
---
## Popular
```
GET /api/popular
```
Most popular anime of all time.
---
## Top
```
GET /api/top
```
Top anime by score (all time).
---
## Upcoming
```
GET /api/upcoming
```
Most anticipated upcoming anime.
---
## Recent
```
GET /api/recent
```
Currently airing anime.
---
## Spotlight
```
GET /api/spotlight
```
Top 10 trending + popular anime for hero banners.
---
## Schedule
```
GET /api/schedule?page=1&per_page=20
```
Airing schedule with timestamps.
---
## 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
```
GET /api/info/20605
```
Complete anime info including characters, relations, recommendations, trailer, stats.
---
## Characters
```
GET /api/anime/20605/characters?page=1&per_page=25
```
Paginated character list with voice actors.
---
## Relations
```
GET /api/anime/20605/relations
```
Related anime (sequels, prequels, spin-offs).
---
## Recommendations
```
GET /api/anime/20605/recommendations?page=1&per_page=10
```
Community recommendations sorted by rating.
---
## Episodes
```
GET /api/episodes/20605
```
Episode list from all providers with sub/dub support.
---
## Batch Episodes
```
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)
```
GET /api/watch/kiwi/20605/sub/animepahe-1
```
Streaming sources via URL path (recommended).
| 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
@@ -32,7 +582,7 @@ Complete documentation for all 18 MiruroAPI endpoints.
GET /api/health
```
Returns API health status, version, uptime, memory usage, and available providers.
API health status, version, uptime, memory, cache stats.
**Response:**
@@ -41,11 +591,12 @@ Returns API health status, version, uptime, memory usage, and available provider
"success": true,
"results": {
"status": "healthy",
"version": "1.2.0",
"version": "2.0.0",
"uptime": "0h 0m 34s",
"node": "v24.14.1",
"memory": { "used": "13MB", "total": "15MB" },
"endpoints": 16,
"memory": { "used": "13MB", "total": "15MB", "rss": "25MB" },
"cache": { "size": 12, "maxSize": 100 },
"endpoints": 35,
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
}
}
@@ -59,317 +610,20 @@ Returns API health status, version, uptime, memory usage, and available provider
GET /api/stats
```
Returns request statistics, cache info, and memory usage.
Cache & API statistics including request count and success rate.
---
## Search
## OpenAPI
```
GET /api/search
GET /api/openapi
```
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `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`
**Response:**
```json
{
"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"]
}
]
}
}
```
Returns the OpenAPI 3.0.3 specification in JSON format.
---
## Suggestions
```
GET /api/suggestions
```
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `query` | string | Yes | Search keyword |
**Example:** `GET /api/suggestions?query=naruto`
Returns fast autocomplete suggestions with title, poster, format, status, year, and episode count.
---
## Filter
```
GET /api/filter
```
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `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`
---
## Trending
```
GET /api/trending
```
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `per_page` | number | No | 20 | Results per page |
**Example:** `GET /api/trending?per_page=5`
---
## Popular
```
GET /api/popular
```
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `per_page` | number | No | 20 | Results per page |
---
## Upcoming
```
GET /api/upcoming
```
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `per_page` | number | No | 20 | Results per page |
---
## Recent
```
GET /api/recent
```
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `per_page` | number | No | 20 | Results per page |
---
## Spotlight
```
GET /api/spotlight
```
Returns featured/spotlight anime for the hero carousel.
---
## Schedule
```
GET /api/schedule
```
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `date` | string | No | today | Date in YYYY-MM-DD format |
---
## Anime Info
```
GET /api/info/:id
```
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `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
```
GET /api/anime/:id/characters
```
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | number | Yes | AniList anime ID |
**Example:** `GET /api/anime/20/characters`
Returns characters with voice actors.
---
## Relations
```
GET /api/anime/:id/relations
```
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | number | Yes | AniList anime ID |
Returns related anime (sequels, prequels, side stories, etc.).
---
## Recommendations
```
GET /api/anime/:id/recommendations
```
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | number | Yes | AniList anime ID |
Returns recommended anime based on the given ID.
---
## Episodes
```
GET /api/episodes/:id
```
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | number | Yes | AniList anime ID |
**Example:** `GET /api/episodes/20`
**Response:**
```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
}
]
}
}
}
}
}
```
---
## Watch (Streaming)
```
GET /api/watch/:provider/:anilistId/:category/:slug
```
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `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`
**Response:**
```json
{
"success": true,
"results": {
"streams": [
{
"url": "https://vault-01.uwucdn.top/stream/.../uwu.m3u8",
"type": "hls",
"quality": "360p",
"resolution": { "width": 640, "height": 360 },
"codec": "h264",
"audio": "sub",
"fansub": "df68",
"referer": "https://kwik.cx/e/..."
}
],
"download": "https://pahe.win/LJmbA"
}
}
```
> 📝 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": "..." }`
+1480
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "miruro-api",
"version": "1.3.0",
"version": "2.1.4",
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
"main": "server.js",
"scripts": {
@@ -9,10 +9,11 @@
"test": "node test.js"
},
"dependencies": {
"express": "^4.21.0",
"axios": "^1.8.0",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.0"
"dotenv": "^16.4.0",
"express": "^4.21.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
+6 -6
View File
@@ -10,7 +10,7 @@
<meta property="og:type" content="website" />
<meta property="og:url" content="https://mirurotvapi.vercel.app" />
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
<meta property="og:description" content="Free RESTful API for anime streaming data. 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. 35 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
<meta property="og:site_name" content="MiruroAPI" />
<meta property="og:locale" content="en_US" />
@@ -26,13 +26,13 @@
<meta name="twitter:site" content="@Shineii86" />
<meta name="twitter:creator" content="@Shineii86" />
<meta name="twitter:title" content="MiruroAPI — Free Anime Streaming API" />
<meta name="twitter:description" content="Free RESTful API for anime streaming data. 18 endpoints, 12 providers, M3U8 streaming URLs." />
<meta name="twitter:description" content="Free RESTful API for anime streaming data. 35 endpoints, 12 providers, M3U8 streaming URLs." />
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/og-image.png" />
<meta name="twitter:image:alt" content="MiruroAPI - Free Anime Streaming API" />
<meta name="twitter:domain" content="mirurotvapi.vercel.app" />
<!-- ──────────────── Generic Meta ──────────────── -->
<meta name="description" content="Free RESTful API for anime streaming data. 18 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
<meta name="description" content="Free RESTful API for anime streaming data. 35 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
<meta name="author" content="Shinei Nouzen" />
<meta name="keywords" content="anime api, free api, rest api, anime streaming, m3u8, anime data, anilist, miruro, episode streaming, sub, dub" />
<meta name="robots" content="index, follow" />
@@ -278,7 +278,7 @@
"@context": "https://schema.org",
"@type": "WebAPI",
"name": "MiruroAPI",
"description": "Free RESTful API for anime streaming data. 18 endpoints, 12 providers, M3U8 streaming URLs.",
"description": "Free RESTful API for anime streaming data. 35 endpoints, 12 providers, M3U8 streaming URLs.",
"url": "https://mirurotvapi.vercel.app",
"image": "https://mirurotvapi.vercel.app/og-image.png",
"documentation": "https://mirurotvapi.vercel.app/docs",
@@ -326,7 +326,7 @@
<section class="hero container">
<div class="hero-badge"><span class="dot"></span> Free &middot; No API key &middot; Open source</div>
<h1>The <span class="grad">Anime Streaming</span> API<br/>for Your Next Project</h1>
<p class="hero-sub">Search, discover, and stream anime with M3U8 URLs. Powered by AniList GraphQL + Miruro providers. 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. 35 endpoints, 12 providers, zero config.</p>
<div class="hero-actions">
<a href="#playground" class="btn btn-primary">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
@@ -370,7 +370,7 @@ fetch("https://mirurotvapi.vercel.app/api/search?query=naruto")
<section class="stats">
<div class="container">
<div class="stats-grid">
<div class="reveal"><div class="stat-value grad" data-count="18">0</div><div class="stat-label">API Endpoints</div></div>
<div class="reveal"><div class="stat-value grad" data-count="35">0</div><div class="stat-label">API Endpoints</div></div>
<div class="reveal reveal-delay-1"><div class="stat-value grad" data-count="12">0</div><div class="stat-label">Streaming Providers</div></div>
<div class="reveal reveal-delay-2"><div class="stat-value grad" data-count="5000">0</div><div class="stat-label">Anime Indexed</div></div>
<div class="reveal reveal-delay-3"><div class="stat-value grad">M3U8</div><div class="stat-label">Stream Format</div></div>
+315 -118
View File
@@ -3,7 +3,7 @@
"info": {
"title": "MiruroAPI",
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
"version": "1.1.0",
"version": "2.0.1",
"contact": {
"name": "Shineii86",
"url": "https://github.com/Shineii86/MiruroAPI"
@@ -18,230 +18,427 @@
"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": {
"/api/health": {
"get": {
"tags": ["System"],
"summary": "Health check",
"operationId": "health",
"responses": {
"200": {
"description": "API is healthy",
"content": { "application/json": { "schema": { "type": "object" } } }
}
}
}
},
"/api/search": {
"get": {
"tags": ["Search & Discovery"],
"tags": ["Search"],
"summary": "Search anime by name",
"operationId": "searchAnime",
"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": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
],
"responses": {
"200": { "description": "Search results with pagination" }
}
]
}
},
"/api/suggestions": {
"get": {
"tags": ["Search & Discovery"],
"tags": ["Search"],
"summary": "Autocomplete suggestions",
"operationId": "getSuggestions",
"parameters": [
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "description": "Lightweight suggestions (max 8)" }
}
]
}
},
"/api/filter": {
"get": {
"tags": ["Search & Discovery"],
"summary": "Advanced filter / browse",
"operationId": "filterAnime",
"tags": ["Search"],
"summary": "Advanced filter",
"parameters": [
{ "name": "genre", "in": "query", "schema": { "type": "string" }, "description": "e.g. Action, Romance" },
{ "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "e.g. Isekai, Time Skip" },
{ "name": "genre", "in": "query", "schema": { "type": "string" } },
{ "name": "tag", "in": "query", "schema": { "type": "string" } },
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
{ "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": "status", "in": "query", "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"] } },
{ "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": "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"] } },
{ "name": "sort", "in": "query", "schema": { "type": "string", "default": "POPULARITY_DESC" } },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
],
"responses": {
"200": { "description": "Filtered results with pagination" }
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
]
}
},
"/api/multi-search": {
"get": {
"tags": ["Search"],
"summary": "Multi-parameter search"
}
},
"/api/trending": {
"get": {
"tags": ["Collections"],
"summary": "Trending anime",
"operationId": "getTrending",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Trending anime list" } }
"summary": "Trending anime"
}
},
"/api/trending/daily": {
"get": {
"tags": ["Collections"],
"summary": "Daily trending anime"
}
},
"/api/trending/weekly": {
"get": {
"tags": ["Collections"],
"summary": "Weekly trending anime"
}
},
"/api/popular": {
"get": {
"tags": ["Collections"],
"summary": "Most popular anime",
"operationId": "getPopular",
"parameters": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Popular anime list" } }
"summary": "Most popular anime"
}
},
"/api/top": {
"get": {
"tags": ["Collections"],
"summary": "Top anime by score"
}
},
"/api/upcoming": {
"get": {
"tags": ["Collections"],
"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" } }
"summary": "Upcoming anime"
}
},
"/api/recent": {
"get": {
"tags": ["Collections"],
"summary": "Recently updated 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" } }
"summary": "Recently aired anime"
}
},
"/api/spotlight": {
"get": {
"tags": ["Collections"],
"summary": "Spotlight / featured anime",
"operationId": "getSpotlight",
"responses": { "200": { "description": "Top 10 trending + popular" } }
"summary": "Spotlight/featured anime"
}
},
"/api/schedule": {
"get": {
"tags": ["Collections"],
"summary": "Airing schedule",
"operationId": "getSchedule",
"summary": "Airing schedule"
}
},
"/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": [
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "Upcoming episodes with timestamps" } }
{ "name": "year", "in": "path", "required": true, "schema": { "type": "integer" } },
{ "name": "season", "in": "path", "required": true, "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } }
]
}
},
"/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}": {
"get": {
"tags": ["Anime Details"],
"summary": "Full anime info",
"operationId": "getAnimeInfo",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
],
"responses": { "200": { "description": "Complete anime metadata, characters, relations, recommendations" } }
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
]
}
},
"/api/anime/{id}/characters": {
"get": {
"tags": ["Anime Details"],
"summary": "Characters with voice actors",
"operationId": "getCharacters",
"summary": "Characters",
"parameters": [
{ "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" } }
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
]
}
},
"/api/anime/{id}/relations": {
"get": {
"tags": ["Anime Details"],
"summary": "Related anime",
"operationId": "getRelations",
"summary": "Relations",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
],
"responses": { "200": { "description": "Sequels, prequels, side stories, etc." } }
]
}
},
"/api/anime/{id}/recommendations": {
"get": {
"tags": ["Anime Details"],
"summary": "Community recommendations",
"operationId": "getRecommendations",
"summary": "Recommendations",
"parameters": [
{ "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" } }
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
]
}
},
"/api/episodes/{id}": {
"get": {
"tags": ["Streaming"],
"summary": "Episode list from all providers",
"operationId": "getEpisodes",
"summary": "Episode list",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
],
"responses": { "200": { "description": "Episodes organized by provider and audio type (sub/dub)" } }
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
]
}
},
"/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": {
"get": {
"tags": ["Streaming"],
"summary": "Streaming sources (detailed)",
"operationId": "getSources",
"parameters": [
{ "name": "episodeId", "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": "category", "in": "query", "schema": { "type": "string", "enum": ["sub", "dub"], "default": "sub" } }
],
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } }
]
}
},
"/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}": {
"get": {
"tags": ["Streaming"],
"summary": "Streaming sources (simple slug-based)",
"operationId": "getWatchSources",
"summary": "Streaming sources (simple) with subtitles",
"description": "Returns streaming sources, subtitles (if available), and best stream with quality fallback",
"parameters": [
{ "name": "provider", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "anilistId", "in": "path", "required": true, "schema": { "type": "integer" } },
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string", "enum": ["sub", "dub"] } },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "e.g. animepahe-1" }
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string" } },
{ "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": [
{ "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" }
"bestStream": { "type": "object" }
}
}
}
}
}
}
}
}
}
},
"/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/health": {
"get": {
"tags": ["System"],
"summary": "Health check"
}
},
"/api/stats": {
"get": {
"tags": ["System"],
"summary": "API statistics"
}
},
"/api/openapi": {
"get": {
"tags": ["System"],
"summary": "OpenAPI spec"
}
}
}
}
+119 -36
View File
@@ -5,9 +5,8 @@
*
* @description
* Main entry point for the MiruroAPI Express server.
* Configures CORS, middleware, static files, Swagger docs,
* API routes, and 404 handling. Starts the server on the
* configured port.
* Configures CORS, compression, logging, security headers,
* rate limiting, API routes, and 404 handling.
*
* @exports
* None (side-effect: starts Express server)
@@ -19,7 +18,9 @@
const express = require("express");
const cors = require("cors");
const compression = require("compression");
const path = require("path");
const fs = require("fs");
require("dotenv").config();
// ══════════════════════════════════════════════════════════════
@@ -28,54 +29,138 @@ require("dotenv").config();
const app = express();
const PORT = process.env.PORT || 3000;
const startTime = Date.now();
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",")
: 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
// ══════════════════════════════════════════════════════════════
// NOTE: Single unified CORS middleware — handles all origin validation
// ---- FEATURE: Unified CORS middleware with preflight handling ----
app.use((req, res, next) => {
const origin = req.headers.origin;
if (
!allowedOrigins ||
allowedOrigins.includes("*") ||
(origin && allowedOrigins.includes(origin))
) {
const isProduction = process.env.VERCEL || process.env.NODE_ENV === "production";
if (allowedOrigins && allowedOrigins.length > 0) {
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-Methods", "GET");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-No-Compression");
res.setHeader("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
// ══════════════════════════════════════════════════════════════
// SECURITY HEADERS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Security headers ----
app.use((req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-Content-Type-Options", "nosniff");
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("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
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
// ══════════════════════════════════════════════════════════════
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
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Clean URL routes (no .html extension) ----
const fs = require("fs");
const publicDir = path.join(__dirname, "public");
app.get("/tos", (req, res) => {
@@ -90,7 +175,6 @@ app.get("/privacy", (req, res) => {
// SWAGGER UI DOCS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Interactive Swagger UI documentation at /docs ----
app.get("/docs", (req, res) => {
res.sendFile(path.join(publicDir, "docs.html"));
});
@@ -99,25 +183,9 @@ app.get("/docs", (req, res) => {
// RESPONSE HELPERS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Standardized JSON response wrapper ----
/**
* Wraps data in a standardized success JSON response.
*
* @param {object} res - Express response object
* @param {*} data - The data to return in the response
* @param {number} status - HTTP status code (default: 200)
*/
const jsonResponse = (res, data, status = 200) =>
res.status(status).json({ success: true, results: data });
// ---- FEATURE: Standardized error response wrapper ----
/**
* Returns a standardized error JSON response.
*
* @param {object} res - Express response object
* @param {string} message - Error message to return
* @param {number} status - HTTP status code (default: 500)
*/
const jsonError = (res, message = "Internal server error", status = 500) =>
res.status(status).json({ success: false, message });
@@ -128,7 +196,11 @@ const jsonError = (res, message = "Internal server error", status = 500) =>
// ---- FEATURE: Rate limiting (100 requests per minute per IP) ----
const requestCounts = new Map();
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 windowMs = 60000;
const maxRequests = 100;
@@ -148,6 +220,15 @@ app.use((req, res, next) => {
}
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();
});
@@ -156,7 +237,7 @@ app.use((req, res, next) => {
// ══════════════════════════════════════════════════════════════
const apiRoutes = require("./src/routes/apiRoutes");
app.use("/api", apiRoutes(jsonResponse, jsonError));
app.use("/api", apiRoutes(jsonResponse, jsonError, startTime));
// ══════════════════════════════════════════════════════════════
// 404 HANDLER
@@ -164,7 +245,7 @@ app.use("/api", apiRoutes(jsonResponse, jsonError));
// ---- FEATURE: Global error handler ----
app.use((err, req, res, next) => {
console.error(err.stack);
console.error(`[ERROR] ${err.message}`);
res.status(err.status || 500).json({
success: false,
message: err.message || "Internal server error",
@@ -187,12 +268,14 @@ app.use((req, res) => {
});
// ══════════════════════════════════════════════════════════════
// SERVER START
// SERVER START (only when running standalone, not on Vercel)
// ══════════════════════════════════════════════════════════════
if (!process.env.VERCEL) {
app.listen(PORT, () => {
console.info(`MiruroAPI listening at ${PORT}`);
console.log(`[${new Date().toISOString()}] MiruroAPI v2.1.4 listening at ${PORT}`);
});
}
module.exports = app;
-444
View File
@@ -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
View File
@@ -538,7 +538,7 @@ const getSchedule = async (page = 1, perPage = 20) => {
* @returns {Promise<object>} Paginated filtered results
*/
const filterAnime = async ({
genre, tag, year, season, format, status,
search, genre, tag, year, season, format, status,
sort = "POPULARITY_DESC", page = 1, perPage = 20,
}) => {
const SORT_MAP = {
@@ -548,13 +548,14 @@ const filterAnime = async ({
START_DATE_DESC: "START_DATE_DESC",
FAVOURITES_DESC: "FAVOURITES_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 variables = { page, perPage };
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 (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"); }
@@ -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 = {
anilistQuery,
searchAnime,
@@ -596,6 +930,17 @@ module.exports = {
getSpotlight,
getSchedule,
filterAnime,
getAllGenres,
getAllTags,
getTopAnime,
getTrendingDaily,
getTrendingWeekly,
getSeasonalAnime,
getAnimeByStudio,
getCharacterInfo,
getStaffInfo,
searchCharacters,
searchStaff,
MEDIA_LIST_FIELDS,
MEDIA_FULL_FIELDS,
};
+18 -1
View File
@@ -104,6 +104,15 @@ const setCache = (key, data, ttl = DEFAULT_TTL) => {
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 });
};
@@ -120,6 +129,14 @@ const clearCache = () => {
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
+371 -58
View File
@@ -20,6 +20,7 @@
const axios = require("axios");
const { Buffer } = require("buffer");
const zlib = require("zlib");
const { getCached, setCache } = require("./cache");
// ══════════════════════════════════════════════════════════════
// MIRURO PIPE CONFIGURATION
@@ -42,6 +43,7 @@ const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
Referer: "https://www.miruro.tv/",
Origin: "https://www.miruro.tv",
};
// ══════════════════════════════════════════════════════════════
@@ -66,29 +68,139 @@ const encodePipeRequest = (payload) => {
// ---- FEATURE: Decode base64+gzip pipe response ----
/**
* Decodes a pipe response from base64url + gzip format into a plain object.
* The Miruro pipe returns all responses in this compressed format.
* Decodes a pipe response from base64url + XOR obfuscation + gzip format.
* The Miruro pipe returns all responses XORed with PIPE_OBF_KEY then gzipped.
*
* @param {string} encodedStr - The base64url-encoded gzipped response
* @type {Buffer}
*/
const PIPE_OBF_KEY = Buffer.from(
process.env.PIPE_OBF_KEY || "71951034f8fbcf53d89db52ceb3dc22c",
"hex"
);
/**
* @param {string} encodedStr - The base64url-encoded response
* @returns {object} Decoded JSON object
* @throws {Error} If decoding or decompression fails
*
* @example
* const data = decodePipeResponse(res.text);
* console.log(data.providers); // { kiwi: {...}, bee: {...} }
*/
const decodePipeResponse = (encodedStr) => {
try {
// NOTE: Add padding if necessary (base64url may omit trailing =)
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
const compressed = Buffer.from(padded, "base64url");
const decompressed = zlib.gunzipSync(compressed);
const raw = Buffer.from(padded, "base64url");
// NOTE: XOR decode with obfuscation key before gzip decompression
const xored = Buffer.alloc(raw.length);
for (let i = 0; i < raw.length; i++) {
xored[i] = raw[i] ^ PIPE_OBF_KEY[i % PIPE_OBF_KEY.length];
}
const decompressed = zlib.gunzipSync(xored);
return JSON.parse(decompressed.toString("utf-8"));
} catch (e) {
throw new Error("Failed to decode pipe response: " + e.message);
}
};
// ---- FEATURE: Pipe request with retry and exponential backoff ----
/**
* Makes a pipe API request with retry logic and exponential backoff.
* The pipe endpoint intermittently rate-limits (444/connection reset) from datacenter IPs.
* Retries up to 3 times with increasing delays (1s, 2s, 4s).
*
* @param {string} path - Pipe path ("episodes" or "sources")
* @param {object} query - Query parameters for the pipe
* @param {number} [maxRetries=3] - Maximum number of retries
* @returns {Promise<object>} Decoded pipe response
* @throws {Error} If all retries fail
*/
const pipeRequest = async (path, query, maxRetries = 3) => {
const payload = { path, method: "GET", query, body: null, version: "0.2.0" };
const encodedReq = encodePipeRequest(payload);
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 20000,
maxRedirects: 5,
});
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
return decodePipeResponse(res.text || res.data);
} catch (e) {
lastError = e;
const status = e.response?.status;
// NOTE: Don't retry on non-retryable errors (4xx except 444)
if (status && status >= 400 && status < 500 && status !== 444) {
throw new Error(`Pipe request failed with status ${status}`);
}
// NOTE: Exponential backoff: 1s, 2s, 4s
if (attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw lastError || new Error("Pipe request failed after all retries");
};
// ---- FEATURE: Subtitle proxy for CORS-safe browser access ----
/**
* CDN streams (mt.nekostream.site) have CORS * — browsers load them directly.
* Subtitles use third-party CDNs that may block cross-origin — proxy those.
*
* @type {string}
*/
const PROXY_BASE = "/api/proxy?url=";
/**
* Proxies a URL through /api/proxy for CORS bypass.
*
* @param {string} url - Raw URL
* @param {string} [referer] - Optional referer to pass
* @returns {string} Proxied URL
*/
const proxyUrl = (url, referer) => {
if (!url || !url.startsWith("http")) return url;
try {
const ref = referer ? "&referer=" + encodeURIComponent(referer) : "";
return PROXY_BASE + encodeURIComponent(url) + ref;
} catch {
return url;
}
};
/**
* Streams are returned as-is (raw CDN URLs).
* The browser loads them directly — CDN has CORS * and serves real content to residential IPs.
* Server-side requests get decoy PNG images, so proxying through /api/proxy defeats the purpose.
*
* @param {object} sources - Decoded sources with streams array
* @returns {object} Sources with raw stream URLs
*/
const proxyStreams = (sources) => {
// NOTE: Intentionally return raw CDN URLs — the CDN serves real video to browsers
// but decoy PNG images to server-side requests. The browser's residential IP is required.
return sources;
};
/**
* Proxies subtitle URLs through /api/proxy for CORS bypass.
* Subtitles come from third-party CDNs that may not have CORS headers.
*
* @param {object} sources - Decoded sources with subtitles array
* @returns {object} Sources with proxied subtitle URLs
*/
const proxySubtitles = (sources) => {
if (!sources?.subtitles) return sources;
sources.subtitles = sources.subtitles.map((s) => {
const raw = s.url || s.file;
if (!raw || !raw.startsWith("http")) return s;
const proxied = proxyUrl(raw, "https://www.miruro.tv/");
return { ...s, url: proxied, file: proxied };
});
return sources;
};
// ---- FEATURE: Decode base64 episode ID to plain text ----
/**
* Decodes a base64-encoded episode ID back to plain text.
@@ -116,20 +228,30 @@ 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.
* Saves the original base64 value in a `rawPipeId` field on episode objects
* so the pipe sources endpoint can be called directly from the client.
* Returns a deep clone — does not mutate the original object.
*
* @param {object|Array} obj - The object or array to process
* @returns {object|Array} The same object with decoded IDs (mutated in place)
* @returns {object|Array} A new object with decoded IDs
*/
const deepTranslate = (obj) => {
if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) {
if (key === "id" && typeof obj[key] === "string") {
obj[key] = translateId(obj[key]);
} else if (typeof obj[key] === "object") {
deepTranslate(obj[key]);
if (Array.isArray(obj)) {
return obj.map((item) => deepTranslate(item));
}
const clone = { ...obj };
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;
};
@@ -173,9 +295,9 @@ const injectSourceSlugs = (data, anilistId) => {
for (const ep of epList) {
if (ep.id && ep.number) {
const origId = ep.id;
// NOTE: Take only the prefix before ":" for the slug
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
// NOTE: rawPipeId is already set by deepTranslate — preserve original base64
// Take only the prefix before ":" for the slug
const prefix = ep.id.includes(":") ? ep.id.split(":")[0] : ep.id;
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
}
}
@@ -192,30 +314,21 @@ const injectSourceSlugs = (data, anilistId) => {
// ---- 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.
* Uses pipeRequest with retry logic and caches results for 5 minutes.
*
* @param {number} anilistId - The AniList anime ID
* @returns {Promise<object>} Decoded episode data with providers
* @throws {Error} If the pipe request fails
*/
const fetchRawEpisodes = async (anilistId) => {
const payload = {
path: "episodes",
method: "GET",
query: { anilistId },
body: null,
version: "0.1.0",
};
const cacheKey = `pipe:episodes:${anilistId}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
});
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
const data = decodePipeResponse(res.text || res.data);
return deepTranslate(data);
const data = await pipeRequest("episodes", { anilistId });
const result = deepTranslate(data);
setCache(cacheKey, result, 300 * 1000);
return result;
};
// ---- FEATURE: Get episodes with slug-based IDs ----
@@ -247,7 +360,8 @@ const getEpisodes = async (anilistId) => {
// ---- 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.
* Uses pipeRequest with retry logic and caches results for 10 minutes.
* Stream URLs are ephemeral but CDNs serve the same content for hours.
*
* @param {string} episodeId - The raw episode ID from the pipe
* @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk")
@@ -260,23 +374,21 @@ const getEpisodes = async (anilistId) => {
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
*/
const getSources = async (episodeId, provider, anilistId, category = "sub") => {
const encId = Buffer.from(episodeId).toString("base64url");
const payload = {
path: "sources",
method: "GET",
query: { episodeId: encId, provider, category, anilistId },
body: null,
version: "0.1.0",
};
const cacheKey = `pipe:sources:${episodeId}:${provider}:${category}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const encodedReq = encodePipeRequest(payload);
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
headers: HEADERS,
timeout: 15000,
const encId = Buffer.from(episodeId).toString("base64url");
const sources = await pipeRequest("sources", {
episodeId: encId,
provider,
category,
anilistId,
});
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
return deepTranslate(decodePipeResponse(res.text || res.data));
const result = proxySubtitles(proxyStreams(deepTranslate(sources)));
setCache(cacheKey, result, 600 * 1000);
return result;
};
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
@@ -304,14 +416,27 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
const episodes = provData.episodes?.[category] || [];
let targetId = null;
// NOTE: Resolve slug back to the original pipe episode ID
// NOTE: Handle both raw pipe IDs ("animedao:witch-hat-atelier:1") and slugged IDs
// ("watch/bonk/147105/sub/animedao-1") which may exist if cache was mutated by injectSourceSlugs.
// Match slug against: last path segment (slugged) OR prefix-{number} (raw).
for (const ep of episodes) {
const origId = ep.id || "";
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
const generated = `${prefix}-${ep.number}`;
const rawId = ep.id || "";
let match = false;
if (generated === slug) {
targetId = origId;
// Case 1: Slugged ID — "watch/{provider}/{anilistId}/{category}/{prefix}-{number}"
if (rawId.includes("/")) {
const slugSuffix = rawId.split("/").pop();
match = slugSuffix === slug;
}
// Case 2: Raw pipe ID — "animedao:witch-hat-atelier:1"
else if (rawId.includes(":")) {
const prefix = rawId.split(":")[0];
match = `${prefix}-${ep.number}` === slug;
}
if (match) {
// Use rawPipeId if available (decoded pipe format), otherwise use the ep.id
targetId = ep.rawPipeId ? translateId(ep.rawPipeId) : rawId;
break;
}
}
@@ -320,10 +445,198 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
return getSources(targetId, provider, anilistId, category);
};
// ══════════════════════════════════════════════════════════════
// SUBTITLE EXTRACTION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Extract subtitle URLs from streaming sources ----
/**
* Extracts and formats subtitle URLs from the pipe sources response.
* Handles multiple subtitle formats from different providers.
*
* Provider formats:
* bonk: { file, label, kind, default, language, format, encoding }
* others: { url, name, lang, format }
*
* @param {object} sources - The sources response from getSources/getWatchSources
* @returns {Array} Array of subtitle objects { url, label, language, kind, format, isDefault }
*/
const extractSubtitles = (sources) => {
const raw = sources.subtitles || sources.captions || [];
if (!Array.isArray(raw)) return [];
return raw.map((sub) => ({
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);
};
// ---- FEATURE: Extract skip timestamps (OP/ED) from sources ----
/**
* Extracts skip timestamps (OP/ED) from the pipe sources response.
* Only available for providers with skip_times capability (e.g., bonk).
*
* @param {object} sources - The sources response from getSources/getWatchSources
* @returns {object|null} Skip timestamps object or null
*/
const extractSkipTimes = (sources) => {
const skipTimes = sources.skipTimes || sources.skip || null;
if (!skipTimes || typeof skipTimes !== "object") return null;
return {
intro: skipTimes.intro || skipTimes.op || null,
outro: skipTimes.outro || skipTimes.ed || null,
preview: skipTimes.preview || null,
};
};
// ══════════════════════════════════════════════════════════════
// QUALITY FALLBACK
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Get best available stream with quality fallback ----
/**
* Returns the best available HLS stream with automatic quality fallback.
* Tries 1080p → 720p → 480p → 360p in order.
* Falls back to first HLS stream if no quality match.
*
* @param {object} sources - The sources response
* @param {string} [preferredQuality="1080p"] - Preferred quality
* @returns {object|null} Best available stream object or null
*/
const getBestStream = (sources, preferredQuality = "1080p") => {
const streams = (sources.streams || []).filter((s) => s.url);
if (streams.length === 0) return null;
const hlsStreams = streams.filter((s) => s.type === "hls" || !s.type || s.url?.endsWith(".m3u8") || s.url?.includes("m3u8"));
const dashStreams = streams.filter((s) => s.type === "dash" || s.url?.endsWith(".mpd"));
const 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 URL
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Get download URL for an episode ----
/**
* Fetches the download URL for a specific episode from the pipe.
* Returns the direct download link, subtitles, and best stream if available.
*
* @param {string} provider - Provider name
* @param {number} anilistId - The AniList anime ID
* @param {string} category - Audio category ("sub" or "dub")
* @param {string} slug - Episode slug (e.g., "animepahe-1")
* @returns {Promise<object>} Object with download URL, subtitles, best stream, and metadata
*/
const getDownloadUrl = async (provider, anilistId, category, slug) => {
const sources = await getWatchSources(provider, anilistId, category, slug);
const subtitles = extractSubtitles(sources);
const bestStream = getBestStream(sources);
return {
download: sources.download || null,
subtitles,
bestStream,
provider,
anilistId,
category,
slug,
hasDownload: !!sources.download,
hasSubtitles: subtitles.length > 0,
};
};
// ══════════════════════════════════════════════════════════════
// BATCH EPISODES
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Get sources for multiple episodes at once ----
/**
* Fetches streaming sources for multiple episodes in parallel.
* Useful for preloading next episodes or batch downloading.
*
* @param {string} provider - Provider name
* @param {number} anilistId - The AniList anime ID
* @param {string} category - Audio category ("sub" or "dub")
* @param {string[]} slugs - Array of episode slugs (e.g., ["animepahe-1", "animepahe-2"])
* @returns {Promise<object>} Object mapping slug → sources (with errors for failed ones)
*/
const getBatchSources = async (provider, anilistId, category, slugs) => {
const results = {};
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,
};
}
};
// NOTE: Fetch up to 5 episodes in parallel to avoid rate limiting
const batchSize = 5;
for (let i = 0; i < slugs.length; i += batchSize) {
const batch = slugs.slice(i, i + batchSize);
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 = {
getEpisodes,
getSources,
getWatchSources,
getDownloadUrl,
getBatchSources,
extractSubtitles,
extractSkipTimes,
getBestStream,
encodePipeRequest,
decodePipeResponse,
translateId,
+74
View File
@@ -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
+843 -40
View File
File diff suppressed because it is too large Load Diff
+47 -4
View File
@@ -4,8 +4,8 @@
* Repository: https://github.com/Shineii86/MiruroAPI
*
* @description
* Integration test suite for MiruroAPI endpoints.
* Tests all major endpoints for correct response format.
* Integration test suite for MiruroAPI v2.1.0 endpoints.
* Tests all 36 endpoints for correct response format.
*
* @author Shinei Nouzen
* @license MIT
@@ -15,24 +15,67 @@
const BASE = process.env.API_URL || "https://mirurotvapi.vercel.app/api";
const tests = [
// System
{ name: "Health", url: "/health" },
{ name: "Stats", url: "/stats" },
{ name: "OpenAPI", url: "/openapi" },
{ name: "Providers", url: "/providers" },
// Search & Discovery
{ name: "Search", url: "/search?query=naruto" },
{ name: "Suggestions", url: "/suggestions?query=naruto" },
{ name: "Filter", url: "/filter?genre=Action&per_page=1" },
{ name: "Multi-Search", url: "/multi-search?q=naruto&genre=Action" },
// Collections
{ 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: "Top", url: "/top?per_page=1" },
{ name: "Upcoming", url: "/upcoming?per_page=1" },
{ name: "Recent", url: "/recent?per_page=1" },
{ name: "Spotlight", url: "/spotlight" },
{ name: "Schedule", url: "/schedule" },
{ name: "Random", url: "/random" },
{ name: "Upcoming Countdown", url: "/upcoming/countdown" },
// Seasonal & Studio
{ name: "Seasonal", url: "/season/2025/spring" },
{ name: "Studio", url: "/studio/MAPPA" },
// Genres & Tags
{ 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" },
// Character & Staff
{ name: "Character", url: "/character/1" },
{ name: "Staff", url: "/staff/1" },
{ name: "Characters Search", url: "/characters?query=kaneki" },
{ name: "Staff Search", url: "/staff?query=oda" },
// Anime Details
{ 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: "Recommendations", url: "/anime/20/recommendations" },
// Streaming
{ name: "Episodes", url: "/episodes/20" },
{ name: "Batch Episodes", url: "/episodes/20/batch?provider=kiwi&category=sub&slugs=animepahe-1,animepahe-2" },
{ name: "Stream", url: "/stream?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
{ name: "Download", url: "/download?provider=kiwi&anilistId=20&category=sub&slug=animepahe-1" },
{ name: "Watch", url: "/watch/kiwi/20/sub/animepahe-1" },
// Utility
{ name: "Compare", url: "/compare/20/21" },
{ name: "Genre Stats", url: "/stats/genre" },
{ name: "Calendar", url: "/calendar" },
{ name: "Timeline", url: "/timeline/20" },
];
let passed = 0;
@@ -61,7 +104,7 @@ async function runTest(test) {
}
async function runAll() {
console.log(`\n🧪 Running ${tests.length} tests...\n`);
console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`);
for (const test of tests) {
await runTest(test);
+26 -3
View File
@@ -2,12 +2,35 @@
"version": 2,
"builds": [
{
"src": "server.js",
"src": "api/index.js",
"use": "@vercel/node"
},
{
"src": "api/proxy.js",
"use": "@vercel/node"
}
],
"routes": [
{ "src": "/api/(.*)", "dest": "server.js" },
{ "src": "/(.*)", "dest": "server.js" }
{
"src": "/api/proxy",
"dest": "/api/proxy.js"
},
{
"src": "/api/(.*)",
"dest": "/api/index.js"
}
],
"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=()" }
]
}
]
}