mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 18:25:11 +00:00
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 |
+10
-2
@@ -1,5 +1,13 @@
|
|||||||
# Origins you want to allow
|
# Origins you want to allow (comma-separated, use * for all)
|
||||||
ALLOWED_ORIGINS=https://site1.com,https://site2.com
|
ALLOWED_ORIGINS=https://miruro.tv,https://miruro.to,https://miruro.bz
|
||||||
|
|
||||||
# Port (optional, defaults to 3000)
|
# Port (optional, defaults to 3000)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Pipe XOR obfuscation key (hex string, 32 chars)
|
||||||
|
# Required for pipe response decoding
|
||||||
|
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
|
||||||
|
|||||||
+146
@@ -1,5 +1,151 @@
|
|||||||
# 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
|
## v2.0.0
|
||||||
### Major Release — 35 endpoints, streaming improvements, new features
|
### Major Release — 35 endpoints, streaming improvements, new features
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
<img src="https://img.shields.io/badge/AniList-GraphQL-02A6E4?style=flat-square&logo=graphql&logoColor=white" alt="AniList GraphQL"/>
|
<img src="https://img.shields.io/badge/AniList-GraphQL-02A6E4?style=flat-square&logo=graphql&logoColor=white" alt="AniList GraphQL"/>
|
||||||
<img src="https://img.shields.io/badge/Vercel-Serverless-000000?style=flat-square&logo=vercel&logoColor=white" alt="Vercel"/>
|
<img src="https://img.shields.io/badge/Vercel-Serverless-000000?style=flat-square&logo=vercel&logoColor=white" alt="Vercel"/>
|
||||||
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
|
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
|
||||||
<img src="https://img.shields.io/badge/Version-2.0.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
<img src="https://img.shields.io/badge/Version-2.1.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
||||||
<img src="https://img.shields.io/badge/Endpoints-35-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
<img src="https://img.shields.io/badge/Endpoints-36-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
||||||
<img src="https://img.shields.io/badge/Providers-12-a855f7?style=flat-square&logoColor=white" alt="Providers"/>
|
<img src="https://img.shields.io/badge/Providers-12-a855f7?style=flat-square&logoColor=white" alt="Providers"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1408,6 +1408,7 @@ docker run -p 3000:3000 miruroapi
|
|||||||
|
|
||||||
| Version | Date | Key Changes |
|
| Version | Date | Key Changes |
|
||||||
|:---|:---|:---|
|
|:---|:---|:---|
|
||||||
|
| **2.0.1** | 2026-06-24 | Streaming improvements — better subtitle extraction, quality fallback for providers without metadata |
|
||||||
| **2.0.0** | 2026-06-25 | Major upgrade — 35 endpoints, compression, streaming improvements, new features |
|
| **2.0.0** | 2026-06-25 | Major upgrade — 35 endpoints, compression, streaming improvements, new features |
|
||||||
| **1.4.0** | 2026-06-25 | Creator attribution middleware, Vercel deployment fix, security headers |
|
| **1.4.0** | 2026-06-25 | Creator attribution middleware, Vercel deployment fix, security headers |
|
||||||
| **1.3.0** | 2026-06-25 | Full landing page upgrade with premium design |
|
| **1.3.0** | 2026-06-25 | Full landing page upgrade with premium design |
|
||||||
|
|||||||
+117
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
+27
-1
@@ -1,6 +1,6 @@
|
|||||||
# API Endpoints Reference
|
# API Endpoints Reference
|
||||||
|
|
||||||
Complete documentation for all 35 MiruroAPI v2.0.0 endpoints.
|
Complete documentation for all 35 MiruroAPI v2.0.1 endpoints.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -498,6 +498,32 @@ Streaming sources via URL path (recommended).
|
|||||||
| category | string | sub or dub |
|
| category | string | sub or dub |
|
||||||
| slug | string | Episode slug |
|
| 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
|
## Compare
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "miruro-api",
|
"name": "miruro-api",
|
||||||
"version": "2.0.0",
|
"version": "2.1.4",
|
||||||
"description": "RESTful API for miruro.tv - 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": {
|
||||||
|
|||||||
+42
-3
@@ -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": "2.0.0",
|
"version": "2.0.1",
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "Shineii86",
|
"name": "Shineii86",
|
||||||
"url": "https://github.com/Shineii86/MiruroAPI"
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
@@ -339,13 +339,52 @@
|
|||||||
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
|
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Streaming"],
|
"tags": ["Streaming"],
|
||||||
"summary": "Streaming sources (simple)",
|
"summary": "Streaming sources (simple) with subtitles",
|
||||||
|
"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" } },
|
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
]
|
],
|
||||||
|
"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}": {
|
"/api/compare/{id1}/{id2}": {
|
||||||
|
|||||||
@@ -88,11 +88,19 @@ app.use((req, res, next) => {
|
|||||||
// ---- FEATURE: Unified CORS middleware with preflight handling ----
|
// ---- 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, OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
@@ -113,6 +121,7 @@ app.use((req, res, next) => {
|
|||||||
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("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
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=()");
|
||||||
@@ -190,7 +199,8 @@ app.use((req, res, next) => {
|
|||||||
const ip =
|
const ip =
|
||||||
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
||||||
req.ip ||
|
req.ip ||
|
||||||
req.connection.remoteAddress;
|
req.connection?.remoteAddress ||
|
||||||
|
"unknown";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
const maxRequests = 100;
|
const maxRequests = 100;
|
||||||
@@ -211,12 +221,11 @@ app.use((req, res, next) => {
|
|||||||
|
|
||||||
timestamps.push(now);
|
timestamps.push(now);
|
||||||
|
|
||||||
// NOTE: Periodic cleanup — prune expired IP entries when map grows large
|
// NOTE: Aggressive cleanup on serverless — prune all empty entries every request
|
||||||
if (requestCounts.size > 200) {
|
// since memory is per-invocation and doesn't persist
|
||||||
|
if (requestCounts.size > 50) {
|
||||||
for (const [key, val] of requestCounts) {
|
for (const [key, val] of requestCounts) {
|
||||||
if (val.filter((t) => now - t < windowMs).length === 0) {
|
if (val.length === 0) requestCounts.delete(key);
|
||||||
requestCounts.delete(key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +268,14 @@ app.use((req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// SERVER START
|
// SERVER START (only when running standalone, not on Vercel)
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
if (!process.env.VERCEL) {
|
||||||
console.log(`[${new Date().toISOString()}] MiruroAPI v2.0.0 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)
|
|
||||||
+12
-13
@@ -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"); }
|
||||||
@@ -607,9 +608,9 @@ const getAllGenres = async () => {
|
|||||||
* @returns {Promise<Array>} Array of tag objects { name, description, category, rank, isGeneralSpoiler, isMediaSpoiler, isAdult }
|
* @returns {Promise<Array>} Array of tag objects { name, description, category, rank, isGeneralSpoiler, isMediaSpoiler, isAdult }
|
||||||
*/
|
*/
|
||||||
const getAllTags = async () => {
|
const getAllTags = async () => {
|
||||||
const gql = `query { TagCollection { name description category rank isGeneralSpoiler isMediaSpoiler isAdult } }`;
|
const gql = `query { MediaTagCollection { name description category isGeneralSpoiler isMediaSpoiler isAdult } }`;
|
||||||
const data = await anilistQuery(gql);
|
const data = await anilistQuery(gql);
|
||||||
return data.TagCollection;
|
return data.MediaTagCollection;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -660,7 +661,7 @@ const getTrendingDaily = async (page = 1, perPage = 20) => {
|
|||||||
query ($page: Int, $perPage: Int) {
|
query ($page: Int, $perPage: Int) {
|
||||||
Page(page: $page, perPage: $perPage) {
|
Page(page: $page, perPage: $perPage) {
|
||||||
pageInfo { total currentPage lastPage hasNextPage perPage }
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
media(type: ANIME, sort: TRENDING_DESC, trending: 7) {
|
media(type: ANIME, sort: TRENDING_DESC) {
|
||||||
${MEDIA_LIST_FIELDS}
|
${MEDIA_LIST_FIELDS}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,17 +682,18 @@ const getTrendingDaily = async (page = 1, perPage = 20) => {
|
|||||||
// ---- FEATURE: Weekly trending anime ----
|
// ---- FEATURE: Weekly trending anime ----
|
||||||
/**
|
/**
|
||||||
* Fetches currently trending anime (last 30 days).
|
* 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} [page=1] - Page number
|
||||||
* @param {number} [perPage=20] - Results per page
|
* @param {number} [perPage=40] - Results per page (default 40, wider than daily)
|
||||||
* @returns {Promise<object>} Paginated trending results
|
* @returns {Promise<object>} Paginated trending results
|
||||||
*/
|
*/
|
||||||
const getTrendingWeekly = async (page = 1, perPage = 20) => {
|
const getTrendingWeekly = async (page = 1, perPage = 40) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($page: Int, $perPage: Int) {
|
query ($page: Int, $perPage: Int) {
|
||||||
Page(page: $page, perPage: $perPage) {
|
Page(page: $page, perPage: $perPage) {
|
||||||
pageInfo { total currentPage lastPage hasNextPage perPage }
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
media(type: ANIME, sort: TRENDING_DESC, trending: 30) {
|
media(type: ANIME, sort: TRENDING_DESC) {
|
||||||
${MEDIA_LIST_FIELDS}
|
${MEDIA_LIST_FIELDS}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -760,7 +762,7 @@ const getAnimeByStudio = async (studioName, page = 1, perPage = 20) => {
|
|||||||
query ($name: String, $page: Int, $perPage: Int) {
|
query ($name: String, $page: Int, $perPage: Int) {
|
||||||
Studio(search: $name) {
|
Studio(search: $name) {
|
||||||
id name isAnimationStudio siteUrl
|
id name isAnimationStudio siteUrl
|
||||||
media(type: ANIME, sort: POPULARITY_DESC, page: $page, perPage: $perPage) {
|
media(sort: POPULARITY_DESC, page: $page, perPage: $perPage) {
|
||||||
pageInfo { total currentPage lastPage hasNextPage perPage }
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
nodes { ${MEDIA_LIST_FIELDS} }
|
nodes { ${MEDIA_LIST_FIELDS} }
|
||||||
}
|
}
|
||||||
@@ -806,12 +808,11 @@ const getCharacterInfo = async (id) => {
|
|||||||
image { large medium }
|
image { large medium }
|
||||||
description gender favourites siteUrl
|
description gender favourites siteUrl
|
||||||
dateOfBirth { year month day }
|
dateOfBirth { year month day }
|
||||||
dateOfDeath { year month day }
|
|
||||||
age bloodType
|
age bloodType
|
||||||
media(perPage: 25, sort: POPULARITY_DESC) {
|
media(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
pageInfo { total currentPage lastPage hasNextPage perPage }
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
edges {
|
edges {
|
||||||
role
|
characterRole
|
||||||
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -838,12 +839,10 @@ const getStaffInfo = async (id) => {
|
|||||||
image { large medium }
|
image { large medium }
|
||||||
description gender favourites siteUrl
|
description gender favourites siteUrl
|
||||||
dateOfBirth { year month day }
|
dateOfBirth { year month day }
|
||||||
dateOfDeath { year month day }
|
|
||||||
age homeTown yearsActive
|
age homeTown yearsActive
|
||||||
staffMedia(perPage: 25, sort: POPULARITY_DESC) {
|
staffMedia(perPage: 25, sort: POPULARITY_DESC) {
|
||||||
pageInfo { total currentPage lastPage hasNextPage perPage }
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
||||||
edges {
|
edges {
|
||||||
role
|
|
||||||
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
node { id title { romaji english } coverImage { large } format episodes status meanScore }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+245
-74
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,19 +452,48 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
|
|||||||
// ---- FEATURE: Extract subtitle URLs from streaming sources ----
|
// ---- FEATURE: Extract subtitle URLs from streaming sources ----
|
||||||
/**
|
/**
|
||||||
* Extracts and formats subtitle URLs from the pipe sources response.
|
* Extracts and formats subtitle URLs from the pipe sources response.
|
||||||
* Subtitles are usually in WebVTT format with language tags.
|
* 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
|
* @param {object} sources - The sources response from getSources/getWatchSources
|
||||||
* @returns {Array} Array of subtitle objects { url, label, language, format }
|
* @returns {Array} Array of subtitle objects { url, label, language, kind, format, isDefault }
|
||||||
*/
|
*/
|
||||||
const extractSubtitles = (sources) => {
|
const extractSubtitles = (sources) => {
|
||||||
const subtitles = sources.subtitles || sources.captions || [];
|
const raw = sources.subtitles || sources.captions || [];
|
||||||
return subtitles.map((sub) => ({
|
|
||||||
url: sub.url || sub.file,
|
if (!Array.isArray(raw)) return [];
|
||||||
label: sub.label || sub.name || "Unknown",
|
|
||||||
language: sub.lang || sub.language || sub.label || "en",
|
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",
|
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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -349,17 +503,22 @@ const extractSubtitles = (sources) => {
|
|||||||
// ---- FEATURE: Get best available stream with quality fallback ----
|
// ---- FEATURE: Get best available stream with quality fallback ----
|
||||||
/**
|
/**
|
||||||
* Returns the best available HLS stream with automatic quality fallback.
|
* Returns the best available HLS stream with automatic quality fallback.
|
||||||
* Tries 1080p → 720p → 360p in order.
|
* Tries 1080p → 720p → 480p → 360p in order.
|
||||||
|
* Falls back to first HLS stream if no quality match.
|
||||||
*
|
*
|
||||||
* @param {object} sources - The sources response
|
* @param {object} sources - The sources response
|
||||||
* @param {string} [preferredQuality="1080p"] - Preferred quality
|
* @param {string} [preferredQuality="1080p"] - Preferred quality
|
||||||
* @returns {object|null} Best available stream object or null
|
* @returns {object|null} Best available stream object or null
|
||||||
*/
|
*/
|
||||||
const getBestStream = (sources, preferredQuality = "1080p") => {
|
const getBestStream = (sources, preferredQuality = "1080p") => {
|
||||||
const streams = (sources.streams || []).filter((s) => s.type === "hls" && s.url);
|
const streams = (sources.streams || []).filter((s) => s.url);
|
||||||
if (streams.length === 0) return null;
|
if (streams.length === 0) return null;
|
||||||
|
|
||||||
// NOTE: Try preferred quality first, then fallback
|
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 qualityOrder = ["1080p", "720p", "480p", "360p"];
|
||||||
const startIdx = qualityOrder.indexOf(preferredQuality);
|
const startIdx = qualityOrder.indexOf(preferredQuality);
|
||||||
const ordered = startIdx >= 0
|
const ordered = startIdx >= 0
|
||||||
@@ -367,12 +526,17 @@ const getBestStream = (sources, preferredQuality = "1080p") => {
|
|||||||
: qualityOrder;
|
: qualityOrder;
|
||||||
|
|
||||||
for (const q of ordered) {
|
for (const q of ordered) {
|
||||||
const match = streams.find((s) => s.quality === q);
|
const match = usable.find((s) => {
|
||||||
|
const quality = (s.quality || s.label || "").toLowerCase();
|
||||||
|
return quality.includes(q);
|
||||||
|
});
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Return first available if no quality match
|
const active = usable.find((s) => s.isActive);
|
||||||
return streams[0];
|
if (active) return active;
|
||||||
|
|
||||||
|
return usable[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -382,23 +546,29 @@ const getBestStream = (sources, preferredQuality = "1080p") => {
|
|||||||
// ---- FEATURE: Get download URL for an episode ----
|
// ---- FEATURE: Get download URL for an episode ----
|
||||||
/**
|
/**
|
||||||
* Fetches the download URL for a specific episode from the pipe.
|
* Fetches the download URL for a specific episode from the pipe.
|
||||||
* Returns the direct download link if available.
|
* Returns the direct download link, subtitles, and best stream if available.
|
||||||
*
|
*
|
||||||
* @param {string} provider - Provider name
|
* @param {string} provider - Provider name
|
||||||
* @param {number} anilistId - The AniList anime ID
|
* @param {number} anilistId - The AniList anime ID
|
||||||
* @param {string} category - Audio category ("sub" or "dub")
|
* @param {string} category - Audio category ("sub" or "dub")
|
||||||
* @param {string} slug - Episode slug (e.g., "animepahe-1")
|
* @param {string} slug - Episode slug (e.g., "animepahe-1")
|
||||||
* @returns {Promise<object>} Object with download URL and metadata
|
* @returns {Promise<object>} Object with download URL, subtitles, best stream, and metadata
|
||||||
*/
|
*/
|
||||||
const getDownloadUrl = async (provider, anilistId, category, slug) => {
|
const getDownloadUrl = async (provider, anilistId, category, slug) => {
|
||||||
const sources = await getWatchSources(provider, anilistId, category, slug);
|
const sources = await getWatchSources(provider, anilistId, category, slug);
|
||||||
|
const subtitles = extractSubtitles(sources);
|
||||||
|
const bestStream = getBestStream(sources);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
download: sources.download || null,
|
download: sources.download || null,
|
||||||
|
subtitles,
|
||||||
|
bestStream,
|
||||||
provider,
|
provider,
|
||||||
anilistId,
|
anilistId,
|
||||||
category,
|
category,
|
||||||
slug,
|
slug,
|
||||||
hasDownload: !!sources.download,
|
hasDownload: !!sources.download,
|
||||||
|
hasSubtitles: subtitles.length > 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -465,6 +635,7 @@ module.exports = {
|
|||||||
getDownloadUrl,
|
getDownloadUrl,
|
||||||
getBatchSources,
|
getBatchSources,
|
||||||
extractSubtitles,
|
extractSubtitles,
|
||||||
|
extractSkipTimes,
|
||||||
getBestStream,
|
getBestStream,
|
||||||
encodePipeRequest,
|
encodePipeRequest,
|
||||||
decodePipeResponse,
|
decodePipeResponse,
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ const addCreatorInfo = (req, res, next) => {
|
|||||||
if (typeof data === "object" && data !== null && !data.creator) {
|
if (typeof data === "object" && data !== null && !data.creator) {
|
||||||
data = {
|
data = {
|
||||||
...data,
|
...data,
|
||||||
...creatorInfo,
|
creator: creatorInfo.creator,
|
||||||
|
github: creatorInfo.github,
|
||||||
|
telegram: creatorInfo.telegram,
|
||||||
timestamp: new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", hour12: true }),
|
timestamp: new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", hour12: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+248
-42
@@ -23,6 +23,18 @@ const pipe = require("../helpers/pipe");
|
|||||||
const { getCached, setCache } = require("../helpers/cache");
|
const { getCached, setCache } = require("../helpers/cache");
|
||||||
const { addCreatorInfo } = require("../middleware/creatorInfo");
|
const { addCreatorInfo } = require("../middleware/creatorInfo");
|
||||||
|
|
||||||
|
// ---- FEATURE: Input sanitization helpers ----
|
||||||
|
const sanitizeId = (val) => {
|
||||||
|
const n = parseInt(val);
|
||||||
|
return isNaN(n) || n <= 0 ? null : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeString = (val, maxLen = 100) => {
|
||||||
|
if (typeof val !== "string") return null;
|
||||||
|
const cleaned = val.replace(/[<>"'`;\\]/g, "").trim();
|
||||||
|
return cleaned.length > 0 && cleaned.length <= maxLen ? cleaned : null;
|
||||||
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
// ROUTE REGISTRATION
|
// ROUTE REGISTRATION
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -116,16 +128,10 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Multi-parameter search (query + filters combined) ----
|
// ---- FEATURE: Multi-parameter search (query + filters combined) ----
|
||||||
router.get("/multi-search", async (req, res) => {
|
router.get("/multi-search", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q, genre, tag, year, season, format, status, sort = "SEARCH_MATCH", page = 1, per_page = 20 } = req.query;
|
const { q, genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
|
||||||
|
|
||||||
// NOTE: If only query is provided, use regular search
|
|
||||||
if (q && !genre && !tag && !year && !season && !format && !status) {
|
|
||||||
const result = await anilist.searchAnime(q, parseInt(page), parseInt(per_page));
|
|
||||||
return jsonResponse(res, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: If filters only, use filter endpoint
|
|
||||||
const result = await anilist.filterAnime({
|
const result = await anilist.filterAnime({
|
||||||
|
search: q || null,
|
||||||
genre, tag, year: year ? parseInt(year) : null,
|
genre, tag, year: year ? parseInt(year) : null,
|
||||||
season, format, status, sort,
|
season, format, status, sort,
|
||||||
page: parseInt(page), perPage: parseInt(per_page),
|
page: parseInt(page), perPage: parseInt(per_page),
|
||||||
@@ -320,8 +326,10 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Random anime of the day ----
|
// ---- FEATURE: Random anime of the day ----
|
||||||
router.get("/random", async (req, res) => {
|
router.get("/random", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// NOTE: Get random page from trending to ensure variety
|
// NOTE: Disable CDN cache + use request timestamp for true randomness
|
||||||
const randomPage = Math.floor(Math.random() * 50) + 1;
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
||||||
|
res.setHeader("Pragma", "no-cache");
|
||||||
|
const randomPage = (Date.now() % 499) + 1;
|
||||||
const data = await anilist.getCollection("POPULARITY_DESC", null, randomPage, 1);
|
const data = await anilist.getCollection("POPULARITY_DESC", null, randomPage, 1);
|
||||||
const anime = data.results[0];
|
const anime = data.results[0];
|
||||||
|
|
||||||
@@ -339,13 +347,15 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Seasonal anime by year and season ----
|
// ---- FEATURE: Seasonal anime by year and season ----
|
||||||
router.get("/season/:year/:season", async (req, res) => {
|
router.get("/season/:year/:season", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { year, season } = req.params;
|
const year = sanitizeId(req.params.year);
|
||||||
|
const season = sanitizeString(req.params.season?.toUpperCase());
|
||||||
|
if (!year || !season) return jsonError(res, "Invalid year or season", 400);
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `season:${year}:${season}:${page}:${per_page}`;
|
const cacheKey = `season:${year}:${season}:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getSeasonalAnime(parseInt(year), season, parseInt(page), parseInt(per_page));
|
result = await anilist.getSeasonalAnime(year, season, parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 300 * 1000);
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,13 +368,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Anime by studio name ----
|
// ---- FEATURE: Anime by studio name ----
|
||||||
router.get("/studio/:name", async (req, res) => {
|
router.get("/studio/:name", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const name = sanitizeString(decodeURIComponent(req.params.name));
|
||||||
|
if (!name) return jsonError(res, "Invalid studio name", 400);
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `studio:${name}:${page}:${per_page}`;
|
const cacheKey = `studio:${name}:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getAnimeByStudio(decodeURIComponent(name), parseInt(page), parseInt(per_page));
|
result = await anilist.getAnimeByStudio(name, parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 300 * 1000);
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,13 +424,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Anime by genre name ----
|
// ---- FEATURE: Anime by genre name ----
|
||||||
router.get("/genre/:name", async (req, res) => {
|
router.get("/genre/:name", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const name = sanitizeString(decodeURIComponent(req.params.name));
|
||||||
|
if (!name) return jsonError(res, "Invalid genre name", 400);
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `genre:${name}:${page}:${per_page}`;
|
const cacheKey = `genre:${name}:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.filterAnime({ genre: decodeURIComponent(name), page: parseInt(page), perPage: parseInt(per_page) });
|
result = await anilist.filterAnime({ genre: name, page: parseInt(page), perPage: parseInt(per_page) });
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,13 +444,14 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Anime by year ----
|
// ---- FEATURE: Anime by year ----
|
||||||
router.get("/year/:year", async (req, res) => {
|
router.get("/year/:year", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { year } = req.params;
|
const year = sanitizeId(req.params.year);
|
||||||
|
if (!year) return jsonError(res, "Invalid year", 400);
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `year:${year}:${page}:${per_page}`;
|
const cacheKey = `year:${year}:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.filterAnime({ year: parseInt(year), page: parseInt(page), perPage: parseInt(per_page) });
|
result = await anilist.filterAnime({ year, page: parseInt(page), perPage: parseInt(per_page) });
|
||||||
setCache(cacheKey, result, 300 * 1000);
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +464,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Anime by status ----
|
// ---- FEATURE: Anime by status ----
|
||||||
router.get("/status/:status", async (req, res) => {
|
router.get("/status/:status", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status } = req.params;
|
const status = sanitizeString(req.params.status?.toUpperCase());
|
||||||
|
if (!status) return jsonError(res, "Invalid status", 400);
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `status:${status}:${page}:${per_page}`;
|
const cacheKey = `status:${status}:${page}:${per_page}`;
|
||||||
|
|
||||||
@@ -470,7 +484,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Anime by format ----
|
// ---- FEATURE: Anime by format ----
|
||||||
router.get("/format/:format", async (req, res) => {
|
router.get("/format/:format", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { format } = req.params;
|
const format = sanitizeString(req.params.format?.toUpperCase());
|
||||||
|
if (!format) return jsonError(res, "Invalid format", 400);
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `format:${format}:${page}:${per_page}`;
|
const cacheKey = `format:${format}:${page}:${per_page}`;
|
||||||
|
|
||||||
@@ -493,8 +508,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ---- FEATURE: Character detail with anime list ----
|
// ---- FEATURE: Character detail with anime list ----
|
||||||
router.get("/character/:id", async (req, res) => {
|
router.get("/character/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = sanitizeId(req.params.id);
|
||||||
if (isNaN(id)) return jsonError(res, "Invalid character ID", 400);
|
if (!id) return jsonError(res, "Invalid character ID", 400);
|
||||||
|
|
||||||
const cacheKey = `character:${id}`;
|
const cacheKey = `character:${id}`;
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
@@ -505,15 +520,16 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
|
|
||||||
jsonResponse(res, result);
|
jsonResponse(res, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
jsonError(res, err.message);
|
const status = err.message.includes("not found") || err.message.includes("404") ? 404 : 500;
|
||||||
|
jsonError(res, err.message, status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Staff detail with anime list ----
|
// ---- FEATURE: Staff detail with anime list ----
|
||||||
router.get("/staff/:id", async (req, res) => {
|
router.get("/staff/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = sanitizeId(req.params.id);
|
||||||
if (isNaN(id)) return jsonError(res, "Invalid staff ID", 400);
|
if (!id) return jsonError(res, "Invalid staff ID", 400);
|
||||||
|
|
||||||
const cacheKey = `staff:${id}`;
|
const cacheKey = `staff:${id}`;
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
@@ -524,7 +540,8 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
|
|
||||||
jsonResponse(res, result);
|
jsonResponse(res, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
jsonError(res, err.message);
|
const status = err.message.includes("not found") || err.message.includes("404") ? 404 : 500;
|
||||||
|
jsonError(res, err.message, status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -559,6 +576,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ---- FEATURE: Complete anime info by AniList ID ----
|
// ---- FEATURE: Complete anime info by AniList ID ----
|
||||||
|
// ---- FEATURE: Full anime info with metadata enrichment ----
|
||||||
router.get("/info/:id", async (req, res) => {
|
router.get("/info/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
@@ -571,7 +589,35 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
setCache(cacheKey, result, 300 * 1000);
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(res, result);
|
// NOTE: Enrich with miruro-style metadata fields
|
||||||
|
const enriched = {
|
||||||
|
...result,
|
||||||
|
alternateName: result.synonyms || [],
|
||||||
|
aggregateRating: result.averageScore ? {
|
||||||
|
ratingValue: result.averageScore,
|
||||||
|
bestRating: 100,
|
||||||
|
ratingCount: result.favourites || 0,
|
||||||
|
meanScore: result.meanScore || null,
|
||||||
|
} : null,
|
||||||
|
sameAs: (result.externalLinks || []).map((l) => l.url),
|
||||||
|
externalLinks: (result.externalLinks || []).map((l) => ({
|
||||||
|
url: l.url,
|
||||||
|
site: l.site,
|
||||||
|
type: l.type,
|
||||||
|
})),
|
||||||
|
productionCompany: (result.studios?.nodes || [])
|
||||||
|
.filter((s) => !s.isAnimationStudio)
|
||||||
|
.map((s) => ({ name: s.name, siteUrl: s.siteUrl })),
|
||||||
|
animationStudio: (result.studios?.nodes || [])
|
||||||
|
.filter((s) => s.isAnimationStudio)
|
||||||
|
.map((s) => ({ name: s.name, siteUrl: s.siteUrl })),
|
||||||
|
streamingEpisodes: result.streamingEpisodes || [],
|
||||||
|
trailer: result.trailer || null,
|
||||||
|
nextAiringEpisode: result.nextAiringEpisode || null,
|
||||||
|
tags: (result.tags || []).map((t) => t.name),
|
||||||
|
};
|
||||||
|
|
||||||
|
jsonResponse(res, enriched);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
jsonError(res, err.message);
|
jsonError(res, err.message);
|
||||||
}
|
}
|
||||||
@@ -668,6 +714,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Streaming sources with quality fallback ----
|
// ---- FEATURE: Streaming sources with quality fallback ----
|
||||||
|
// ---- FEATURE: Stream with quality fallback, subtitles, and skip times ----
|
||||||
router.get("/stream", async (req, res) => {
|
router.get("/stream", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { provider, anilistId, category = "sub", slug, quality = "1080p" } = req.query;
|
const { provider, anilistId, category = "sub", slug, quality = "1080p" } = req.query;
|
||||||
@@ -678,11 +725,13 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
const sources = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
const sources = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
||||||
const bestStream = pipe.getBestStream(sources, quality);
|
const bestStream = pipe.getBestStream(sources, quality);
|
||||||
const subtitles = pipe.extractSubtitles(sources);
|
const subtitles = pipe.extractSubtitles(sources);
|
||||||
|
const skipTimes = pipe.extractSkipTimes(sources);
|
||||||
|
|
||||||
jsonResponse(res, {
|
jsonResponse(res, {
|
||||||
...sources,
|
...sources,
|
||||||
bestStream,
|
bestStream,
|
||||||
subtitles,
|
subtitles,
|
||||||
|
skipTimes,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
jsonError(res, err.message);
|
jsonError(res, err.message);
|
||||||
@@ -708,8 +757,15 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
|
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { provider, anilistId, category, slug } = req.params;
|
const { provider, anilistId, category, slug } = req.params;
|
||||||
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
const sources = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
||||||
jsonResponse(res, result);
|
const subtitles = pipe.extractSubtitles(sources);
|
||||||
|
const skipTimes = pipe.extractSkipTimes(sources);
|
||||||
|
|
||||||
|
jsonResponse(res, {
|
||||||
|
...sources,
|
||||||
|
subtitles,
|
||||||
|
skipTimes,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
jsonError(res, err.message);
|
jsonError(res, err.message);
|
||||||
}
|
}
|
||||||
@@ -785,16 +841,20 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
const genres = await anilist.getAllGenres();
|
const genres = await anilist.getAllGenres();
|
||||||
const stats = {};
|
const stats = {};
|
||||||
|
|
||||||
// NOTE: Fetch top anime for each genre (limited to avoid rate limiting)
|
const topGenres = genres.slice(0, 10);
|
||||||
for (const genre of genres.slice(0, 10)) {
|
const results = await Promise.allSettled(
|
||||||
try {
|
topGenres.map((genre) =>
|
||||||
const data = await anilist.filterAnime({ genre, perPage: 1 });
|
anilist.filterAnime({ genre, perPage: 1 }).then((data) => ({
|
||||||
stats[genre] = {
|
genre,
|
||||||
total: data.total,
|
total: data.total,
|
||||||
topAnime: data.results[0] || null,
|
topAnime: data.results[0] || null,
|
||||||
};
|
})).catch(() => ({ genre, total: 0, topAnime: null }))
|
||||||
} catch {
|
)
|
||||||
stats[genre] = { total: 0, topAnime: null };
|
);
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "fulfilled") {
|
||||||
|
stats[r.value.genre] = { total: r.value.total, topAnime: r.value.topAnime };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +880,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// NOTE: Get airing schedule for the month
|
// NOTE: Get airing schedule for the month
|
||||||
const schedule = await anilist.getSchedule(1, 50);
|
const schedule = await anilist.getSchedule(1, 200);
|
||||||
const monthStart = new Date(y, m - 1, 1);
|
const monthStart = new Date(y, m - 1, 1);
|
||||||
const monthEnd = new Date(y, m, 0, 23, 59, 59);
|
const monthEnd = new Date(y, m, 0, 23, 59, 59);
|
||||||
|
|
||||||
@@ -907,6 +967,151 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
// HEALTH & STATS
|
// HEALTH & STATS
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Provider capabilities and configuration ----
|
||||||
|
router.get("/providers", (req, res) => {
|
||||||
|
const providers = {
|
||||||
|
kiwi: {
|
||||||
|
name: "kiwi",
|
||||||
|
visible: true,
|
||||||
|
player: "native",
|
||||||
|
parent: null,
|
||||||
|
relationship: null,
|
||||||
|
capabilities: { sub: true, ssub: false, download: true, skip_times: false, thumbnails: false },
|
||||||
|
proxy: { rotate: false, segments: true },
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
pewe: {
|
||||||
|
name: "pewe",
|
||||||
|
visible: true,
|
||||||
|
player: "native",
|
||||||
|
parent: null,
|
||||||
|
relationship: null,
|
||||||
|
capabilities: { sub: true, ssub: false, download: false, skip_times: false, thumbnails: false },
|
||||||
|
proxy: { rotate: false, segments: true },
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
bonk: {
|
||||||
|
name: "bonk",
|
||||||
|
visible: true,
|
||||||
|
player: "native",
|
||||||
|
parent: null,
|
||||||
|
relationship: null,
|
||||||
|
variantOrder: ["ssub", "sub"],
|
||||||
|
capabilities: { sub: true, ssub: true, download: true, skip_times: true, thumbnails: false },
|
||||||
|
proxy: { rotate: false, segments: false },
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
bee: {
|
||||||
|
name: "bee",
|
||||||
|
visible: true,
|
||||||
|
player: "native",
|
||||||
|
parent: null,
|
||||||
|
relationship: null,
|
||||||
|
capabilities: { sub: false, ssub: true, download: false, skip_times: false, thumbnails: false },
|
||||||
|
proxy: { rotate: false, segments: true },
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
ally: {
|
||||||
|
name: "ally",
|
||||||
|
visible: true,
|
||||||
|
player: "native",
|
||||||
|
parent: null,
|
||||||
|
relationship: null,
|
||||||
|
capabilities: { sub: true, ssub: false, download: true, skip_times: false, thumbnails: true },
|
||||||
|
proxy: false,
|
||||||
|
cors: true,
|
||||||
|
fallback: 2,
|
||||||
|
},
|
||||||
|
moo: {
|
||||||
|
name: "moo",
|
||||||
|
visible: true,
|
||||||
|
player: "native",
|
||||||
|
parent: null,
|
||||||
|
relationship: null,
|
||||||
|
capabilities: { sub: true, ssub: false, download: true, skip_times: false, thumbnails: false },
|
||||||
|
proxy: { rotate: false, segments: true },
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
hop: {
|
||||||
|
name: "hop",
|
||||||
|
visible: true,
|
||||||
|
player: "native",
|
||||||
|
parent: null,
|
||||||
|
relationship: null,
|
||||||
|
capabilities: { sub: false, ssub: true, download: false, skip_times: false, thumbnails: true },
|
||||||
|
proxy: { rotate: false, segments: true },
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
nun: {
|
||||||
|
name: "nun",
|
||||||
|
visible: true,
|
||||||
|
player: "iframe",
|
||||||
|
parent: "ally",
|
||||||
|
relationship: "embed",
|
||||||
|
capabilities: { sub: true, ssub: false, download: false, skip_times: false, thumbnails: false },
|
||||||
|
proxy: false,
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
bun: {
|
||||||
|
name: "bun",
|
||||||
|
visible: true,
|
||||||
|
player: "iframe",
|
||||||
|
parent: "bee",
|
||||||
|
relationship: "embed",
|
||||||
|
capabilities: { sub: false, ssub: true, download: false, skip_times: false, thumbnails: false },
|
||||||
|
proxy: false,
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
twin: {
|
||||||
|
name: "twin",
|
||||||
|
visible: true,
|
||||||
|
player: "iframe",
|
||||||
|
parent: "bonk",
|
||||||
|
relationship: "embed",
|
||||||
|
variantOrder: ["sub", "ssub"],
|
||||||
|
capabilities: { sub: true, ssub: true, download: false, skip_times: false, thumbnails: false },
|
||||||
|
proxy: false,
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
cog: {
|
||||||
|
name: "cog",
|
||||||
|
visible: true,
|
||||||
|
player: "iframe",
|
||||||
|
parent: "moo",
|
||||||
|
relationship: "embed",
|
||||||
|
capabilities: { sub: true, ssub: false, download: false, skip_times: false, thumbnails: false },
|
||||||
|
proxy: false,
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
telli: {
|
||||||
|
name: "telli",
|
||||||
|
visible: false,
|
||||||
|
player: "iframe",
|
||||||
|
parent: "kiwi",
|
||||||
|
relationship: "embed",
|
||||||
|
capabilities: { sub: true, ssub: false, download: false, skip_times: false, thumbnails: false },
|
||||||
|
proxy: false,
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const order = ["kiwi", "pewe", "bonk", "bee", "ally", "moo", "hop", "nun", "bun", "twin", "cog", "telli"];
|
||||||
|
|
||||||
|
jsonResponse(res, {
|
||||||
|
providers,
|
||||||
|
order,
|
||||||
|
total: order.length,
|
||||||
|
description: "Provider capabilities and configuration",
|
||||||
|
capabilities: {
|
||||||
|
sub: "External subtitles (WebVTT)",
|
||||||
|
ssub: "Soft subtitles (embedded in video stream)",
|
||||||
|
download: "Direct download URL available",
|
||||||
|
skip_times: "OP/ED skip timestamps available",
|
||||||
|
thumbnails: "Episode thumbnail images available",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- FEATURE: Health check endpoint ----
|
// ---- FEATURE: Health check endpoint ----
|
||||||
router.get("/health", (req, res) => {
|
router.get("/health", (req, res) => {
|
||||||
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
||||||
@@ -918,7 +1123,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
success: true,
|
success: true,
|
||||||
results: {
|
results: {
|
||||||
status: "healthy",
|
status: "healthy",
|
||||||
version: "2.0.0",
|
version: "2.1.4",
|
||||||
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
||||||
uptimeSeconds: uptime,
|
uptimeSeconds: uptime,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -932,7 +1137,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
size: require("../helpers/cache").getCacheSize(),
|
size: require("../helpers/cache").getCacheSize(),
|
||||||
maxSize: 100,
|
maxSize: 100,
|
||||||
},
|
},
|
||||||
endpoints: 35,
|
endpoints: 36,
|
||||||
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
|
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -960,7 +1165,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
currentSize: require("../helpers/cache").getCacheSize(),
|
currentSize: require("../helpers/cache").getCacheSize(),
|
||||||
description: "Map-based cache with TTL expiration and FIFO eviction",
|
description: "Map-based cache with TTL expiration and FIFO eviction",
|
||||||
},
|
},
|
||||||
endpoints: 35,
|
endpoints: 36,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -973,7 +1178,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
info: {
|
info: {
|
||||||
title: "MiruroAPI",
|
title: "MiruroAPI",
|
||||||
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
|
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
|
||||||
version: "2.0.0",
|
version: "2.1.4",
|
||||||
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
|
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
@@ -1020,6 +1225,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
|
|||||||
"/stats/genre": { get: { summary: "Genre statistics", tags: ["Utility"] } },
|
"/stats/genre": { get: { summary: "Genre statistics", tags: ["Utility"] } },
|
||||||
"/calendar": { get: { summary: "Monthly calendar", tags: ["Utility"] } },
|
"/calendar": { get: { summary: "Monthly calendar", tags: ["Utility"] } },
|
||||||
"/timeline/{id}": { get: { summary: "Anime timeline", tags: ["Utility"] } },
|
"/timeline/{id}": { get: { summary: "Anime timeline", tags: ["Utility"] } },
|
||||||
|
"/providers": { get: { summary: "Provider capabilities", tags: ["System"] } },
|
||||||
"/health": { get: { summary: "Health check", tags: ["System"] } },
|
"/health": { get: { summary: "Health check", tags: ["System"] } },
|
||||||
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
|
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
|
||||||
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* Repository: https://github.com/Shineii86/MiruroAPI
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Integration test suite for MiruroAPI v2.0.0 endpoints.
|
* Integration test suite for MiruroAPI v2.1.0 endpoints.
|
||||||
* Tests all 35 endpoints for correct response format.
|
* Tests all 36 endpoints for correct response format.
|
||||||
*
|
*
|
||||||
* @author Shinei Nouzen
|
* @author Shinei Nouzen
|
||||||
* @license MIT
|
* @license MIT
|
||||||
@@ -19,6 +19,7 @@ const tests = [
|
|||||||
{ name: "Health", url: "/health" },
|
{ name: "Health", url: "/health" },
|
||||||
{ name: "Stats", url: "/stats" },
|
{ name: "Stats", url: "/stats" },
|
||||||
{ name: "OpenAPI", url: "/openapi" },
|
{ name: "OpenAPI", url: "/openapi" },
|
||||||
|
{ name: "Providers", url: "/providers" },
|
||||||
|
|
||||||
// Search & Discovery
|
// Search & Discovery
|
||||||
{ name: "Search", url: "/search?query=naruto" },
|
{ name: "Search", url: "/search?query=naruto" },
|
||||||
@@ -103,7 +104,7 @@ async function runTest(test) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
console.log(`\n🧪 MiruroAPI v2.0.0 — Running ${tests.length} tests...\n`);
|
console.log(`\n🧪 MiruroAPI v2.1.4 — Running ${tests.length} tests...\n`);
|
||||||
|
|
||||||
for (const test of tests) {
|
for (const test of tests) {
|
||||||
await runTest(test);
|
await runTest(test);
|
||||||
|
|||||||
+32
-4
@@ -1,8 +1,36 @@
|
|||||||
{
|
{
|
||||||
"functions": {
|
"version": 2,
|
||||||
"api/index.js": {
|
"builds": [
|
||||||
"includeFiles": "src/**",
|
{
|
||||||
"maxDuration": 30
|
"src": "api/index.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "api/proxy.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"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=()" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user