2 Commits
6 changed files with 259 additions and 13 deletions
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## 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
+2 -2
View File
@@ -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/Vercel-Serverless-000000?style=flat-square&logo=vercel&logoColor=white" alt="Vercel"/>
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
<img src="https://img.shields.io/badge/Version-2.0.1-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/Version-2.1.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
<img src="https://img.shields.io/badge/Endpoints-36-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
<img src="https://img.shields.io/badge/Providers-12-a855f7?style=flat-square&logoColor=white" alt="Providers"/>
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "miruro-api",
"version": "2.0.1",
"version": "2.1.0",
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
"main": "server.js",
"scripts": {
+22
View File
@@ -339,6 +339,8 @@ const getWatchSources = async (provider, anilistId, category, slug) => {
const extractSubtitles = (sources) => {
const raw = sources.subtitles || sources.captions || [];
if (!Array.isArray(raw)) return [];
return raw.map((sub) => ({
url: sub.file || sub.url || null,
label: sub.label || sub.name || sub.language || "Unknown",
@@ -350,6 +352,25 @@ const extractSubtitles = (sources) => {
})).filter((sub) => sub.url);
};
// ---- FEATURE: Extract skip timestamps (OP/ED) from sources ----
/**
* Extracts skip timestamps (OP/ED) from the pipe sources response.
* Only available for providers with skip_times capability (e.g., bonk).
*
* @param {object} sources - The sources response from getSources/getWatchSources
* @returns {object|null} Skip timestamps object or null
*/
const extractSkipTimes = (sources) => {
const skipTimes = sources.skipTimes || sources.skip || null;
if (!skipTimes || typeof skipTimes !== "object") return null;
return {
intro: skipTimes.intro || skipTimes.op || null,
outro: skipTimes.outro || skipTimes.ed || null,
preview: skipTimes.preview || null,
};
};
// ══════════════════════════════════════════════════════════════
// QUALITY FALLBACK
// ══════════════════════════════════════════════════════════════
@@ -483,6 +504,7 @@ module.exports = {
getDownloadUrl,
getBatchSources,
extractSubtitles,
extractSkipTimes,
getBestStream,
encodePipeRequest,
decodePipeResponse,
+192 -7
View File
@@ -559,6 +559,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Complete anime info by AniList ID ----
// ---- FEATURE: Full anime info with metadata enrichment ----
router.get("/info/:id", async (req, res) => {
try {
const id = parseInt(req.params.id);
@@ -571,7 +572,35 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
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) {
jsonError(res, err.message);
}
@@ -668,6 +697,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
});
// ---- FEATURE: Streaming sources with quality fallback ----
// ---- FEATURE: Stream with quality fallback, subtitles, and skip times ----
router.get("/stream", async (req, res) => {
try {
const { provider, anilistId, category = "sub", slug, quality = "1080p" } = req.query;
@@ -678,11 +708,13 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
const sources = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
const bestStream = pipe.getBestStream(sources, quality);
const subtitles = pipe.extractSubtitles(sources);
const skipTimes = pipe.extractSkipTimes(sources);
jsonResponse(res, {
...sources,
bestStream,
subtitles,
skipTimes,
});
} catch (err) {
jsonError(res, err.message);
@@ -708,8 +740,15 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => {
try {
const { provider, anilistId, category, slug } = req.params;
const result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
jsonResponse(res, result);
const sources = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
const subtitles = pipe.extractSubtitles(sources);
const skipTimes = pipe.extractSkipTimes(sources);
jsonResponse(res, {
...sources,
subtitles,
skipTimes,
});
} catch (err) {
jsonError(res, err.message);
}
@@ -907,6 +946,151 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
// 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 ----
router.get("/health", (req, res) => {
const uptime = Math.floor((Date.now() - startTime) / 1000);
@@ -918,7 +1102,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
success: true,
results: {
status: "healthy",
version: "2.0.0",
version: "2.1.0",
uptime: `${hours}h ${minutes}m ${seconds}s`,
uptimeSeconds: uptime,
timestamp: new Date().toISOString(),
@@ -932,7 +1116,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
size: require("../helpers/cache").getCacheSize(),
maxSize: 100,
},
endpoints: 35,
endpoints: 36,
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
},
});
@@ -960,7 +1144,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
currentSize: require("../helpers/cache").getCacheSize(),
description: "Map-based cache with TTL expiration and FIFO eviction",
},
endpoints: 35,
endpoints: 36,
timestamp: new Date().toISOString(),
},
});
@@ -973,7 +1157,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
info: {
title: "MiruroAPI",
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
version: "2.0.0",
version: "2.1.0",
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
},
servers: [
@@ -1020,6 +1204,7 @@ const createApiRoutes = (jsonResponse, jsonError, startTime) => {
"/stats/genre": { get: { summary: "Genre statistics", tags: ["Utility"] } },
"/calendar": { get: { summary: "Monthly calendar", tags: ["Utility"] } },
"/timeline/{id}": { get: { summary: "Anime timeline", tags: ["Utility"] } },
"/providers": { get: { summary: "Provider capabilities", tags: ["System"] } },
"/health": { get: { summary: "Health check", tags: ["System"] } },
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
+4 -3
View File
@@ -4,8 +4,8 @@
* Repository: https://github.com/Shineii86/MiruroAPI
*
* @description
* Integration test suite for MiruroAPI v2.0.1 endpoints.
* Tests all 35 endpoints for correct response format.
* Integration test suite for MiruroAPI v2.1.0 endpoints.
* Tests all 36 endpoints for correct response format.
*
* @author Shinei Nouzen
* @license MIT
@@ -19,6 +19,7 @@ const tests = [
{ name: "Health", url: "/health" },
{ name: "Stats", url: "/stats" },
{ name: "OpenAPI", url: "/openapi" },
{ name: "Providers", url: "/providers" },
// Search & Discovery
{ name: "Search", url: "/search?query=naruto" },
@@ -103,7 +104,7 @@ async function runTest(test) {
}
async function runAll() {
console.log(`\n🧪 MiruroAPI v2.0.1 — Running ${tests.length} tests...\n`);
console.log(`\n🧪 MiruroAPI v2.1.0 — Running ${tests.length} tests...\n`);
for (const test of tests) {
await runTest(test);