Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b510589e | ||
|
|
8297d8f27d | ||
|
|
1d0ec95f19 | ||
|
|
94421af2dd | ||
|
|
7394a1c94c | ||
|
|
b59ca7e1ec | ||
|
|
5acf8a2e1e | ||
|
|
00275adb42 | ||
|
|
90cdd26241 | ||
|
|
4509c0add3 | ||
|
|
d1dec20788 | ||
|
|
bff4c5b7c6 | ||
|
|
84d7e75372 | ||
|
|
e35d934190 | ||
|
|
398db2b4f0 | ||
|
|
94b59b3bc9 | ||
|
|
6cd2b9a19e | ||
|
|
76c47368a7 | ||
|
|
f208becd27 | ||
|
|
74f3dcd7c3 | ||
|
|
90b0999d7d | ||
|
|
f7b3e32cb7 | ||
|
|
a65138e575 | ||
|
|
399f9b1917 | ||
|
|
4ec1ff69f4 | ||
|
|
56e3500d3e | ||
|
|
9b1ac11ff0 | ||
|
|
3ab0bae18d | ||
|
|
33e5e8e136 | ||
|
|
3675bbba77 | ||
|
|
9ada6e1016 | ||
|
|
3a28c4d182 | ||
|
|
68f7ea7cf4 | ||
|
|
37e70c3bda | ||
|
|
af08c91624 | ||
|
|
1433afdca1 | ||
|
|
b73d8d09bf | ||
|
|
99d7c15750 | ||
|
|
faa4888f77 | ||
|
|
a1cc22e84a | ||
|
|
d2193670f4 | ||
|
|
20041a8984 | ||
|
|
14d5c22fc7 | ||
|
|
240e90fe51 | ||
|
|
18a73c324e |
@@ -0,0 +1,13 @@
|
|||||||
|
# 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,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.vercel/
|
.vercel/
|
||||||
|
deploy.cjs
|
||||||
|
|||||||
@@ -1,11 +1,259 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
- Added scroll-reveal animations (IntersectionObserver)
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
- Real Miruro SVG favicons used in header and footer
|
||||||
|
|
||||||
|
## v1.2.0
|
||||||
|
- Fixed critical response format bug: all endpoints now return {success: true, results: data}
|
||||||
|
- 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
|
||||||
|
|
||||||
## v1.1.0
|
## v1.1.0
|
||||||
- Added Swagger UI interactive docs at /docs
|
- Added Swagger UI interactive docs at /docs
|
||||||
- Added OpenAPI 3.0 spec at /openapi.json
|
- Added OpenAPI 3.0 spec at /openapi.json
|
||||||
- Added mappings field in episodes response (anilistId, malId, kitsuId)
|
- Added mappings field in episodes response
|
||||||
- Added Docker support (Dockerfile + .dockerignore)
|
- Added Docker support (Dockerfile + .dockerignore)
|
||||||
- Improved landing page with premium design (Walter-style)
|
- Improved landing page with premium design
|
||||||
- Added "Try it" buttons on all endpoints
|
- Added "Try it" buttons on all endpoints
|
||||||
- Health endpoint now includes version, providers list, and endpoint count
|
- Health endpoint now includes version, providers list, and endpoint count
|
||||||
- Version bump to 1.1.0
|
- Version bump to 1.1.0
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Contributing to MiruroAPI
|
||||||
|
|
||||||
|
Thank you for your interest in contributing! Here's how you can help.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/MiruroAPI.git`
|
||||||
|
3. Create a branch: `git checkout -b feature/amazing-feature`
|
||||||
|
4. Make your changes
|
||||||
|
5. Commit: `git commit -m 'feat: add amazing feature'`
|
||||||
|
6. Push: `git push origin feature/amazing-feature`
|
||||||
|
7. Open a Pull Request
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Server runs on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
MiruroAPI/
|
||||||
|
├── server.js # Express entry point
|
||||||
|
├── src/
|
||||||
|
│ ├── helpers/ # AniList, pipe, cache integrations
|
||||||
|
│ └── routes/ # Express routes
|
||||||
|
├── public/ # Static files (landing page, Swagger UI)
|
||||||
|
└── docs/ # API documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a New Endpoint
|
||||||
|
|
||||||
|
1. Create helper in `src/helpers/yourHelper.js` if needed
|
||||||
|
2. Add route in `src/routes/apiRoutes.js`
|
||||||
|
3. Update `docs/endpoints.md` with documentation
|
||||||
|
4. Test with `curl http://localhost:3000/api/your-endpoint`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Use CommonJS (`require`/`module.exports`)
|
||||||
|
- Add JSDoc comments for new functions
|
||||||
|
- Follow existing patterns in the codebase
|
||||||
|
- Keep helpers focused on one task
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
- `feat:` new feature
|
||||||
|
- `fix:` bug fix
|
||||||
|
- `docs:` documentation changes
|
||||||
|
- `refactor:` code refactoring
|
||||||
|
- `test:` adding tests
|
||||||
|
- `chore:` maintenance
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
- Keep PRs focused on one feature or fix
|
||||||
|
- Update CHANGELOG.md with your changes
|
||||||
|
- Test your changes locally before submitting
|
||||||
|
- Follow existing code style
|
||||||
|
|
||||||
|
## Reporting Issues
|
||||||
|
|
||||||
|
- Use GitHub Issues for bug reports
|
||||||
|
- Include steps to reproduce
|
||||||
|
- Include expected vs actual behavior
|
||||||
|
- Include your Node.js version
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require("../server");
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Miruro Assets
|
||||||
|
|
||||||
|
Scraped from official Miruro domains for MiruroAPI project.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- **Official**: https://www.miruro.com (primary hub)
|
||||||
|
- **Streaming**: https://miruro.tv, https://miruro.to, https://miruro.bz, https://miruro.ru
|
||||||
|
- **Status**: https://status.miruro.com/status/miruro
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/
|
||||||
|
├── favicons/ # Favicons and app icons
|
||||||
|
│ ├── icon-dark-1024x1024.svg # Dark mode SVG favicon
|
||||||
|
│ ├── icon-light-1024x1024.svg # Light mode SVG favicon
|
||||||
|
│ ├── icon-512x512.png # Main app icon (PNG)
|
||||||
|
│ ├── favicon.ico # Classic ICO favicon
|
||||||
|
│ ├── apple-touch-icon-180x180.png # iOS home screen icon
|
||||||
|
│ └── maskable-icon-512x512.png # Android adaptive icon / OG image
|
||||||
|
├── logos/
|
||||||
|
│ └── status-logo.png # Status page logo
|
||||||
|
├── fonts/ # Inter + FontAwesome subset
|
||||||
|
│ ├── inter.woff2
|
||||||
|
│ ├── fa-brands-400.woff2
|
||||||
|
│ ├── fa-solid-900.woff2
|
||||||
|
│ └── fa-minimal.css
|
||||||
|
└── media/ # Testimonial avatars from homepage
|
||||||
|
└── *.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Social Links
|
||||||
|
|
||||||
|
- Twitter: https://twitter.com/miruro_official
|
||||||
|
- Discord: https://discord.com/invite/pjsdJp96mY
|
||||||
|
- Reddit: https://reddit.com/r/miruro
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All streaming domains (tv, to, bz, ru) use identical assets
|
||||||
|
- SVG favicons support dark/light mode via `prefers-color-scheme`
|
||||||
|
- OG image is `maskable-icon-512x512.png`
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#ffffff;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#000000;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.4.0 - Minimal subset for miruro hub
|
||||||
|
* License: https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
*/
|
||||||
|
@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(fa-solid-900.woff2) format("woff2")}
|
||||||
|
@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(fa-brands-400.woff2) format("woff2")}
|
||||||
|
.fa,.fab,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}
|
||||||
|
.fas{font-family:"Font Awesome 6 Free";font-weight:900}
|
||||||
|
.fab{font-family:"Font Awesome 6 Brands";font-weight:400}
|
||||||
|
.fa-arrow-right:before{content:"\f08b"}
|
||||||
|
.fa-arrow-up:before{content:"\f176"}
|
||||||
|
.fa-bars:before{content:"\f0c9"}
|
||||||
|
.fa-chart-bar:before{content:"\f080"}
|
||||||
|
.fa-check-circle:before{content:"\f058"}
|
||||||
|
.fa-globe:before{content:"\f57c"}
|
||||||
|
.fa-language:before{content:"\f1ab"}
|
||||||
|
.fa-play-circle:before{content:"\f144"}
|
||||||
|
.fa-server:before{content:"\f233"}
|
||||||
|
.fa-shield-alt:before{content:"\f3ed"}
|
||||||
|
.fa-star:before{content:"\f699"}
|
||||||
|
.fa-times:before{content:"\f00d"}
|
||||||
|
.fa-users:before{content:"\e594"}
|
||||||
|
.fa-discord:before{content:"\f392"}
|
||||||
|
.fa-reddit:before{content:"\f1a2"}
|
||||||
|
.fa-twitter:before{content:"\f081"}
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,190 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
MiruroAPI/
|
||||||
|
├── server.js # Express entry point, port 3000
|
||||||
|
├── package.json # name: "miruro-api"
|
||||||
|
├── vercel.json # Routes /api/* and /* to server.js
|
||||||
|
├── Dockerfile # Docker support (node:20-alpine)
|
||||||
|
├── .dockerignore # Docker ignore file
|
||||||
|
│
|
||||||
|
├── public/ # Static files served from process.cwd()
|
||||||
|
│ ├── index.html # Premium landing page (SVG icons, live console)
|
||||||
|
│ ├── docs.html # Swagger UI interactive documentation
|
||||||
|
│ ├── openapi.json # OpenAPI 3.0 spec
|
||||||
|
│ ├── 404.html # Glitch animation error page
|
||||||
|
│ ├── manifest.json # PWA manifest (theme: #A855F7)
|
||||||
|
│ ├── robots.txt # Crawler directives
|
||||||
|
│ ├── sitemap.xml # 4 pages (/, /tos, /privacy, /api)
|
||||||
|
│ ├── icon-dark.svg # Miruro dark mode SVG icon
|
||||||
|
│ ├── icon-light.svg # Miruro light mode SVG icon
|
||||||
|
│ ├── icon-512x512.png # Miruro app icon
|
||||||
|
│ ├── favicon.ico # Classic favicon
|
||||||
|
│ ├── apple-touch-icon-180x180.png # iOS home screen icon
|
||||||
|
│ ├── og-image.png # OG/Twitter share image
|
||||||
|
│ ├── privacy.html # Privacy policy (served at /privacy)
|
||||||
|
│ └── tos.html # Terms of service (served at /tos)
|
||||||
|
│
|
||||||
|
├── assets/ # Scraped Miruro assets
|
||||||
|
│ ├── favicons/ # All favicon variants
|
||||||
|
│ ├── logos/ # Status page logo
|
||||||
|
│ ├── fonts/ # Inter + FontAwesome
|
||||||
|
│ └── media/ # Testimonial avatars
|
||||||
|
│
|
||||||
|
├── docs/ # API documentation
|
||||||
|
│ ├── index.md # Overview, quick start, features
|
||||||
|
│ ├── endpoints.md # Full API reference (18 endpoints)
|
||||||
|
│ ├── streaming.md # Streaming flow guide (3-step)
|
||||||
|
│ ├── examples.md # cURL, JavaScript, Python
|
||||||
|
│ └── architecture.md # This file
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ ├── helpers/
|
||||||
|
│ │ ├── anilist.js # AniList GraphQL integration
|
||||||
|
│ │ ├── pipe.js # Miruro pipe integration
|
||||||
|
│ │ └── cache.js # In-memory Map cache with TTL
|
||||||
|
│ │
|
||||||
|
│ └── routes/
|
||||||
|
│ └── apiRoutes.js # All route definitions
|
||||||
|
│
|
||||||
|
├── test.js # Integration test suite
|
||||||
|
├── CHANGELOG.md # Version history
|
||||||
|
├── LICENSE # MIT License
|
||||||
|
└── README.md # Full API documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|------------|
|
||||||
|
| Runtime | Node.js |
|
||||||
|
| Framework | Express.js |
|
||||||
|
| Metadata | AniList GraphQL (`graphql.anilist.co`) |
|
||||||
|
| Streaming | Miruro pipe (`miruro.tv/api/secure/pipe`) |
|
||||||
|
| Deployment | Vercel (Serverless) |
|
||||||
|
| Caching | In-memory Map with 1-5 min TTL |
|
||||||
|
| Static Files | Express static middleware |
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Request
|
||||||
|
↓
|
||||||
|
Vercel Routes (/api/* → server.js)
|
||||||
|
↓
|
||||||
|
Express Router (apiRoutes.js)
|
||||||
|
↓
|
||||||
|
Helper (e.g., anilist.js or pipe.js)
|
||||||
|
↓
|
||||||
|
HTTP Request to AniList GraphQL / Miruro pipe
|
||||||
|
↓
|
||||||
|
Response decoded (base64url + gzip for pipe)
|
||||||
|
↓
|
||||||
|
Returns structured JSON
|
||||||
|
↓
|
||||||
|
Client Response
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/episodes/:anilistId
|
||||||
|
↓
|
||||||
|
Returns: providers with sub/dub episode lists
|
||||||
|
↓
|
||||||
|
/api/watch/:provider/:anilistId/:category/:slug
|
||||||
|
↓
|
||||||
|
Returns: streams[] with M3U8 URLs, quality, codec, fansub
|
||||||
|
↓
|
||||||
|
Play M3U8 in HLS player (hls.js, video.js, native)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching Strategy
|
||||||
|
|
||||||
|
- **Type:** In-memory Map
|
||||||
|
- **TTL:** 1-5 minutes depending on endpoint
|
||||||
|
- **Max Size:** 100 entries with FIFO eviction
|
||||||
|
- **Key:** Full request URL
|
||||||
|
- **Behavior:** First request fetches from source, subsequent requests served from cache
|
||||||
|
- **Eviction:** Automatic when TTL expires or max size reached
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
### AniList GraphQL
|
||||||
|
|
||||||
|
- **Endpoint:** `https://graphql.anilist.co`
|
||||||
|
- **Data:** Search, suggestions, info, characters, relations, recommendations, filter, schedule
|
||||||
|
- **Format:** Standard GraphQL queries
|
||||||
|
|
||||||
|
### Miruro Pipe
|
||||||
|
|
||||||
|
- **Endpoint:** `https://miruro.tv/api/secure/pipe`
|
||||||
|
- **Data:** Episodes, streaming sources (M3U8 URLs)
|
||||||
|
- **Encoding:** Base64url + gzip compressed request/response
|
||||||
|
- **Providers:** kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli
|
||||||
|
|
||||||
|
## Vercel Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"src": "server.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/api/(.*)",
|
||||||
|
"dest": "server.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/(.*)",
|
||||||
|
"dest": "server.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/api/*` routes to Express for API handling
|
||||||
|
- `/*` routes to Express for static file serving
|
||||||
|
- Static files served from `process.cwd()` (Vercel serverless compatible)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PORT` | `3000` | Server port (Express mode only) |
|
||||||
|
| `ALLOWED_ORIGINS` | `*` | Comma-separated allowed origins |
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
All API responses follow this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Error description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| express | ^4.21.0 | Web framework |
|
||||||
|
| axios | ^1.8.0 | HTTP requests |
|
||||||
|
| cors | ^2.8.5 | CORS headers |
|
||||||
|
| dotenv | ^16.4.0 | Environment variables |
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
# API Endpoints Reference
|
||||||
|
|
||||||
|
Complete documentation for all 35 MiruroAPI v2.0.1 endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
API health status, version, uptime, memory, cache stats.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"uptime": "0h 0m 34s",
|
||||||
|
"node": "v24.14.1",
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache & API statistics including request count and success rate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/openapi
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the OpenAPI 3.0.3 specification in JSON format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 📝 All endpoints return `{ "success": true, "results": {...} }` on success and `{ "success": false, "message": "..." }` on error.
|
||||||
|
>
|
||||||
|
> 🎬 Creator attribution is injected into every response: `{ "creator": "Shinei Nouzen", "github": "...", "telegram": "...", "message": "...", "timestamp": "..." }`
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Code Examples
|
||||||
|
|
||||||
|
All examples are tested and working with the live API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cURL
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/search?query=naruto"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Suggestions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/suggestions?query=naruto"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anime Info
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/info/20"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Episodes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Characters
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/anime/20/characters"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream URL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/watch/kiwi/20/sub/animepahe-1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/filter?genre=Action&per_page=3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trending
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/trending?per_page=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Popular
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/popular?per_page=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/schedule?date=2026-06-09"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript (Browser)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function searchAnime(query) {
|
||||||
|
const res = await fetch(`https://mirurotvapi.vercel.app/api/search?query=${encodeURIComponent(query)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
return data.results.results.map(anime => ({
|
||||||
|
id: anime.id,
|
||||||
|
title: anime.title.english || anime.title.romaji,
|
||||||
|
format: anime.format,
|
||||||
|
episodes: anime.episodes,
|
||||||
|
score: anime.averageScore
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
searchAnime('naruto').then(results => {
|
||||||
|
results.forEach(a => console.log(`${a.title} (${a.format}) — Score: ${a.score}`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anime Info
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getAnimeInfo(anilistId) {
|
||||||
|
const res = await fetch(`https://mirurotvapi.vercel.app/api/info/${anilistId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const info = data.results;
|
||||||
|
console.log(`Title: ${info.title.english || info.title.romaji}`);
|
||||||
|
console.log(`Format: ${info.format}`);
|
||||||
|
console.log(`Status: ${info.status}`);
|
||||||
|
console.log(`Episodes: ${info.episodes || 'Unknown'}`);
|
||||||
|
console.log(`Score: ${info.averageScore}`);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAnimeInfo(20);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Streaming Flow
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getStreamUrl(anilistId) {
|
||||||
|
// Step 1: Get episodes
|
||||||
|
const episodesRes = await fetch(`https://mirurotvapi.vercel.app/api/episodes/${anilistId}`);
|
||||||
|
const episodesData = await episodesRes.json();
|
||||||
|
const providers = episodesData.results.providers;
|
||||||
|
|
||||||
|
const provider = Object.keys(providers)[0];
|
||||||
|
const episodes = providers[provider].episodes.sub;
|
||||||
|
|
||||||
|
if (!episodes || episodes.length === 0) {
|
||||||
|
throw new Error('No episodes found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeId = episodes[0].id;
|
||||||
|
const parts = episodeId.split('/');
|
||||||
|
const category = parts[3];
|
||||||
|
const slug = parts[4];
|
||||||
|
|
||||||
|
// Step 2: Get stream URLs
|
||||||
|
const streamRes = await fetch(`https://mirurotvapi.vercel.app/api/watch/${provider}/${anilistId}/${category}/${slug}`);
|
||||||
|
const streamData = await streamRes.json();
|
||||||
|
|
||||||
|
return streamData.results.streams[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
getStreamUrl(20)
|
||||||
|
.then(url => {
|
||||||
|
console.log('Stream URL:', url);
|
||||||
|
// Use with HLS.js player
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Error:', err.message));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Characters
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getCharacters(anilistId) {
|
||||||
|
const res = await fetch(`https://mirurotvapi.vercel.app/api/anime/${anilistId}/characters`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
return data.results.edges.map(edge => ({
|
||||||
|
name: edge.node.name.full,
|
||||||
|
role: edge.role,
|
||||||
|
voiceActor: edge.voiceActors[0]?.name.full || 'N/A'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCharacters(20).then(chars => {
|
||||||
|
chars.forEach(c => console.log(`${c.name} (${c.role}) — VA: ${c.voiceActor}`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node.js (with axios)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function searchAnime(query) {
|
||||||
|
const { data } = await axios.get('https://mirurotvapi.vercel.app/api/search', {
|
||||||
|
params: { query }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.results.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchAnime('naruto').then(results => {
|
||||||
|
results.forEach(a => console.log(`${a.title.english || a.title.romaji}`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Streaming Flow
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function getStreamUrl(anilistId) {
|
||||||
|
const BASE = 'https://mirurotvapi.vercel.app/api';
|
||||||
|
|
||||||
|
// Step 1: Get episodes
|
||||||
|
const { data: episodesData } = await axios.get(`${BASE}/episodes/${anilistId}`);
|
||||||
|
const providers = episodesData.results.providers;
|
||||||
|
|
||||||
|
const provider = Object.keys(providers)[0];
|
||||||
|
const episodes = providers[provider].episodes.sub;
|
||||||
|
|
||||||
|
if (!episodes || episodes.length === 0) {
|
||||||
|
throw new Error('No episodes found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeId = episodes[0].id;
|
||||||
|
const parts = episodeId.split('/');
|
||||||
|
const category = parts[3];
|
||||||
|
const slug = parts[4];
|
||||||
|
|
||||||
|
// Step 2: Get stream URLs
|
||||||
|
const { data: streamData } = await axios.get(`${BASE}/watch/${provider}/${anilistId}/${category}/${slug}`);
|
||||||
|
|
||||||
|
return streamData.results.streams[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
getStreamUrl(20)
|
||||||
|
.then(url => console.log('Stream URL:', url))
|
||||||
|
.catch(err => console.error('Error:', err.message));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by Genre
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function filterAnime(genre, perPage = 5) {
|
||||||
|
const { data } = await axios.get('https://mirurotvapi.vercel.app/api/filter', {
|
||||||
|
params: { genre, per_page: perPage }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.results.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterAnime('Action', 3).then(anime => {
|
||||||
|
anime.forEach(a => console.log(`${a.title.english || a.title.romaji} (Score: ${a.averageScore})`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function getSchedule(date) {
|
||||||
|
const { data } = await axios.get('https://mirurotvapi.vercel.app/api/schedule', {
|
||||||
|
params: { date }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Scheduled anime:', data.results.length);
|
||||||
|
return data.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSchedule('2026-06-09');
|
||||||
|
```
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# MiruroAPI Documentation
|
||||||
|
|
||||||
|
> Free REST API for anime streaming data. 18 endpoints, 12 providers, M3U8 URLs. No API key required.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MiruroAPI is a free, open-source REST API that provides anime data by aggregating from AniList GraphQL and Miruro streaming providers. It's built with Node.js and Express, and deployed on Vercel.
|
||||||
|
|
||||||
|
**Base URL:** `https://mirurotvapi.vercel.app/api`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Search for anime
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/search?query=naruto"
|
||||||
|
|
||||||
|
# Get anime info
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/info/20"
|
||||||
|
|
||||||
|
# Get episodes
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Live Response — Search:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"page": 1,
|
||||||
|
"perPage": 20,
|
||||||
|
"total": 5000,
|
||||||
|
"hasNextPage": true,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"title": { "romaji": "NARUTO", "english": "Naruto" },
|
||||||
|
"coverImage": { "large": "https://s4.anilist.co/file/..." },
|
||||||
|
"format": "TV",
|
||||||
|
"episodes": 220,
|
||||||
|
"status": "FINISHED",
|
||||||
|
"averageScore": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **18 Endpoints** — Search, info, episodes, streaming, characters, relations, and more
|
||||||
|
- **12 Streaming Providers** — M3U8 URLs with resolution, codec, fansub info
|
||||||
|
- **No API Key** — Just make requests, no registration needed
|
||||||
|
- **In-Memory Cache** — 1-5 minute TTL for fast responses
|
||||||
|
- **CORS Enabled** — Access from any domain
|
||||||
|
- **JSON Responses** — Standardized `{success, results}` format
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [API Endpoints Reference](endpoints.md) — Complete endpoint documentation with real responses
|
||||||
|
- [Streaming Flow Guide](streaming.md) — How to get stream URLs step by step
|
||||||
|
- [Code Examples](examples.md) — cURL, JavaScript, Python (all tested and working)
|
||||||
|
- [Architecture](architecture.md) — Project structure and design decisions
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
All endpoints return JSON in this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Error description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Rate limiting is enforced at 100 requests per minute per IP address. The API also uses a 1-5 minute in-memory cache. Repeated requests for the same data will be served from cache.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This API is for **educational purposes only**. It fetches data from AniList GraphQL and Miruro streaming providers. We are not affiliated with or endorsed by Miruro. All content belongs to its respective owners.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Streaming Flow Guide
|
||||||
|
|
||||||
|
This guide explains how to get streaming URLs from MiruroAPI in 3 steps.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Get Episodes → /api/episodes/:anilistId
|
||||||
|
Step 2: Get Stream URLs → /api/watch/:provider/:anilistId/:category/:slug
|
||||||
|
Step 3: Play M3U8 → Use HLS player
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Get Episodes
|
||||||
|
|
||||||
|
Each anime has episodes from multiple providers. Fetch the episode list with the AniList ID.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/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!",
|
||||||
|
"image": "https://image.tmdb.org/t/p/original/...",
|
||||||
|
"airDate": "2002-10-03",
|
||||||
|
"audio": "sub",
|
||||||
|
"filler": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Key field:** `id` in each episode — Pass this to the next step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Get Stream URLs
|
||||||
|
|
||||||
|
Use the episode `id` from Step 1 to get M3U8 streaming URLs.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/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",
|
||||||
|
"isActive": false,
|
||||||
|
"referer": "https://kwik.cx/e/..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"download": "https://pahe.win/LJmbA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Done!** Use `streams[0].url` in your HLS video player.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Play M3U8
|
||||||
|
|
||||||
|
### HLS.js (Browser)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||||
|
<video id="player" controls></video>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const url = 'https://vault-01.uwucdn.top/stream/.../uwu.m3u8';
|
||||||
|
const video = document.getElementById('player');
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = url; // Native HLS (Safari)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getStreamUrl(anilistId) {
|
||||||
|
// Step 1: Get episodes
|
||||||
|
const episodesRes = await fetch(`https://mirurotvapi.vercel.app/api/episodes/${anilistId}`);
|
||||||
|
const episodesData = await episodesRes.json();
|
||||||
|
const providers = episodesData.results.providers;
|
||||||
|
|
||||||
|
// Pick first provider with sub episodes
|
||||||
|
const provider = Object.keys(providers)[0];
|
||||||
|
const episodes = providers[provider].episodes.sub;
|
||||||
|
|
||||||
|
if (!episodes || episodes.length === 0) {
|
||||||
|
throw new Error('No episodes found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use first episode's id
|
||||||
|
const episodeId = episodes[0].id; // "watch/kiwi/20/sub/animepahe-1"
|
||||||
|
const parts = episodeId.split('/');
|
||||||
|
const category = parts[3]; // "sub"
|
||||||
|
const slug = parts[4]; // "animepahe-1"
|
||||||
|
|
||||||
|
// Step 2: Get stream URLs
|
||||||
|
const streamRes = await fetch(`https://mirurotvapi.vercel.app/api/watch/${provider}/${anilistId}/${category}/${slug}`);
|
||||||
|
const streamData = await streamRes.json();
|
||||||
|
|
||||||
|
return streamData.results.streams[0].url; // "https://...m3u8"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
getStreamUrl(20)
|
||||||
|
.then(url => console.log('Stream URL:', url))
|
||||||
|
.catch(err => console.error('Error:', err));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def get_stream_url(anilist_id):
|
||||||
|
base = "https://mirurotvapi.vercel.app/api"
|
||||||
|
|
||||||
|
# Step 1: Get episodes
|
||||||
|
episodes_res = requests.get(f"{base}/episodes/{anilist_id}")
|
||||||
|
episodes_data = episodes_res.json()
|
||||||
|
providers = episodes_data['results']['providers']
|
||||||
|
|
||||||
|
# Pick first provider
|
||||||
|
provider = list(providers.keys())[0]
|
||||||
|
episodes = providers[provider]['episodes']['sub']
|
||||||
|
|
||||||
|
if not episodes:
|
||||||
|
raise Exception("No episodes found")
|
||||||
|
|
||||||
|
episode_id = episodes[0]['id'] # "watch/kiwi/20/sub/animepahe-1"
|
||||||
|
parts = episode_id.split('/')
|
||||||
|
category = parts[3]
|
||||||
|
slug = parts[4]
|
||||||
|
|
||||||
|
# Step 2: Get stream URLs
|
||||||
|
stream_res = requests.get(f"{base}/watch/{provider}/{anilist_id}/{category}/{slug}")
|
||||||
|
stream_data = stream_res.json()
|
||||||
|
|
||||||
|
return stream_data['results']['streams'][0]['url']
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
url = get_stream_url(20)
|
||||||
|
print(f"Stream URL: {url}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub & Dub
|
||||||
|
|
||||||
|
Providers return both `sub` and `dub` episode lists:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const eps = await fetch("/api/episodes/20").then(r => r.json());
|
||||||
|
const kiwi = eps.results.providers.kiwi.episodes;
|
||||||
|
|
||||||
|
// Get sub episodes
|
||||||
|
const subEps = kiwi.sub; // [{ id: "watch/kiwi/20/sub/animepahe-1", ... }]
|
||||||
|
|
||||||
|
// Get dub episodes (if available)
|
||||||
|
const dubEps = kiwi.dub; // [{ id: "watch/kiwi/20/dub/...", ... }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Providers
|
||||||
|
|
||||||
|
| Provider | Sub | Dub | Download | Skip Times |
|
||||||
|
|:---|:---:|:---:|:---:|:---:|
|
||||||
|
| kiwi | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| pewe | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| bee | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| bonk | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| bun | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| ally | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| nun | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| twin | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| cog | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| moo | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| hop | ❌ | ✅ | ❌ | ❌ |
|
||||||
|
| telli | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| "No episodes found" | Check the AniList ID is correct (use `/api/search` first) |
|
||||||
|
| "No providers" | The anime may not be available on any provider |
|
||||||
|
| Stream URL 403 | Some servers require specific referrer headers |
|
||||||
|
| CORS error | Use a proxy or access from server-side |
|
||||||
@@ -1,28 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "miruro-api",
|
"name": "miruro-api",
|
||||||
"version": "1.0.0",
|
"version": "2.1.4",
|
||||||
"description": "RESTful API for miruro.com - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js",
|
"dev": "nodemon server.js",
|
||||||
"test": "node test.js"
|
"test": "node test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
|
||||||
"axios": "^1.8.0",
|
"axios": "^1.8.0",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0"
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anime",
|
"anime",
|
||||||
"api",
|
"api",
|
||||||
"miruro",
|
"miruro",
|
||||||
"anilist",
|
"anilist",
|
||||||
|
"graphql",
|
||||||
"streaming",
|
"streaming",
|
||||||
"m3u8",
|
"m3u8",
|
||||||
"hls"
|
"hls",
|
||||||
|
"rest-api",
|
||||||
|
"express"
|
||||||
],
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
|
},
|
||||||
"author": "Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ <157171073+Shineii86@users.noreply.github.com>",
|
"author": "Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ <157171073+Shineii86@users.noreply.github.com>",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>404 — MiruroAPI</title>
|
||||||
|
<meta name="theme-color" content="#0A0A12" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0A0A12;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--indigo:#6366F1;--green:#22C55E;--red:#EF4444;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||||||
|
.ambient{position:fixed;inset:0;pointer-events:none}
|
||||||
|
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.1}
|
||||||
|
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px;animation:drift 24s ease-in-out infinite}
|
||||||
|
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px;animation:drift 20s ease-in-out infinite reverse}
|
||||||
|
@keyframes drift{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(40px,-30px) scale(1.08)}}
|
||||||
|
.container{position:relative;z-index:1;text-align:center;padding:40px;max-width:560px}
|
||||||
|
.code{font-size:clamp(6rem,18vw,14rem);font-weight:900;line-height:1;letter-spacing:-.06em;background:linear-gradient(135deg,var(--purple),var(--pink),var(--indigo));-webkit-background-clip:text;-webkit-text-fill-color:transparent;animation:float 4s ease-in-out infinite}
|
||||||
|
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}}
|
||||||
|
.title{font-size:1.4rem;font-weight:700;margin:16px 0 8px}
|
||||||
|
.desc{color:var(--text2);font-size:.95rem;margin-bottom:32px;line-height:1.7}
|
||||||
|
.actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:10px;font-size:.88rem;font-weight:600;text-decoration:none;border:none;cursor:pointer;transition:all .25s;font-family:inherit}
|
||||||
|
.btn svg{width:16px;height:16px}
|
||||||
|
.btn-primary{background:linear-gradient(135deg,var(--purple),var(--pink));color:#fff;box-shadow:0 4px 24px rgba(168,85,247,.3)}
|
||||||
|
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(168,85,247,.4)}
|
||||||
|
.btn-ghost{background:var(--glass);border:1px solid rgba(255,255,255,.1);color:var(--text)}
|
||||||
|
.btn-ghost:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.15)}
|
||||||
|
.glitch{position:relative;display:inline-block}
|
||||||
|
.glitch::before,.glitch::after{content:attr(data-text);position:absolute;top:0;left:0;width:100%;height:100%;background:var(--bg)}
|
||||||
|
.glitch::before{left:2px;text-shadow:-2px 0 var(--pink);clip-path:inset(0 0 0 0);animation:glitch-1 2s infinite linear alternate-reverse}
|
||||||
|
.glitch::after{left:-2px;text-shadow:2px 0 var(--indigo);clip-path:inset(0 0 0 0);animation:glitch-2 3s infinite linear alternate-reverse}
|
||||||
|
@keyframes glitch-1{0%{clip-path:inset(40% 0 61% 0)}20%{clip-path:inset(92% 0 1% 0)}40%{clip-path:inset(43% 0 1% 0)}60%{clip-path:inset(25% 0 58% 0)}80%{clip-path:inset(54% 0 7% 0)}100%{clip-path:inset(58% 0 43% 0)}}
|
||||||
|
@keyframes glitch-2{0%{clip-path:inset(65% 0 13% 0)}20%{clip-path:inset(79% 0 2% 0)}40%{clip-path:inset(48% 0 38% 0)}60%{clip-path:inset(33% 0 2% 0)}80%{clip-path:inset(16% 0 62% 0)}100%{clip-path:inset(27% 0 42% 0)}}
|
||||||
|
.status-line{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:24px;color:var(--text3);font-size:.78rem;font-family:'JetBrains Mono',monospace}
|
||||||
|
.status-dot{width:6px;height:6px;border-radius:50%;background:var(--red);animation:pulse 1.5s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||||
|
.scanline{position:fixed;inset:0;z-index:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.03) 2px,rgba(0,0,0,.03) 4px)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
||||||
|
<div class="scanline"></div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="code"><span class="glitch" data-text="404">404</span></div>
|
||||||
|
<div class="title">Endpoint Not Found</div>
|
||||||
|
<div class="desc">The path you requested doesn't exist in the MiruroAPI.<br/>Check the documentation or try a valid endpoint.</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a href="/api/health" class="btn btn-ghost">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
|
API Status
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI#readme" target="_blank" class="btn btn-ghost">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="status-line"><span class="status-dot"></span> HTTP 404 · Not Found</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,134 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<!-- ──────────────── OG Meta ──────────────── -->
|
||||||
|
<meta property="og:title" content="MiruroAPI — Swagger UI Documentation">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://mirurotvapi.vercel.app/docs">
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png">
|
||||||
|
<meta property="og:description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||||
|
<meta property="og:site_name" content="MiruroAPI">
|
||||||
|
<meta property="og:locale" content="en_US">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="MiruroAPI — Swagger UI Documentation">
|
||||||
|
<meta name="twitter:description" content="Interactive Swagger UI documentation for MiruroAPI.">
|
||||||
|
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/og-image.png">
|
||||||
|
<meta name="description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="theme-color" content="#0b1622">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512x512.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||||
|
<link rel="canonical" href="https://mirurotvapi.vercel.app/docs">
|
||||||
|
|
||||||
|
<title>MiruroAPI — Swagger UI</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1622;
|
||||||
|
--bg2: #0f172a;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent2: #818cf8;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── BASE ──────────────── */
|
||||||
|
html { box-sizing: border-box; }
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── CUSTOM HEADER ──────────────── */
|
||||||
|
.docs-header {
|
||||||
|
background: linear-gradient(135deg, var(--bg2), #1e1b4b);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
padding: 20px 40px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.docs-brand { display: flex; align-items: center; gap: 12px; text-decoration: none; }
|
||||||
|
.docs-logo {
|
||||||
|
width: 32px; height: 32px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white; font-weight: 900; font-size: 16px;
|
||||||
|
}
|
||||||
|
.docs-title { color: var(--text); font-weight: 700; font-size: 1em; }
|
||||||
|
.docs-subtitle { color: var(--muted); font-size: 0.8em; margin-left: 8px; }
|
||||||
|
.docs-links { display: flex; gap: 16px; }
|
||||||
|
.docs-links a {
|
||||||
|
color: var(--muted); text-decoration: none; font-size: 0.85em;
|
||||||
|
font-weight: 500; transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.docs-links a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ──────────────── SWAGGER UI OVERRIDES ──────────────── */
|
||||||
|
.swagger-ui .topbar { display: none; }
|
||||||
|
.swagger-ui .info .title { color: var(--text) !important; }
|
||||||
|
.swagger-ui .info .description p { color: var(--muted) !important; }
|
||||||
|
.swagger-ui .scheme-container { background: rgba(15, 23, 42, 0.5) !important; box-shadow: none !important; }
|
||||||
|
.swagger-ui .opblock-tag { color: var(--accent) !important; border-color: rgba(255,255,255,0.05) !important; }
|
||||||
|
.swagger-ui .opblock .opblock-summary-description { color: var(--muted) !important; }
|
||||||
|
.swagger-ui .btn { box-shadow: none !important; }
|
||||||
|
.swagger-ui .btn.execute { background: linear-gradient(135deg, var(--accent), var(--accent2)) !important; }
|
||||||
|
.swagger-ui .model-box { background: rgba(15, 23, 42, 0.3) !important; }
|
||||||
|
.swagger-ui .scheme-container .schemes > select { color: var(--text); background: var(--bg2); }
|
||||||
|
|
||||||
|
/* ──────────────── BACK LINK ──────────────── */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
color: var(--accent); text-decoration: none;
|
||||||
|
font-size: 0.85em; font-weight: 500;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ──────────────── CUSTOM HEADER ──────────────── -->
|
||||||
|
<div class="docs-header">
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<a href="/" class="docs-brand">
|
||||||
|
<img src="/icon-512x512.png" alt="Miruro" class="docs-logo" style="object-fit:contain; filter:brightness(0) invert();">
|
||||||
|
<span class="docs-title">MiruroAPI</span>
|
||||||
|
</a>
|
||||||
|
<span class="docs-subtitle">Swagger UI Documentation</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-links">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/openapi.json">OpenAPI Spec</a>
|
||||||
|
<a href="/api/health">Health</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ──────────────── SWAGGER UI ──────────────── -->
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
SwaggerUIBundle({
|
||||||
|
url: "/openapi.json",
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
deepLinking: true,
|
||||||
|
defaultModelsExpandDepth: -1,
|
||||||
|
docExpansion: "list",
|
||||||
|
filter: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#ffffff;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#000000;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "MiruroAPI",
|
||||||
|
"short_name": "MiruroAPI",
|
||||||
|
"description": "Free REST API for anime streaming data. 18 endpoints, 12 providers, M3U8 URLs. No API key required.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"id": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"lang": "en",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0A0A12",
|
||||||
|
"theme_color": "#A855F7",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-dark.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["developer", "entertainment"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "MiruroAPI",
|
"title": "MiruroAPI",
|
||||||
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
|
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
|
||||||
"version": "1.1.0",
|
"version": "2.0.1",
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "Shineii86",
|
"name": "Shineii86",
|
||||||
"url": "https://github.com/Shineii86/MiruroAPI"
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
@@ -18,230 +18,427 @@
|
|||||||
"description": "Production"
|
"description": "Production"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "Search", "description": "Search, filter, suggestions" },
|
||||||
|
{ "name": "Collections", "description": "Trending, popular, top, schedule" },
|
||||||
|
{ "name": "Seasonal & Studio", "description": "Seasonal anime, studio filter" },
|
||||||
|
{ "name": "Genres & Tags", "description": "Genre, year, status, format filters" },
|
||||||
|
{ "name": "Character & Staff", "description": "Character/staff details and search" },
|
||||||
|
{ "name": "Anime Details", "description": "Info, characters, relations, recommendations" },
|
||||||
|
{ "name": "Streaming", "description": "Episodes, sources, stream, download" },
|
||||||
|
{ "name": "Utility", "description": "Compare, stats, calendar, timeline" },
|
||||||
|
{ "name": "System", "description": "Health, stats, OpenAPI" }
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/health": {
|
|
||||||
"get": {
|
|
||||||
"tags": ["System"],
|
|
||||||
"summary": "Health check",
|
|
||||||
"operationId": "health",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "API is healthy",
|
|
||||||
"content": { "application/json": { "schema": { "type": "object" } } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/search": {
|
"/api/search": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Search & Discovery"],
|
"tags": ["Search"],
|
||||||
"summary": "Search anime by name",
|
"summary": "Search anime by name",
|
||||||
"operationId": "searchAnime",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Search query" },
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
||||||
],
|
]
|
||||||
"responses": {
|
|
||||||
"200": { "description": "Search results with pagination" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/suggestions": {
|
"/api/suggestions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Search & Discovery"],
|
"tags": ["Search"],
|
||||||
"summary": "Autocomplete suggestions",
|
"summary": "Autocomplete suggestions",
|
||||||
"operationId": "getSuggestions",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
]
|
||||||
"responses": {
|
|
||||||
"200": { "description": "Lightweight suggestions (max 8)" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/filter": {
|
"/api/filter": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Search & Discovery"],
|
"tags": ["Search"],
|
||||||
"summary": "Advanced filter / browse",
|
"summary": "Advanced filter",
|
||||||
"operationId": "filterAnime",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "genre", "in": "query", "schema": { "type": "string" }, "description": "e.g. Action, Romance" },
|
{ "name": "genre", "in": "query", "schema": { "type": "string" } },
|
||||||
{ "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "e.g. Isekai, Time Skip" },
|
{ "name": "tag", "in": "query", "schema": { "type": "string" } },
|
||||||
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
|
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
|
||||||
{ "name": "season", "in": "query", "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } },
|
{ "name": "season", "in": "query", "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } },
|
||||||
{ "name": "format", "in": "query", "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL", "MUSIC"] } },
|
{ "name": "format", "in": "query", "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL"] } },
|
||||||
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"] } },
|
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED"] } },
|
||||||
{ "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["SCORE_DESC", "POPULARITY_DESC", "TRENDING_DESC", "START_DATE_DESC", "FAVOURITES_DESC", "UPDATED_AT_DESC"], "default": "POPULARITY_DESC" } },
|
{ "name": "sort", "in": "query", "schema": { "type": "string", "default": "POPULARITY_DESC" } },
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
||||||
],
|
]
|
||||||
"responses": {
|
}
|
||||||
"200": { "description": "Filtered results with pagination" }
|
},
|
||||||
}
|
"/api/multi-search": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Search"],
|
||||||
|
"summary": "Multi-parameter search"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/trending": {
|
"/api/trending": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Trending anime",
|
"summary": "Trending anime"
|
||||||
"operationId": "getTrending",
|
}
|
||||||
"parameters": [
|
},
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
"/api/trending/daily": {
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
"get": {
|
||||||
],
|
"tags": ["Collections"],
|
||||||
"responses": { "200": { "description": "Trending anime list" } }
|
"summary": "Daily trending anime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/trending/weekly": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Weekly trending anime"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/popular": {
|
"/api/popular": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Most popular anime",
|
"summary": "Most popular anime"
|
||||||
"operationId": "getPopular",
|
}
|
||||||
"parameters": [
|
},
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
"/api/top": {
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
"get": {
|
||||||
],
|
"tags": ["Collections"],
|
||||||
"responses": { "200": { "description": "Popular anime list" } }
|
"summary": "Top anime by score"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/upcoming": {
|
"/api/upcoming": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Upcoming anime",
|
"summary": "Upcoming anime"
|
||||||
"operationId": "getUpcoming",
|
|
||||||
"parameters": [
|
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Upcoming anime list" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/recent": {
|
"/api/recent": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Recently updated anime",
|
"summary": "Recently aired anime"
|
||||||
"operationId": "getRecent",
|
|
||||||
"parameters": [
|
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Recent anime list" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/spotlight": {
|
"/api/spotlight": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Spotlight / featured anime",
|
"summary": "Spotlight/featured anime"
|
||||||
"operationId": "getSpotlight",
|
|
||||||
"responses": { "200": { "description": "Top 10 trending + popular" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/schedule": {
|
"/api/schedule": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Collections"],
|
"tags": ["Collections"],
|
||||||
"summary": "Airing schedule",
|
"summary": "Airing schedule"
|
||||||
"operationId": "getSchedule",
|
}
|
||||||
|
},
|
||||||
|
"/api/random": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Random anime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/upcoming/countdown": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Next 10 episodes airing soon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/season/{year}/{season}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Seasonal & Studio"],
|
||||||
|
"summary": "Seasonal anime",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
{ "name": "year", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
{ "name": "season", "in": "path", "required": true, "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Upcoming episodes with timestamps" } }
|
}
|
||||||
|
},
|
||||||
|
"/api/studio/{name}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Seasonal & Studio"],
|
||||||
|
"summary": "Anime by studio",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/genres": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "All genres"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/tags": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "All tags"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/genre/{name}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by genre",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/year/{year}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by year",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "year", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/status/{status}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by status",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "status", "in": "path", "required": true, "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED"] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/format/{format}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Genres & Tags"],
|
||||||
|
"summary": "Anime by format",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "format", "in": "path", "required": true, "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL"] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/character/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Character detail",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/staff/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Staff detail",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/characters": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Search characters",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/staff": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Character & Staff"],
|
||||||
|
"summary": "Search staff",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/info/{id}": {
|
"/api/info/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Full anime info",
|
"summary": "Full anime info",
|
||||||
"operationId": "getAnimeInfo",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Complete anime metadata, characters, relations, recommendations" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/anime/{id}/characters": {
|
"/api/anime/{id}/characters": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Characters with voice actors",
|
"summary": "Characters",
|
||||||
"operationId": "getCharacters",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
]
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 25 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Paginated character list" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/anime/{id}/relations": {
|
"/api/anime/{id}/relations": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Related anime",
|
"summary": "Relations",
|
||||||
"operationId": "getRelations",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Sequels, prequels, side stories, etc." } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/anime/{id}/recommendations": {
|
"/api/anime/{id}/recommendations": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Anime Details"],
|
"tags": ["Anime Details"],
|
||||||
"summary": "Community recommendations",
|
"summary": "Recommendations",
|
||||||
"operationId": "getRecommendations",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
]
|
||||||
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 10 } }
|
|
||||||
],
|
|
||||||
"responses": { "200": { "description": "Recommended anime sorted by rating" } }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/episodes/{id}": {
|
"/api/episodes/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Streaming"],
|
"tags": ["Streaming"],
|
||||||
"summary": "Episode list from all providers",
|
"summary": "Episode list",
|
||||||
"operationId": "getEpisodes",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "Episodes organized by provider and audio type (sub/dub)" } }
|
}
|
||||||
|
},
|
||||||
|
"/api/episodes/{id}/batch": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Batch episode sources",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "provider", "in": "query", "schema": { "type": "string", "default": "kiwi" } },
|
||||||
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } },
|
||||||
|
{ "name": "slugs", "in": "query", "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/sources": {
|
"/api/sources": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Streaming"],
|
"tags": ["Streaming"],
|
||||||
"summary": "Streaming sources (detailed)",
|
"summary": "Streaming sources (detailed)",
|
||||||
"operationId": "getSources",
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "episodeId", "in": "query", "required": true, "schema": { "type": "string" } },
|
{ "name": "episodeId", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
||||||
{ "name": "category", "in": "query", "schema": { "type": "string", "enum": ["sub", "dub"], "default": "sub" } }
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } }
|
||||||
],
|
]
|
||||||
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
|
}
|
||||||
|
},
|
||||||
|
"/api/stream": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Stream with quality fallback",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } },
|
||||||
|
{ "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "quality", "in": "query", "schema": { "type": "string", "default": "1080p" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/download": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Download URL",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "category", "in": "query", "schema": { "type": "string", "default": "sub" } },
|
||||||
|
{ "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
|
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Streaming"],
|
"tags": ["Streaming"],
|
||||||
"summary": "Streaming sources (simple slug-based)",
|
"summary": "Streaming sources (simple) with subtitles",
|
||||||
"operationId": "getWatchSources",
|
"description": "Returns streaming sources, subtitles (if available), and best stream with quality fallback",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "provider", "in": "path", "required": true, "schema": { "type": "string" } },
|
{ "name": "provider", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "anilistId", "in": "path", "required": true, "schema": { "type": "integer" } },
|
{ "name": "anilistId", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string", "enum": ["sub", "dub"] } },
|
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "e.g. animepahe-1" }
|
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
],
|
],
|
||||||
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": { "type": "boolean" },
|
||||||
|
"results": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"streams": { "type": "array" },
|
||||||
|
"download": { "type": "string" },
|
||||||
|
"subtitles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": { "type": "string", "description": "VTT subtitle file URL" },
|
||||||
|
"label": { "type": "string", "description": "Display name" },
|
||||||
|
"language": { "type": "string", "description": "Language code" },
|
||||||
|
"kind": { "type": "string", "description": "subtitles or captions" },
|
||||||
|
"format": { "type": "string", "description": "vtt format" },
|
||||||
|
"encoding": { "type": "string", "description": "utf-8" },
|
||||||
|
"isDefault": { "type": "boolean", "description": "Is default subtitle" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"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" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Privacy Policy for MiruroAPI. We do not collect, store, or share personal data." />
|
||||||
|
<meta property="og:title" content="Privacy Policy — MiruroAPI" />
|
||||||
|
<meta property="og:description" content="Privacy Policy for MiruroAPI. We do not collect, store, or share personal data." />
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Privacy Policy — MiruroAPI</title>
|
||||||
|
<meta name="theme-color" content="#0A0A12" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0A0A12;--bg2:#111120;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.8}
|
||||||
|
.ambient{position:fixed;inset:0;pointer-events:none;z-index:0}
|
||||||
|
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.08}
|
||||||
|
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px}
|
||||||
|
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px}
|
||||||
|
header{position:sticky;top:0;z-index:100;background:rgba(10,10,18,.88);backdrop-filter:blur(24px);border-bottom:1px solid var(--border);padding:0 24px}
|
||||||
|
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:60px}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:1rem}
|
||||||
|
.logo span{background:linear-gradient(135deg,var(--purple),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
||||||
|
h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin-bottom:8px}
|
||||||
|
.updated{color:var(--text3);font-size:.82rem;margin-bottom:40px;font-family:'JetBrains Mono',monospace}
|
||||||
|
h2{font-size:1.2rem;font-weight:700;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--border)}
|
||||||
|
h2:first-of-type{border-top:none;padding-top:0}
|
||||||
|
p{color:var(--text2);font-size:.92rem;margin-bottom:16px}
|
||||||
|
ul{color:var(--text2);font-size:.92rem;margin:0 0 16px 20px}
|
||||||
|
li{margin-bottom:6px}
|
||||||
|
code{font-family:'JetBrains Mono',monospace;font-size:.82rem;background:rgba(168,85,247,.1);color:var(--purple);padding:2px 6px;border-radius:4px}
|
||||||
|
a{color:var(--purple);text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
footer{position:relative;z-index:1;border-top:1px solid var(--border);padding:24px;text-align:center;color:var(--text3);font-size:.75rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
||||||
|
<header><div class="header-inner"><a href="/" class="logo">📺 <span>Miruro</span>API</a></div></header>
|
||||||
|
<div class="content">
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<div class="updated">Last updated: 2026-06-09</div>
|
||||||
|
|
||||||
|
<h2>1. Overview</h2>
|
||||||
|
<p>MiruroAPI ("the Service") is a free, open-source REST API that provides anime data by aggregating information from AniList GraphQL and Miruro streaming providers. This Privacy Policy explains how we handle data when you use the Service.</p>
|
||||||
|
|
||||||
|
<h2>2. Data We Collect</h2>
|
||||||
|
<p>The Service is designed to be <strong>stateless and privacy-preserving</strong>. We do not collect, store, or process any personal data from API consumers.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>No user accounts</strong> — The API requires no authentication, registration, or API keys.</li>
|
||||||
|
<li><strong>No cookies</strong> — We do not set tracking cookies on API responses.</li>
|
||||||
|
<li><strong>No analytics</strong> — We do not use Google Analytics, Mixpanel, or any third-party analytics on API endpoints.</li>
|
||||||
|
<li><strong>No logging of personal data</strong> — Server logs may contain IP addresses and request paths (standard Vercel/Node.js behavior), but these are not stored, sold, or shared.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Third-Party Data Sources</h2>
|
||||||
|
<p>The Service fetches data from <code>graphql.anilist.co</code> (AniList) and <code>miruro.tv</code> (streaming providers). This data (anime titles, descriptions, images, episode information) belongs to the respective content owners. We do not claim ownership of this data.</p>
|
||||||
|
<p>If you are a content owner and wish to have your data removed from this API, please open an issue on our <a href="https://github.com/Shineii86/MiruroAPI" target="_blank" rel="noopener noreferrer">GitHub repository</a>.</p>
|
||||||
|
|
||||||
|
<h2>4. Data Storage</h2>
|
||||||
|
<p>The Service uses a simple in-memory cache (1-5 minute TTL) to reduce repeated API calls. This cache is:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Purely temporary and reset on every server restart</li>
|
||||||
|
<li>Not persisted to any database</li>
|
||||||
|
<li>Not associated with any user identity</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Data Sharing</h2>
|
||||||
|
<p>We do not sell, trade, or share any data with third parties. The API is provided as-is for educational and development purposes.</p>
|
||||||
|
|
||||||
|
<h2>6. Children's Privacy</h2>
|
||||||
|
<p>The Service is not directed at children under 13. We do not knowingly collect information from children under 13.</p>
|
||||||
|
|
||||||
|
<h2>7. Changes to This Policy</h2>
|
||||||
|
<p>We may update this Privacy Policy from time to time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
||||||
|
|
||||||
|
<h2>8. Contact</h2>
|
||||||
|
<p>For questions about this Privacy Policy, open an issue at <a href="https://github.com/Shineii86/MiruroAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/MiruroAPI/issues</a>.</p>
|
||||||
|
</div>
|
||||||
|
<footer>© 2026 MiruroAPI · <a href="/" style="color:var(--purple)">Home</a> · <a href="/tos" style="color:var(--purple)">Terms</a></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# MiruroAPI — https://mirurotvapi.vercel.app
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /api
|
||||||
|
|
||||||
|
Sitemap: https://mirurotvapi.vercel.app/sitemap.xml
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/api/health</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/privacy</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/tos</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Terms of Service for MiruroAPI. Acceptable use, rate limits, liability, and DMCA policy." />
|
||||||
|
<meta property="og:title" content="Terms of Service — MiruroAPI" />
|
||||||
|
<meta property="og:description" content="Terms of Service for MiruroAPI. Acceptable use, rate limits, liability, and DMCA policy." />
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Terms of Service — MiruroAPI</title>
|
||||||
|
<meta name="theme-color" content="#0A0A12" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0A0A12;--bg2:#111120;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.8}
|
||||||
|
.ambient{position:fixed;inset:0;pointer-events:none;z-index:0}
|
||||||
|
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.08}
|
||||||
|
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px}
|
||||||
|
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px}
|
||||||
|
header{position:sticky;top:0;z-index:100;background:rgba(10,10,18,.88);backdrop-filter:blur(24px);border-bottom:1px solid var(--border);padding:0 24px}
|
||||||
|
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:60px}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:1rem}
|
||||||
|
.logo span{background:linear-gradient(135deg,var(--purple),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
||||||
|
h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin-bottom:8px}
|
||||||
|
.updated{color:var(--text3);font-size:.82rem;margin-bottom:40px;font-family:'JetBrains Mono',monospace}
|
||||||
|
h2{font-size:1.2rem;font-weight:700;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--border)}
|
||||||
|
h2:first-of-type{border-top:none;padding-top:0}
|
||||||
|
p{color:var(--text2);font-size:.92rem;margin-bottom:16px}
|
||||||
|
ul{color:var(--text2);font-size:.92rem;margin:0 0 16px 20px}
|
||||||
|
li{margin-bottom:6px}
|
||||||
|
code{font-family:'JetBrains Mono',monospace;font-size:.82rem;background:rgba(168,85,247,.1);color:var(--purple);padding:2px 6px;border-radius:4px}
|
||||||
|
a{color:var(--purple);text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
strong{color:var(--text)}
|
||||||
|
footer{position:relative;z-index:1;border-top:1px solid var(--border);padding:24px;text-align:center;color:var(--text3);font-size:.75rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
||||||
|
<header><div class="header-inner"><a href="/" class="logo">📺 <span>Miruro</span>API</a></div></header>
|
||||||
|
<div class="content">
|
||||||
|
<h1>Terms of Service</h1>
|
||||||
|
<div class="updated">Last updated: 2026-06-09</div>
|
||||||
|
|
||||||
|
<h2>1. Acceptance of Terms</h2>
|
||||||
|
<p>By accessing or using MiruroAPI ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
|
||||||
|
|
||||||
|
<h2>2. Description of Service</h2>
|
||||||
|
<p>MiruroAPI is a free, open-source REST API that provides anime data by aggregating information from AniList GraphQL and Miruro streaming providers. The Service is provided "as is" without warranties of any kind.</p>
|
||||||
|
|
||||||
|
<h2>3. Educational Purpose</h2>
|
||||||
|
<p>The Service is built and maintained for <strong>educational and learning purposes only</strong>. It demonstrates API design, GraphQL integration, and serverless deployment techniques.</p>
|
||||||
|
|
||||||
|
<h2>4. No Affiliation</h2>
|
||||||
|
<p>The Service is <strong>not affiliated with, endorsed by, or connected to</strong> miruro.tv, Miruro, or any of its subsidiaries or affiliates. All trademarks and content belong to their respective owners.</p>
|
||||||
|
|
||||||
|
<h2>5. Third-Party Content</h2>
|
||||||
|
<p>All anime data, images, descriptions, and other content displayed through the API belong to their respective copyright holders (AniList, Miruro). The Service does not host, store, or distribute any copyrighted media.</p>
|
||||||
|
|
||||||
|
<h2>6. Acceptable Use</h2>
|
||||||
|
<p>You agree to use the Service responsibly and in compliance with all applicable laws. You must not:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Use the Service for any illegal purpose</li>
|
||||||
|
<li>Attempt to disrupt or overload the Service</li>
|
||||||
|
<li>Scrape the Service to build a competing product</li>
|
||||||
|
<li>Redistribute copyrighted content obtained through the Service</li>
|
||||||
|
<li>Use the Service to infringe on the rights of others</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>7. Rate Limiting</h2>
|
||||||
|
<p>The Service uses in-memory caching with configurable TTL. Rate limiting is enforced at 100 requests per minute per IP address. Exceeding this limit will result in a 429 status code.</p>
|
||||||
|
|
||||||
|
<h2>8. Availability</h2>
|
||||||
|
<p>The Service is hosted on Vercel's free tier and may experience downtime. We do not guarantee 100% availability. The Service may be modified, updated, or discontinued at any time without notice.</p>
|
||||||
|
|
||||||
|
<h2>9. Limitation of Liability</h2>
|
||||||
|
<p>In no event shall the creators of MiruroAPI be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of the Service.</p>
|
||||||
|
|
||||||
|
<h2>10. Intellectual Property</h2>
|
||||||
|
<p>The source code of MiruroAPI is open source and available on <a href="https://github.com/Shineii86/MiruroAPI" target="_blank" rel="noopener noreferrer">GitHub</a> under the repository's license terms.</p>
|
||||||
|
|
||||||
|
<h2>11. DMCA / Takedowns</h2>
|
||||||
|
<p>If you believe your copyrighted content is being displayed through this API without authorization, please open an issue at <a href="https://github.com/Shineii86/MiruroAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/MiruroAPI/issues</a> and we will promptly remove it.</p>
|
||||||
|
|
||||||
|
<h2>12. Changes to Terms</h2>
|
||||||
|
<p>We reserve the right to modify these Terms at any time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
||||||
|
|
||||||
|
<h2>13. Contact</h2>
|
||||||
|
<p>For questions about these Terms, open an issue at <a href="https://github.com/Shineii86/MiruroAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/MiruroAPI/issues</a>.</p>
|
||||||
|
</div>
|
||||||
|
<footer>© 2026 MiruroAPI · <a href="/" style="color:var(--purple)">Home</a> · <a href="/privacy" style="color:var(--purple)">Privacy</a></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
- type: web
|
||||||
|
name: MiruroAPI
|
||||||
|
env: node
|
||||||
|
buildCommand: npm install
|
||||||
|
startCommand: npm start
|
||||||
|
envVars:
|
||||||
|
- key: ALLOWED_ORIGINS
|
||||||
|
value: https://miruro.tv,https://miruro.to,https://miruro.bz,https://miruro.ru
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Main entry point for the MiruroAPI Express server.
|
* Main entry point for the MiruroAPI Express server.
|
||||||
* Configures CORS, middleware, static files, Swagger docs,
|
* Configures CORS, compression, logging, security headers,
|
||||||
* API routes, and 404 handling. Starts the server on the
|
* rate limiting, API routes, and 404 handling.
|
||||||
* configured port.
|
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* None (side-effect: starts Express server)
|
* None (side-effect: starts Express server)
|
||||||
@@ -19,7 +18,9 @@
|
|||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
|
const compression = require("compression");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -28,104 +29,163 @@ require("dotenv").config();
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const startTime = Date.now();
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(",")
|
? process.env.ALLOWED_ORIGINS.split(",")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COMPRESSION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Gzip compression — reduces response size by 30-70% ----
|
||||||
|
app.use(compression({
|
||||||
|
level: 6,
|
||||||
|
threshold: 1024,
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers["x-no-compression"]) return false;
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// REQUEST LOGGING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Request logging — method, path, status, response time ----
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const originalEnd = res.end;
|
||||||
|
|
||||||
|
res.end = function (...args) {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const status = res.statusCode;
|
||||||
|
const ip =
|
||||||
|
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
||||||
|
req.ip ||
|
||||||
|
req.connection.remoteAddress;
|
||||||
|
|
||||||
|
// NOTE: Only log API requests, skip static files
|
||||||
|
if (req.path.startsWith("/api/")) {
|
||||||
|
const log = `[${new Date().toISOString()}] ${req.method} ${req.path} ${status} ${duration}ms ${ip}`;
|
||||||
|
if (status >= 400) {
|
||||||
|
console.error(log);
|
||||||
|
} else {
|
||||||
|
console.log(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalEnd.apply(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// CORS MIDDLEWARE
|
// CORS MIDDLEWARE
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// NOTE: Single unified CORS middleware — handles all origin validation
|
// ---- FEATURE: Unified CORS middleware with preflight handling ----
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (
|
const isProduction = process.env.VERCEL || process.env.NODE_ENV === "production";
|
||||||
!allowedOrigins ||
|
|
||||||
allowedOrigins.includes("*") ||
|
if (allowedOrigins && allowedOrigins.length > 0) {
|
||||||
(origin && allowedOrigins.includes(origin))
|
if (allowedOrigins.includes("*")) {
|
||||||
) {
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
} else if (origin && allowedOrigins.includes(origin)) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
|
} else if (!origin && !isProduction) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
} else if (origin) {
|
||||||
|
return res.status(403).json({ success: false, message: "Origin not allowed" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||||
}
|
}
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET");
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-No-Compression");
|
||||||
|
res.setHeader("Access-Control-Max-Age", "86400");
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SECURITY HEADERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Security headers ----
|
// ---- FEATURE: Security headers ----
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader("X-Frame-Options", "DENY");
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||||
|
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CACHE HEADERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Cache-Control headers for API responses ----
|
||||||
|
app.use("/api", (req, res, next) => {
|
||||||
|
// NOTE: Different cache durations based on endpoint type
|
||||||
|
if (req.path.includes("/health") || req.path.includes("/stats")) {
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
} else if (req.path.includes("/search") || req.path.includes("/suggestions")) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=60");
|
||||||
|
} else if (req.path.includes("/episodes") || req.path.includes("/sources") || req.path.includes("/watch")) {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=120");
|
||||||
|
} else {
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
}
|
||||||
|
res.setHeader("Vary", "Accept-Encoding");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// STATIC FILES
|
// STATIC FILES
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "public"), { redirect: false }));
|
app.use(express.static(path.join(__dirname, "public"), {
|
||||||
|
redirect: false,
|
||||||
|
maxAge: "1d",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CLEAN URL ROUTES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const publicDir = path.join(__dirname, "public");
|
||||||
|
|
||||||
|
app.get("/tos", (req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, "tos.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/privacy", (req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, "privacy.html"));
|
||||||
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SWAGGER UI DOCS
|
// SWAGGER UI DOCS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Interactive Swagger UI documentation at /docs ----
|
|
||||||
app.get("/docs", (req, res) => {
|
app.get("/docs", (req, res) => {
|
||||||
res.send(`<!DOCTYPE html>
|
res.sendFile(path.join(publicDir, "docs.html"));
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>MiruroAPI — Swagger UI</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
||||||
<style>body{margin:0;background:#0b1622;}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
||||||
<script>
|
|
||||||
SwaggerUIBundle({
|
|
||||||
url: "/openapi.json",
|
|
||||||
dom_id: "#swagger-ui",
|
|
||||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
|
||||||
layout: "BaseLayout",
|
|
||||||
deepLinking: true,
|
|
||||||
defaultModelsExpandDepth: -1,
|
|
||||||
docExpansion: "list",
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// RESPONSE HELPERS
|
// 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) =>
|
const jsonResponse = (res, data, status = 200) =>
|
||||||
res.status(status).json({ success: true, results: data });
|
res.status(status).json({ success: true, results: data });
|
||||||
|
|
||||||
// ---- FEATURE: Standardized error response wrapper ----
|
|
||||||
/**
|
|
||||||
* 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) =>
|
const jsonError = (res, message = "Internal server error", status = 500) =>
|
||||||
res.status(status).json({ success: false, message });
|
res.status(status).json({ success: false, message });
|
||||||
|
|
||||||
@@ -136,7 +196,11 @@ const jsonError = (res, message = "Internal server error", status = 500) =>
|
|||||||
// ---- FEATURE: Rate limiting (100 requests per minute per IP) ----
|
// ---- FEATURE: Rate limiting (100 requests per minute per IP) ----
|
||||||
const requestCounts = new Map();
|
const requestCounts = new Map();
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const ip = req.ip || req.connection.remoteAddress;
|
const ip =
|
||||||
|
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
||||||
|
req.ip ||
|
||||||
|
req.connection?.remoteAddress ||
|
||||||
|
"unknown";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
const maxRequests = 100;
|
const maxRequests = 100;
|
||||||
@@ -156,6 +220,15 @@ app.use((req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
timestamps.push(now);
|
timestamps.push(now);
|
||||||
|
|
||||||
|
// NOTE: Aggressive cleanup on serverless — prune all empty entries every request
|
||||||
|
// since memory is per-invocation and doesn't persist
|
||||||
|
if (requestCounts.size > 50) {
|
||||||
|
for (const [key, val] of requestCounts) {
|
||||||
|
if (val.length === 0) requestCounts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,7 +237,7 @@ app.use((req, res, next) => {
|
|||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const apiRoutes = require("./src/routes/apiRoutes");
|
const apiRoutes = require("./src/routes/apiRoutes");
|
||||||
app.use("/api", apiRoutes(jsonResponse, jsonError));
|
app.use("/api", apiRoutes(jsonResponse, jsonError, startTime));
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// 404 HANDLER
|
// 404 HANDLER
|
||||||
@@ -172,7 +245,7 @@ app.use("/api", apiRoutes(jsonResponse, jsonError));
|
|||||||
|
|
||||||
// ---- FEATURE: Global error handler ----
|
// ---- FEATURE: Global error handler ----
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
console.error(`[ERROR] ${err.message}`);
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: err.message || "Internal server error",
|
message: err.message || "Internal server error",
|
||||||
@@ -187,16 +260,22 @@ app.use((req, res) => {
|
|||||||
message: "Endpoint not found",
|
message: "Endpoint not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.sendFile(path.join(__dirname, "public", "index.html"));
|
const notFoundPath = path.join(publicDir, "404.html");
|
||||||
|
if (fs.existsSync(notFoundPath)) {
|
||||||
|
return res.status(404).sendFile(notFoundPath);
|
||||||
|
}
|
||||||
|
res.sendFile(path.join(publicDir, "index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SERVER START
|
// SERVER START (only when running standalone, not on Vercel)
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
if (!process.env.VERCEL) {
|
||||||
console.info(`MiruroAPI listening at ${PORT}`);
|
app.listen(PORT, () => {
|
||||||
});
|
console.log(`[${new Date().toISOString()}] MiruroAPI v2.1.4 listening at ${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|
||||||
|
|||||||
@@ -1,444 +0,0 @@
|
|||||||
from flask import Flask, jsonify, request
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
ANILIST_API = 'https://graphql.anilist.co'
|
|
||||||
|
|
||||||
MEDIA_FIELDS = '''
|
|
||||||
id
|
|
||||||
idMal
|
|
||||||
title { romaji english native userPreferred }
|
|
||||||
description(asHtml: false)
|
|
||||||
coverImage { large medium color }
|
|
||||||
bannerImage
|
|
||||||
format
|
|
||||||
status
|
|
||||||
episodes
|
|
||||||
duration
|
|
||||||
season
|
|
||||||
seasonYear
|
|
||||||
genres
|
|
||||||
tags { name }
|
|
||||||
averageScore
|
|
||||||
meanScore
|
|
||||||
popularity
|
|
||||||
trending
|
|
||||||
favourites
|
|
||||||
startDate { year month day }
|
|
||||||
endDate { year month day }
|
|
||||||
nextAiringEpisode { episode timeUntilAiring airingAt }
|
|
||||||
studios(isMain: true) { edges { node { id name } isMain } }
|
|
||||||
trailer { id site thumbnail url }
|
|
||||||
rankings { rank type context year season allTime }
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def anilist_query(query, variables=None):
|
|
||||||
if variables is None:
|
|
||||||
variables = {}
|
|
||||||
res = requests.post(ANILIST_API, json={'query': query, 'variables': variables})
|
|
||||||
data = res.json()
|
|
||||||
if 'errors' in data:
|
|
||||||
raise Exception(data['errors'][0]['message'])
|
|
||||||
return data['data']
|
|
||||||
|
|
||||||
|
|
||||||
def format_media(m):
|
|
||||||
return {
|
|
||||||
'id': m['id'],
|
|
||||||
'idMal': m.get('idMal'),
|
|
||||||
'title': m.get('title', {}),
|
|
||||||
'description': m.get('description'),
|
|
||||||
'coverImage': m.get('coverImage', {}),
|
|
||||||
'bannerImage': m.get('bannerImage'),
|
|
||||||
'format': m.get('format'),
|
|
||||||
'status': m.get('status'),
|
|
||||||
'episodes': m.get('episodes'),
|
|
||||||
'duration': m.get('duration'),
|
|
||||||
'season': m.get('season'),
|
|
||||||
'seasonYear': m.get('seasonYear'),
|
|
||||||
'genres': m.get('genres', []),
|
|
||||||
'tags': [t['name'] for t in (m.get('tags') or [])],
|
|
||||||
'averageScore': m.get('averageScore'),
|
|
||||||
'meanScore': m.get('meanScore'),
|
|
||||||
'popularity': m.get('popularity'),
|
|
||||||
'trending': m.get('trending'),
|
|
||||||
'favourites': m.get('favourites'),
|
|
||||||
'startDate': m.get('startDate'),
|
|
||||||
'endDate': m.get('endDate'),
|
|
||||||
'nextAiringEpisode': m.get('nextAiringEpisode'),
|
|
||||||
'studios': [
|
|
||||||
{'id': e['node']['id'], 'name': e['node']['name'], 'isMain': e['isMain']}
|
|
||||||
for e in (m.get('studios', {}).get('edges') or [])
|
|
||||||
],
|
|
||||||
'trailer': m.get('trailer'),
|
|
||||||
'rankings': m.get('rankings', []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def home():
|
|
||||||
return jsonify({
|
|
||||||
'name': 'Miruro Anime API',
|
|
||||||
'version': '1.0.0',
|
|
||||||
'status': 'running',
|
|
||||||
'endpoints': [
|
|
||||||
'GET /api/trending',
|
|
||||||
'GET /api/popular',
|
|
||||||
'GET /api/top',
|
|
||||||
'GET /api/seasonal?season=SPRING&year=2026',
|
|
||||||
'GET /api/schedule?day=MONDAY',
|
|
||||||
'GET /api/airing',
|
|
||||||
'GET /api/search?query=naruto&sort=SCORE_DESC&page=1',
|
|
||||||
'GET /api/info/<id>',
|
|
||||||
'GET /api/info/<id>/episodes',
|
|
||||||
'GET /api/characters/<id>',
|
|
||||||
'GET /api/recommendations/<id>',
|
|
||||||
'GET /api/genres',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/trending')
|
|
||||||
def trending():
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'query {{ Page(perPage: {limit}) {{ media(sort: TRENDING_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} }} }}'
|
|
||||||
data = anilist_query(query)
|
|
||||||
return jsonify({'success': True, 'data': [format_media(m) for m in data['Page']['media']]})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/popular')
|
|
||||||
def popular():
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}'
|
|
||||||
data = anilist_query(query)
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'data': [format_media(m) for m in data['Page']['media']],
|
|
||||||
'pagination': data['Page']['pageInfo'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/top')
|
|
||||||
def top():
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'query {{ Page(page: {page}, perPage: {limit}) {{ media(sort: SCORE_DESC, type: ANIME) {{ {MEDIA_FIELDS} }} pageInfo {{ total currentPage lastPage perPage }} }} }}'
|
|
||||||
data = anilist_query(query)
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'data': [format_media(m) for m in data['Page']['media']],
|
|
||||||
'pagination': data['Page']['pageInfo'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/seasonal')
|
|
||||||
def seasonal():
|
|
||||||
season = request.args.get('season', 'SPRING').upper()
|
|
||||||
year = request.args.get('year', 2026, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
query = f'''query ($season: MediaSeason, $seasonYear: Int) {{
|
|
||||||
Page(perPage: {limit}) {{
|
|
||||||
media(season: $season, seasonYear: $seasonYear, sort: POPULARITY_DESC, type: ANIME) {{ {MEDIA_FIELDS} }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'season': season, 'seasonYear': year})
|
|
||||||
return jsonify({'success': True, 'season': season, 'year': year, 'data': [format_media(m) for m in data['Page']['media']] })
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/schedule')
|
|
||||||
def schedule():
|
|
||||||
day = request.args.get('day', '').upper()
|
|
||||||
query = f'''query {{
|
|
||||||
Page(perPage: 50) {{
|
|
||||||
media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
coverImage {{ large medium color }}
|
|
||||||
nextAiringEpisode {{ episode timeUntilAiring airingAt }}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query)
|
|
||||||
episodes = []
|
|
||||||
for m in data['Page']['media']:
|
|
||||||
if not m.get('nextAiringEpisode'):
|
|
||||||
continue
|
|
||||||
ep = m['nextAiringEpisode']
|
|
||||||
air_date = time.gmtime(ep['airingAt'])
|
|
||||||
dow = ['SUNDAY','MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY']
|
|
||||||
entry = {
|
|
||||||
'id': m['id'],
|
|
||||||
'title': m['title']['userPreferred'],
|
|
||||||
'coverImage': m.get('coverImage', {}).get('large'),
|
|
||||||
'nextEpisode': ep['episode'],
|
|
||||||
'airingAt': ep['airingAt'],
|
|
||||||
'dayOfWeek': dow[air_date.tm_wday],
|
|
||||||
'timeUntilAiring': ep['timeUntilAiring'],
|
|
||||||
}
|
|
||||||
if day and dow[air_date.tm_wday] != day:
|
|
||||||
continue
|
|
||||||
episodes.append(entry)
|
|
||||||
episodes.sort(key=lambda x: x['airingAt'])
|
|
||||||
return jsonify({'success': True, 'data': episodes})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/airing')
|
|
||||||
def airing():
|
|
||||||
query = f'''query {{
|
|
||||||
Page(perPage: 25) {{
|
|
||||||
media(sort: TRENDING_DESC, type: ANIME, status: RELEASING) {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
coverImage {{ large medium color }}
|
|
||||||
bannerImage
|
|
||||||
format
|
|
||||||
episodes
|
|
||||||
averageScore
|
|
||||||
nextAiringEpisode {{ episode timeUntilAiring airingAt }}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query)
|
|
||||||
result = []
|
|
||||||
for m in data['Page']['media']:
|
|
||||||
if not m.get('nextAiringEpisode'):
|
|
||||||
continue
|
|
||||||
result.append({
|
|
||||||
'id': m['id'],
|
|
||||||
'title': m['title']['userPreferred'],
|
|
||||||
'coverImage': m.get('coverImage', {}).get('large'),
|
|
||||||
'bannerImage': m.get('bannerImage'),
|
|
||||||
'format': m.get('format'),
|
|
||||||
'episodes': m.get('episodes'),
|
|
||||||
'averageScore': m.get('averageScore'),
|
|
||||||
'nextEpisode': m['nextAiringEpisode']['episode'],
|
|
||||||
'airingAt': m['nextAiringEpisode']['airingAt'],
|
|
||||||
'timeUntilAiring': m['nextAiringEpisode']['timeUntilAiring'],
|
|
||||||
})
|
|
||||||
return jsonify({'success': True, 'data': result})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/search')
|
|
||||||
def search():
|
|
||||||
q = request.args.get('query', '')
|
|
||||||
sort = request.args.get('sort', 'POPULARITY_DESC').upper()
|
|
||||||
genre = request.args.get('genre', '')
|
|
||||||
fmt = request.args.get('format', '').upper()
|
|
||||||
status = request.args.get('status', '').upper()
|
|
||||||
season = request.args.get('season', '').upper()
|
|
||||||
year = request.args.get('year', '', type=str)
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
limit = request.args.get('limit', 20, type=int)
|
|
||||||
|
|
||||||
if not q and not genre:
|
|
||||||
return jsonify({'success': False, 'error': 'query or genre parameter required'}), 400
|
|
||||||
|
|
||||||
sort_map = {
|
|
||||||
'SCORE_DESC': 'SCORE_DESC', 'POPULARITY_DESC': 'POPULARITY_DESC',
|
|
||||||
'TRENDING_DESC': 'TRENDING_DESC', 'FAVOURITES_DESC': 'FAVOURITES_DESC',
|
|
||||||
'START_DATE_DESC': 'START_DATE_DESC', 'UPDATED_AT_DESC': 'UPDATED_AT_DESC',
|
|
||||||
}
|
|
||||||
s = sort_map.get(sort, 'POPULARITY_DESC')
|
|
||||||
|
|
||||||
filters = ''
|
|
||||||
variables = {'search': q or None, 'sort': s, 'page': page, 'perPage': limit}
|
|
||||||
if genre:
|
|
||||||
filters += ', genre: $genre'
|
|
||||||
variables['genre'] = genre
|
|
||||||
if fmt:
|
|
||||||
filters += ', format: $format'
|
|
||||||
variables['format'] = fmt
|
|
||||||
if status:
|
|
||||||
filters += ', status: $status'
|
|
||||||
variables['status'] = status
|
|
||||||
if season:
|
|
||||||
filters += ', season: $season'
|
|
||||||
variables['season'] = season
|
|
||||||
if year:
|
|
||||||
filters += ', seasonYear: $seasonYear'
|
|
||||||
variables['seasonYear'] = int(year)
|
|
||||||
|
|
||||||
query = f'''query ($search: String, $sort: [MediaSort], $genre: String, $format: MediaFormat, $status: MediaStatus, $season: MediaSeason, $seasonYear: Int, $page: Int, $perPage: Int) {{
|
|
||||||
Page(page: $page, perPage: $perPage) {{
|
|
||||||
media(search: $search, type: ANIME, sort: $sort{filters}) {{ {MEDIA_FIELDS} }}
|
|
||||||
pageInfo {{ total currentPage lastPage perPage }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, variables)
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'data': [format_media(m) for m in data['Page']['media']],
|
|
||||||
'pagination': data['Page']['pageInfo'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/info/<int:anime_id>')
|
|
||||||
def info(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
{MEDIA_FIELDS}
|
|
||||||
relations {{
|
|
||||||
edges {{
|
|
||||||
node {{ id title {{ romaji english native userPreferred }} format coverImage {{ large medium }} }}
|
|
||||||
relationType
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
externalLinks {{ id site url type language }}
|
|
||||||
streamingEpisodes {{ title thumbnail url site }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
m = data['Media']
|
|
||||||
result = format_media(m)
|
|
||||||
result['relations'] = [
|
|
||||||
{
|
|
||||||
'id': e['node']['id'],
|
|
||||||
'title': e['node']['title']['userPreferred'],
|
|
||||||
'format': e['node']['format'],
|
|
||||||
'coverImage': e['node']['coverImage'].get('large') if e['node'].get('coverImage') else None,
|
|
||||||
'relationType': e['relationType'],
|
|
||||||
}
|
|
||||||
for e in (m.get('relations', {}).get('edges') or [])
|
|
||||||
]
|
|
||||||
result['externalLinks'] = m.get('externalLinks') or []
|
|
||||||
result['streamingEpisodes'] = m.get('streamingEpisodes') or []
|
|
||||||
return jsonify({'success': True, 'data': result})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/info/<int:anime_id>/episodes')
|
|
||||||
def episodes(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
episodes
|
|
||||||
streamingEpisodes {{ title thumbnail url site }}
|
|
||||||
externalLinks {{ id site url type }}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
m = data['Media']
|
|
||||||
eps = []
|
|
||||||
for i, ep in enumerate(m.get('streamingEpisodes') or []):
|
|
||||||
eps.append({
|
|
||||||
'number': i + 1,
|
|
||||||
'title': ep.get('title'),
|
|
||||||
'thumbnail': ep.get('thumbnail'),
|
|
||||||
'url': ep.get('url'),
|
|
||||||
'site': ep.get('site'),
|
|
||||||
})
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'animeId': m['id'],
|
|
||||||
'title': m['title']['userPreferred'],
|
|
||||||
'totalEpisodes': m.get('episodes'),
|
|
||||||
'episodes': eps,
|
|
||||||
'providers': ['kiwi', 'pewe', 'bee', 'bonk', 'bun', 'ally', 'nun', 'twin', 'cog', 'moo', 'hop'],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/characters/<int:anime_id>')
|
|
||||||
def characters(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
id
|
|
||||||
characters(sort: ROLE, perPage: 25) {{
|
|
||||||
edges {{
|
|
||||||
node {{ id name {{ full native alternative }} image {{ large medium }} gender age bloodType }}
|
|
||||||
role
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'animeId': data['Media']['id'],
|
|
||||||
'characters': [
|
|
||||||
{
|
|
||||||
'id': e['node']['id'],
|
|
||||||
'name': e['node']['name'],
|
|
||||||
'image': e['node']['image'],
|
|
||||||
'role': e['role'],
|
|
||||||
'gender': e['node'].get('gender'),
|
|
||||||
'age': e['node'].get('age'),
|
|
||||||
'bloodType': e['node'].get('bloodType'),
|
|
||||||
}
|
|
||||||
for e in data['Media']['characters']['edges']
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/recommendations/<int:anime_id>')
|
|
||||||
def recommendations(anime_id):
|
|
||||||
query = f'''query ($id: Int) {{
|
|
||||||
Media(id: $id, type: ANIME) {{
|
|
||||||
id
|
|
||||||
recommendations(perPage: 10, sort: RATING_DESC) {{
|
|
||||||
edges {{
|
|
||||||
node {{
|
|
||||||
id rating
|
|
||||||
mediaRecommendation {{
|
|
||||||
id
|
|
||||||
title {{ romaji english native userPreferred }}
|
|
||||||
coverImage {{ large medium color }}
|
|
||||||
format episodes averageScore popularity
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}'''
|
|
||||||
data = anilist_query(query, {'id': anime_id})
|
|
||||||
if not data.get('Media'):
|
|
||||||
return jsonify({'success': False, 'error': 'Anime not found'}), 404
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'animeId': data['Media']['id'],
|
|
||||||
'recommendations': [
|
|
||||||
{
|
|
||||||
'id': e['node']['mediaRecommendation']['id'],
|
|
||||||
'title': e['node']['mediaRecommendation']['title']['userPreferred'],
|
|
||||||
'coverImage': e['node']['mediaRecommendation']['coverImage'].get('large'),
|
|
||||||
'format': e['node']['mediaRecommendation']['format'],
|
|
||||||
'episodes': e['node']['mediaRecommendation']['episodes'],
|
|
||||||
'averageScore': e['node']['mediaRecommendation']['averageScore'],
|
|
||||||
'rating': e['node']['rating'],
|
|
||||||
}
|
|
||||||
for e in data['Media']['recommendations']['edges']
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/genres')
|
|
||||||
def genres():
|
|
||||||
data = anilist_query('{ GenreCollection }')
|
|
||||||
return jsonify({'success': True, 'data': data['GenreCollection']})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print('\n 🎬 Miruro Anime API running on http://localhost:3000\n')
|
|
||||||
print(' Endpoints:')
|
|
||||||
print(' GET /api/trending')
|
|
||||||
print(' GET /api/popular')
|
|
||||||
print(' GET /api/top')
|
|
||||||
print(' GET /api/seasonal?season=SPRING&year=2026')
|
|
||||||
print(' GET /api/schedule?day=MONDAY')
|
|
||||||
print(' GET /api/airing')
|
|
||||||
print(' GET /api/search?query=naruto')
|
|
||||||
print(' GET /api/info/<id>')
|
|
||||||
print(' GET /api/info/<id>/episodes')
|
|
||||||
print(' GET /api/characters/<id>')
|
|
||||||
print(' GET /api/recommendations/<id>')
|
|
||||||
print(' GET /api/genres\n')
|
|
||||||
app.run(host='0.0.0.0', port=3000, debug=True)
|
|
||||||
@@ -538,7 +538,7 @@ const getSchedule = async (page = 1, perPage = 20) => {
|
|||||||
* @returns {Promise<object>} Paginated filtered results
|
* @returns {Promise<object>} Paginated filtered results
|
||||||
*/
|
*/
|
||||||
const filterAnime = async ({
|
const filterAnime = async ({
|
||||||
genre, tag, year, season, format, status,
|
search, genre, tag, year, season, format, status,
|
||||||
sort = "POPULARITY_DESC", page = 1, perPage = 20,
|
sort = "POPULARITY_DESC", page = 1, perPage = 20,
|
||||||
}) => {
|
}) => {
|
||||||
const SORT_MAP = {
|
const SORT_MAP = {
|
||||||
@@ -548,13 +548,14 @@ const filterAnime = async ({
|
|||||||
START_DATE_DESC: "START_DATE_DESC",
|
START_DATE_DESC: "START_DATE_DESC",
|
||||||
FAVOURITES_DESC: "FAVOURITES_DESC",
|
FAVOURITES_DESC: "FAVOURITES_DESC",
|
||||||
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
||||||
|
SEARCH_MATCH: "SEARCH_MATCH",
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: Build dynamic GraphQL arguments based on provided filters
|
|
||||||
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
|
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
|
||||||
const variables = { page, perPage };
|
const variables = { page, perPage };
|
||||||
const varTypes = ["$page: Int", "$perPage: Int"];
|
const varTypes = ["$page: Int", "$perPage: Int"];
|
||||||
|
|
||||||
|
if (search) { args.push("search: $search"); variables.search = search; varTypes.push("$search: String"); }
|
||||||
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
|
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
|
||||||
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
|
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
|
||||||
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
|
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
|
||||||
@@ -584,6 +585,339 @@ const filterAnime = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// GENRES & CATEGORIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available genres ----
|
||||||
|
/**
|
||||||
|
* Returns the complete list of AniList genres.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Array of genre name strings
|
||||||
|
*/
|
||||||
|
const getAllGenres = async () => {
|
||||||
|
const gql = `query { GenreCollection }`;
|
||||||
|
const data = await anilistQuery(gql);
|
||||||
|
return data.GenreCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Get all available tags ----
|
||||||
|
/**
|
||||||
|
* Returns the complete list of AniList tags with metadata.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Array of tag objects { name, description, category, rank, isGeneralSpoiler, isMediaSpoiler, isAdult }
|
||||||
|
*/
|
||||||
|
const getAllTags = async () => {
|
||||||
|
const gql = `query { MediaTagCollection { name description category isGeneralSpoiler isMediaSpoiler isAdult } }`;
|
||||||
|
const data = await anilistQuery(gql);
|
||||||
|
return data.MediaTagCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// TOP & TRENDING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Top anime by score (all time) ----
|
||||||
|
/**
|
||||||
|
* Fetches top-rated anime of all time sorted by score.
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated top anime results
|
||||||
|
*/
|
||||||
|
const getTopAnime = async (page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: SCORE_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Daily trending anime ----
|
||||||
|
/**
|
||||||
|
* Fetches currently trending anime (last 7 days).
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated trending results
|
||||||
|
*/
|
||||||
|
const getTrendingDaily = async (page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: TRENDING_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Weekly trending anime ----
|
||||||
|
/**
|
||||||
|
* Fetches currently trending anime (last 30 days).
|
||||||
|
* Returns more results than daily to cover a wider window.
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=40] - Results per page (default 40, wider than daily)
|
||||||
|
* @returns {Promise<object>} Paginated trending results
|
||||||
|
*/
|
||||||
|
const getTrendingWeekly = async (page = 1, perPage = 40) => {
|
||||||
|
const gql = `
|
||||||
|
query ($page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, sort: TRENDING_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEASONAL & STUDIO
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Seasonal anime by year and season ----
|
||||||
|
/**
|
||||||
|
* Fetches anime for a specific season and year.
|
||||||
|
*
|
||||||
|
* @param {number} year - The year (e.g., 2025)
|
||||||
|
* @param {string} season - The season (WINTER, SPRING, SUMMER, FALL)
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated seasonal anime results
|
||||||
|
*/
|
||||||
|
const getSeasonalAnime = async (year, season, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($year: Int, $season: MediaSeason, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
media(type: ANIME, season: $season, seasonYear: $year, sort: POPULARITY_DESC) {
|
||||||
|
${MEDIA_LIST_FIELDS}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { year, season: season.toUpperCase(), page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.media,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Anime by studio name ----
|
||||||
|
/**
|
||||||
|
* Fetches all anime produced by a specific studio.
|
||||||
|
*
|
||||||
|
* @param {string} studioName - The studio name to search for
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated studio anime results
|
||||||
|
*/
|
||||||
|
const getAnimeByStudio = async (studioName, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($name: String, $page: Int, $perPage: Int) {
|
||||||
|
Studio(search: $name) {
|
||||||
|
id name isAnimationStudio siteUrl
|
||||||
|
media(sort: POPULARITY_DESC, page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
nodes { ${MEDIA_LIST_FIELDS} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { name: studioName, page, perPage });
|
||||||
|
const studio = data.Studio;
|
||||||
|
|
||||||
|
if (!studio) throw new Error("Studio not found");
|
||||||
|
|
||||||
|
return {
|
||||||
|
studio: {
|
||||||
|
id: studio.id,
|
||||||
|
name: studio.name,
|
||||||
|
isAnimationStudio: studio.isAnimationStudio,
|
||||||
|
siteUrl: studio.siteUrl,
|
||||||
|
},
|
||||||
|
page: studio.media.pageInfo.currentPage,
|
||||||
|
perPage: studio.media.pageInfo.perPage,
|
||||||
|
total: studio.media.pageInfo.total,
|
||||||
|
hasNextPage: studio.media.pageInfo.hasNextPage,
|
||||||
|
results: studio.media.nodes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CHARACTER & STAFF
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Character detail with anime list ----
|
||||||
|
/**
|
||||||
|
* Fetches detailed character info including all anime appearances.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList character ID
|
||||||
|
* @returns {Promise<object>} Character details with anime list
|
||||||
|
*/
|
||||||
|
const getCharacterInfo = async (id) => {
|
||||||
|
const gql = `
|
||||||
|
query ($id: Int) {
|
||||||
|
Character(id: $id) {
|
||||||
|
id name { full native userPreferred }
|
||||||
|
image { large medium }
|
||||||
|
description gender favourites siteUrl
|
||||||
|
dateOfBirth { year month day }
|
||||||
|
age bloodType
|
||||||
|
media(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
edges {
|
||||||
|
characterRole
|
||||||
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { id });
|
||||||
|
if (!data.Character) throw new Error("Character not found");
|
||||||
|
return data.Character;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Staff detail with anime list ----
|
||||||
|
/**
|
||||||
|
* Fetches detailed staff info including all anime works.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList staff ID
|
||||||
|
* @returns {Promise<object>} Staff details with anime list
|
||||||
|
*/
|
||||||
|
const getStaffInfo = async (id) => {
|
||||||
|
const gql = `
|
||||||
|
query ($id: Int) {
|
||||||
|
Staff(id: $id) {
|
||||||
|
id name { full native userPreferred }
|
||||||
|
image { large medium }
|
||||||
|
description gender favourites siteUrl
|
||||||
|
dateOfBirth { year month day }
|
||||||
|
age homeTown yearsActive
|
||||||
|
staffMedia(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
edges {
|
||||||
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { id });
|
||||||
|
if (!data.Staff) throw new Error("Staff not found");
|
||||||
|
return data.Staff;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Search characters by name ----
|
||||||
|
/**
|
||||||
|
* Searches AniList characters by name.
|
||||||
|
*
|
||||||
|
* @param {string} query - Character name to search
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated character search results
|
||||||
|
*/
|
||||||
|
const searchCharacters = async (query, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($search: String, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
characters(search: $search, sort: SEARCH_MATCH) {
|
||||||
|
id name { full native userPreferred } image { large medium } gender favourites siteUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { search: query, page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.characters,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Search staff by name ----
|
||||||
|
/**
|
||||||
|
* Searches AniList staff by name.
|
||||||
|
*
|
||||||
|
* @param {string} query - Staff name to search
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated staff search results
|
||||||
|
*/
|
||||||
|
const searchStaff = async (query, page = 1, perPage = 20) => {
|
||||||
|
const gql = `
|
||||||
|
query ($search: String, $page: Int, $perPage: Int) {
|
||||||
|
Page(page: $page, perPage: $perPage) {
|
||||||
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
|
staff(search: $search, sort: SEARCH_MATCH) {
|
||||||
|
id name { full native userPreferred } image { large medium } gender favourites siteUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const data = await anilistQuery(gql, { search: query, page, perPage });
|
||||||
|
const pageData = data.Page;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pageData.pageInfo.currentPage,
|
||||||
|
perPage: pageData.pageInfo.perPage,
|
||||||
|
total: pageData.pageInfo.total,
|
||||||
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
|
results: pageData.staff,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
anilistQuery,
|
anilistQuery,
|
||||||
searchAnime,
|
searchAnime,
|
||||||
@@ -596,6 +930,17 @@ module.exports = {
|
|||||||
getSpotlight,
|
getSpotlight,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
filterAnime,
|
filterAnime,
|
||||||
|
getAllGenres,
|
||||||
|
getAllTags,
|
||||||
|
getTopAnime,
|
||||||
|
getTrendingDaily,
|
||||||
|
getTrendingWeekly,
|
||||||
|
getSeasonalAnime,
|
||||||
|
getAnimeByStudio,
|
||||||
|
getCharacterInfo,
|
||||||
|
getStaffInfo,
|
||||||
|
searchCharacters,
|
||||||
|
searchStaff,
|
||||||
MEDIA_LIST_FIELDS,
|
MEDIA_LIST_FIELDS,
|
||||||
MEDIA_FULL_FIELDS,
|
MEDIA_FULL_FIELDS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ const setCache = (key, data, ttl = DEFAULT_TTL) => {
|
|||||||
cache.delete(firstKey);
|
cache.delete(firstKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Periodic cleanup — prune all expired entries when cache reaches 80% capacity
|
||||||
|
// Prevents unbounded memory growth from stale entries that were never accessed
|
||||||
|
if (cache.size >= MAX_CACHE_SIZE * 0.8) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of cache) {
|
||||||
|
if (now > v.expiry) cache.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cache.set(key, { data, expiry: Date.now() + ttl });
|
cache.set(key, { data, expiry: Date.now() + ttl });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,6 +129,14 @@ const clearCache = () => {
|
|||||||
cache.clear();
|
cache.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { getCached, setCache, clearCache };
|
// ---- FEATURE: Get current cache size ----
|
||||||
|
/**
|
||||||
|
* Returns the current number of entries in the cache.
|
||||||
|
*
|
||||||
|
* @returns {number} Current cache size
|
||||||
|
*/
|
||||||
|
const getCacheSize = () => cache.size;
|
||||||
|
|
||||||
|
module.exports = { getCached, setCache, clearCache, getCacheSize };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════ END: cache.js
|
// ══════════════════════════════════════════════════════════════ END: cache.js
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const { Buffer } = require("buffer");
|
const { Buffer } = require("buffer");
|
||||||
const zlib = require("zlib");
|
const zlib = require("zlib");
|
||||||
|
const { getCached, setCache } = require("./cache");
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// MIRURO PIPE CONFIGURATION
|
// MIRURO PIPE CONFIGURATION
|
||||||
@@ -42,6 +43,7 @@ const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
|
|||||||
const HEADERS = {
|
const HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
"User-Agent": "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/",
|
Referer: "https://www.miruro.tv/",
|
||||||
|
Origin: "https://www.miruro.tv",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -66,29 +68,139 @@ const encodePipeRequest = (payload) => {
|
|||||||
|
|
||||||
// ---- FEATURE: Decode base64+gzip pipe response ----
|
// ---- FEATURE: Decode base64+gzip pipe response ----
|
||||||
/**
|
/**
|
||||||
* Decodes a pipe response from base64url + gzip format into a plain object.
|
* Decodes a pipe response from base64url + XOR obfuscation + gzip format.
|
||||||
* The Miruro pipe returns all responses in this compressed 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
|
* @returns {object} Decoded JSON object
|
||||||
* @throws {Error} If decoding or decompression fails
|
* @throws {Error} If decoding or decompression fails
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const data = decodePipeResponse(res.text);
|
|
||||||
* console.log(data.providers); // { kiwi: {...}, bee: {...} }
|
|
||||||
*/
|
*/
|
||||||
const decodePipeResponse = (encodedStr) => {
|
const decodePipeResponse = (encodedStr) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: Add padding if necessary (base64url may omit trailing =)
|
|
||||||
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
const padded = encodedStr + "=".repeat((4 - (encodedStr.length % 4)) % 4);
|
||||||
const compressed = Buffer.from(padded, "base64url");
|
const raw = Buffer.from(padded, "base64url");
|
||||||
const decompressed = zlib.gunzipSync(compressed);
|
// 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"));
|
return JSON.parse(decompressed.toString("utf-8"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error("Failed to decode pipe response: " + e.message);
|
throw new Error("Failed to decode pipe response: " + e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: 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 ----
|
// ---- FEATURE: Decode base64 episode ID to plain text ----
|
||||||
/**
|
/**
|
||||||
* Decodes a base64-encoded episode ID back 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 ----
|
// ---- FEATURE: Recursively decode all base64 IDs in a nested object ----
|
||||||
/**
|
/**
|
||||||
* Walks a JSON structure and decodes any base64 "id" fields.
|
* 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
|
* @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) => {
|
const deepTranslate = (obj) => {
|
||||||
if (obj && typeof obj === "object") {
|
if (obj && typeof obj === "object") {
|
||||||
for (const key of Object.keys(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
if (key === "id" && typeof obj[key] === "string") {
|
return obj.map((item) => deepTranslate(item));
|
||||||
obj[key] = translateId(obj[key]);
|
}
|
||||||
} else if (typeof obj[key] === "object") {
|
const clone = { ...obj };
|
||||||
deepTranslate(obj[key]);
|
for (const key of Object.keys(clone)) {
|
||||||
|
if (key === "id" && typeof clone[key] === "string") {
|
||||||
|
if (clone.number !== undefined) {
|
||||||
|
clone.rawPipeId = clone[key];
|
||||||
|
}
|
||||||
|
clone[key] = translateId(clone[key]);
|
||||||
|
} else if (typeof clone[key] === "object") {
|
||||||
|
clone[key] = deepTranslate(clone[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return clone;
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
};
|
};
|
||||||
@@ -173,9 +295,9 @@ const injectSourceSlugs = (data, anilistId) => {
|
|||||||
|
|
||||||
for (const ep of epList) {
|
for (const ep of epList) {
|
||||||
if (ep.id && ep.number) {
|
if (ep.id && ep.number) {
|
||||||
const origId = ep.id;
|
// NOTE: rawPipeId is already set by deepTranslate — preserve original base64
|
||||||
// NOTE: Take only the prefix before ":" for the slug
|
// Take only the prefix before ":" for the slug
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
const prefix = ep.id.includes(":") ? ep.id.split(":")[0] : ep.id;
|
||||||
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
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 ----
|
// ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ----
|
||||||
/**
|
/**
|
||||||
* Internal helper to fetch and decode raw episode data from the 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
|
* @param {number} anilistId - The AniList anime ID
|
||||||
* @returns {Promise<object>} Decoded episode data with providers
|
* @returns {Promise<object>} Decoded episode data with providers
|
||||||
* @throws {Error} If the pipe request fails
|
* @throws {Error} If the pipe request fails
|
||||||
*/
|
*/
|
||||||
const fetchRawEpisodes = async (anilistId) => {
|
const fetchRawEpisodes = async (anilistId) => {
|
||||||
const payload = {
|
const cacheKey = `pipe:episodes:${anilistId}`;
|
||||||
path: "episodes",
|
const cached = getCached(cacheKey);
|
||||||
method: "GET",
|
if (cached) return cached;
|
||||||
query: { anilistId },
|
|
||||||
body: null,
|
|
||||||
version: "0.1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const data = await pipeRequest("episodes", { anilistId });
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const result = deepTranslate(data);
|
||||||
headers: HEADERS,
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
timeout: 15000,
|
return result;
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
|
|
||||||
const data = decodePipeResponse(res.text || res.data);
|
|
||||||
return deepTranslate(data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Get episodes with slug-based IDs ----
|
// ---- FEATURE: Get episodes with slug-based IDs ----
|
||||||
@@ -247,7 +360,8 @@ const getEpisodes = async (anilistId) => {
|
|||||||
// ---- FEATURE: Get streaming sources (detailed endpoint) ----
|
// ---- FEATURE: Get streaming sources (detailed endpoint) ----
|
||||||
/**
|
/**
|
||||||
* Fetches M3U8 streaming sources for a specific episode.
|
* 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} episodeId - The raw episode ID from the pipe
|
||||||
* @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk")
|
* @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"
|
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
|
||||||
*/
|
*/
|
||||||
const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
||||||
const encId = Buffer.from(episodeId).toString("base64url");
|
const cacheKey = `pipe:sources:${episodeId}:${provider}:${category}`;
|
||||||
const payload = {
|
const cached = getCached(cacheKey);
|
||||||
path: "sources",
|
if (cached) return cached;
|
||||||
method: "GET",
|
|
||||||
query: { episodeId: encId, provider, category, anilistId },
|
|
||||||
body: null,
|
|
||||||
version: "0.1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encId = Buffer.from(episodeId).toString("base64url");
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const sources = await pipeRequest("sources", {
|
||||||
headers: HEADERS,
|
episodeId: encId,
|
||||||
timeout: 15000,
|
provider,
|
||||||
|
category,
|
||||||
|
anilistId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
const result = proxySubtitles(proxyStreams(deepTranslate(sources)));
|
||||||
return deepTranslate(decodePipeResponse(res.text || res.data));
|
setCache(cacheKey, result, 600 * 1000);
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
|
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
|
||||||
@@ -304,14 +416,27 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
|
|||||||
const episodes = provData.episodes?.[category] || [];
|
const episodes = provData.episodes?.[category] || [];
|
||||||
let targetId = null;
|
let targetId = null;
|
||||||
|
|
||||||
// NOTE: Resolve slug back to the original pipe episode ID
|
// 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) {
|
for (const ep of episodes) {
|
||||||
const origId = ep.id || "";
|
const rawId = ep.id || "";
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
let match = false;
|
||||||
const generated = `${prefix}-${ep.number}`;
|
|
||||||
|
|
||||||
if (generated === slug) {
|
// Case 1: Slugged ID — "watch/{provider}/{anilistId}/{category}/{prefix}-{number}"
|
||||||
targetId = origId;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,10 +445,198 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
|
|||||||
return getSources(targetId, provider, anilistId, category);
|
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 = {
|
module.exports = {
|
||||||
getEpisodes,
|
getEpisodes,
|
||||||
getSources,
|
getSources,
|
||||||
getWatchSources,
|
getWatchSources,
|
||||||
|
getDownloadUrl,
|
||||||
|
getBatchSources,
|
||||||
|
extractSubtitles,
|
||||||
|
extractSkipTimes,
|
||||||
|
getBestStream,
|
||||||
encodePipeRequest,
|
encodePipeRequest,
|
||||||
decodePipeResponse,
|
decodePipeResponse,
|
||||||
translateId,
|
translateId,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — test.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Integration test suite for MiruroAPI v2.1.0 endpoints.
|
||||||
|
* Tests all 36 endpoints for correct response format.
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
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: "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;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
async function runTest(test) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}${test.url}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log(`❌ ${test.name} - HTTP ${res.status}`);
|
||||||
|
failed++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success === true && data.results) {
|
||||||
|
console.log(`✅ ${test.name}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${test.name} - Invalid response format`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ ${test.name} - ${error.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAll() {
|
||||||
|
console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`);
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
await runTest(test);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📊 Results: ${passed} passed, ${failed} failed, ${tests.length} total\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runAll();
|
||||||
@@ -2,12 +2,35 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"builds": [
|
"builds": [
|
||||||
{
|
{
|
||||||
"src": "server.js",
|
"src": "api/index.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "api/proxy.js",
|
||||||
"use": "@vercel/node"
|
"use": "@vercel/node"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"routes": [
|
"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=()" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||