Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1433afdca1 | ||
|
|
b73d8d09bf | ||
|
|
99d7c15750 | ||
|
|
faa4888f77 | ||
|
|
a1cc22e84a | ||
|
|
d2193670f4 | ||
|
|
20041a8984 | ||
|
|
14d5c22fc7 | ||
|
|
240e90fe51 | ||
|
|
18a73c324e | ||
|
|
4b083a9bf7 | ||
|
|
b0aa5d2281 | ||
|
|
c13e0bbd7e |
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.vercel
|
||||||
|
.git
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Origins you want to allow
|
||||||
|
ALLOWED_ORIGINS=https://site1.com,https://site2.com
|
||||||
|
|
||||||
|
# Port (optional, defaults to 3000)
|
||||||
|
PORT=3000
|
||||||
@@ -1,5 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.3.0
|
||||||
|
- Full landing page upgrade to match AniKotoAPI style
|
||||||
|
- Added canvas particle effects with cyan-colored floating particles
|
||||||
|
- Added scroll-reveal animations (IntersectionObserver)
|
||||||
|
- Added animated stat counters that count up on scroll
|
||||||
|
- Added infinite scrolling marquee with 8 feature badges
|
||||||
|
- Added live interactive API console with 5 preset endpoint buttons
|
||||||
|
- Added sidebar endpoint explorer with 16 endpoints and multi-language code tabs (cURL, JavaScript, Python)
|
||||||
|
- Added 4 interactive playground cards (Search, Info, Episodes, Trending)
|
||||||
|
- Added custom scrollbar styling
|
||||||
|
- Added JetBrains Mono font for code blocks
|
||||||
|
- Added 4-column grid footer with API, Resources, Legal sections
|
||||||
|
- Added fixed header with scroll-aware blur/glass effect
|
||||||
|
- Added ambient orb gradient animations
|
||||||
|
- Added CTA section with gradient overlay
|
||||||
|
- Color scheme updated to match MiruroAPI brand (cyan #38bdf8 + indigo #818cf8)
|
||||||
|
- All branding updated: AniKotoAPI references → MiruroAPI
|
||||||
|
- OG/Twitter meta tags updated for mirurotvapi.vercel.app
|
||||||
|
- JSON-LD structured data updated with image, category, programmingModel
|
||||||
|
- Real Miruro SVG favicons used in header and footer
|
||||||
|
|
||||||
|
## v1.2.0
|
||||||
|
- Fixed critical response format bug: all endpoints now return {success: true, results: data}
|
||||||
|
- Root cause: createApiRoutes() had (app, jsonResponse, jsonError) but was called with (jsonResponse, jsonError), shifting all params
|
||||||
|
- Every jsonResponse() call was actually calling jsonError(), returning {success: false, message: data}
|
||||||
|
- Health endpoint worked because it used res.json() directly, bypassing the wrapper
|
||||||
|
- All 18 endpoints verified working: health, stats, search, suggestions, filter, trending, popular, upcoming, recent, spotlight, schedule, info, characters, relations, recommendations, episodes, watch
|
||||||
|
|
||||||
|
## v1.1.0
|
||||||
|
- Added Swagger UI interactive docs at /docs
|
||||||
|
- Added OpenAPI 3.0 spec at /openapi.json
|
||||||
|
- Added mappings field in episodes response (anilistId, malId, kitsuId)
|
||||||
|
- Added Docker support (Dockerfile + .dockerignore)
|
||||||
|
- Improved landing page with premium design (Walter-style)
|
||||||
|
- Added "Try it" buttons on all endpoints
|
||||||
|
- Health endpoint now includes version, providers list, and endpoint count
|
||||||
|
- Version bump to 1.1.0
|
||||||
|
|
||||||
## v1.0.0
|
## v1.0.0
|
||||||
- Initial release
|
- Initial release
|
||||||
- AniList GraphQL integration for anime metadata
|
- AniList GraphQL integration for anime metadata
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Contributing to MiruroAPI
|
||||||
|
|
||||||
|
Thank you for your interest in contributing! Here's how you can help.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/MiruroAPI.git`
|
||||||
|
3. Create a branch: `git checkout -b feature/amazing-feature`
|
||||||
|
4. Make your changes
|
||||||
|
5. Commit: `git commit -m 'feat: add amazing feature'`
|
||||||
|
6. Push: `git push origin feature/amazing-feature`
|
||||||
|
7. Open a Pull Request
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Server runs on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
MiruroAPI/
|
||||||
|
├── server.js # Express entry point
|
||||||
|
├── src/
|
||||||
|
│ ├── helpers/ # AniList, pipe, cache integrations
|
||||||
|
│ └── routes/ # Express routes
|
||||||
|
├── public/ # Static files (landing page, Swagger UI)
|
||||||
|
└── docs/ # API documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a New Endpoint
|
||||||
|
|
||||||
|
1. Create helper in `src/helpers/yourHelper.js` if needed
|
||||||
|
2. Add route in `src/routes/apiRoutes.js`
|
||||||
|
3. Update `docs/endpoints.md` with documentation
|
||||||
|
4. Test with `curl http://localhost:3000/api/your-endpoint`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Use CommonJS (`require`/`module.exports`)
|
||||||
|
- Add JSDoc comments for new functions
|
||||||
|
- Follow existing patterns in the codebase
|
||||||
|
- Keep helpers focused on one task
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
- `feat:` new feature
|
||||||
|
- `fix:` bug fix
|
||||||
|
- `docs:` documentation changes
|
||||||
|
- `refactor:` code refactoring
|
||||||
|
- `test:` adding tests
|
||||||
|
- `chore:` maintenance
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
- Keep PRs focused on one feature or fix
|
||||||
|
- Update CHANGELOG.md with your changes
|
||||||
|
- Test your changes locally before submitting
|
||||||
|
- Follow existing code style
|
||||||
|
|
||||||
|
## Reporting Issues
|
||||||
|
|
||||||
|
- Use GitHub Issues for bug reports
|
||||||
|
- Include steps to reproduce
|
||||||
|
- Include expected vs actual behavior
|
||||||
|
- Include your Node.js version
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Miruro Assets
|
||||||
|
|
||||||
|
Scraped from official Miruro domains for MiruroAPI project.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- **Official**: https://www.miruro.com (primary hub)
|
||||||
|
- **Streaming**: https://miruro.tv, https://miruro.to, https://miruro.bz, https://miruro.ru
|
||||||
|
- **Status**: https://status.miruro.com/status/miruro
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/
|
||||||
|
├── favicons/ # Favicons and app icons
|
||||||
|
│ ├── icon-dark-1024x1024.svg # Dark mode SVG favicon
|
||||||
|
│ ├── icon-light-1024x1024.svg # Light mode SVG favicon
|
||||||
|
│ ├── icon-512x512.png # Main app icon (PNG)
|
||||||
|
│ ├── favicon.ico # Classic ICO favicon
|
||||||
|
│ ├── apple-touch-icon-180x180.png # iOS home screen icon
|
||||||
|
│ └── maskable-icon-512x512.png # Android adaptive icon / OG image
|
||||||
|
├── logos/
|
||||||
|
│ └── status-logo.png # Status page logo
|
||||||
|
├── fonts/ # Inter + FontAwesome subset
|
||||||
|
│ ├── inter.woff2
|
||||||
|
│ ├── fa-brands-400.woff2
|
||||||
|
│ ├── fa-solid-900.woff2
|
||||||
|
│ └── fa-minimal.css
|
||||||
|
└── media/ # Testimonial avatars from homepage
|
||||||
|
└── *.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Social Links
|
||||||
|
|
||||||
|
- Twitter: https://twitter.com/miruro_official
|
||||||
|
- Discord: https://discord.com/invite/pjsdJp96mY
|
||||||
|
- Reddit: https://reddit.com/r/miruro
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All streaming domains (tv, to, bz, ru) use identical assets
|
||||||
|
- SVG favicons support dark/light mode via `prefers-color-scheme`
|
||||||
|
- OG image is `maskable-icon-512x512.png`
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#ffffff;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#000000;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
/*!
|
||||||
|
* Font Awesome Free 6.4.0 - Minimal subset for miruro hub
|
||||||
|
* License: https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||||
|
*/
|
||||||
|
@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(fa-solid-900.woff2) format("woff2")}
|
||||||
|
@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(fa-brands-400.woff2) format("woff2")}
|
||||||
|
.fa,.fab,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}
|
||||||
|
.fas{font-family:"Font Awesome 6 Free";font-weight:900}
|
||||||
|
.fab{font-family:"Font Awesome 6 Brands";font-weight:400}
|
||||||
|
.fa-arrow-right:before{content:"\f08b"}
|
||||||
|
.fa-arrow-up:before{content:"\f176"}
|
||||||
|
.fa-bars:before{content:"\f0c9"}
|
||||||
|
.fa-chart-bar:before{content:"\f080"}
|
||||||
|
.fa-check-circle:before{content:"\f058"}
|
||||||
|
.fa-globe:before{content:"\f57c"}
|
||||||
|
.fa-language:before{content:"\f1ab"}
|
||||||
|
.fa-play-circle:before{content:"\f144"}
|
||||||
|
.fa-server:before{content:"\f233"}
|
||||||
|
.fa-shield-alt:before{content:"\f3ed"}
|
||||||
|
.fa-star:before{content:"\f699"}
|
||||||
|
.fa-times:before{content:"\f00d"}
|
||||||
|
.fa-users:before{content:"\e594"}
|
||||||
|
.fa-discord:before{content:"\f392"}
|
||||||
|
.fa-reddit:before{content:"\f1a2"}
|
||||||
|
.fa-twitter:before{content:"\f081"}
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,190 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
MiruroAPI/
|
||||||
|
├── server.js # Express entry point, port 3000
|
||||||
|
├── package.json # name: "miruro-api"
|
||||||
|
├── vercel.json # Routes /api/* and /* to server.js
|
||||||
|
├── Dockerfile # Docker support (node:20-alpine)
|
||||||
|
├── .dockerignore # Docker ignore file
|
||||||
|
│
|
||||||
|
├── public/ # Static files served from process.cwd()
|
||||||
|
│ ├── index.html # Premium landing page (SVG icons, live console)
|
||||||
|
│ ├── docs.html # Swagger UI interactive documentation
|
||||||
|
│ ├── openapi.json # OpenAPI 3.0 spec
|
||||||
|
│ ├── 404.html # Glitch animation error page
|
||||||
|
│ ├── manifest.json # PWA manifest (theme: #A855F7)
|
||||||
|
│ ├── robots.txt # Crawler directives
|
||||||
|
│ ├── sitemap.xml # 4 pages (/, /tos, /privacy, /api)
|
||||||
|
│ ├── icon-dark.svg # Miruro dark mode SVG icon
|
||||||
|
│ ├── icon-light.svg # Miruro light mode SVG icon
|
||||||
|
│ ├── icon-512x512.png # Miruro app icon
|
||||||
|
│ ├── favicon.ico # Classic favicon
|
||||||
|
│ ├── apple-touch-icon-180x180.png # iOS home screen icon
|
||||||
|
│ ├── og-image.png # OG/Twitter share image
|
||||||
|
│ ├── privacy.html # Privacy policy (served at /privacy)
|
||||||
|
│ └── tos.html # Terms of service (served at /tos)
|
||||||
|
│
|
||||||
|
├── assets/ # Scraped Miruro assets
|
||||||
|
│ ├── favicons/ # All favicon variants
|
||||||
|
│ ├── logos/ # Status page logo
|
||||||
|
│ ├── fonts/ # Inter + FontAwesome
|
||||||
|
│ └── media/ # Testimonial avatars
|
||||||
|
│
|
||||||
|
├── docs/ # API documentation
|
||||||
|
│ ├── index.md # Overview, quick start, features
|
||||||
|
│ ├── endpoints.md # Full API reference (18 endpoints)
|
||||||
|
│ ├── streaming.md # Streaming flow guide (3-step)
|
||||||
|
│ ├── examples.md # cURL, JavaScript, Python
|
||||||
|
│ └── architecture.md # This file
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ ├── helpers/
|
||||||
|
│ │ ├── anilist.js # AniList GraphQL integration
|
||||||
|
│ │ ├── pipe.js # Miruro pipe integration
|
||||||
|
│ │ └── cache.js # In-memory Map cache with TTL
|
||||||
|
│ │
|
||||||
|
│ └── routes/
|
||||||
|
│ └── apiRoutes.js # All route definitions
|
||||||
|
│
|
||||||
|
├── test.js # Integration test suite
|
||||||
|
├── CHANGELOG.md # Version history
|
||||||
|
├── LICENSE # MIT License
|
||||||
|
└── README.md # Full API documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|------------|
|
||||||
|
| Runtime | Node.js |
|
||||||
|
| Framework | Express.js |
|
||||||
|
| Metadata | AniList GraphQL (`graphql.anilist.co`) |
|
||||||
|
| Streaming | Miruro pipe (`miruro.tv/api/secure/pipe`) |
|
||||||
|
| Deployment | Vercel (Serverless) |
|
||||||
|
| Caching | In-memory Map with 1-5 min TTL |
|
||||||
|
| Static Files | Express static middleware |
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Request
|
||||||
|
↓
|
||||||
|
Vercel Routes (/api/* → server.js)
|
||||||
|
↓
|
||||||
|
Express Router (apiRoutes.js)
|
||||||
|
↓
|
||||||
|
Helper (e.g., anilist.js or pipe.js)
|
||||||
|
↓
|
||||||
|
HTTP Request to AniList GraphQL / Miruro pipe
|
||||||
|
↓
|
||||||
|
Response decoded (base64url + gzip for pipe)
|
||||||
|
↓
|
||||||
|
Returns structured JSON
|
||||||
|
↓
|
||||||
|
Client Response
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/episodes/:anilistId
|
||||||
|
↓
|
||||||
|
Returns: providers with sub/dub episode lists
|
||||||
|
↓
|
||||||
|
/api/watch/:provider/:anilistId/:category/:slug
|
||||||
|
↓
|
||||||
|
Returns: streams[] with M3U8 URLs, quality, codec, fansub
|
||||||
|
↓
|
||||||
|
Play M3U8 in HLS player (hls.js, video.js, native)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching Strategy
|
||||||
|
|
||||||
|
- **Type:** In-memory Map
|
||||||
|
- **TTL:** 1-5 minutes depending on endpoint
|
||||||
|
- **Max Size:** 100 entries with FIFO eviction
|
||||||
|
- **Key:** Full request URL
|
||||||
|
- **Behavior:** First request fetches from source, subsequent requests served from cache
|
||||||
|
- **Eviction:** Automatic when TTL expires or max size reached
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
### AniList GraphQL
|
||||||
|
|
||||||
|
- **Endpoint:** `https://graphql.anilist.co`
|
||||||
|
- **Data:** Search, suggestions, info, characters, relations, recommendations, filter, schedule
|
||||||
|
- **Format:** Standard GraphQL queries
|
||||||
|
|
||||||
|
### Miruro Pipe
|
||||||
|
|
||||||
|
- **Endpoint:** `https://miruro.tv/api/secure/pipe`
|
||||||
|
- **Data:** Episodes, streaming sources (M3U8 URLs)
|
||||||
|
- **Encoding:** Base64url + gzip compressed request/response
|
||||||
|
- **Providers:** kiwi, pewe, bee, bonk, bun, ally, nun, twin, cog, moo, hop, telli
|
||||||
|
|
||||||
|
## Vercel Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"src": "server.js",
|
||||||
|
"use": "@vercel/node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/api/(.*)",
|
||||||
|
"dest": "server.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/(.*)",
|
||||||
|
"dest": "server.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/api/*` routes to Express for API handling
|
||||||
|
- `/*` routes to Express for static file serving
|
||||||
|
- Static files served from `process.cwd()` (Vercel serverless compatible)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PORT` | `3000` | Server port (Express mode only) |
|
||||||
|
| `ALLOWED_ORIGINS` | `*` | Comma-separated allowed origins |
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
All API responses follow this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Error description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| express | ^4.21.0 | Web framework |
|
||||||
|
| axios | ^1.8.0 | HTTP requests |
|
||||||
|
| cors | ^2.8.5 | CORS headers |
|
||||||
|
| dotenv | ^16.4.0 | Environment variables |
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
# API Endpoints Reference
|
||||||
|
|
||||||
|
Complete documentation for all 18 MiruroAPI endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Health Check](#health-check)
|
||||||
|
- [Stats](#stats)
|
||||||
|
- [Search](#search)
|
||||||
|
- [Suggestions](#suggestions)
|
||||||
|
- [Filter](#filter)
|
||||||
|
- [Trending](#trending)
|
||||||
|
- [Popular](#popular)
|
||||||
|
- [Upcoming](#upcoming)
|
||||||
|
- [Recent](#recent)
|
||||||
|
- [Spotlight](#spotlight)
|
||||||
|
- [Schedule](#schedule)
|
||||||
|
- [Anime Info](#anime-info)
|
||||||
|
- [Characters](#characters)
|
||||||
|
- [Relations](#relations)
|
||||||
|
- [Recommendations](#recommendations)
|
||||||
|
- [Episodes](#episodes)
|
||||||
|
- [Watch (Streaming)](#watch-streaming)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns API health status, version, uptime, memory usage, and available providers.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"uptime": "0h 0m 34s",
|
||||||
|
"node": "v24.14.1",
|
||||||
|
"memory": { "used": "13MB", "total": "15MB" },
|
||||||
|
"endpoints": 16,
|
||||||
|
"providers": ["kiwi","pewe","bee","bonk","bun","ally","nun","twin","cog","moo","hop","telli"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns request statistics, cache info, and memory usage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/search
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `query` | string | Yes | — | Search keyword |
|
||||||
|
| `page` | number | No | 1 | Page number |
|
||||||
|
| `per_page` | number | No | 20 | Results per page |
|
||||||
|
|
||||||
|
**Example:** `GET /api/search?query=naruto&per_page=2`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"page": 1,
|
||||||
|
"perPage": 2,
|
||||||
|
"total": 5000,
|
||||||
|
"hasNextPage": true,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"title": { "romaji": "NARUTO", "english": "Naruto", "native": "NARUTO -ナルト-" },
|
||||||
|
"coverImage": { "large": "https://s4.anilist.co/file/..." },
|
||||||
|
"format": "TV",
|
||||||
|
"episodes": 220,
|
||||||
|
"status": "FINISHED",
|
||||||
|
"averageScore": 80,
|
||||||
|
"genres": ["Action","Adventure","Comedy","Drama","Fantasy","Supernatural"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggestions
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/suggestions
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `query` | string | Yes | Search keyword |
|
||||||
|
|
||||||
|
**Example:** `GET /api/suggestions?query=naruto`
|
||||||
|
|
||||||
|
Returns fast autocomplete suggestions with title, poster, format, status, year, and episode count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/filter
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `genre` | string | No | — | Genre name (e.g. "Action") |
|
||||||
|
| `tag` | string | No | — | Tag name |
|
||||||
|
| `year` | number | No | — | Release year |
|
||||||
|
| `season` | string | No | — | FALL, WINTER, SPRING, SUMMER |
|
||||||
|
| `format` | string | No | — | TV, MOVIE, OVA, ONA, SPECIAL, MUSIC |
|
||||||
|
| `status` | string | No | — | RELEASING, FINISHED, NOT_YET_RELEASED, CANCELLED |
|
||||||
|
| `sort` | string | No | POPULARITY_DESC | Sort order |
|
||||||
|
| `page` | number | No | 1 | Page number |
|
||||||
|
| `per_page` | number | No | 20 | Results per page |
|
||||||
|
|
||||||
|
**Example:** `GET /api/filter?genre=Action&year=2024&season=WINTER&per_page=3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trending
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/trending
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `per_page` | number | No | 20 | Results per page |
|
||||||
|
|
||||||
|
**Example:** `GET /api/trending?per_page=5`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Popular
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/popular
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `per_page` | number | No | 20 | Results per page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upcoming
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/upcoming
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `per_page` | number | No | 20 | Results per page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/recent
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `per_page` | number | No | 20 | Results per page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spotlight
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/spotlight
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns featured/spotlight anime for the hero carousel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schedule
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Description |
|
||||||
|
|-------|------|----------|---------|-------------|
|
||||||
|
| `date` | string | No | today | Date in YYYY-MM-DD format |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anime Info
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/info/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | number | Yes | AniList anime ID |
|
||||||
|
|
||||||
|
**Example:** `GET /api/info/20`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"id": 20,
|
||||||
|
"idMal": 20,
|
||||||
|
"title": { "romaji": "NARUTO", "english": "Naruto", "native": "NARUTO -ナルト-" },
|
||||||
|
"description": "Naruto Uzumaki, a hyperactive and knuckle-headed ninja...",
|
||||||
|
"coverImage": { "large": "https://s4.anilist.co/file/..." },
|
||||||
|
"format": "TV",
|
||||||
|
"episodes": 220,
|
||||||
|
"duration": 23,
|
||||||
|
"status": "FINISHED",
|
||||||
|
"averageScore": 80,
|
||||||
|
"genres": ["Action","Adventure","Comedy","Drama","Fantasy","Supernatural"],
|
||||||
|
"studios": [{ "name": "Studio Pierrot", "isAnimationStudio": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Characters
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/anime/:id/characters
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | number | Yes | AniList anime ID |
|
||||||
|
|
||||||
|
**Example:** `GET /api/anime/20/characters`
|
||||||
|
|
||||||
|
Returns characters with voice actors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/anime/:id/relations
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | number | Yes | AniList anime ID |
|
||||||
|
|
||||||
|
Returns related anime (sequels, prequels, side stories, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/anime/:id/recommendations
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | number | Yes | AniList anime ID |
|
||||||
|
|
||||||
|
Returns recommended anime based on the given ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Episodes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/episodes/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | number | Yes | AniList anime ID |
|
||||||
|
|
||||||
|
**Example:** `GET /api/episodes/20`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"providers": {
|
||||||
|
"kiwi": {
|
||||||
|
"meta": { "id": "1571", "title": "Naruto", "type": "TV" },
|
||||||
|
"episodes": {
|
||||||
|
"sub": [
|
||||||
|
{
|
||||||
|
"id": "watch/kiwi/20/sub/animepahe-1",
|
||||||
|
"number": 1,
|
||||||
|
"title": "Enter: Naruto Uzumaki!",
|
||||||
|
"airDate": "2002-10-03",
|
||||||
|
"audio": "sub",
|
||||||
|
"filler": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Watch (Streaming)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/watch/:provider/:anilistId/:category/:slug
|
||||||
|
```
|
||||||
|
|
||||||
|
| Param | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `provider` | string | Yes | Provider name (kiwi, pewe, etc.) |
|
||||||
|
| `anilistId` | number | Yes | AniList anime ID |
|
||||||
|
| `category` | string | Yes | sub or dub |
|
||||||
|
| `slug` | string | Yes | Episode slug from episodes response |
|
||||||
|
|
||||||
|
**Example:** `GET /api/watch/kiwi/20/sub/animepahe-1`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"url": "https://vault-01.uwucdn.top/stream/.../uwu.m3u8",
|
||||||
|
"type": "hls",
|
||||||
|
"quality": "360p",
|
||||||
|
"resolution": { "width": 640, "height": 360 },
|
||||||
|
"codec": "h264",
|
||||||
|
"audio": "sub",
|
||||||
|
"fansub": "df68",
|
||||||
|
"referer": "https://kwik.cx/e/..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"download": "https://pahe.win/LJmbA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Code Examples
|
||||||
|
|
||||||
|
All examples are tested and working with the live API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cURL
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/search?query=naruto"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Suggestions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/suggestions?query=naruto"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anime Info
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/info/20"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Episodes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Characters
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/anime/20/characters"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream URL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/watch/kiwi/20/sub/animepahe-1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/filter?genre=Action&per_page=3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trending
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/trending?per_page=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Popular
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/popular?per_page=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/schedule?date=2026-06-09"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript (Browser)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function searchAnime(query) {
|
||||||
|
const res = await fetch(`https://mirurotvapi.vercel.app/api/search?query=${encodeURIComponent(query)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
return data.results.results.map(anime => ({
|
||||||
|
id: anime.id,
|
||||||
|
title: anime.title.english || anime.title.romaji,
|
||||||
|
format: anime.format,
|
||||||
|
episodes: anime.episodes,
|
||||||
|
score: anime.averageScore
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
searchAnime('naruto').then(results => {
|
||||||
|
results.forEach(a => console.log(`${a.title} (${a.format}) — Score: ${a.score}`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anime Info
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getAnimeInfo(anilistId) {
|
||||||
|
const res = await fetch(`https://mirurotvapi.vercel.app/api/info/${anilistId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const info = data.results;
|
||||||
|
console.log(`Title: ${info.title.english || info.title.romaji}`);
|
||||||
|
console.log(`Format: ${info.format}`);
|
||||||
|
console.log(`Status: ${info.status}`);
|
||||||
|
console.log(`Episodes: ${info.episodes || 'Unknown'}`);
|
||||||
|
console.log(`Score: ${info.averageScore}`);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAnimeInfo(20);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Streaming Flow
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getStreamUrl(anilistId) {
|
||||||
|
// Step 1: Get episodes
|
||||||
|
const episodesRes = await fetch(`https://mirurotvapi.vercel.app/api/episodes/${anilistId}`);
|
||||||
|
const episodesData = await episodesRes.json();
|
||||||
|
const providers = episodesData.results.providers;
|
||||||
|
|
||||||
|
const provider = Object.keys(providers)[0];
|
||||||
|
const episodes = providers[provider].episodes.sub;
|
||||||
|
|
||||||
|
if (!episodes || episodes.length === 0) {
|
||||||
|
throw new Error('No episodes found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeId = episodes[0].id;
|
||||||
|
const parts = episodeId.split('/');
|
||||||
|
const category = parts[3];
|
||||||
|
const slug = parts[4];
|
||||||
|
|
||||||
|
// Step 2: Get stream URLs
|
||||||
|
const streamRes = await fetch(`https://mirurotvapi.vercel.app/api/watch/${provider}/${anilistId}/${category}/${slug}`);
|
||||||
|
const streamData = await streamRes.json();
|
||||||
|
|
||||||
|
return streamData.results.streams[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
getStreamUrl(20)
|
||||||
|
.then(url => {
|
||||||
|
console.log('Stream URL:', url);
|
||||||
|
// Use with HLS.js player
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Error:', err.message));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Characters
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getCharacters(anilistId) {
|
||||||
|
const res = await fetch(`https://mirurotvapi.vercel.app/api/anime/${anilistId}/characters`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
return data.results.edges.map(edge => ({
|
||||||
|
name: edge.node.name.full,
|
||||||
|
role: edge.role,
|
||||||
|
voiceActor: edge.voiceActors[0]?.name.full || 'N/A'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCharacters(20).then(chars => {
|
||||||
|
chars.forEach(c => console.log(`${c.name} (${c.role}) — VA: ${c.voiceActor}`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node.js (with axios)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function searchAnime(query) {
|
||||||
|
const { data } = await axios.get('https://mirurotvapi.vercel.app/api/search', {
|
||||||
|
params: { query }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.results.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchAnime('naruto').then(results => {
|
||||||
|
results.forEach(a => console.log(`${a.title.english || a.title.romaji}`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Streaming Flow
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function getStreamUrl(anilistId) {
|
||||||
|
const BASE = 'https://mirurotvapi.vercel.app/api';
|
||||||
|
|
||||||
|
// Step 1: Get episodes
|
||||||
|
const { data: episodesData } = await axios.get(`${BASE}/episodes/${anilistId}`);
|
||||||
|
const providers = episodesData.results.providers;
|
||||||
|
|
||||||
|
const provider = Object.keys(providers)[0];
|
||||||
|
const episodes = providers[provider].episodes.sub;
|
||||||
|
|
||||||
|
if (!episodes || episodes.length === 0) {
|
||||||
|
throw new Error('No episodes found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeId = episodes[0].id;
|
||||||
|
const parts = episodeId.split('/');
|
||||||
|
const category = parts[3];
|
||||||
|
const slug = parts[4];
|
||||||
|
|
||||||
|
// Step 2: Get stream URLs
|
||||||
|
const { data: streamData } = await axios.get(`${BASE}/watch/${provider}/${anilistId}/${category}/${slug}`);
|
||||||
|
|
||||||
|
return streamData.results.streams[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
getStreamUrl(20)
|
||||||
|
.then(url => console.log('Stream URL:', url))
|
||||||
|
.catch(err => console.error('Error:', err.message));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by Genre
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function filterAnime(genre, perPage = 5) {
|
||||||
|
const { data } = await axios.get('https://mirurotvapi.vercel.app/api/filter', {
|
||||||
|
params: { genre, per_page: perPage }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.results.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterAnime('Action', 3).then(anime => {
|
||||||
|
anime.forEach(a => console.log(`${a.title.english || a.title.romaji} (Score: ${a.averageScore})`));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function getSchedule(date) {
|
||||||
|
const { data } = await axios.get('https://mirurotvapi.vercel.app/api/schedule', {
|
||||||
|
params: { date }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Scheduled anime:', data.results.length);
|
||||||
|
return data.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSchedule('2026-06-09');
|
||||||
|
```
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# MiruroAPI Documentation
|
||||||
|
|
||||||
|
> Free REST API for anime streaming data. 18 endpoints, 12 providers, M3U8 URLs. No API key required.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MiruroAPI is a free, open-source REST API that provides anime data by aggregating from AniList GraphQL and Miruro streaming providers. It's built with Node.js and Express, and deployed on Vercel.
|
||||||
|
|
||||||
|
**Base URL:** `https://mirurotvapi.vercel.app/api`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Search for anime
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/search?query=naruto"
|
||||||
|
|
||||||
|
# Get anime info
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/info/20"
|
||||||
|
|
||||||
|
# Get episodes
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Live Response — Search:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"page": 1,
|
||||||
|
"perPage": 20,
|
||||||
|
"total": 5000,
|
||||||
|
"hasNextPage": true,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"title": { "romaji": "NARUTO", "english": "Naruto" },
|
||||||
|
"coverImage": { "large": "https://s4.anilist.co/file/..." },
|
||||||
|
"format": "TV",
|
||||||
|
"episodes": 220,
|
||||||
|
"status": "FINISHED",
|
||||||
|
"averageScore": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **18 Endpoints** — Search, info, episodes, streaming, characters, relations, and more
|
||||||
|
- **12 Streaming Providers** — M3U8 URLs with resolution, codec, fansub info
|
||||||
|
- **No API Key** — Just make requests, no registration needed
|
||||||
|
- **In-Memory Cache** — 1-5 minute TTL for fast responses
|
||||||
|
- **CORS Enabled** — Access from any domain
|
||||||
|
- **JSON Responses** — Standardized `{success, results}` format
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [API Endpoints Reference](endpoints.md) — Complete endpoint documentation with real responses
|
||||||
|
- [Streaming Flow Guide](streaming.md) — How to get stream URLs step by step
|
||||||
|
- [Code Examples](examples.md) — cURL, JavaScript, Python (all tested and working)
|
||||||
|
- [Architecture](architecture.md) — Project structure and design decisions
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
All endpoints return JSON in this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Error description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Rate limiting is enforced at 100 requests per minute per IP address. The API also uses a 1-5 minute in-memory cache. Repeated requests for the same data will be served from cache.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This API is for **educational purposes only**. It fetches data from AniList GraphQL and Miruro streaming providers. We are not affiliated with or endorsed by Miruro. All content belongs to its respective owners.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Streaming Flow Guide
|
||||||
|
|
||||||
|
This guide explains how to get streaming URLs from MiruroAPI in 3 steps.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Get Episodes → /api/episodes/:anilistId
|
||||||
|
Step 2: Get Stream URLs → /api/watch/:provider/:anilistId/:category/:slug
|
||||||
|
Step 3: Play M3U8 → Use HLS player
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Get Episodes
|
||||||
|
|
||||||
|
Each anime has episodes from multiple providers. Fetch the episode list with the AniList ID.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/episodes/20"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"providers": {
|
||||||
|
"kiwi": {
|
||||||
|
"meta": { "id": "1571", "title": "Naruto", "type": "TV" },
|
||||||
|
"episodes": {
|
||||||
|
"sub": [
|
||||||
|
{
|
||||||
|
"id": "watch/kiwi/20/sub/animepahe-1",
|
||||||
|
"number": 1,
|
||||||
|
"title": "Enter: Naruto Uzumaki!",
|
||||||
|
"image": "https://image.tmdb.org/t/p/original/...",
|
||||||
|
"airDate": "2002-10-03",
|
||||||
|
"audio": "sub",
|
||||||
|
"filler": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Key field:** `id` in each episode — Pass this to the next step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Get Stream URLs
|
||||||
|
|
||||||
|
Use the episode `id` from Step 1 to get M3U8 streaming URLs.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://mirurotvapi.vercel.app/api/watch/kiwi/20/sub/animepahe-1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"results": {
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"url": "https://vault-01.uwucdn.top/stream/.../uwu.m3u8",
|
||||||
|
"type": "hls",
|
||||||
|
"quality": "360p",
|
||||||
|
"resolution": { "width": 640, "height": 360 },
|
||||||
|
"codec": "h264",
|
||||||
|
"audio": "sub",
|
||||||
|
"fansub": "df68",
|
||||||
|
"isActive": false,
|
||||||
|
"referer": "https://kwik.cx/e/..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"download": "https://pahe.win/LJmbA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Done!** Use `streams[0].url` in your HLS video player.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Play M3U8
|
||||||
|
|
||||||
|
### HLS.js (Browser)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||||
|
<video id="player" controls></video>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const url = 'https://vault-01.uwucdn.top/stream/.../uwu.m3u8';
|
||||||
|
const video = document.getElementById('player');
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = url; // Native HLS (Safari)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getStreamUrl(anilistId) {
|
||||||
|
// Step 1: Get episodes
|
||||||
|
const episodesRes = await fetch(`https://mirurotvapi.vercel.app/api/episodes/${anilistId}`);
|
||||||
|
const episodesData = await episodesRes.json();
|
||||||
|
const providers = episodesData.results.providers;
|
||||||
|
|
||||||
|
// Pick first provider with sub episodes
|
||||||
|
const provider = Object.keys(providers)[0];
|
||||||
|
const episodes = providers[provider].episodes.sub;
|
||||||
|
|
||||||
|
if (!episodes || episodes.length === 0) {
|
||||||
|
throw new Error('No episodes found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use first episode's id
|
||||||
|
const episodeId = episodes[0].id; // "watch/kiwi/20/sub/animepahe-1"
|
||||||
|
const parts = episodeId.split('/');
|
||||||
|
const category = parts[3]; // "sub"
|
||||||
|
const slug = parts[4]; // "animepahe-1"
|
||||||
|
|
||||||
|
// Step 2: Get stream URLs
|
||||||
|
const streamRes = await fetch(`https://mirurotvapi.vercel.app/api/watch/${provider}/${anilistId}/${category}/${slug}`);
|
||||||
|
const streamData = await streamRes.json();
|
||||||
|
|
||||||
|
return streamData.results.streams[0].url; // "https://...m3u8"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
getStreamUrl(20)
|
||||||
|
.then(url => console.log('Stream URL:', url))
|
||||||
|
.catch(err => console.error('Error:', err));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def get_stream_url(anilist_id):
|
||||||
|
base = "https://mirurotvapi.vercel.app/api"
|
||||||
|
|
||||||
|
# Step 1: Get episodes
|
||||||
|
episodes_res = requests.get(f"{base}/episodes/{anilist_id}")
|
||||||
|
episodes_data = episodes_res.json()
|
||||||
|
providers = episodes_data['results']['providers']
|
||||||
|
|
||||||
|
# Pick first provider
|
||||||
|
provider = list(providers.keys())[0]
|
||||||
|
episodes = providers[provider]['episodes']['sub']
|
||||||
|
|
||||||
|
if not episodes:
|
||||||
|
raise Exception("No episodes found")
|
||||||
|
|
||||||
|
episode_id = episodes[0]['id'] # "watch/kiwi/20/sub/animepahe-1"
|
||||||
|
parts = episode_id.split('/')
|
||||||
|
category = parts[3]
|
||||||
|
slug = parts[4]
|
||||||
|
|
||||||
|
# Step 2: Get stream URLs
|
||||||
|
stream_res = requests.get(f"{base}/watch/{provider}/{anilist_id}/{category}/{slug}")
|
||||||
|
stream_data = stream_res.json()
|
||||||
|
|
||||||
|
return stream_data['results']['streams'][0]['url']
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
url = get_stream_url(20)
|
||||||
|
print(f"Stream URL: {url}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub & Dub
|
||||||
|
|
||||||
|
Providers return both `sub` and `dub` episode lists:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const eps = await fetch("/api/episodes/20").then(r => r.json());
|
||||||
|
const kiwi = eps.results.providers.kiwi.episodes;
|
||||||
|
|
||||||
|
// Get sub episodes
|
||||||
|
const subEps = kiwi.sub; // [{ id: "watch/kiwi/20/sub/animepahe-1", ... }]
|
||||||
|
|
||||||
|
// Get dub episodes (if available)
|
||||||
|
const dubEps = kiwi.dub; // [{ id: "watch/kiwi/20/dub/...", ... }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Providers
|
||||||
|
|
||||||
|
| Provider | Sub | Dub | Download | Skip Times |
|
||||||
|
|:---|:---:|:---:|:---:|:---:|
|
||||||
|
| kiwi | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| pewe | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| bee | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| bonk | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| bun | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| ally | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| nun | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| twin | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| cog | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| moo | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| hop | ❌ | ✅ | ❌ | ❌ |
|
||||||
|
| telli | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| "No episodes found" | Check the AniList ID is correct (use `/api/search` first) |
|
||||||
|
| "No providers" | The anime may not be available on any provider |
|
||||||
|
| Stream URL 403 | Some servers require specific referrer headers |
|
||||||
|
| CORS error | Use a proxy or access from server-side |
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "miruro-api",
|
"name": "miruro-api",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "RESTful API for miruro.com - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
"description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js",
|
"dev": "nodemon server.js",
|
||||||
"test": "node test.js"
|
"test": "node test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,15 +14,25 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0"
|
"dotenv": "^16.4.0"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anime",
|
"anime",
|
||||||
"api",
|
"api",
|
||||||
"miruro",
|
"miruro",
|
||||||
"anilist",
|
"anilist",
|
||||||
|
"graphql",
|
||||||
"streaming",
|
"streaming",
|
||||||
"m3u8",
|
"m3u8",
|
||||||
"hls"
|
"hls",
|
||||||
|
"rest-api",
|
||||||
|
"express"
|
||||||
],
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
|
},
|
||||||
"author": "Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ <157171073+Shineii86@users.noreply.github.com>",
|
"author": "Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ <157171073+Shineii86@users.noreply.github.com>",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>404 — MiruroAPI</title>
|
||||||
|
<meta name="theme-color" content="#0A0A12" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0A0A12;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--indigo:#6366F1;--green:#22C55E;--red:#EF4444;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||||||
|
.ambient{position:fixed;inset:0;pointer-events:none}
|
||||||
|
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.1}
|
||||||
|
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px;animation:drift 24s ease-in-out infinite}
|
||||||
|
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px;animation:drift 20s ease-in-out infinite reverse}
|
||||||
|
@keyframes drift{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(40px,-30px) scale(1.08)}}
|
||||||
|
.container{position:relative;z-index:1;text-align:center;padding:40px;max-width:560px}
|
||||||
|
.code{font-size:clamp(6rem,18vw,14rem);font-weight:900;line-height:1;letter-spacing:-.06em;background:linear-gradient(135deg,var(--purple),var(--pink),var(--indigo));-webkit-background-clip:text;-webkit-text-fill-color:transparent;animation:float 4s ease-in-out infinite}
|
||||||
|
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}}
|
||||||
|
.title{font-size:1.4rem;font-weight:700;margin:16px 0 8px}
|
||||||
|
.desc{color:var(--text2);font-size:.95rem;margin-bottom:32px;line-height:1.7}
|
||||||
|
.actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:10px;font-size:.88rem;font-weight:600;text-decoration:none;border:none;cursor:pointer;transition:all .25s;font-family:inherit}
|
||||||
|
.btn svg{width:16px;height:16px}
|
||||||
|
.btn-primary{background:linear-gradient(135deg,var(--purple),var(--pink));color:#fff;box-shadow:0 4px 24px rgba(168,85,247,.3)}
|
||||||
|
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(168,85,247,.4)}
|
||||||
|
.btn-ghost{background:var(--glass);border:1px solid rgba(255,255,255,.1);color:var(--text)}
|
||||||
|
.btn-ghost:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.15)}
|
||||||
|
.glitch{position:relative;display:inline-block}
|
||||||
|
.glitch::before,.glitch::after{content:attr(data-text);position:absolute;top:0;left:0;width:100%;height:100%;background:var(--bg)}
|
||||||
|
.glitch::before{left:2px;text-shadow:-2px 0 var(--pink);clip-path:inset(0 0 0 0);animation:glitch-1 2s infinite linear alternate-reverse}
|
||||||
|
.glitch::after{left:-2px;text-shadow:2px 0 var(--indigo);clip-path:inset(0 0 0 0);animation:glitch-2 3s infinite linear alternate-reverse}
|
||||||
|
@keyframes glitch-1{0%{clip-path:inset(40% 0 61% 0)}20%{clip-path:inset(92% 0 1% 0)}40%{clip-path:inset(43% 0 1% 0)}60%{clip-path:inset(25% 0 58% 0)}80%{clip-path:inset(54% 0 7% 0)}100%{clip-path:inset(58% 0 43% 0)}}
|
||||||
|
@keyframes glitch-2{0%{clip-path:inset(65% 0 13% 0)}20%{clip-path:inset(79% 0 2% 0)}40%{clip-path:inset(48% 0 38% 0)}60%{clip-path:inset(33% 0 2% 0)}80%{clip-path:inset(16% 0 62% 0)}100%{clip-path:inset(27% 0 42% 0)}}
|
||||||
|
.status-line{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:24px;color:var(--text3);font-size:.78rem;font-family:'JetBrains Mono',monospace}
|
||||||
|
.status-dot{width:6px;height:6px;border-radius:50%;background:var(--red);animation:pulse 1.5s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||||
|
.scanline{position:fixed;inset:0;z-index:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.03) 2px,rgba(0,0,0,.03) 4px)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
||||||
|
<div class="scanline"></div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="code"><span class="glitch" data-text="404">404</span></div>
|
||||||
|
<div class="title">Endpoint Not Found</div>
|
||||||
|
<div class="desc">The path you requested doesn't exist in the MiruroAPI.<br/>Check the documentation or try a valid endpoint.</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a href="/api/health" class="btn btn-ghost">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
|
API Status
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI#readme" target="_blank" class="btn btn-ghost">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
Docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="status-line"><span class="status-dot"></span> HTTP 404 · Not Found</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,134 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<!-- ──────────────── OG Meta ──────────────── -->
|
||||||
|
<meta property="og:title" content="MiruroAPI — Swagger UI Documentation">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://mirurotvapi.vercel.app/docs">
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png">
|
||||||
|
<meta property="og:description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||||
|
<meta property="og:site_name" content="MiruroAPI">
|
||||||
|
<meta property="og:locale" content="en_US">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="MiruroAPI — Swagger UI Documentation">
|
||||||
|
<meta name="twitter:description" content="Interactive Swagger UI documentation for MiruroAPI.">
|
||||||
|
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/og-image.png">
|
||||||
|
<meta name="description" content="Interactive Swagger UI documentation for MiruroAPI. Test all 16 endpoints directly from the browser.">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="theme-color" content="#0b1622">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512x512.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||||
|
<link rel="canonical" href="https://mirurotvapi.vercel.app/docs">
|
||||||
|
|
||||||
|
<title>MiruroAPI — Swagger UI</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1622;
|
||||||
|
--bg2: #0f172a;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent2: #818cf8;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── BASE ──────────────── */
|
||||||
|
html { box-sizing: border-box; }
|
||||||
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────── CUSTOM HEADER ──────────────── */
|
||||||
|
.docs-header {
|
||||||
|
background: linear-gradient(135deg, var(--bg2), #1e1b4b);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
padding: 20px 40px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.docs-brand { display: flex; align-items: center; gap: 12px; text-decoration: none; }
|
||||||
|
.docs-logo {
|
||||||
|
width: 32px; height: 32px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white; font-weight: 900; font-size: 16px;
|
||||||
|
}
|
||||||
|
.docs-title { color: var(--text); font-weight: 700; font-size: 1em; }
|
||||||
|
.docs-subtitle { color: var(--muted); font-size: 0.8em; margin-left: 8px; }
|
||||||
|
.docs-links { display: flex; gap: 16px; }
|
||||||
|
.docs-links a {
|
||||||
|
color: var(--muted); text-decoration: none; font-size: 0.85em;
|
||||||
|
font-weight: 500; transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.docs-links a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ──────────────── SWAGGER UI OVERRIDES ──────────────── */
|
||||||
|
.swagger-ui .topbar { display: none; }
|
||||||
|
.swagger-ui .info .title { color: var(--text) !important; }
|
||||||
|
.swagger-ui .info .description p { color: var(--muted) !important; }
|
||||||
|
.swagger-ui .scheme-container { background: rgba(15, 23, 42, 0.5) !important; box-shadow: none !important; }
|
||||||
|
.swagger-ui .opblock-tag { color: var(--accent) !important; border-color: rgba(255,255,255,0.05) !important; }
|
||||||
|
.swagger-ui .opblock .opblock-summary-description { color: var(--muted) !important; }
|
||||||
|
.swagger-ui .btn { box-shadow: none !important; }
|
||||||
|
.swagger-ui .btn.execute { background: linear-gradient(135deg, var(--accent), var(--accent2)) !important; }
|
||||||
|
.swagger-ui .model-box { background: rgba(15, 23, 42, 0.3) !important; }
|
||||||
|
.swagger-ui .scheme-container .schemes > select { color: var(--text); background: var(--bg2); }
|
||||||
|
|
||||||
|
/* ──────────────── BACK LINK ──────────────── */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
color: var(--accent); text-decoration: none;
|
||||||
|
font-size: 0.85em; font-weight: 500;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ──────────────── CUSTOM HEADER ──────────────── -->
|
||||||
|
<div class="docs-header">
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<a href="/" class="docs-brand">
|
||||||
|
<img src="/icon-512x512.png" alt="Miruro" class="docs-logo" style="object-fit:contain; filter:brightness(0) invert();">
|
||||||
|
<span class="docs-title">MiruroAPI</span>
|
||||||
|
</a>
|
||||||
|
<span class="docs-subtitle">Swagger UI Documentation</span>
|
||||||
|
</div>
|
||||||
|
<div class="docs-links">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/openapi.json">OpenAPI Spec</a>
|
||||||
|
<a href="/api/health">Health</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ──────────────── SWAGGER UI ──────────────── -->
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
SwaggerUIBundle({
|
||||||
|
url: "/openapi.json",
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
deepLinking: true,
|
||||||
|
defaultModelsExpandDepth: -1,
|
||||||
|
docExpansion: "list",
|
||||||
|
filter: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#ffffff;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 270.93333 270.93332" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-41.580053,-91.524707)">
|
||||||
|
<path style="fill:#000000;fill-opacity:1;stroke-width:1.70387"
|
||||||
|
d="m 247.27543,314.70152 c -6.10955,-6.70519 -16.14364,-12.9409 -13.93299,-23.42798 2.12389,-20.22967 4.00106,-40.51893 3.43613,-60.89302 0.32828,-23.84505 -0.7151,-47.80644 -4.67234,-71.32308 -21.29031,23.99166 -31.12328,55.278 -42.21767,84.73199 -3.89541,12.27935 -21.71766,7.83903 -21.86694,-4.43985 -6.30241,-22.41752 -15.57712,-44.12149 -29.46365,-62.93722 -3.96932,-3.43831 -12.49125,-22.4167 -14.54817,-9.93684 -7.96323,36.33701 -8.31833,73.85796 -6.24645,110.84721 2.19408,10.98054 -3.58307,20.65309 -14.39585,23.75435 -7.691633,1.63664 -18.686185,16.40367 -24.689738,6.4484 -5.549778,-6.39186 -6.386914,-14.77824 3.524397,-16.80023 3.650034,-4.06414 14.883155,-3.99688 13.428515,-10.68708 -0.363081,-6.16925 -0.502499,-12.34844 -0.722494,-18.52376 -6.011978,0.12421 -20.194572,9.14755 -21.048961,0.29803 4.558972,-3.34862 14.481259,-4.72681 17.663382,-6.27314 -5.30158,-0.36703 -12.065997,2.00733 -16.203654,-1.67557 2.654494,-5.22286 11.415092,-2.80527 16.759698,-3.75272 5.21548,0.98551 2.118507,-6.2941 3.203468,-9.41871 1.362186,-31.92446 2.830394,-64.401 12.138507,-95.17623 1.6606,-12.81687 17.56784,-17.5421 25.47531,-6.99678 20.47568,20.4649 38.19519,44.34874 47.58582,71.98892 4.61856,-4.03449 7.71667,-17.72071 12.32719,-24.95391 9.91397,-19.20957 21.02511,-38.64633 37.33735,-53.16525 7.54853,-8.78842 20.64347,-3.59999 20.62749,7.78422 8.57544,35.65229 6.90072,72.55248 7.54692,108.94394 6.47271,0.53048 13.50356,-0.74591 19.53337,1.55892 -0.40011,6.92584 -15.88203,1.54318 -17.9692,4.29818 5.80571,3.1568 22.13502,1.93763 20.18934,10.35691 -7.79577,3.09455 -24.35445,-11.17611 -22.65057,3.17853 1.34495,11.08 -7.66567,25.29061 3.96573,32.80224 7.93679,6.06024 16.23111,15.77249 3.76586,22.34018 -6.35101,7.76687 -12.98193,-5.80286 -17.8798,-8.95065 z m -36.07374,-34.0964 c -9.58671,-2.83824 -11.53728,-17.20921 -2.66126,-22.20068 9.07411,-6.48726 22.3771,4.08888 18.20146,14.33921 -1.92914,6.54286 -9.18288,9.79938 -15.5402,7.86147 z m -75.26977,-2.55526 c -11.469,-1.7362 -13.22099,-19.59021 -2.32646,-23.45757 10.02949,-4.7738 21.46818,7.15397 15.81668,16.84718 -2.45128,4.77109 -8.13415,7.97894 -13.49022,6.61039 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,46 +1,816 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MiruroAPI - Free Anime Streaming API</title>
|
<title>MiruroAPI — Free Anime Streaming API</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%2326A3D6'/%3E%3Cstop offset='100%25' style='stop-color:%237B2FF7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23g)'/%3E%3Ctext x='50' y='62' text-anchor='middle' fill='white' font-family='Arial Black' font-size='32' font-weight='bold'%3EM%3C/text%3E%3C/svg%3E">
|
|
||||||
|
<!-- ──────────────── Basic OG Tags ──────────────── -->
|
||||||
|
<meta property="og:title" content="MiruroAPI — Free Anime Streaming API" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://mirurotvapi.vercel.app" />
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
|
<meta property="og:description" content="Free RESTful API for anime streaming data. 18 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
|
||||||
|
<meta property="og:site_name" content="MiruroAPI" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
|
<!-- ──────────────── Image Optimization ──────────────── -->
|
||||||
|
<meta property="og:image:secure_url" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="MiruroAPI - Free Anime Streaming API" />
|
||||||
|
|
||||||
|
<!-- ──────────────── Twitter Card Tags ──────────────── -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:site" content="@Shineii86" />
|
||||||
|
<meta name="twitter:creator" content="@Shineii86" />
|
||||||
|
<meta name="twitter:title" content="MiruroAPI — Free Anime Streaming API" />
|
||||||
|
<meta name="twitter:description" content="Free RESTful API for anime streaming data. 18 endpoints, 12 providers, M3U8 streaming URLs." />
|
||||||
|
<meta name="twitter:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
|
<meta name="twitter:image:alt" content="MiruroAPI - Free Anime Streaming API" />
|
||||||
|
<meta name="twitter:domain" content="mirurotvapi.vercel.app" />
|
||||||
|
|
||||||
|
<!-- ──────────────── Generic Meta ──────────────── -->
|
||||||
|
<meta name="description" content="Free RESTful API for anime streaming data. 18 endpoints, 12 providers, M3U8 streaming URLs. Powered by AniList GraphQL and Miruro." />
|
||||||
|
<meta name="author" content="Shinei Nouzen" />
|
||||||
|
<meta name="keywords" content="anime api, free api, rest api, anime streaming, m3u8, anime data, anilist, miruro, episode streaming, sub, dub" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<meta name="theme-color" content="#0b1622" />
|
||||||
|
<link rel="canonical" href="https://mirurotvapi.vercel.app" />
|
||||||
|
|
||||||
|
<!-- ──────────────── Favicon (Miruro official) ──────────────── -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512x512.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
|
||||||
|
<link rel="mask-icon" href="/icon-dark.svg" color="#38bdf8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
<!-- ──────────────── Fonts ──────────────── -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0b1622; color: #e1e7ef; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
:root{
|
||||||
.container { max-width: 800px; padding: 40px; text-align: center; }
|
--bg:#0b1622;--bg2:#0f172a;--bg3:#1e293b;
|
||||||
h1 { font-size: 2.5em; background: linear-gradient(135deg, #26A3D6, #7B2FF7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 16px; }
|
--surface:rgba(15,23,42,.7);--glass:rgba(255,255,255,.03);
|
||||||
p { color: #a0b1c5; font-size: 1.1em; margin-bottom: 24px; line-height: 1.6; }
|
--border:rgba(255,255,255,.06);--border2:rgba(255,255,255,.1);
|
||||||
.badge { display: inline-block; background: rgba(38,163,214,0.15); color: #26A3D6; padding: 6px 16px; border-radius: 20px; font-size: 0.9em; margin-bottom: 24px; border: 1px solid rgba(38,163,214,0.3); }
|
--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;
|
||||||
.endpoints { text-align: left; background: rgba(20,32,48,0.8); border-radius: 12px; padding: 24px; margin-top: 24px; border: 1px solid rgba(255,255,255,0.05); }
|
--accent:#38bdf8;--accent2:#818cf8;--pink:#EC4899;
|
||||||
.endpoints h3 { color: #26A3D6; margin-bottom: 12px; }
|
--green:#22C55E;--cyan:#06B6D4;--amber:#F59E0B;--red:#EF4444;
|
||||||
.endpoint { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.03); font-family: monospace; font-size: 0.9em; }
|
--grad:linear-gradient(135deg,var(--accent),var(--accent2));
|
||||||
.endpoint:last-child { border-bottom: none; }
|
--grad2:linear-gradient(135deg,var(--accent2),#a78bfa);
|
||||||
.method { color: #10b981; font-weight: bold; }
|
--radius:12px;--radius-lg:16px;--radius-xl:20px;
|
||||||
a { color: #26A3D6; text-decoration: none; }
|
}
|
||||||
a:hover { text-decoration: underline; }
|
html{scroll-behavior:smooth;-webkit-font-smoothing:antialiased}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden;line-height:1.6}
|
||||||
|
::selection{background:rgba(56,189,248,.3);color:#fff}
|
||||||
|
::-webkit-scrollbar{width:6px}
|
||||||
|
::-webkit-scrollbar-track{background:transparent}
|
||||||
|
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}
|
||||||
|
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.15)}
|
||||||
|
|
||||||
|
/* ── Ambient ── */
|
||||||
|
.ambient{position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden}
|
||||||
|
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.12}
|
||||||
|
.orb-1{width:700px;height:700px;background:var(--accent);top:-250px;left:-200px;animation:drift 30s ease-in-out infinite}
|
||||||
|
.orb-2{width:500px;height:500px;background:var(--accent2);bottom:-200px;right:-150px;animation:drift 25s ease-in-out infinite reverse}
|
||||||
|
.orb-3{width:400px;height:400px;background:#a78bfa;top:40%;left:40%;animation:drift 22s ease-in-out infinite 5s}
|
||||||
|
@keyframes drift{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(50px,-40px) scale(1.1)}66%{transform:translate(-30px,30px) scale(.92)}}
|
||||||
|
|
||||||
|
/* ── Particles Canvas ── */
|
||||||
|
#particles{position:fixed;inset:0;z-index:0;pointer-events:none}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
.wrap{position:relative;z-index:1}
|
||||||
|
.container{max-width:1120px;margin:0 auto;padding:0 24px}
|
||||||
|
.container-lg{max-width:1280px;margin:0 auto;padding:0 24px}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header{position:fixed;top:0;left:0;right:0;z-index:1000;padding:0 24px;transition:all .35s}
|
||||||
|
header.scrolled{background:rgba(11,22,34,.88);backdrop-filter:blur(24px) saturate(1.5);border-bottom:1px solid var(--border)}
|
||||||
|
.header-inner{max-width:1120px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text)}
|
||||||
|
.logo img{width:28px;height:28px;border-radius:6px}
|
||||||
|
.logo-text{font-size:1.1rem;font-weight:700;letter-spacing:-.02em}
|
||||||
|
.logo-text span{background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
nav{display:flex;align-items:center;gap:4px}
|
||||||
|
nav a{color:var(--text2);text-decoration:none;font-size:.8rem;font-weight:500;padding:6px 14px;border-radius:8px;transition:all .2s}
|
||||||
|
nav a:hover{color:var(--text);background:var(--glass)}
|
||||||
|
nav a.active{color:var(--accent)}
|
||||||
|
.nav-cta{margin-left:8px;background:var(--grad)!important;color:#fff!important;padding:7px 16px!important;border-radius:8px!important;font-weight:600!important}
|
||||||
|
.nav-cta:hover{opacity:.9;transform:translateY(-1px)}
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.hero{padding:140px 0 80px;text-align:center;position:relative}
|
||||||
|
.hero-badge{display:inline-flex;align-items:center;gap:8px;background:var(--glass);border:1px solid var(--border);padding:6px 16px;border-radius:100px;font-size:.75rem;color:var(--text2);margin-bottom:24px}
|
||||||
|
.hero-badge .dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||||
|
.hero h1{font-size:clamp(2.5rem,5.5vw,4rem);font-weight:900;line-height:1.1;letter-spacing:-.04em;margin-bottom:20px}
|
||||||
|
.hero h1 .grad{background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
.hero-sub{color:var(--text2);font-size:1.05rem;max-width:540px;margin:0 auto 40px;line-height:1.7}
|
||||||
|
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:48px}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border-radius:10px;font-size:.88rem;font-weight:600;text-decoration:none;border:none;cursor:pointer;transition:all .25s;font-family:inherit}
|
||||||
|
.btn svg{width:16px;height:16px}
|
||||||
|
.btn-primary{background:var(--grad);color:#fff;box-shadow:0 4px 24px rgba(56,189,248,.3)}
|
||||||
|
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(56,189,248,.4)}
|
||||||
|
.btn-ghost{background:var(--glass);border:1px solid var(--border2);color:var(--text)}
|
||||||
|
.btn-ghost:hover{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.15)}
|
||||||
|
|
||||||
|
/* ── Console ── */
|
||||||
|
.console{max-width:720px;margin:0 auto;border-radius:var(--radius-xl);overflow:hidden;border:1px solid var(--border2);background:var(--bg2);box-shadow:0 24px 80px rgba(0,0,0,.5),0 0 120px rgba(56,189,248,.08)}
|
||||||
|
.console-bar{display:flex;align-items:center;gap:8px;padding:14px 18px;background:rgba(255,255,255,.02);border-bottom:1px solid var(--border)}
|
||||||
|
.console-dot{width:10px;height:10px;border-radius:50%}
|
||||||
|
.console-dot:nth-child(1){background:#FF5F57}
|
||||||
|
.console-dot:nth-child(2){background:#FEBC2E}
|
||||||
|
.console-dot:nth-child(3){background:#28C840}
|
||||||
|
.console-title{margin-left:12px;font-size:.72rem;color:var(--text3);font-family:'JetBrains Mono',monospace}
|
||||||
|
.console-body{display:flex;min-height:180px}
|
||||||
|
.console-input{flex:1;padding:18px;font-family:'JetBrains Mono',monospace;font-size:.8rem;line-height:1.8;color:var(--text);border:none;background:transparent;resize:none;outline:none}
|
||||||
|
.console-input::placeholder{color:var(--text3)}
|
||||||
|
.console-output{flex:1;padding:18px;border-left:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:.78rem;line-height:1.8;color:var(--green);overflow:auto;max-height:300px;white-space:pre-wrap}
|
||||||
|
.console-actions{display:flex;gap:8px;padding:12px 18px;border-top:1px solid var(--border);background:rgba(255,255,255,.01)}
|
||||||
|
.console-btn{padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:.75rem;font-weight:500;cursor:pointer;transition:all .2s;font-family:'JetBrains Mono',monospace}
|
||||||
|
.console-btn:hover{background:var(--glass);color:var(--text)}
|
||||||
|
.console-btn.active{background:rgba(56,189,248,.15);color:var(--accent);border-color:rgba(56,189,248,.3)}
|
||||||
|
.console-send{margin-left:auto;padding:6px 16px;border-radius:6px;border:none;background:var(--grad);color:#fff;font-size:.75rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:'JetBrains Mono',monospace}
|
||||||
|
.console-send:hover{opacity:.9;transform:translateY(-1px)}
|
||||||
|
|
||||||
|
/* ── Stats ── */
|
||||||
|
.stats{padding:60px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||||
|
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;text-align:center}
|
||||||
|
.stat-value{font-size:2rem;font-weight:900;letter-spacing:-.03em}
|
||||||
|
.stat-value.grad{background:var(--grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
.stat-label{font-size:.78rem;color:var(--text3);margin-top:4px;font-weight:500}
|
||||||
|
|
||||||
|
/* ── Section ── */
|
||||||
|
.section{padding:100px 0}
|
||||||
|
.section-label{display:inline-flex;align-items:center;gap:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;color:var(--accent);font-weight:700;margin-bottom:12px}
|
||||||
|
.section-title{font-size:clamp(1.8rem,3vw,2.4rem);font-weight:800;letter-spacing:-.03em;margin-bottom:10px;line-height:1.2}
|
||||||
|
.section-desc{color:var(--text2);font-size:1rem;max-width:520px;line-height:1.7;margin-bottom:40px}
|
||||||
|
|
||||||
|
/* ── Features Grid ── */
|
||||||
|
.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||||
|
.feature-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:32px;transition:all .35s;position:relative;overflow:hidden}
|
||||||
|
.feature-card::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,rgba(56,189,248,.05),rgba(129,140,248,.05));opacity:0;transition:opacity .35s}
|
||||||
|
.feature-card:hover{border-color:rgba(56,189,248,.2);transform:translateY(-4px);box-shadow:0 16px 48px rgba(0,0,0,.3)}
|
||||||
|
.feature-card:hover::before{opacity:1}
|
||||||
|
.feature-icon{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:18px;position:relative;z-index:1}
|
||||||
|
.feature-icon svg{width:22px;height:22px}
|
||||||
|
.fi-accent{background:rgba(56,189,248,.12);color:var(--accent)}
|
||||||
|
.fi-accent2{background:rgba(129,140,248,.12);color:var(--accent2)}
|
||||||
|
.fi-green{background:rgba(34,197,94,.12);color:var(--green)}
|
||||||
|
.fi-cyan{background:rgba(6,182,212,.12);color:var(--cyan)}
|
||||||
|
.fi-amber{background:rgba(245,158,11,.12);color:var(--amber)}
|
||||||
|
.fi-pink{background:rgba(236,72,153,.12);color:var(--pink)}
|
||||||
|
.feature-title{font-size:1rem;font-weight:700;margin-bottom:8px;position:relative;z-index:1}
|
||||||
|
.feature-desc{color:var(--text2);font-size:.84rem;line-height:1.7;position:relative;z-index:1}
|
||||||
|
|
||||||
|
/* ── Endpoint Explorer ── */
|
||||||
|
.explorer{display:grid;grid-template-columns:240px 1fr;gap:1px;background:var(--border);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--border)}
|
||||||
|
.explorer-nav{background:var(--bg2);padding:8px}
|
||||||
|
.explorer-nav-btn{display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:var(--text2);font-size:.8rem;font-weight:500;border-radius:8px;cursor:pointer;transition:all .2s;text-align:left;font-family:inherit}
|
||||||
|
.explorer-nav-btn:hover{background:var(--glass);color:var(--text)}
|
||||||
|
.explorer-nav-btn.active{background:rgba(56,189,248,.1);color:var(--accent)}
|
||||||
|
.explorer-nav-btn svg{width:16px;height:16px;flex-shrink:0}
|
||||||
|
.explorer-panel{background:var(--bg2);padding:24px;min-height:400px}
|
||||||
|
.explorer-panel-title{font-size:1.1rem;font-weight:700;margin-bottom:4px}
|
||||||
|
.explorer-panel-desc{color:var(--text2);font-size:.84rem;margin-bottom:20px;line-height:1.6}
|
||||||
|
.explorer-method{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:6px;font-size:.7rem;font-weight:700;font-family:'JetBrains Mono',monospace;letter-spacing:.04em;margin-bottom:12px}
|
||||||
|
.method-get{background:rgba(34,197,94,.12);color:var(--green)}
|
||||||
|
.explorer-path{font-family:'JetBrains Mono',monospace;font-size:.85rem;color:var(--text);margin-bottom:16px;padding:12px 16px;background:rgba(0,0,0,.3);border-radius:8px;border:1px solid var(--border)}
|
||||||
|
.explorer-params{margin-bottom:16px}
|
||||||
|
.explorer-params-title{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text3);font-weight:600;margin-bottom:8px}
|
||||||
|
.param-row{display:grid;grid-template-columns:120px 80px 1fr;gap:12px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.03);font-size:.8rem}
|
||||||
|
.param-name{font-family:'JetBrains Mono',monospace;color:var(--pink);font-weight:500}
|
||||||
|
.param-type{font-family:'JetBrains Mono',monospace;color:var(--accent2);font-size:.75rem}
|
||||||
|
.param-desc{color:var(--text2)}
|
||||||
|
.explorer-code{background:rgba(0,0,0,.3);border-radius:8px;padding:16px;font-family:'JetBrains Mono',monospace;font-size:.78rem;line-height:1.8;color:var(--text2);overflow-x:auto;border:1px solid var(--border)}
|
||||||
|
.explorer-code .kw{color:var(--accent2)}
|
||||||
|
.explorer-code .str{color:var(--green)}
|
||||||
|
.explorer-code .cmt{color:var(--text3)}
|
||||||
|
.explorer-code .fn{color:var(--cyan)}
|
||||||
|
.explorer-code .num{color:var(--amber)}
|
||||||
|
|
||||||
|
/* ── Code Tabs ── */
|
||||||
|
.code-tabs{display:flex;gap:2px;background:rgba(0,0,0,.2);border-radius:8px;padding:3px;margin-bottom:12px;width:fit-content}
|
||||||
|
.code-tab{padding:6px 14px;border-radius:6px;border:none;background:transparent;color:var(--text3);font-size:.72rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:'JetBrains Mono',monospace}
|
||||||
|
.code-tab.active{background:rgba(56,189,248,.15);color:var(--accent)}
|
||||||
|
.code-tab:hover:not(.active){color:var(--text2)}
|
||||||
|
.code-block{display:none}
|
||||||
|
.code-block.active{display:block}
|
||||||
|
|
||||||
|
/* ── Playground ── */
|
||||||
|
.playground-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
||||||
|
.playground-header{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
||||||
|
.playground-header svg{width:18px;height:18px;color:var(--accent)}
|
||||||
|
.playground-header h3{font-size:1rem;font-weight:700}
|
||||||
|
.playground-body{padding:24px}
|
||||||
|
.playground-inputs{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
|
||||||
|
.playground-inputs input,.playground-inputs select{flex:1;min-width:200px;padding:10px 14px;border-radius:8px;border:1px solid var(--border);background:rgba(0,0,0,.3);color:var(--text);font-size:.85rem;font-family:'JetBrains Mono',monospace;outline:none;transition:border-color .2s}
|
||||||
|
.playground-inputs input:focus,.playground-inputs select:focus{border-color:var(--accent)}
|
||||||
|
.playground-inputs input::placeholder{color:var(--text3)}
|
||||||
|
.playground-inputs select option{background:var(--bg2)}
|
||||||
|
.playground-output{background:rgba(0,0,0,.3);border:1px solid var(--border);border-radius:8px;padding:16px;font-family:'JetBrains Mono',monospace;font-size:.78rem;line-height:1.7;color:var(--green);max-height:400px;overflow:auto;white-space:pre-wrap;display:none}
|
||||||
|
.playground-output.visible{display:block}
|
||||||
|
|
||||||
|
/* ── Marquee ── */
|
||||||
|
.marquee{padding:40px 0;overflow:hidden;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||||
|
.marquee-track{display:flex;gap:40px;animation:marquee 30s linear infinite;width:max-content}
|
||||||
|
.marquee-item{display:flex;align-items:center;gap:8px;color:var(--text3);font-size:.8rem;font-weight:500;white-space:nowrap}
|
||||||
|
.marquee-item svg{width:16px;height:16px;opacity:.5}
|
||||||
|
@keyframes marquee{0%{transform:translateX(0)}100%{transform:translateX(-50%)}}
|
||||||
|
|
||||||
|
/* ── CTA ── */
|
||||||
|
.cta{text-align:center;padding:100px 0}
|
||||||
|
.cta-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:60px 40px;position:relative;overflow:hidden}
|
||||||
|
.cta-box::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,rgba(56,189,248,.08),rgba(129,140,248,.08))}
|
||||||
|
.cta-title{font-size:clamp(1.8rem,3vw,2.4rem);font-weight:800;letter-spacing:-.03em;margin-bottom:12px;position:relative}
|
||||||
|
.cta-desc{color:var(--text2);font-size:1rem;margin-bottom:28px;position:relative}
|
||||||
|
.cta-actions{display:flex;gap:12px;justify-content:center;position:relative}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
footer{padding:60px 0 40px;border-top:1px solid var(--border)}
|
||||||
|
.footer-grid{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:40px;margin-bottom:40px}
|
||||||
|
.footer-brand .logo{margin-bottom:12px}
|
||||||
|
.footer-brand p{color:var(--text3);font-size:.82rem;line-height:1.7;max-width:280px}
|
||||||
|
.footer-col h4{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text3);font-weight:600;margin-bottom:16px}
|
||||||
|
.footer-col a{display:block;color:var(--text2);text-decoration:none;font-size:.84rem;padding:4px 0;transition:color .2s}
|
||||||
|
.footer-col a:hover{color:var(--accent)}
|
||||||
|
.footer-bottom{display:flex;align-items:center;justify-content:space-between;padding-top:24px;border-top:1px solid var(--border);color:var(--text3);font-size:.75rem}
|
||||||
|
.footer-social{display:flex;gap:12px}
|
||||||
|
.footer-social a{color:var(--text3);transition:color .2s}
|
||||||
|
.footer-social a:hover{color:var(--accent)}
|
||||||
|
.footer-social svg{width:18px;height:18px}
|
||||||
|
|
||||||
|
/* ── Scroll Reveal ── */
|
||||||
|
.reveal{opacity:0;transform:translateY(30px);transition:all .7s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.reveal.visible{opacity:1;transform:translateY(0)}
|
||||||
|
.reveal-delay-1{transition-delay:.1s}
|
||||||
|
.reveal-delay-2{transition-delay:.2s}
|
||||||
|
.reveal-delay-3{transition-delay:.3s}
|
||||||
|
.reveal-delay-4{transition-delay:.4s}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media(max-width:900px){
|
||||||
|
.features-grid{grid-template-columns:repeat(2,1fr)}
|
||||||
|
.explorer{grid-template-columns:1fr}
|
||||||
|
.stats-grid{grid-template-columns:repeat(2,1fr)}
|
||||||
|
.footer-grid{grid-template-columns:1fr 1fr}
|
||||||
|
}
|
||||||
|
@media(max-width:640px){
|
||||||
|
.hero{padding:100px 0 60px}
|
||||||
|
.hero h1{font-size:2rem}
|
||||||
|
.features-grid{grid-template-columns:1fr}
|
||||||
|
.console-body{flex-direction:column}
|
||||||
|
.console-output{border-left:none;border-top:1px solid var(--border)}
|
||||||
|
.stats-grid{grid-template-columns:repeat(2,1fr);gap:16px}
|
||||||
|
.footer-grid{grid-template-columns:1fr}
|
||||||
|
nav{display:none}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- ──────────────── JSON-LD Structured Data ──────────────── -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebAPI",
|
||||||
|
"name": "MiruroAPI",
|
||||||
|
"description": "Free RESTful API for anime streaming data. 18 endpoints, 12 providers, M3U8 streaming URLs.",
|
||||||
|
"url": "https://mirurotvapi.vercel.app",
|
||||||
|
"image": "https://mirurotvapi.vercel.app/og-image.png",
|
||||||
|
"documentation": "https://mirurotvapi.vercel.app/docs",
|
||||||
|
"provider": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "MiruroAPI",
|
||||||
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
|
},
|
||||||
|
"category": "Anime Streaming API",
|
||||||
|
"endpoint": "https://mirurotvapi.vercel.app/api",
|
||||||
|
"programmingModel": "REST",
|
||||||
|
"documentationUrl": "https://github.com/Shineii86/MiruroAPI/tree/main/docs"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div class="ambient">
|
||||||
|
<div class="orb orb-1"></div>
|
||||||
|
<div class="orb orb-2"></div>
|
||||||
|
<div class="orb orb-3"></div>
|
||||||
|
</div>
|
||||||
|
<canvas id="particles"></canvas>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header id="header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<img src="/icon-dark.svg" alt="Miruro" width="28" height="28" style="border-radius:6px" />
|
||||||
|
<span class="logo-text"><span>Miruro</span>API</span>
|
||||||
|
</a>
|
||||||
|
<nav>
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#endpoints">Endpoints</a>
|
||||||
|
<a href="#playground">Playground</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||||
|
<a href="/docs" target="_blank" class="nav-cta">Swagger UI</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="hero container">
|
||||||
|
<div class="hero-badge"><span class="dot"></span> Free · No API key · Open source</div>
|
||||||
|
<h1>The <span class="grad">Anime Streaming</span> API<br/>for Your Next Project</h1>
|
||||||
|
<p class="hero-sub">Search, discover, and stream anime with M3U8 URLs. Powered by AniList GraphQL + Miruro providers. 18 endpoints, 12 providers, zero config.</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a href="#playground" class="btn btn-primary">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
Try It Live
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank" class="btn btn-ghost">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
Star on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Console -->
|
||||||
|
<div class="console" id="heroConsole">
|
||||||
|
<div class="console-bar">
|
||||||
|
<span class="console-dot"></span><span class="console-dot"></span><span class="console-dot"></span>
|
||||||
|
<span class="console-title">mirurotvapi.vercel.app — API Console</span>
|
||||||
|
</div>
|
||||||
|
<div class="console-body">
|
||||||
|
<textarea class="console-input" id="consoleInput" rows="6" spellcheck="false">// Search for anime
|
||||||
|
fetch("https://mirurotvapi.vercel.app/api/search?query=naruto")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log(data.results);
|
||||||
|
});</textarea>
|
||||||
|
<div class="console-output" id="consoleOutput"><span style="color:var(--text3)">// Click "Run" to see the response</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="console-actions">
|
||||||
|
<button class="console-btn active" onclick="setEndpoint('search', event)">Search</button>
|
||||||
|
<button class="console-btn" onclick="setEndpoint('trending', event)">Trending</button>
|
||||||
|
<button class="console-btn" onclick="setEndpoint('episodes', event)">Episodes</button>
|
||||||
|
<button class="console-btn" onclick="setEndpoint('info', event)">Info</button>
|
||||||
|
<button class="console-btn" onclick="setEndpoint('health', event)">Health</button>
|
||||||
|
<button class="console-send" onclick="runConsole()">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:12px;height:12px;display:inline"><polygon points="5 3 19 12 5 21 5 3"/></svg> Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<section class="stats">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="badge">v1.0.0</div>
|
<div class="stats-grid">
|
||||||
<h1>MiruroAPI</h1>
|
<div class="reveal"><div class="stat-value grad" data-count="18">0</div><div class="stat-label">API Endpoints</div></div>
|
||||||
<p>Free RESTful API for anime streaming data. Powered by <a href="https://anilist.co">AniList GraphQL</a> and <a href="https://miruro.to">Miruro</a> streaming providers.</p>
|
<div class="reveal reveal-delay-1"><div class="stat-value grad" data-count="12">0</div><div class="stat-label">Streaming Providers</div></div>
|
||||||
<div class="endpoints">
|
<div class="reveal reveal-delay-2"><div class="stat-value grad" data-count="5000">0</div><div class="stat-label">Anime Indexed</div></div>
|
||||||
<h3>Endpoints</h3>
|
<div class="reveal reveal-delay-3"><div class="stat-value grad">M3U8</div><div class="stat-label">Stream Format</div></div>
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/search?query=naruto</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/trending</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/popular</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/upcoming</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/recent</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/spotlight</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/schedule</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/filter?genre=Action</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/info/:id</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/episodes/:id</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/watch/:provider/:id/:category/:slug</div>
|
|
||||||
<div class="endpoint"><span class="method">GET</span> /api/health</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Marquee -->
|
||||||
|
<div class="marquee">
|
||||||
|
<div class="marquee-track">
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 5-min cache TTL</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg> Zero config</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> No auth needed</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8m-4-4v4"/></svg> Vercel deployed</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> AniList GraphQL</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> REST JSON</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/></svg> CORS enabled</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> Open source</div>
|
||||||
|
<!-- duplicate for seamless loop -->
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 5-min cache TTL</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg> Zero config</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> No auth needed</div>
|
||||||
|
<div class="marquee-item"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8m-4-4v4"/></svg> Vercel deployed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<section class="section" id="features">
|
||||||
|
<div class="container">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-label">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||||
|
Features
|
||||||
|
</div>
|
||||||
|
<h2 class="section-title">Built for developers</h2>
|
||||||
|
<p class="section-desc">Everything you need to build an anime streaming website, mobile app, or Discord bot. No API keys, no rate limits, no hassle.</p>
|
||||||
|
</div>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card reveal">
|
||||||
|
<div class="feature-icon fi-accent">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">Full-text Search</div>
|
||||||
|
<div class="feature-desc">Search by title, synonyms, genres, tags. Get autocomplete suggestions and paginated results via AniList GraphQL.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card reveal reveal-delay-1">
|
||||||
|
<div class="feature-icon fi-accent2">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 7h5M17 17h5"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">M3U8 Streaming</div>
|
||||||
|
<div class="feature-desc">Get direct M3U8 streaming URLs from 12 providers. Supports sub, dub, download, skip timestamps, and thumbnails.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card reveal reveal-delay-2">
|
||||||
|
<div class="feature-icon fi-green">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">Rich Metadata</div>
|
||||||
|
<div class="feature-desc">Characters, staff, relations, recommendations, trailer, stats, format, status, season, episodes, and more from AniList.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card reveal">
|
||||||
|
<div class="feature-icon fi-cyan">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">Advanced Filters</div>
|
||||||
|
<div class="feature-desc">Filter by genre, tag, year, season, format, status, sort order. Multi-select support with AniList genre IDs.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card reveal reveal-delay-1">
|
||||||
|
<div class="feature-icon fi-amber">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">Schedule & Calendar</div>
|
||||||
|
<div class="feature-desc">View upcoming episodes with UNIX timestamps. Perfect for building calendar views and notification systems.</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card reveal reveal-delay-2">
|
||||||
|
<div class="feature-icon fi-pink">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">Fast & Cached</div>
|
||||||
|
<div class="feature-desc">In-memory cache with 5-minute TTL and max 100 entries. Snappy responses with reduced upstream requests.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Endpoints -->
|
||||||
|
<section class="section" id="endpoints" style="background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)">
|
||||||
|
<div class="container">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-label">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
|
API Reference
|
||||||
|
</div>
|
||||||
|
<h2 class="section-title">Endpoints</h2>
|
||||||
|
<p class="section-desc">All endpoints return JSON. Base URL: <code style="color:var(--accent);background:rgba(56,189,248,.1);padding:2px 8px;border-radius:4px">https://mirurotvapi.vercel.app/api</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="explorer reveal" id="explorer">
|
||||||
|
<div class="explorer-nav" id="explorerNav"></div>
|
||||||
|
<div class="explorer-panel" id="explorerPanel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Playground -->
|
||||||
|
<section class="section" id="playground">
|
||||||
|
<div class="container">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-label">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
Interactive
|
||||||
|
</div>
|
||||||
|
<h2 class="section-title">Playground</h2>
|
||||||
|
<p class="section-desc">Test endpoints directly in your browser. No signup required.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="playground-card reveal">
|
||||||
|
<div class="playground-header">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<h3>Search Anime</h3>
|
||||||
|
</div>
|
||||||
|
<div class="playground-body">
|
||||||
|
<div class="playground-inputs">
|
||||||
|
<input id="pgSearch" type="text" placeholder="e.g. naruto, one piece" aria-label="Search anime" />
|
||||||
|
<button class="btn btn-primary" onclick="pgRun('search')" style="padding:10px 20px">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="playground-output" id="pgSearchOut"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="playground-card reveal reveal-delay-1">
|
||||||
|
<div class="playground-header">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
<h3>Anime Info</h3>
|
||||||
|
</div>
|
||||||
|
<div class="playground-body">
|
||||||
|
<div class="playground-inputs">
|
||||||
|
<input id="pgInfo" type="text" placeholder="AniList ID (e.g. 20)" />
|
||||||
|
<button class="btn btn-primary" onclick="pgRun('info')" style="padding:10px 20px">Get Info</button>
|
||||||
|
</div>
|
||||||
|
<div class="playground-output" id="pgInfoOut"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Episodes -->
|
||||||
|
<div class="playground-card reveal reveal-delay-2">
|
||||||
|
<div class="playground-header">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
<h3>Get Episodes</h3>
|
||||||
|
</div>
|
||||||
|
<div class="playground-body">
|
||||||
|
<div class="playground-inputs">
|
||||||
|
<input id="pgEpisodes" type="text" placeholder="AniList ID (e.g. 20)" />
|
||||||
|
<button class="btn btn-primary" onclick="pgRun('episodes')" style="padding:10px 20px">Get Episodes</button>
|
||||||
|
</div>
|
||||||
|
<div class="playground-output" id="pgEpisodesOut"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Trending -->
|
||||||
|
<div class="playground-card reveal reveal-delay-3">
|
||||||
|
<div class="playground-header">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||||
|
<h3>Trending Anime</h3>
|
||||||
|
</div>
|
||||||
|
<div class="playground-body">
|
||||||
|
<div class="playground-inputs">
|
||||||
|
<button class="btn btn-primary" onclick="pgRun('trending')" style="padding:10px 20px">Get Trending</button>
|
||||||
|
</div>
|
||||||
|
<div class="playground-output" id="pgTrendingOut"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="cta">
|
||||||
|
<div class="container">
|
||||||
|
<div class="cta-box reveal">
|
||||||
|
<h2 class="cta-title">Start building with MiruroAPI</h2>
|
||||||
|
<p class="cta-desc">Free, open source, no API key required. Deploy your own instance or use ours.</p>
|
||||||
|
<div class="cta-actions">
|
||||||
|
<a href="/docs" target="_blank" class="btn btn-primary">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
Swagger UI Docs
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank" class="btn btn-ghost">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
Source Code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer>
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<img src="/icon-dark.svg" alt="Miruro" width="24" height="24" style="border-radius:6px" />
|
||||||
|
<span class="logo-text"><span>Miruro</span>API</span>
|
||||||
|
</a>
|
||||||
|
<p>Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro providers. Built with Node.js and deployed on Vercel.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>API</h4>
|
||||||
|
<a href="/api/search?query=naruto">Search</a>
|
||||||
|
<a href="/api/trending">Trending</a>
|
||||||
|
<a href="/api/popular">Popular</a>
|
||||||
|
<a href="/api/upcoming">Upcoming</a>
|
||||||
|
<a href="/api/schedule">Schedule</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Resources</h4>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI#readme" target="_blank" rel="noopener noreferrer">Documentation</a>
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI/blob/main/CHANGELOG.md" target="_blank" rel="noopener noreferrer">Changelog</a>
|
||||||
|
<a href="/docs" target="_blank" rel="noopener noreferrer">Swagger UI</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Legal</h4>
|
||||||
|
<a href="/privacy">Privacy Policy</a>
|
||||||
|
<a href="/tos">Terms of Service</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<span>© 2026 MiruroAPI. Educational use only.</span>
|
||||||
|
<div class="footer-social">
|
||||||
|
<a href="https://github.com/Shineii86/MiruroAPI" target="_blank" aria-label="GitHub">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ── Particles ── */
|
||||||
|
const canvas = document.getElementById('particles');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let particles = [];
|
||||||
|
function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
resizeCanvas();
|
||||||
|
class Particle {
|
||||||
|
constructor() { this.reset(); }
|
||||||
|
reset() {
|
||||||
|
this.x = Math.random() * canvas.width;
|
||||||
|
this.y = Math.random() * canvas.height;
|
||||||
|
this.size = Math.random() * 1.5 + .5;
|
||||||
|
this.speedX = (Math.random() - .5) * .3;
|
||||||
|
this.speedY = (Math.random() - .5) * .3;
|
||||||
|
this.opacity = Math.random() * .3 + .1;
|
||||||
|
}
|
||||||
|
update() {
|
||||||
|
this.x += this.speedX;
|
||||||
|
this.y += this.speedY;
|
||||||
|
if (this.x < 0 || this.x > canvas.width || this.y < 0 || this.y > canvas.height) this.reset();
|
||||||
|
}
|
||||||
|
draw() {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(56,189,248,${this.opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 60; i++) particles.push(new Particle());
|
||||||
|
function animateParticles() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
particles.forEach(p => { p.update(); p.draw(); });
|
||||||
|
requestAnimationFrame(animateParticles);
|
||||||
|
}
|
||||||
|
animateParticles();
|
||||||
|
|
||||||
|
/* ── Header Scroll ── */
|
||||||
|
const header = document.getElementById('header');
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
header.classList.toggle('scrolled', window.scrollY > 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Scroll Reveal ── */
|
||||||
|
const reveals = document.querySelectorAll('.reveal');
|
||||||
|
const revealObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); revealObserver.unobserve(e.target); } });
|
||||||
|
}, { threshold: .1, rootMargin: '0px 0px -40px 0px' });
|
||||||
|
reveals.forEach(el => revealObserver.observe(el));
|
||||||
|
|
||||||
|
/* ── Animated Counters ── */
|
||||||
|
document.querySelectorAll('[data-count]').forEach(el => {
|
||||||
|
const target = parseInt(el.dataset.count);
|
||||||
|
const obs = new IntersectionObserver(([e]) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
let current = 0;
|
||||||
|
const step = Math.ceil(target / 30);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
current += step;
|
||||||
|
if (current >= target) { current = target; clearInterval(timer); }
|
||||||
|
el.textContent = current >= 1000 ? current.toLocaleString() + '+' : current;
|
||||||
|
}, 30);
|
||||||
|
obs.unobserve(el);
|
||||||
|
}
|
||||||
|
}, { threshold: .5 });
|
||||||
|
obs.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Console ── */
|
||||||
|
const BASE = 'https://mirurotvapi.vercel.app/api';
|
||||||
|
const endpoints = {
|
||||||
|
search: { url: '/search?query=naruto', label: 'Search' },
|
||||||
|
trending: { url: '/trending', label: 'Trending' },
|
||||||
|
episodes: { url: '/episodes/20', label: 'Episodes' },
|
||||||
|
info: { url: '/info/20', label: 'Info' },
|
||||||
|
health: { url: '/health', label: 'Health' }
|
||||||
|
};
|
||||||
|
function setEndpoint(key, event) {
|
||||||
|
const ep = endpoints[key];
|
||||||
|
document.getElementById('consoleInput').value = `// ${ep.label}\nfetch("${BASE}${ep.url}")\n .then(r => r.json())\n .then(data => {\n console.log(data);\n });`;
|
||||||
|
document.querySelectorAll('.console-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
}
|
||||||
|
async function runConsole() {
|
||||||
|
const out = document.getElementById('consoleOutput');
|
||||||
|
out.innerHTML = '<span style="color:var(--amber)">Loading...</span>';
|
||||||
|
const input = document.getElementById('consoleInput').value;
|
||||||
|
const urlMatch = input.match(/fetch\("([^"]+)"\)/);
|
||||||
|
if (!urlMatch) { out.innerHTML = '<span style="color:var(--red)">No fetch URL found</span>'; return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(urlMatch[1]);
|
||||||
|
const data = await res.json();
|
||||||
|
out.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<span style="color:var(--red)">Error: ${e.message}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function syntaxHighlight(json) {
|
||||||
|
return json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"([^"]+)"(?=\s*:)/g, '<span style="color:var(--pink)">"$1"</span>')
|
||||||
|
.replace(/: "([^"]*)"/g, ': <span style="color:var(--green)">"$1"</span>')
|
||||||
|
.replace(/: (\d+)/g, ': <span style="color:var(--amber)">$1</span>')
|
||||||
|
.replace(/: (true|false|null)/g, ': <span style="color:var(--cyan)">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Explorer ── */
|
||||||
|
const explorerData = [
|
||||||
|
{ id: 'search', title: 'Search', desc: 'Full-text anime search with 20+ fields per result. Paginated.', path: '/search?query={q}&page={n}&per_page={n}', params: [{ name: 'query', type: 'string', desc: 'Search query' }, { name: 'page', type: 'number', desc: 'Page (default 1)' }, { name: 'per_page', type: 'number', desc: 'Results per page (default 20)' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/search?query=naruto"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/search?query=naruto");\nconst data = await res.json();\nconsole.log(data.results);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/search", params={"query": "naruto"})\nprint(r.json())' } },
|
||||||
|
{ id: 'suggestions', title: 'Suggestions', desc: 'Lightweight autocomplete for dropdowns. Max 8 results.', path: '/suggestions?query={q}', params: [{ name: 'query', type: 'string', desc: 'Partial query' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/suggestions?query=naru"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/suggestions?query=naru");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/suggestions", params={"query": "naru"})\nprint(r.json())' } },
|
||||||
|
{ id: 'info', title: 'Anime Info', desc: 'Everything AniList has — characters, staff, relations, recommendations, trailer, stats.', path: '/info/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/info/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/info/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/info/20")\nprint(r.json())' } },
|
||||||
|
{ id: 'characters', title: 'Characters', desc: 'Paginated characters with Japanese voice actors.', path: '/characters/{anilistId}?page={n}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }, { name: 'page', type: 'number', desc: 'Page (default 1)' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/characters/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/characters/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/characters/20")\nprint(r.json())' } },
|
||||||
|
{ id: 'relations', title: 'Relations', desc: 'Sequels, prequels, side stories, spin-offs.', path: '/relations/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/relations/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/relations/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/relations/20")\nprint(r.json())' } },
|
||||||
|
{ id: 'recommendations', title: 'Recommendations', desc: 'Community recommendations sorted by rating.', path: '/recommendations/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/recommendations/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/recommendations/20");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/recommendations/20")\nprint(r.json())' } },
|
||||||
|
{ id: 'episodes', title: 'Episodes', desc: 'Fetch all episodes from multiple providers (sub, dub, download).', path: '/episodes/{anilistId}', params: [{ name: 'anilistId', type: 'number', desc: 'AniList ID' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/episodes/20"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/episodes/20");\nconst data = await res.json();\nconsole.log(data.providers);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/episodes/20")\nprint(r.json())' } },
|
||||||
|
{ id: 'watch', title: 'Watch / Stream', desc: 'Get M3U8 streaming URL from episode ID. 12 providers.', path: '/watch/{provider}/{id}/{category}/{slug}', params: [{ name: 'provider', type: 'string', desc: 'Provider name (kiwi, pewe, etc.)' }, { name: 'id', type: 'string', desc: 'Episode ID' }, { name: 'category', type: 'string', desc: 'Category (sub, dub, raw)' }, { name: 'slug', type: 'string', desc: 'Episode slug' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/watch/kiwi/episode-id/sub/episode-slug"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/watch/kiwi/episode-id/sub/episode-slug");\nconst data = await res.json();\nconsole.log(data.streams[0].url);', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/watch/kiwi/episode-id/sub/episode-slug")\nprint(r.json())' } },
|
||||||
|
{ id: 'trending', title: 'Trending', desc: 'Currently trending anime across the community.', path: '/trending', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/trending"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/trending");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/trending")\nprint(r.json())' } },
|
||||||
|
{ id: 'popular', title: 'Popular', desc: 'Most popular anime of all time by user count.', path: '/popular', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/popular"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/popular");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/popular")\nprint(r.json())' } },
|
||||||
|
{ id: 'upcoming', title: 'Upcoming', desc: 'Most anticipated anime that haven\'t aired yet.', path: '/upcoming', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/upcoming"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/upcoming");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/upcoming")\nprint(r.json())' } },
|
||||||
|
{ id: 'recent', title: 'Recent Episodes', desc: 'Currently airing / this season\'s anime.', path: '/recent', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/recent"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/recent");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/recent")\nprint(r.json())' } },
|
||||||
|
{ id: 'spotlight', title: 'Spotlight', desc: 'Top 10 trending + popular. Perfect for hero banners.', path: '/spotlight', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/spotlight"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/spotlight");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/spotlight")\nprint(r.json())' } },
|
||||||
|
{ id: 'schedule', title: 'Schedule', desc: 'Upcoming episodes with UNIX timestamps.', path: '/schedule', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/schedule"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/schedule");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/schedule")\nprint(r.json())' } },
|
||||||
|
{ id: 'filter', title: 'Filter', desc: 'Filter by genre, tag, year, season, format, status, sort.', path: '/filter?genre={g}&format={f}&year={y}', params: [{ name: 'genre', type: 'number', desc: 'AniList genre ID' }, { name: 'format', type: 'string', desc: 'TV, MOVIE, OVA, etc.' }, { name: 'year', type: 'number', desc: 'Release year' }, { name: 'season', type: 'string', desc: 'WINTER, SPRING, SUMMER, FALL' }], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/filter?genre=1&format=TV"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/filter?genre=1&format=TV");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/filter", params={"genre": "1", "format": "TV"})\nprint(r.json())' } },
|
||||||
|
{ id: 'genres', title: 'Genres', desc: 'List all available genres with AniList IDs.', path: '/genres', params: [], code: { curl: 'curl "https://mirurotvapi.vercel.app/api/genres"', js: 'const res = await fetch("https://mirurotvapi.vercel.app/api/genres");\nconst data = await res.json();', python: 'import requests\nr = requests.get("https://mirurotvapi.vercel.app/api/genres")\nprint(r.json())' } },
|
||||||
|
];
|
||||||
|
const navEl = document.getElementById('explorerNav');
|
||||||
|
const panelEl = document.getElementById('explorerPanel');
|
||||||
|
let activeLang = 'curl';
|
||||||
|
|
||||||
|
function renderExplorer(ep) {
|
||||||
|
const paramsHtml = ep.params.map(p => `<div class="param-row"><span class="param-name">${p.name}</span><span class="param-type">${p.type}</span><span class="param-desc">${p.desc}</span></div>`).join('');
|
||||||
|
panelEl.innerHTML = `
|
||||||
|
<div class="explorer-method method-get">GET</div>
|
||||||
|
<div class="explorer-panel-title">${ep.title}</div>
|
||||||
|
<div class="explorer-panel-desc">${ep.desc}</div>
|
||||||
|
<div class="explorer-path">${ep.path}</div>
|
||||||
|
${ep.params.length ? `<div class="explorer-params"><div class="explorer-params-title">Parameters</div>${paramsHtml}</div>` : ''}
|
||||||
|
<div class="code-tabs">
|
||||||
|
<button class="code-tab ${activeLang==='curl'?'active':''}" onclick="switchLang('curl',this)">cURL</button>
|
||||||
|
<button class="code-tab ${activeLang==='js'?'active':''}" onclick="switchLang('js',this)">JavaScript</button>
|
||||||
|
<button class="code-tab ${activeLang==='python'?'active':''}" onclick="switchLang('python',this)">Python</button>
|
||||||
|
</div>
|
||||||
|
<div class="explorer-code" id="explorerCode">${ep.code[activeLang]}</div>`;
|
||||||
|
}
|
||||||
|
function switchLang(lang, btn) {
|
||||||
|
activeLang = lang;
|
||||||
|
document.querySelectorAll('.code-tab').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const activeEp = explorerData.find(e => e.id === navEl.querySelector('.active')?.dataset.id) || explorerData[0];
|
||||||
|
document.getElementById('explorerCode').textContent = activeEp.code[lang];
|
||||||
|
}
|
||||||
|
explorerData.forEach((ep, i) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `explorer-nav-btn ${i===0?'active':''}`;
|
||||||
|
btn.dataset.id = ep.id;
|
||||||
|
btn.innerHTML = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>${ep.title}`;
|
||||||
|
btn.onclick = () => {
|
||||||
|
navEl.querySelectorAll('.explorer-nav-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
renderExplorer(ep);
|
||||||
|
};
|
||||||
|
navEl.appendChild(btn);
|
||||||
|
});
|
||||||
|
renderExplorer(explorerData[0]);
|
||||||
|
|
||||||
|
/* ── Playground ── */
|
||||||
|
async function pgRun(type) {
|
||||||
|
let url, outEl;
|
||||||
|
if (type === 'search') {
|
||||||
|
const q = document.getElementById('pgSearch').value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
url = `${BASE}/search?query=${encodeURIComponent(q)}`;
|
||||||
|
outEl = document.getElementById('pgSearchOut');
|
||||||
|
} else if (type === 'info') {
|
||||||
|
const id = document.getElementById('pgInfo').value.trim();
|
||||||
|
if (!id) return;
|
||||||
|
url = `${BASE}/info/${id}`;
|
||||||
|
outEl = document.getElementById('pgInfoOut');
|
||||||
|
} else if (type === 'episodes') {
|
||||||
|
const id = document.getElementById('pgEpisodes').value.trim();
|
||||||
|
if (!id) return;
|
||||||
|
url = `${BASE}/episodes/${id}`;
|
||||||
|
outEl = document.getElementById('pgEpisodesOut');
|
||||||
|
} else if (type === 'trending') {
|
||||||
|
url = `${BASE}/trending`;
|
||||||
|
outEl = document.getElementById('pgTrendingOut');
|
||||||
|
}
|
||||||
|
outEl.classList.add('visible');
|
||||||
|
outEl.innerHTML = '<span style="color:var(--amber)">Loading...</span>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
outEl.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
outEl.innerHTML = `<span style="color:var(--red)">Error: ${e.message}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('pgSearch')?.addEventListener('keydown', e => { if (e.key === 'Enter') pgRun('search'); });
|
||||||
|
document.getElementById('pgInfo')?.addEventListener('keydown', e => { if (e.key === 'Enter') pgRun('info'); });
|
||||||
|
document.getElementById('pgEpisodes')?.addEventListener('keydown', e => { if (e.key === 'Enter') pgRun('episodes'); });
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "MiruroAPI",
|
||||||
|
"short_name": "MiruroAPI",
|
||||||
|
"description": "Free REST API for anime streaming data. 18 endpoints, 12 providers, M3U8 URLs. No API key required.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"id": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"lang": "en",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0A0A12",
|
||||||
|
"theme_color": "#A855F7",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-dark.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["developer", "entertainment"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,247 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "MiruroAPI",
|
||||||
|
"description": "Free RESTful API for anime streaming data. Powered by AniList GraphQL and Miruro streaming providers.",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"contact": {
|
||||||
|
"name": "Shineii86",
|
||||||
|
"url": "https://github.com/Shineii86/MiruroAPI"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "MIT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "https://mirurotvapi.vercel.app",
|
||||||
|
"description": "Production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/api/health": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["System"],
|
||||||
|
"summary": "Health check",
|
||||||
|
"operationId": "health",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "API is healthy",
|
||||||
|
"content": { "application/json": { "schema": { "type": "object" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/search": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Search & Discovery"],
|
||||||
|
"summary": "Search anime by name",
|
||||||
|
"operationId": "searchAnime",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Search query" },
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Search results with pagination" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/suggestions": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Search & Discovery"],
|
||||||
|
"summary": "Autocomplete suggestions",
|
||||||
|
"operationId": "getSuggestions",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "query", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Lightweight suggestions (max 8)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/filter": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Search & Discovery"],
|
||||||
|
"summary": "Advanced filter / browse",
|
||||||
|
"operationId": "filterAnime",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "genre", "in": "query", "schema": { "type": "string" }, "description": "e.g. Action, Romance" },
|
||||||
|
{ "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "e.g. Isekai, Time Skip" },
|
||||||
|
{ "name": "year", "in": "query", "schema": { "type": "integer" } },
|
||||||
|
{ "name": "season", "in": "query", "schema": { "type": "string", "enum": ["WINTER", "SPRING", "SUMMER", "FALL"] } },
|
||||||
|
{ "name": "format", "in": "query", "schema": { "type": "string", "enum": ["TV", "MOVIE", "OVA", "ONA", "SPECIAL", "MUSIC"] } },
|
||||||
|
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["RELEASING", "FINISHED", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"] } },
|
||||||
|
{ "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["SCORE_DESC", "POPULARITY_DESC", "TRENDING_DESC", "START_DATE_DESC", "FAVOURITES_DESC", "UPDATED_AT_DESC"], "default": "POPULARITY_DESC" } },
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 50 } }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Filtered results with pagination" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/trending": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Trending anime",
|
||||||
|
"operationId": "getTrending",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Trending anime list" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/popular": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Most popular anime",
|
||||||
|
"operationId": "getPopular",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Popular anime list" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/upcoming": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Upcoming anime",
|
||||||
|
"operationId": "getUpcoming",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Upcoming anime list" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/recent": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Recently updated anime",
|
||||||
|
"operationId": "getRecent",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Recent anime list" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/spotlight": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Spotlight / featured anime",
|
||||||
|
"operationId": "getSpotlight",
|
||||||
|
"responses": { "200": { "description": "Top 10 trending + popular" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/schedule": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Collections"],
|
||||||
|
"summary": "Airing schedule",
|
||||||
|
"operationId": "getSchedule",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Upcoming episodes with timestamps" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/info/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Anime Details"],
|
||||||
|
"summary": "Full anime info",
|
||||||
|
"operationId": "getAnimeInfo",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Complete anime metadata, characters, relations, recommendations" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/anime/{id}/characters": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Anime Details"],
|
||||||
|
"summary": "Characters with voice actors",
|
||||||
|
"operationId": "getCharacters",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 25 } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Paginated character list" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/anime/{id}/relations": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Anime Details"],
|
||||||
|
"summary": "Related anime",
|
||||||
|
"operationId": "getRelations",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Sequels, prequels, side stories, etc." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/anime/{id}/recommendations": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Anime Details"],
|
||||||
|
"summary": "Community recommendations",
|
||||||
|
"operationId": "getRecommendations",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||||
|
{ "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 10 } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Recommended anime sorted by rating" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/episodes/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Episode list from all providers",
|
||||||
|
"operationId": "getEpisodes",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "AniList anime ID" }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "Episodes organized by provider and audio type (sub/dub)" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/sources": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Streaming sources (detailed)",
|
||||||
|
"operationId": "getSources",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "episodeId", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "anilistId", "in": "query", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "category", "in": "query", "schema": { "type": "string", "enum": ["sub", "dub"], "default": "sub" } }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/watch/{provider}/{anilistId}/{category}/{slug}": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Streaming"],
|
||||||
|
"summary": "Streaming sources (simple slug-based)",
|
||||||
|
"operationId": "getWatchSources",
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "provider", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "anilistId", "in": "path", "required": true, "schema": { "type": "integer" } },
|
||||||
|
{ "name": "category", "in": "path", "required": true, "schema": { "type": "string", "enum": ["sub", "dub"] } },
|
||||||
|
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "e.g. animepahe-1" }
|
||||||
|
],
|
||||||
|
"responses": { "200": { "description": "M3U8 streams, subtitles, intro/outro timestamps" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
{ "name": "System", "description": "Health & status" },
|
||||||
|
{ "name": "Search & Discovery", "description": "Search, suggestions, and advanced filtering" },
|
||||||
|
{ "name": "Collections", "description": "Trending, popular, upcoming, recent, spotlight, schedule" },
|
||||||
|
{ "name": "Anime Details", "description": "Full anime info, characters, relations, recommendations" },
|
||||||
|
{ "name": "Streaming", "description": "Episode lists and M3U8 streaming sources" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Privacy Policy for MiruroAPI. We do not collect, store, or share personal data." />
|
||||||
|
<meta property="og:title" content="Privacy Policy — MiruroAPI" />
|
||||||
|
<meta property="og:description" content="Privacy Policy for MiruroAPI. We do not collect, store, or share personal data." />
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Privacy Policy — MiruroAPI</title>
|
||||||
|
<meta name="theme-color" content="#0A0A12" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0A0A12;--bg2:#111120;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.8}
|
||||||
|
.ambient{position:fixed;inset:0;pointer-events:none;z-index:0}
|
||||||
|
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.08}
|
||||||
|
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px}
|
||||||
|
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px}
|
||||||
|
header{position:sticky;top:0;z-index:100;background:rgba(10,10,18,.88);backdrop-filter:blur(24px);border-bottom:1px solid var(--border);padding:0 24px}
|
||||||
|
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:60px}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:1rem}
|
||||||
|
.logo span{background:linear-gradient(135deg,var(--purple),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
||||||
|
h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin-bottom:8px}
|
||||||
|
.updated{color:var(--text3);font-size:.82rem;margin-bottom:40px;font-family:'JetBrains Mono',monospace}
|
||||||
|
h2{font-size:1.2rem;font-weight:700;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--border)}
|
||||||
|
h2:first-of-type{border-top:none;padding-top:0}
|
||||||
|
p{color:var(--text2);font-size:.92rem;margin-bottom:16px}
|
||||||
|
ul{color:var(--text2);font-size:.92rem;margin:0 0 16px 20px}
|
||||||
|
li{margin-bottom:6px}
|
||||||
|
code{font-family:'JetBrains Mono',monospace;font-size:.82rem;background:rgba(168,85,247,.1);color:var(--purple);padding:2px 6px;border-radius:4px}
|
||||||
|
a{color:var(--purple);text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
footer{position:relative;z-index:1;border-top:1px solid var(--border);padding:24px;text-align:center;color:var(--text3);font-size:.75rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
||||||
|
<header><div class="header-inner"><a href="/" class="logo">📺 <span>Miruro</span>API</a></div></header>
|
||||||
|
<div class="content">
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<div class="updated">Last updated: 2026-06-09</div>
|
||||||
|
|
||||||
|
<h2>1. Overview</h2>
|
||||||
|
<p>MiruroAPI ("the Service") is a free, open-source REST API that provides anime data by aggregating information from AniList GraphQL and Miruro streaming providers. This Privacy Policy explains how we handle data when you use the Service.</p>
|
||||||
|
|
||||||
|
<h2>2. Data We Collect</h2>
|
||||||
|
<p>The Service is designed to be <strong>stateless and privacy-preserving</strong>. We do not collect, store, or process any personal data from API consumers.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>No user accounts</strong> — The API requires no authentication, registration, or API keys.</li>
|
||||||
|
<li><strong>No cookies</strong> — We do not set tracking cookies on API responses.</li>
|
||||||
|
<li><strong>No analytics</strong> — We do not use Google Analytics, Mixpanel, or any third-party analytics on API endpoints.</li>
|
||||||
|
<li><strong>No logging of personal data</strong> — Server logs may contain IP addresses and request paths (standard Vercel/Node.js behavior), but these are not stored, sold, or shared.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Third-Party Data Sources</h2>
|
||||||
|
<p>The Service fetches data from <code>graphql.anilist.co</code> (AniList) and <code>miruro.tv</code> (streaming providers). This data (anime titles, descriptions, images, episode information) belongs to the respective content owners. We do not claim ownership of this data.</p>
|
||||||
|
<p>If you are a content owner and wish to have your data removed from this API, please open an issue on our <a href="https://github.com/Shineii86/MiruroAPI" target="_blank" rel="noopener noreferrer">GitHub repository</a>.</p>
|
||||||
|
|
||||||
|
<h2>4. Data Storage</h2>
|
||||||
|
<p>The Service uses a simple in-memory cache (1-5 minute TTL) to reduce repeated API calls. This cache is:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Purely temporary and reset on every server restart</li>
|
||||||
|
<li>Not persisted to any database</li>
|
||||||
|
<li>Not associated with any user identity</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Data Sharing</h2>
|
||||||
|
<p>We do not sell, trade, or share any data with third parties. The API is provided as-is for educational and development purposes.</p>
|
||||||
|
|
||||||
|
<h2>6. Children's Privacy</h2>
|
||||||
|
<p>The Service is not directed at children under 13. We do not knowingly collect information from children under 13.</p>
|
||||||
|
|
||||||
|
<h2>7. Changes to This Policy</h2>
|
||||||
|
<p>We may update this Privacy Policy from time to time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
||||||
|
|
||||||
|
<h2>8. Contact</h2>
|
||||||
|
<p>For questions about this Privacy Policy, open an issue at <a href="https://github.com/Shineii86/MiruroAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/MiruroAPI/issues</a>.</p>
|
||||||
|
</div>
|
||||||
|
<footer>© 2026 MiruroAPI · <a href="/" style="color:var(--purple)">Home</a> · <a href="/tos" style="color:var(--purple)">Terms</a></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# MiruroAPI — https://mirurotvapi.vercel.app
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /api
|
||||||
|
|
||||||
|
Sitemap: https://mirurotvapi.vercel.app/sitemap.xml
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/api/health</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/privacy</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://mirurotvapi.vercel.app/tos</loc>
|
||||||
|
<lastmod>2026-06-09</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content="Terms of Service for MiruroAPI. Acceptable use, rate limits, liability, and DMCA policy." />
|
||||||
|
<meta property="og:title" content="Terms of Service — MiruroAPI" />
|
||||||
|
<meta property="og:description" content="Terms of Service for MiruroAPI. Acceptable use, rate limits, liability, and DMCA policy." />
|
||||||
|
<meta property="og:image" content="https://mirurotvapi.vercel.app/og-image.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Terms of Service — MiruroAPI</title>
|
||||||
|
<meta name="theme-color" content="#0A0A12" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-dark.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0A0A12;--bg2:#111120;--text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;--purple:#A855F7;--pink:#EC4899;--border:rgba(255,255,255,.06);--glass:rgba(255,255,255,.03)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.8}
|
||||||
|
.ambient{position:fixed;inset:0;pointer-events:none;z-index:0}
|
||||||
|
.orb{position:absolute;border-radius:50%;filter:blur(120px);opacity:.08}
|
||||||
|
.orb-1{width:500px;height:500px;background:var(--purple);top:-150px;left:-100px}
|
||||||
|
.orb-2{width:400px;height:400px;background:var(--pink);bottom:-100px;right:-80px}
|
||||||
|
header{position:sticky;top:0;z-index:100;background:rgba(10,10,18,.88);backdrop-filter:blur(24px);border-bottom:1px solid var(--border);padding:0 24px}
|
||||||
|
.header-inner{max-width:720px;margin:0 auto;display:flex;align-items:center;height:60px}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--text);font-weight:700;font-size:1rem}
|
||||||
|
.logo span{background:linear-gradient(135deg,var(--purple),var(--pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
|
.content{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:60px 24px 80px}
|
||||||
|
h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin-bottom:8px}
|
||||||
|
.updated{color:var(--text3);font-size:.82rem;margin-bottom:40px;font-family:'JetBrains Mono',monospace}
|
||||||
|
h2{font-size:1.2rem;font-weight:700;margin:32px 0 12px;padding-top:16px;border-top:1px solid var(--border)}
|
||||||
|
h2:first-of-type{border-top:none;padding-top:0}
|
||||||
|
p{color:var(--text2);font-size:.92rem;margin-bottom:16px}
|
||||||
|
ul{color:var(--text2);font-size:.92rem;margin:0 0 16px 20px}
|
||||||
|
li{margin-bottom:6px}
|
||||||
|
code{font-family:'JetBrains Mono',monospace;font-size:.82rem;background:rgba(168,85,247,.1);color:var(--purple);padding:2px 6px;border-radius:4px}
|
||||||
|
a{color:var(--purple);text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
strong{color:var(--text)}
|
||||||
|
footer{position:relative;z-index:1;border-top:1px solid var(--border);padding:24px;text-align:center;color:var(--text3);font-size:.75rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
||||||
|
<header><div class="header-inner"><a href="/" class="logo">📺 <span>Miruro</span>API</a></div></header>
|
||||||
|
<div class="content">
|
||||||
|
<h1>Terms of Service</h1>
|
||||||
|
<div class="updated">Last updated: 2026-06-09</div>
|
||||||
|
|
||||||
|
<h2>1. Acceptance of Terms</h2>
|
||||||
|
<p>By accessing or using MiruroAPI ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
|
||||||
|
|
||||||
|
<h2>2. Description of Service</h2>
|
||||||
|
<p>MiruroAPI is a free, open-source REST API that provides anime data by aggregating information from AniList GraphQL and Miruro streaming providers. The Service is provided "as is" without warranties of any kind.</p>
|
||||||
|
|
||||||
|
<h2>3. Educational Purpose</h2>
|
||||||
|
<p>The Service is built and maintained for <strong>educational and learning purposes only</strong>. It demonstrates API design, GraphQL integration, and serverless deployment techniques.</p>
|
||||||
|
|
||||||
|
<h2>4. No Affiliation</h2>
|
||||||
|
<p>The Service is <strong>not affiliated with, endorsed by, or connected to</strong> miruro.tv, Miruro, or any of its subsidiaries or affiliates. All trademarks and content belong to their respective owners.</p>
|
||||||
|
|
||||||
|
<h2>5. Third-Party Content</h2>
|
||||||
|
<p>All anime data, images, descriptions, and other content displayed through the API belong to their respective copyright holders (AniList, Miruro). The Service does not host, store, or distribute any copyrighted media.</p>
|
||||||
|
|
||||||
|
<h2>6. Acceptable Use</h2>
|
||||||
|
<p>You agree to use the Service responsibly and in compliance with all applicable laws. You must not:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Use the Service for any illegal purpose</li>
|
||||||
|
<li>Attempt to disrupt or overload the Service</li>
|
||||||
|
<li>Scrape the Service to build a competing product</li>
|
||||||
|
<li>Redistribute copyrighted content obtained through the Service</li>
|
||||||
|
<li>Use the Service to infringe on the rights of others</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>7. Rate Limiting</h2>
|
||||||
|
<p>The Service uses in-memory caching with configurable TTL. Rate limiting is enforced at 100 requests per minute per IP address. Exceeding this limit will result in a 429 status code.</p>
|
||||||
|
|
||||||
|
<h2>8. Availability</h2>
|
||||||
|
<p>The Service is hosted on Vercel's free tier and may experience downtime. We do not guarantee 100% availability. The Service may be modified, updated, or discontinued at any time without notice.</p>
|
||||||
|
|
||||||
|
<h2>9. Limitation of Liability</h2>
|
||||||
|
<p>In no event shall the creators of MiruroAPI be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of the Service.</p>
|
||||||
|
|
||||||
|
<h2>10. Intellectual Property</h2>
|
||||||
|
<p>The source code of MiruroAPI is open source and available on <a href="https://github.com/Shineii86/MiruroAPI" target="_blank" rel="noopener noreferrer">GitHub</a> under the repository's license terms.</p>
|
||||||
|
|
||||||
|
<h2>11. DMCA / Takedowns</h2>
|
||||||
|
<p>If you believe your copyrighted content is being displayed through this API without authorization, please open an issue at <a href="https://github.com/Shineii86/MiruroAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/MiruroAPI/issues</a> and we will promptly remove it.</p>
|
||||||
|
|
||||||
|
<h2>12. Changes to Terms</h2>
|
||||||
|
<p>We reserve the right to modify these Terms at any time. Changes will be posted on this page with an updated "Last updated" date.</p>
|
||||||
|
|
||||||
|
<h2>13. Contact</h2>
|
||||||
|
<p>For questions about these Terms, open an issue at <a href="https://github.com/Shineii86/MiruroAPI/issues" target="_blank" rel="noopener noreferrer">github.com/Shineii86/MiruroAPI/issues</a>.</p>
|
||||||
|
</div>
|
||||||
|
<footer>© 2026 MiruroAPI · <a href="/" style="color:var(--purple)">Home</a> · <a href="/privacy" style="color:var(--purple)">Privacy</a></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
- type: web
|
||||||
|
name: MiruroAPI
|
||||||
|
env: node
|
||||||
|
buildCommand: npm install
|
||||||
|
startCommand: npm start
|
||||||
|
envVars:
|
||||||
|
- key: ALLOWED_ORIGINS
|
||||||
|
value: https://miruro.tv,https://miruro.to,https://miruro.bz,https://miruro.ru
|
||||||
@@ -1,74 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — server.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Main entry point for the MiruroAPI Express server.
|
||||||
|
* Configures CORS, middleware, static files, Swagger docs,
|
||||||
|
* API routes, and 404 handling. Starts the server on the
|
||||||
|
* configured port.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* None (side-effect: starts Express server)
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SERVER CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// CORS
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(",")
|
? process.env.ALLOWED_ORIGINS.split(",")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
app.use(
|
// ══════════════════════════════════════════════════════════════
|
||||||
cors({
|
// CORS MIDDLEWARE
|
||||||
origin: function (origin, callback) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
if (!allowedOrigins || allowedOrigins.includes("*") || !origin) {
|
|
||||||
return callback(null, true);
|
|
||||||
}
|
|
||||||
if (allowedOrigins.includes(origin)) {
|
|
||||||
return callback(null, true);
|
|
||||||
}
|
|
||||||
return callback(null, true);
|
|
||||||
},
|
|
||||||
methods: ["GET", "OPTIONS"],
|
|
||||||
allowedHeaders: ["Content-Type"],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Security headers
|
// NOTE: Single unified CORS middleware — handles all origin validation
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader("access-control-allow-origin", "*");
|
const origin = req.headers.origin;
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
if (
|
||||||
res.setHeader("X-Frame-Options", "DENY");
|
!allowedOrigins ||
|
||||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
allowedOrigins.includes("*") ||
|
||||||
|
(origin && allowedOrigins.includes(origin))
|
||||||
|
) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||||
|
}
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static files
|
// ---- FEATURE: Security headers ----
|
||||||
app.use(express.static(path.join(__dirname, "public")));
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
// Health
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
app.get("/api/health", (req, res) => {
|
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||||
res.json({
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
status: "ok",
|
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
version: "1.0.0",
|
next();
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Routes
|
// ══════════════════════════════════════════════════════════════
|
||||||
const apiRoutes = require("./src/routes/apiRoutes");
|
// STATIC FILES
|
||||||
app.use("/api", apiRoutes);
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// SPA fallback
|
app.use(express.static(path.join(__dirname, "public"), { redirect: false }));
|
||||||
app.get("*", (req, res) => {
|
|
||||||
if (req.path.startsWith("/api/")) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
return res.status(404).json({ success: false, message: "Endpoint not found" });
|
// CLEAN URL ROUTES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Clean URL routes (no .html extension) ----
|
||||||
|
const fs = require("fs");
|
||||||
|
const publicDir = path.join(__dirname, "public");
|
||||||
|
|
||||||
|
app.get("/tos", (req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, "tos.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/privacy", (req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, "privacy.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SWAGGER UI DOCS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Interactive Swagger UI documentation at /docs ----
|
||||||
|
app.get("/docs", (req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, "docs.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// RESPONSE HELPERS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Standardized JSON response wrapper ----
|
||||||
|
/**
|
||||||
|
* Wraps data in a standardized success JSON response.
|
||||||
|
*
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {*} data - The data to return in the response
|
||||||
|
* @param {number} status - HTTP status code (default: 200)
|
||||||
|
*/
|
||||||
|
const jsonResponse = (res, data, status = 200) =>
|
||||||
|
res.status(status).json({ success: true, results: data });
|
||||||
|
|
||||||
|
// ---- FEATURE: Standardized error response wrapper ----
|
||||||
|
/**
|
||||||
|
* Returns a standardized error JSON response.
|
||||||
|
*
|
||||||
|
* @param {object} res - Express response object
|
||||||
|
* @param {string} message - Error message to return
|
||||||
|
* @param {number} status - HTTP status code (default: 500)
|
||||||
|
*/
|
||||||
|
const jsonError = (res, message = "Internal server error", status = 500) =>
|
||||||
|
res.status(status).json({ success: false, message });
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// RATE LIMITING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Rate limiting (100 requests per minute per IP) ----
|
||||||
|
const requestCounts = new Map();
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress;
|
||||||
|
const now = Date.now();
|
||||||
|
const windowMs = 60000;
|
||||||
|
const maxRequests = 100;
|
||||||
|
|
||||||
|
if (!requestCounts.has(ip)) {
|
||||||
|
requestCounts.set(ip, []);
|
||||||
}
|
}
|
||||||
res.sendFile(path.join(__dirname, "public", "index.html"));
|
|
||||||
|
const timestamps = requestCounts.get(ip).filter((t) => now - t < windowMs);
|
||||||
|
requestCounts.set(ip, timestamps);
|
||||||
|
|
||||||
|
if (timestamps.length >= maxRequests) {
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: "Rate limit exceeded. Try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.push(now);
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global error handler
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// API ROUTES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const apiRoutes = require("./src/routes/apiRoutes");
|
||||||
|
app.use("/api", apiRoutes(jsonResponse, jsonError));
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// 404 HANDLER
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Global error handler ----
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
res.status(500).json({ success: false, message: "Internal server error" });
|
res.status(err.status || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: err.message || "Internal server error",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Catch-all 404 handler for undefined routes ----
|
||||||
|
app.use((req, res) => {
|
||||||
|
if (req.path.startsWith("/api/")) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "Endpoint not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const notFoundPath = path.join(publicDir, "404.html");
|
||||||
|
if (fs.existsSync(notFoundPath)) {
|
||||||
|
return res.status(404).sendFile(notFoundPath);
|
||||||
|
}
|
||||||
|
res.sendFile(path.join(publicDir, "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SERVER START
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`MiruroAPI running on port ${PORT}`);
|
console.info(`MiruroAPI listening at ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: server.js
|
||||||
|
|||||||
@@ -1,7 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — anilist.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* AniList GraphQL API integration module. Provides functions for
|
||||||
|
* searching anime, fetching details, browsing collections, and
|
||||||
|
* querying the AniList GraphQL API. All metadata (titles, covers,
|
||||||
|
* genres, scores, characters, relations) comes from AniList.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* searchAnime, getSuggestions, getAnimeInfo, getAnimeCharacters,
|
||||||
|
* getAnimeRelations, getAnimeRecommendations, getCollection,
|
||||||
|
* getSpotlight, getSchedule, filterAnime
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ANILIST API CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AniList GraphQL API endpoint.
|
||||||
|
* Used for all metadata queries (search, trending, info, filter).
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
const ANILIST_URL = "https://graphql.anilist.co";
|
const ANILIST_URL = "https://graphql.anilist.co";
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// GRAPHQL FRAGMENTS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Media list fields fragment (used in collections/search) ----
|
||||||
|
/**
|
||||||
|
* Standard set of fields for anime list responses.
|
||||||
|
* Used in trending, popular, search, filter, and schedule endpoints.
|
||||||
|
* Includes titles, cover, format, scores, genres, studios, and airing info.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
const MEDIA_LIST_FIELDS = `
|
const MEDIA_LIST_FIELDS = `
|
||||||
id
|
id
|
||||||
title { romaji english native }
|
title { romaji english native }
|
||||||
@@ -27,6 +70,14 @@ const MEDIA_LIST_FIELDS = `
|
|||||||
endDate { year month day }
|
endDate { year month day }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// ---- FEATURE: Media full fields fragment (used in info endpoint) ----
|
||||||
|
/**
|
||||||
|
* Extended set of fields for detailed anime info responses.
|
||||||
|
* Includes everything in MEDIA_LIST_FIELDS plus characters, staff,
|
||||||
|
* relations, recommendations, trailer, stats, and external links.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
const MEDIA_FULL_FIELDS = `
|
const MEDIA_FULL_FIELDS = `
|
||||||
id
|
id
|
||||||
idMal
|
idMal
|
||||||
@@ -109,17 +160,53 @@ const MEDIA_FULL_FIELDS = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function anilistQuery(query, variables = {}) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CORE QUERY FUNCTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Execute AniList GraphQL query ----
|
||||||
|
/**
|
||||||
|
* Executes a GraphQL query against the AniList API.
|
||||||
|
* Handles request formatting and error checking.
|
||||||
|
*
|
||||||
|
* @param {string} query - The GraphQL query string
|
||||||
|
* @param {object} [variables={}] - Query variables
|
||||||
|
* @returns {Promise<object>} The `data` field from the AniList response
|
||||||
|
* @throws {Error} If the AniList API returns a non-200 status
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = await anilistQuery(MEDIA_LIST_FIELDS, { search: "naruto" });
|
||||||
|
*/
|
||||||
|
const anilistQuery = async (query, variables = {}) => {
|
||||||
const body = { query, variables };
|
const body = { query, variables };
|
||||||
const res = await axios.post(ANILIST_URL, body, {
|
const res = await axios.post(ANILIST_URL, body, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error("AniList query failed");
|
if (res.status !== 200) throw new Error("AniList query failed");
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
};
|
||||||
|
|
||||||
async function searchAnime(query, page = 1, perPage = 20) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEARCH ENDPOINTS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Full-text anime search with pagination ----
|
||||||
|
/**
|
||||||
|
* Searches AniList for anime matching the given query.
|
||||||
|
* Returns paginated results with full metadata per result.
|
||||||
|
*
|
||||||
|
* @param {string} query - The search query string
|
||||||
|
* @param {number} [page=1] - Page number for pagination
|
||||||
|
* @param {number} [perPage=20] - Results per page (max 50)
|
||||||
|
* @returns {Promise<object>} Paginated search results with page, perPage, total, hasNextPage, results[]
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const results = await searchAnime("naruto", 1, 10);
|
||||||
|
* console.log(results.total); // 5000+
|
||||||
|
*/
|
||||||
|
const searchAnime = async (query, page = 1, perPage = 20) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($search: String, $page: Int, $perPage: Int) {
|
query ($search: String, $page: Int, $perPage: Int) {
|
||||||
Page(page: $page, perPage: $perPage) {
|
Page(page: $page, perPage: $perPage) {
|
||||||
@@ -132,6 +219,7 @@ async function searchAnime(query, page = 1, perPage = 20) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { search: query, page, perPage });
|
const data = await anilistQuery(gql, { search: query, page, perPage });
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -139,9 +227,21 @@ async function searchAnime(query, page = 1, perPage = 20) {
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results: pageData.media,
|
results: pageData.media,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getSuggestions(query) {
|
// ---- FEATURE: Lightweight autocomplete suggestions (max 8) ----
|
||||||
|
/**
|
||||||
|
* Fetches search suggestions for autocomplete dropdowns.
|
||||||
|
* Returns minimal data (id, title, poster, format, status, year).
|
||||||
|
*
|
||||||
|
* @param {string} query - The search query string
|
||||||
|
* @returns {Promise<Array>} Array of up to 8 suggestion objects
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const suggestions = await getSuggestions("one piece");
|
||||||
|
* console.log(suggestions[0].title); // "ONE PIECE"
|
||||||
|
*/
|
||||||
|
const getSuggestions = async (query) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($search: String) {
|
query ($search: String) {
|
||||||
Page(page: 1, perPage: 8) {
|
Page(page: 1, perPage: 8) {
|
||||||
@@ -158,6 +258,7 @@ async function getSuggestions(query) {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { search: query });
|
const data = await anilistQuery(gql, { search: query });
|
||||||
|
|
||||||
return data.Page.media.map((item) => ({
|
return data.Page.media.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title.english || item.title.romaji,
|
title: item.title.english || item.title.romaji,
|
||||||
@@ -168,9 +269,26 @@ async function getSuggestions(query) {
|
|||||||
year: item.startDate?.year,
|
year: item.startDate?.year,
|
||||||
episodes: item.episodes,
|
episodes: item.episodes,
|
||||||
}));
|
}));
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getAnimeInfo(id) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ANIME DETAILS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Complete anime info by AniList ID ----
|
||||||
|
/**
|
||||||
|
* Fetches full anime metadata including characters, staff,
|
||||||
|
* relations, recommendations, trailer, stats, and external links.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList anime ID
|
||||||
|
* @returns {Promise<object>} Complete anime metadata object
|
||||||
|
* @throws {Error} If anime is not found
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const info = await getAnimeInfo(20); // Naruto
|
||||||
|
* console.log(info.title.romaji); // "NARUTO"
|
||||||
|
*/
|
||||||
|
const getAnimeInfo = async (id) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($id: Int) {
|
query ($id: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
Media(id: $id, type: ANIME) {
|
||||||
@@ -180,9 +298,19 @@ async function getAnimeInfo(id) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { id });
|
const data = await anilistQuery(gql, { id });
|
||||||
return data.Media;
|
return data.Media;
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getAnimeCharacters(id, page = 1, perPage = 25) {
|
// ---- FEATURE: Paginated character list with voice actors ----
|
||||||
|
/**
|
||||||
|
* Fetches paginated character list for an anime.
|
||||||
|
* Each character includes name, image, role, and Japanese voice actors.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList anime ID
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=25] - Results per page (max 50)
|
||||||
|
* @returns {Promise<object>} Paginated character list
|
||||||
|
*/
|
||||||
|
const getAnimeCharacters = async (id, page = 1, perPage = 25) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($id: Int, $page: Int, $perPage: Int) {
|
query ($id: Int, $page: Int, $perPage: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
Media(id: $id, type: ANIME) {
|
||||||
@@ -201,6 +329,7 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { id, page, perPage });
|
const data = await anilistQuery(gql, { id, page, perPage });
|
||||||
if (!data.Media) throw new Error("Anime not found");
|
if (!data.Media) throw new Error("Anime not found");
|
||||||
|
|
||||||
const chars = data.Media.characters;
|
const chars = data.Media.characters;
|
||||||
return {
|
return {
|
||||||
page: chars.pageInfo.currentPage,
|
page: chars.pageInfo.currentPage,
|
||||||
@@ -209,9 +338,17 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) {
|
|||||||
hasNextPage: chars.pageInfo.hasNextPage,
|
hasNextPage: chars.pageInfo.hasNextPage,
|
||||||
characters: chars.edges,
|
characters: chars.edges,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getAnimeRelations(id) {
|
// ---- FEATURE: Related anime (sequels, prequels, spin-offs) ----
|
||||||
|
/**
|
||||||
|
* Fetches all related media for an anime — sequels, prequels,
|
||||||
|
* side stories, spin-offs, and source material.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList anime ID
|
||||||
|
* @returns {Promise<object>} Relations with id, title, and relations[]
|
||||||
|
*/
|
||||||
|
const getAnimeRelations = async (id) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($id: Int) {
|
query ($id: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
Media(id: $id, type: ANIME) {
|
||||||
@@ -231,14 +368,25 @@ async function getAnimeRelations(id) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { id });
|
const data = await anilistQuery(gql, { id });
|
||||||
if (!data.Media) throw new Error("Anime not found");
|
if (!data.Media) throw new Error("Anime not found");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: data.Media.id,
|
id: data.Media.id,
|
||||||
title: data.Media.title,
|
title: data.Media.title,
|
||||||
relations: data.Media.relations.edges,
|
relations: data.Media.relations.edges,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getAnimeRecommendations(id, page = 1, perPage = 10) {
|
// ---- FEATURE: Community recommendations sorted by rating ----
|
||||||
|
/**
|
||||||
|
* Fetches paginated community recommendations for an anime.
|
||||||
|
* "If you liked X, you'll like Y" — sorted by highest rating.
|
||||||
|
*
|
||||||
|
* @param {number} id - AniList anime ID
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=10] - Results per page (max 25)
|
||||||
|
* @returns {Promise<object>} Paginated recommendations
|
||||||
|
*/
|
||||||
|
const getAnimeRecommendations = async (id, page = 1, perPage = 10) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($id: Int, $page: Int, $perPage: Int) {
|
query ($id: Int, $page: Int, $perPage: Int) {
|
||||||
Media(id: $id, type: ANIME) {
|
Media(id: $id, type: ANIME) {
|
||||||
@@ -259,6 +407,7 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { id, page, perPage });
|
const data = await anilistQuery(gql, { id, page, perPage });
|
||||||
if (!data.Media) throw new Error("Anime not found");
|
if (!data.Media) throw new Error("Anime not found");
|
||||||
|
|
||||||
const recs = data.Media.recommendations;
|
const recs = data.Media.recommendations;
|
||||||
return {
|
return {
|
||||||
page: recs.pageInfo.currentPage,
|
page: recs.pageInfo.currentPage,
|
||||||
@@ -267,9 +416,24 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) {
|
|||||||
hasNextPage: recs.pageInfo.hasNextPage,
|
hasNextPage: recs.pageInfo.hasNextPage,
|
||||||
recommendations: recs.nodes,
|
recommendations: recs.nodes,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getCollection(sortType, status = null, page = 1, perPage = 20) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COLLECTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Generic collection fetcher with sort and status ----
|
||||||
|
/**
|
||||||
|
* Internal helper for fetching anime collections (trending, popular, etc.).
|
||||||
|
* Builds a dynamic GraphQL query based on sort type and optional status filter.
|
||||||
|
*
|
||||||
|
* @param {string} sortType - AniList sort enum (e.g., "TRENDING_DESC", "POPULARITY_DESC")
|
||||||
|
* @param {string|null} status - Optional status filter (e.g., "RELEASING", "NOT_YET_RELEASED")
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated collection results
|
||||||
|
*/
|
||||||
|
const getCollection = async (sortType, status = null, page = 1, perPage = 20) => {
|
||||||
const statusFilter = status ? `, status: ${status}` : "";
|
const statusFilter = status ? `, status: ${status}` : "";
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($page: Int, $perPage: Int) {
|
query ($page: Int, $perPage: Int) {
|
||||||
@@ -283,6 +447,7 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { page, perPage });
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -290,9 +455,16 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) {
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results: pageData.media,
|
results: pageData.media,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getSpotlight() {
|
// ---- FEATURE: Spotlight/featured anime (top 10 trending + popular) ----
|
||||||
|
/**
|
||||||
|
* Fetches the top 10 trending + popular anime for hero banners.
|
||||||
|
* Combines TRENDING_DESC and POPULARITY_DESC sorts.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array>} Array of up to 10 anime objects
|
||||||
|
*/
|
||||||
|
const getSpotlight = async () => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query {
|
query {
|
||||||
Page(page: 1, perPage: 10) {
|
Page(page: 1, perPage: 10) {
|
||||||
@@ -304,9 +476,18 @@ async function getSpotlight() {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql);
|
const data = await anilistQuery(gql);
|
||||||
return data.Page.media;
|
return data.Page.media;
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getSchedule(page = 1, perPage = 20) {
|
// ---- FEATURE: Airing schedule with timestamps ----
|
||||||
|
/**
|
||||||
|
* Fetches upcoming anime episodes with UNIX timestamps.
|
||||||
|
* Each result includes next_episode number, airingAt, and timeUntilAiring.
|
||||||
|
*
|
||||||
|
* @param {number} [page=1] - Page number
|
||||||
|
* @param {number} [perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated schedule with airing timestamps
|
||||||
|
*/
|
||||||
|
const getSchedule = async (page = 1, perPage = 20) => {
|
||||||
const gql = `
|
const gql = `
|
||||||
query ($page: Int, $perPage: Int) {
|
query ($page: Int, $perPage: Int) {
|
||||||
Page(page: $page, perPage: $perPage) {
|
Page(page: $page, perPage: $perPage) {
|
||||||
@@ -322,12 +503,14 @@ async function getSchedule(page = 1, perPage = 20) {
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, { page, perPage });
|
const data = await anilistQuery(gql, { page, perPage });
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
const results = pageData.airingSchedules.map((item) => ({
|
const results = pageData.airingSchedules.map((item) => ({
|
||||||
...item.media,
|
...item.media,
|
||||||
next_episode: item.episode,
|
next_episode: item.episode,
|
||||||
airingAt: item.airingAt,
|
airingAt: item.airingAt,
|
||||||
timeUntilAiring: item.timeUntilAiring,
|
timeUntilAiring: item.timeUntilAiring,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -335,9 +518,29 @@ async function getSchedule(page = 1, perPage = 20) {
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results,
|
results,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function filterAnime({ genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, perPage = 20 }) {
|
// ---- FEATURE: Advanced anime filter with multiple parameters ----
|
||||||
|
/**
|
||||||
|
* Advanced anime filter supporting genre, tag, year, season,
|
||||||
|
* format, status, and sort. All parameters are optional.
|
||||||
|
*
|
||||||
|
* @param {object} options - Filter options
|
||||||
|
* @param {string} [options.genre] - Genre name (e.g., "Action", "Romance")
|
||||||
|
* @param {string} [options.tag] - Tag name (e.g., "Isekai", "Time Skip")
|
||||||
|
* @param {number} [options.year] - Season year (e.g., 2025)
|
||||||
|
* @param {string} [options.season] - Season (WINTER, SPRING, SUMMER, FALL)
|
||||||
|
* @param {string} [options.format] - Format (TV, MOVIE, OVA, ONA, SPECIAL)
|
||||||
|
* @param {string} [options.status] - Status (RELEASING, FINISHED, NOT_YET_RELEASED)
|
||||||
|
* @param {string} [options.sort="POPULARITY_DESC"] - Sort order
|
||||||
|
* @param {number} [options.page=1] - Page number
|
||||||
|
* @param {number} [options.perPage=20] - Results per page
|
||||||
|
* @returns {Promise<object>} Paginated filtered results
|
||||||
|
*/
|
||||||
|
const filterAnime = async ({
|
||||||
|
genre, tag, year, season, format, status,
|
||||||
|
sort = "POPULARITY_DESC", page = 1, perPage = 20,
|
||||||
|
}) => {
|
||||||
const SORT_MAP = {
|
const SORT_MAP = {
|
||||||
SCORE_DESC: "SCORE_DESC",
|
SCORE_DESC: "SCORE_DESC",
|
||||||
POPULARITY_DESC: "POPULARITY_DESC",
|
POPULARITY_DESC: "POPULARITY_DESC",
|
||||||
@@ -347,6 +550,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
|
|||||||
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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"];
|
||||||
@@ -370,6 +574,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
|
|||||||
`;
|
`;
|
||||||
const data = await anilistQuery(gql, variables);
|
const data = await anilistQuery(gql, variables);
|
||||||
const pageData = data.Page;
|
const pageData = data.Page;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: pageData.pageInfo.currentPage,
|
page: pageData.pageInfo.currentPage,
|
||||||
perPage: pageData.pageInfo.perPage,
|
perPage: pageData.pageInfo.perPage,
|
||||||
@@ -377,7 +582,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
|
|||||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||||
results: pageData.media,
|
results: pageData.media,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
anilistQuery,
|
anilistQuery,
|
||||||
@@ -394,3 +599,5 @@ module.exports = {
|
|||||||
MEDIA_LIST_FIELDS,
|
MEDIA_LIST_FIELDS,
|
||||||
MEDIA_FULL_FIELDS,
|
MEDIA_FULL_FIELDS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: anilist.js
|
||||||
|
|||||||
@@ -1,27 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — cache.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* In-memory caching utility for storing and retrieving API responses.
|
||||||
|
* Implements TTL-based cache invalidation to prevent stale data
|
||||||
|
* while reducing unnecessary requests to AniList/Miruro backends.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getCached, setCache, clearCache
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CACHE STORAGE & CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: In-memory Map-based cache storage ----
|
||||||
|
/**
|
||||||
|
* Internal cache storage using JavaScript Map object.
|
||||||
|
* Stores data with expiry timestamps for TTL-based expiration.
|
||||||
|
*
|
||||||
|
* @type {Map<string, {data: any, expiry: number}>}
|
||||||
|
*/
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
const MAX_CACHE_SIZE = 100;
|
|
||||||
|
// ---- FEATURE: Cache time-to-live configuration (1 minute) ----
|
||||||
|
/**
|
||||||
|
* Default cache TTL (Time-To-Live) in milliseconds.
|
||||||
|
* Default: 1 minute (60,000 ms).
|
||||||
|
* After this duration, cached items are considered stale
|
||||||
|
* and will be removed on next access.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @default 60000
|
||||||
|
*/
|
||||||
const DEFAULT_TTL = 60 * 1000;
|
const DEFAULT_TTL = 60 * 1000;
|
||||||
|
|
||||||
function getCached(key) {
|
// ---- FEATURE: Maximum cache size limit ----
|
||||||
|
/**
|
||||||
|
* Maximum number of entries allowed in the cache.
|
||||||
|
* When exceeded, the oldest entry is evicted (FIFO).
|
||||||
|
* Prevents unbounded memory growth on long-running instances.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @default 100
|
||||||
|
*/
|
||||||
|
const MAX_CACHE_SIZE = 100;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// CACHE OPERATIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Retrieve cached data by key with TTL validation ----
|
||||||
|
/**
|
||||||
|
* Retrieves data from cache if it exists and hasn't expired.
|
||||||
|
* Automatically removes stale entries on access (lazy eviction).
|
||||||
|
*
|
||||||
|
* @param {string} key - The cache key to lookup
|
||||||
|
* @returns {any|null} Cached data if valid, null if expired or missing
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = getCached("search:naruto:1:20");
|
||||||
|
* if (data) {
|
||||||
|
* // Use cached data
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
const getCached = (key) => {
|
||||||
const entry = cache.get(key);
|
const entry = cache.get(key);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
|
// NOTE: Lazy eviction — delete stale entries on access
|
||||||
if (Date.now() > entry.expiry) {
|
if (Date.now() > entry.expiry) {
|
||||||
cache.delete(key);
|
cache.delete(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return entry.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCache(key, data, ttl = DEFAULT_TTL) {
|
return entry.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Store data in cache with TTL expiry ----
|
||||||
|
/**
|
||||||
|
* Stores data in cache with TTL-based expiry.
|
||||||
|
* Evicts oldest entry if cache is at max capacity (FIFO).
|
||||||
|
*
|
||||||
|
* @param {string} key - The cache key to store under
|
||||||
|
* @param {any} data - The data to cache (any serializable type)
|
||||||
|
* @param {number} [ttl=DEFAULT_TTL] - Time-to-live in milliseconds
|
||||||
|
* @returns {void}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Cache search results for 2 minutes
|
||||||
|
* setCache("search:one-piece", results, 120000);
|
||||||
|
*/
|
||||||
|
const setCache = (key, data, ttl = DEFAULT_TTL) => {
|
||||||
|
// NOTE: FIFO eviction — remove oldest entry when cache is full
|
||||||
if (cache.size >= MAX_CACHE_SIZE) {
|
if (cache.size >= MAX_CACHE_SIZE) {
|
||||||
const firstKey = cache.keys().next().value;
|
const firstKey = cache.keys().next().value;
|
||||||
cache.delete(firstKey);
|
cache.delete(firstKey);
|
||||||
}
|
}
|
||||||
cache.set(key, { data, expiry: Date.now() + ttl });
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCache() {
|
cache.set(key, { data, expiry: Date.now() + ttl });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Clear all cached entries from memory ----
|
||||||
|
/**
|
||||||
|
* Clears all entries from the cache. Use when:
|
||||||
|
* - Memory cleanup is needed
|
||||||
|
* - Force-refreshing all data
|
||||||
|
* - Application restart/reset
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const clearCache = () => {
|
||||||
cache.clear();
|
cache.clear();
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports = { getCached, setCache, clearCache };
|
module.exports = { getCached, setCache, clearCache };
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: cache.js
|
||||||
|
|||||||
@@ -1,20 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — pipe.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Miruro streaming pipe integration. Handles encoding/decoding
|
||||||
|
* of the secure/pipe tunnel that Miruro uses for episode lists
|
||||||
|
* and streaming sources. Decodes base64+gzip responses and
|
||||||
|
* injects simplified slug-based episode IDs.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* getEpisodes, getSources, getWatchSources
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const { Buffer } = require("buffer");
|
const { Buffer } = require("buffer");
|
||||||
const zlib = require("zlib");
|
const zlib = require("zlib");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// MIRURO PIPE CONFIGURATION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Miruro's secure pipe endpoint URL.
|
||||||
|
* All streaming requests go through this base64+gzip encoded tunnel.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
|
const MIRURO_PIPE_URL = "https://www.miruro.tv/api/secure/pipe";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request headers mimicking a browser.
|
||||||
|
* Referer is required for the pipe endpoint to accept requests.
|
||||||
|
*
|
||||||
|
* @type {object}
|
||||||
|
*/
|
||||||
const HEADERS = {
|
const HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
"User-Agent": "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/",
|
||||||
};
|
};
|
||||||
|
|
||||||
function encodePipeRequest(payload) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ENCODING / DECODING UTILITIES
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Base64url encode pipe request payload ----
|
||||||
|
/**
|
||||||
|
* Encodes a JSON payload into the base64url format expected by the pipe endpoint.
|
||||||
|
* Used for all pipe API requests (episodes, sources).
|
||||||
|
*
|
||||||
|
* @param {object} payload - The request payload to encode
|
||||||
|
* @returns {string} Base64url-encoded string (no padding)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const encoded = encodePipeRequest({ path: "episodes", method: "GET", query: { anilistId: 20 } });
|
||||||
|
*/
|
||||||
|
const encodePipeRequest = (payload) => {
|
||||||
const json = JSON.stringify(payload);
|
const json = JSON.stringify(payload);
|
||||||
return Buffer.from(json).toString("base64url");
|
return Buffer.from(json).toString("base64url");
|
||||||
}
|
};
|
||||||
|
|
||||||
function decodePipeResponse(encodedStr) {
|
// ---- FEATURE: Decode base64+gzip pipe response ----
|
||||||
|
/**
|
||||||
|
* Decodes a pipe response from base64url + gzip format into a plain object.
|
||||||
|
* The Miruro pipe returns all responses in this compressed format.
|
||||||
|
*
|
||||||
|
* @param {string} encodedStr - The base64url-encoded gzipped response
|
||||||
|
* @returns {object} Decoded JSON object
|
||||||
|
* @throws {Error} If decoding or decompression fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = decodePipeResponse(res.text);
|
||||||
|
* console.log(data.providers); // { kiwi: {...}, bee: {...} }
|
||||||
|
*/
|
||||||
|
const decodePipeResponse = (encodedStr) => {
|
||||||
try {
|
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 compressed = Buffer.from(padded, "base64url");
|
||||||
const decompressed = zlib.gunzipSync(compressed);
|
const decompressed = zlib.gunzipSync(compressed);
|
||||||
@@ -22,20 +87,41 @@ function decodePipeResponse(encodedStr) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error("Failed to decode pipe response: " + e.message);
|
throw new Error("Failed to decode pipe response: " + e.message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function translateId(encodedId) {
|
// ---- FEATURE: Decode base64 episode ID to plain text ----
|
||||||
|
/**
|
||||||
|
* Decodes a base64-encoded episode ID back to plain text.
|
||||||
|
* Episode IDs from the pipe are base64-encoded with ":" separators.
|
||||||
|
*
|
||||||
|
* @param {string} encodedId - The base64url-encoded episode ID
|
||||||
|
* @returns {string} Decoded plain text ID, or original if decoding fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const decoded = translateId("YW5pbWVwaGU6MjA6c3ViOjE=");
|
||||||
|
* // returns "animepahe:20:sub:1"
|
||||||
|
*/
|
||||||
|
const translateId = (encodedId) => {
|
||||||
try {
|
try {
|
||||||
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
|
const padded = encodedId + "=".repeat((4 - (encodedId.length % 4)) % 4);
|
||||||
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
|
const decoded = Buffer.from(padded, "base64url").toString("utf-8");
|
||||||
|
// NOTE: Only return decoded value if it looks like a valid ID (contains ":")
|
||||||
if (decoded.includes(":")) return decoded;
|
if (decoded.includes(":")) return decoded;
|
||||||
return encodedId;
|
return encodedId;
|
||||||
} catch {
|
} catch {
|
||||||
return encodedId;
|
return encodedId;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function deepTranslate(obj) {
|
// ---- FEATURE: Recursively decode all base64 IDs in a nested object ----
|
||||||
|
/**
|
||||||
|
* Walks a JSON structure and decodes any base64 "id" fields.
|
||||||
|
* Handles nested objects and arrays of objects.
|
||||||
|
*
|
||||||
|
* @param {object|Array} obj - The object or array to process
|
||||||
|
* @returns {object|Array} The same object with decoded IDs (mutated in place)
|
||||||
|
*/
|
||||||
|
const deepTranslate = (obj) => {
|
||||||
if (obj && typeof obj === "object") {
|
if (obj && typeof obj === "object") {
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
if (key === "id" && typeof obj[key] === "string") {
|
if (key === "id" && typeof obj[key] === "string") {
|
||||||
@@ -46,33 +132,73 @@ function deepTranslate(obj) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
};
|
||||||
|
|
||||||
function injectSourceSlugs(data, anilistId) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// EPISODE ID INJECTION
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Transform episode IDs into simplified path-based slugs ----
|
||||||
|
/**
|
||||||
|
* Converts raw pipe episode IDs into clean slug format:
|
||||||
|
* watch/{provider}/{anilistId}/{category}/{prefix}-{number}
|
||||||
|
*
|
||||||
|
* This makes episode IDs human-readable and URL-safe for the /watch endpoint.
|
||||||
|
*
|
||||||
|
* @param {object} data - Raw pipe response with providers
|
||||||
|
* @param {number} anilistId - The AniList anime ID
|
||||||
|
* @returns {object} Modified data with slug-based episode IDs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Before: "animepahe:20:sub:1"
|
||||||
|
* // After: "watch/kiwi/20/sub/animepahe-1"
|
||||||
|
*/
|
||||||
|
const injectSourceSlugs = (data, anilistId) => {
|
||||||
const providers = data.providers || {};
|
const providers = data.providers || {};
|
||||||
|
|
||||||
for (const [provName, provData] of Object.entries(providers)) {
|
for (const [provName, provData] of Object.entries(providers)) {
|
||||||
if (!provData || typeof provData !== "object") continue;
|
if (!provData || typeof provData !== "object") continue;
|
||||||
|
|
||||||
let episodes = provData.episodes;
|
let episodes = provData.episodes;
|
||||||
if (!episodes) continue;
|
if (!episodes) continue;
|
||||||
|
|
||||||
|
// NOTE: Some providers return a flat array — wrap it in { sub: [...] }
|
||||||
if (Array.isArray(episodes)) {
|
if (Array.isArray(episodes)) {
|
||||||
provData.episodes = { sub: episodes };
|
provData.episodes = { sub: episodes };
|
||||||
episodes = provData.episodes;
|
episodes = provData.episodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [category, epList] of Object.entries(episodes)) {
|
for (const [category, epList] of Object.entries(episodes)) {
|
||||||
if (!Array.isArray(epList)) continue;
|
if (!Array.isArray(epList)) continue;
|
||||||
|
|
||||||
for (const ep of epList) {
|
for (const ep of epList) {
|
||||||
if (ep.id && ep.number) {
|
if (ep.id && ep.number) {
|
||||||
const origId = ep.id;
|
const origId = ep.id;
|
||||||
|
// NOTE: Take only the prefix before ":" for the slug
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
||||||
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
ep.id = `watch/${provName}/${anilistId}/${category}/${prefix}-${ep.number}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchRawEpisodes(anilistId) {
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// PIPE API FUNCTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Fetch raw decoded episode data from Miruro pipe ----
|
||||||
|
/**
|
||||||
|
* Internal helper to fetch and decode raw episode data from the pipe.
|
||||||
|
* Sends a base64-encoded request and decodes the gzipped response.
|
||||||
|
*
|
||||||
|
* @param {number} anilistId - The AniList anime ID
|
||||||
|
* @returns {Promise<object>} Decoded episode data with providers
|
||||||
|
* @throws {Error} If the pipe request fails
|
||||||
|
*/
|
||||||
|
const fetchRawEpisodes = async (anilistId) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
path: "episodes",
|
path: "episodes",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -80,22 +206,60 @@ async function fetchRawEpisodes(anilistId) {
|
|||||||
body: null,
|
body: null,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encodedReq = encodePipeRequest(payload);
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
||||||
headers: HEADERS,
|
headers: HEADERS,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
|
if (res.status !== 200) throw new Error(`Pipe request failed: ${res.status}`);
|
||||||
const data = decodePipeResponse(res.text || res.data);
|
const data = decodePipeResponse(res.text || res.data);
|
||||||
return deepTranslate(data);
|
return deepTranslate(data);
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getEpisodes(anilistId) {
|
// ---- FEATURE: Get episodes with slug-based IDs ----
|
||||||
|
/**
|
||||||
|
* Fetches the full episode list for an anime from all providers.
|
||||||
|
* Returns episodes with simplified slug-based IDs for the /watch endpoint.
|
||||||
|
*
|
||||||
|
* @param {number} anilistId - The AniList anime ID
|
||||||
|
* @returns {Promise<object>} Episode data with providers and mappings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const episodes = await getEpisodes(20); // Naruto
|
||||||
|
* console.log(Object.keys(episodes.providers)); // ["kiwi", "bee", "bonk", ...]
|
||||||
|
*/
|
||||||
|
const getEpisodes = async (anilistId) => {
|
||||||
const data = await fetchRawEpisodes(anilistId);
|
const data = await fetchRawEpisodes(anilistId);
|
||||||
return injectSourceSlugs(data, anilistId);
|
const result = injectSourceSlugs(data, anilistId);
|
||||||
|
|
||||||
|
// NOTE: Ensure mappings field is present (like Walter's API)
|
||||||
|
if (!result.mappings) {
|
||||||
|
result.mappings = { anilistId };
|
||||||
|
if (result.malId) result.mappings.malId = result.malId;
|
||||||
|
if (result.kitsuId) result.mappings.kitsuId = result.kitsuId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSources(episodeId, provider, anilistId, category = "sub") {
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- FEATURE: Get streaming sources (detailed endpoint) ----
|
||||||
|
/**
|
||||||
|
* Fetches M3U8 streaming sources for a specific episode.
|
||||||
|
* This is the detailed/manual endpoint — use /watch for simpler access.
|
||||||
|
*
|
||||||
|
* @param {string} episodeId - The raw episode ID from the pipe
|
||||||
|
* @param {string} provider - Provider name (e.g., "kiwi", "bee", "bonk")
|
||||||
|
* @param {number} anilistId - The AniList anime ID
|
||||||
|
* @param {string} [category="sub"] - Audio category ("sub" or "dub")
|
||||||
|
* @returns {Promise<object>} Streaming sources with M3U8 URLs, subtitles, timestamps
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const sources = await getSources("animepahe:20:sub:1", "kiwi", 20, "sub");
|
||||||
|
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
|
||||||
|
*/
|
||||||
|
const getSources = async (episodeId, provider, anilistId, category = "sub") => {
|
||||||
const encId = Buffer.from(episodeId).toString("base64url");
|
const encId = Buffer.from(episodeId).toString("base64url");
|
||||||
const payload = {
|
const payload = {
|
||||||
path: "sources",
|
path: "sources",
|
||||||
@@ -104,33 +268,57 @@ async function getSources(episodeId, provider, anilistId, category = "sub") {
|
|||||||
body: null,
|
body: null,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedReq = encodePipeRequest(payload);
|
const encodedReq = encodePipeRequest(payload);
|
||||||
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
const res = await axios.get(`${MIRURO_PIPE_URL}?e=${encodedReq}`, {
|
||||||
headers: HEADERS,
|
headers: HEADERS,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
if (res.status !== 200) throw new Error(`Pipe sources request failed: ${res.status}`);
|
||||||
return deepTranslate(decodePipeResponse(res.text || res.data));
|
return deepTranslate(decodePipeResponse(res.text || res.data));
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getWatchSources(provider, anilistId, category, slug) {
|
// ---- FEATURE: Get streaming sources (simple slug-based endpoint) ----
|
||||||
|
/**
|
||||||
|
* Resolves a slug-based episode ID and fetches its streaming sources.
|
||||||
|
* This is the recommended endpoint — just pass the slug from /episodes.
|
||||||
|
*
|
||||||
|
* @param {string} provider - Provider name (e.g., "kiwi", "bee")
|
||||||
|
* @param {number} anilistId - The AniList anime ID
|
||||||
|
* @param {string} category - Audio category ("sub" or "dub")
|
||||||
|
* @param {string} slug - Episode slug (e.g., "animepahe-1")
|
||||||
|
* @returns {Promise<object>} Streaming sources with M3U8 URLs, subtitles, timestamps
|
||||||
|
* @throws {Error} If provider or episode slug is not found
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const sources = await getWatchSources("kiwi", 20, "sub", "animepahe-1");
|
||||||
|
* console.log(sources.streams[0].url); // "https://.../master.m3u8"
|
||||||
|
*/
|
||||||
|
const getWatchSources = async (provider, anilistId, category, slug) => {
|
||||||
const data = await fetchRawEpisodes(anilistId);
|
const data = await fetchRawEpisodes(anilistId);
|
||||||
const provData = (data.providers || {})[provider];
|
const provData = (data.providers || {})[provider];
|
||||||
|
|
||||||
if (!provData) throw new Error(`Provider ${provider} not found`);
|
if (!provData) throw new Error(`Provider ${provider} not found`);
|
||||||
|
|
||||||
const episodes = provData.episodes?.[category] || [];
|
const episodes = provData.episodes?.[category] || [];
|
||||||
let targetId = null;
|
let targetId = null;
|
||||||
|
|
||||||
|
// NOTE: Resolve slug back to the original pipe episode ID
|
||||||
for (const ep of episodes) {
|
for (const ep of episodes) {
|
||||||
const origId = ep.id || "";
|
const origId = ep.id || "";
|
||||||
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
const prefix = origId.includes(":") ? origId.split(":")[0] : origId;
|
||||||
const generated = `${prefix}-${ep.number}`;
|
const generated = `${prefix}-${ep.number}`;
|
||||||
|
|
||||||
if (generated === slug) {
|
if (generated === slug) {
|
||||||
targetId = origId;
|
targetId = origId;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
|
if (!targetId) throw new Error(`Episode slug '${slug}' not found for provider ${provider}`);
|
||||||
return getSources(targetId, provider, anilistId, category);
|
return getSources(targetId, provider, anilistId, category);
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getEpisodes,
|
getEpisodes,
|
||||||
@@ -142,3 +330,5 @@ module.exports = {
|
|||||||
deepTranslate,
|
deepTranslate,
|
||||||
injectSourceSlugs,
|
injectSourceSlugs,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: pipe.js
|
||||||
|
|||||||
@@ -1,101 +1,203 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — apiRoutes.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Central API router that maps all Express GET endpoints to their
|
||||||
|
* corresponding handler functions. Each route wraps its handler
|
||||||
|
* in try/catch for consistent error handling. Includes health,
|
||||||
|
* stats, and OpenAPI specification endpoints.
|
||||||
|
*
|
||||||
|
* @exports
|
||||||
|
* createApiRoutes - Function that registers all API routes
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const router = express.Router();
|
|
||||||
const anilist = require("../helpers/anilist");
|
const anilist = require("../helpers/anilist");
|
||||||
const pipe = require("../helpers/pipe");
|
const pipe = require("../helpers/pipe");
|
||||||
const { getCached, setCache } = require("../helpers/cache");
|
const { getCached, setCache } = require("../helpers/cache");
|
||||||
|
|
||||||
function handleErrors(res, err) {
|
// ══════════════════════════════════════════════════════════════
|
||||||
console.error(err.message);
|
// ROUTE REGISTRATION
|
||||||
res.status(500).json({ success: false, message: err.message });
|
// ══════════════════════════════════════════════════════════════
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
// ---- FEATURE: Main API route registration function ----
|
||||||
|
/**
|
||||||
|
* Registers all API routes on the Express app.
|
||||||
|
* Receives response helpers for consistent JSON formatting.
|
||||||
|
*
|
||||||
|
* @param {object} app - Express application instance
|
||||||
|
* @param {function} jsonResponse - Standardized success response wrapper
|
||||||
|
* @param {function} jsonError - Standardized error response wrapper
|
||||||
|
* @returns {express.Router} Configured router
|
||||||
|
*/
|
||||||
|
const createApiRoutes = (jsonResponse, jsonError) => {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// REQUEST COUNTER (must be before all routes)
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let requestCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
requestCount++;
|
||||||
|
res.on("finish", () => {
|
||||||
|
if (res.statusCode >= 400) errorCount++;
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// SEARCH & DISCOVERY
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Full-text anime search with pagination ----
|
||||||
router.get("/search", async (req, res) => {
|
router.get("/search", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { query, page = 1, per_page = 20 } = req.query;
|
const { query, page = 1, per_page = 20 } = req.query;
|
||||||
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
|
if (!query) return jsonError(res, "query parameter is required", 400);
|
||||||
|
|
||||||
const cacheKey = `search:${query}:${page}:${per_page}`;
|
const cacheKey = `search:${query}:${page}:${per_page}`;
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
|
result = await anilist.searchAnime(query, parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result);
|
setCache(cacheKey, result);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Suggestions
|
// ---- FEATURE: Lightweight autocomplete suggestions (max 8) ----
|
||||||
router.get("/suggestions", async (req, res) => {
|
router.get("/suggestions", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { query } = req.query;
|
const { query } = req.query;
|
||||||
if (!query) return res.status(400).json({ success: false, message: "query parameter is required" });
|
if (!query) return jsonError(res, "query parameter is required", 400);
|
||||||
|
|
||||||
const cacheKey = `suggestions:${query}`;
|
const cacheKey = `suggestions:${query}`;
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getSuggestions(query);
|
result = await anilist.getSuggestions(query);
|
||||||
|
// NOTE: Cache suggestions longer (2 minutes) — they change less frequently
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trending
|
// ---- FEATURE: Advanced anime filter with multiple parameters ----
|
||||||
|
router.get("/filter", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
|
||||||
|
const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`;
|
||||||
|
|
||||||
|
let result = getCached(cacheKey);
|
||||||
|
if (!result) {
|
||||||
|
result = await anilist.filterAnime({
|
||||||
|
genre, tag, year: year ? parseInt(year) : null,
|
||||||
|
season, format, status, sort,
|
||||||
|
page: parseInt(page), perPage: parseInt(per_page),
|
||||||
|
});
|
||||||
|
setCache(cacheKey, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// COLLECTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Currently trending anime ----
|
||||||
router.get("/trending", async (req, res) => {
|
router.get("/trending", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `trending:${page}:${per_page}`;
|
const cacheKey = `trending:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page));
|
result = await anilist.getCollection("TRENDING_DESC", null, parseInt(page), parseInt(per_page));
|
||||||
|
// NOTE: Cache trending for 2 minutes — changes less frequently than search
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Popular
|
// ---- FEATURE: Most popular anime of all time ----
|
||||||
router.get("/popular", async (req, res) => {
|
router.get("/popular", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `popular:${page}:${per_page}`;
|
const cacheKey = `popular:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
|
result = await anilist.getCollection("POPULARITY_DESC", null, parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upcoming
|
// ---- FEATURE: Most anticipated upcoming anime ----
|
||||||
router.get("/upcoming", async (req, res) => {
|
router.get("/upcoming", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `upcoming:${page}:${per_page}`;
|
const cacheKey = `upcoming:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
|
result = await anilist.getCollection("POPULARITY_DESC", "NOT_YET_RELEASED", parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recent
|
// ---- FEATURE: Currently airing anime ----
|
||||||
router.get("/recent", async (req, res) => {
|
router.get("/recent", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `recent:${page}:${per_page}`;
|
const cacheKey = `recent:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
|
result = await anilist.getCollection("START_DATE_DESC", "RELEASING", parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spotlight
|
// ---- FEATURE: Spotlight/featured anime (top 10) ----
|
||||||
router.get("/spotlight", async (req, res) => {
|
router.get("/spotlight", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const cacheKey = "spotlight";
|
const cacheKey = "spotlight";
|
||||||
@@ -104,119 +206,244 @@ router.get("/spotlight", async (req, res) => {
|
|||||||
result = await anilist.getSpotlight();
|
result = await anilist.getSpotlight();
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schedule
|
// ---- FEATURE: Airing schedule with timestamps ----
|
||||||
router.get("/schedule", async (req, res) => {
|
router.get("/schedule", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, per_page = 20 } = req.query;
|
const { page = 1, per_page = 20 } = req.query;
|
||||||
const cacheKey = `schedule:${page}:${per_page}`;
|
const cacheKey = `schedule:${page}:${per_page}`;
|
||||||
|
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
|
result = await anilist.getSchedule(parseInt(page), parseInt(per_page));
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter
|
jsonResponse(res, result);
|
||||||
router.get("/filter", async (req, res) => {
|
} catch (err) {
|
||||||
try {
|
jsonError(res, err.message);
|
||||||
const { genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, per_page = 20 } = req.query;
|
|
||||||
const cacheKey = `filter:${genre}:${tag}:${year}:${season}:${format}:${status}:${sort}:${page}:${per_page}`;
|
|
||||||
let result = getCached(cacheKey);
|
|
||||||
if (!result) {
|
|
||||||
result = await anilist.filterAnime({
|
|
||||||
genre, tag, year: year ? parseInt(year) : null, season, format, status, sort,
|
|
||||||
page: parseInt(page), perPage: parseInt(per_page),
|
|
||||||
});
|
|
||||||
setCache(cacheKey, result);
|
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Info
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ANIME DETAILS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Complete anime info by AniList ID ----
|
||||||
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);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
|
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
|
||||||
|
|
||||||
const cacheKey = `info:${id}`;
|
const cacheKey = `info:${id}`;
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await anilist.getAnimeInfo(id);
|
result = await anilist.getAnimeInfo(id);
|
||||||
|
// NOTE: Cache info for 5 minutes — detailed data changes infrequently
|
||||||
setCache(cacheKey, result, 300 * 1000);
|
setCache(cacheKey, result, 300 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Characters
|
// ---- FEATURE: Paginated character list with voice actors ----
|
||||||
router.get("/anime/:id/characters", async (req, res) => {
|
router.get("/anime/:id/characters", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const { page = 1, per_page = 25 } = req.query;
|
const { page = 1, per_page = 25 } = req.query;
|
||||||
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
|
const result = await anilist.getAnimeCharacters(id, parseInt(page), parseInt(per_page));
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Relations
|
// ---- FEATURE: Related anime (sequels, prequels, spin-offs) ----
|
||||||
router.get("/anime/:id/relations", async (req, res) => {
|
router.get("/anime/:id/relations", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const result = await anilist.getAnimeRelations(id);
|
const result = await anilist.getAnimeRelations(id);
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recommendations
|
// ---- FEATURE: Community recommendations sorted by rating ----
|
||||||
router.get("/anime/:id/recommendations", async (req, res) => {
|
router.get("/anime/:id/recommendations", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const { page = 1, per_page = 10 } = req.query;
|
const { page = 1, per_page = 10 } = req.query;
|
||||||
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
|
const result = await anilist.getAnimeRecommendations(id, parseInt(page), parseInt(per_page));
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Episodes
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// STREAMING
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Episode list from all providers ----
|
||||||
router.get("/episodes/:id", async (req, res) => {
|
router.get("/episodes/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, message: "Invalid AniList ID" });
|
if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400);
|
||||||
|
|
||||||
const cacheKey = `episodes:${id}`;
|
const cacheKey = `episodes:${id}`;
|
||||||
let result = getCached(cacheKey);
|
let result = getCached(cacheKey);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
result = await pipe.getEpisodes(id);
|
result = await pipe.getEpisodes(id);
|
||||||
|
// NOTE: Cache episodes for 2 minutes — episode lists change during airing
|
||||||
setCache(cacheKey, result, 120 * 1000);
|
setCache(cacheKey, result, 120 * 1000);
|
||||||
}
|
}
|
||||||
res.json({ success: true, results: result });
|
|
||||||
} catch (err) { handleErrors(res, err); }
|
jsonResponse(res, result);
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sources (manual)
|
// ---- FEATURE: Streaming sources (detailed/manual endpoint) ----
|
||||||
router.get("/sources", async (req, res) => {
|
router.get("/sources", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { episodeId, provider, anilistId, category = "sub" } = req.query;
|
const { episodeId, provider, anilistId, category = "sub" } = req.query;
|
||||||
if (!episodeId || !provider || !anilistId) {
|
if (!episodeId || !provider || !anilistId) {
|
||||||
return res.status(400).json({ success: false, message: "episodeId, provider, and anilistId are required" });
|
return jsonError(res, "episodeId, provider, and anilistId are required", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category);
|
const result = await pipe.getSources(episodeId, provider, parseInt(anilistId), category);
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch (simple slug-based sources)
|
// ---- FEATURE: Streaming sources (simple slug-based endpoint) ----
|
||||||
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 result = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug);
|
||||||
res.json({ success: true, results: result });
|
jsonResponse(res, result);
|
||||||
} catch (err) { handleErrors(res, err); }
|
} catch (err) {
|
||||||
|
jsonError(res, err.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// HEALTH & STATS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ---- FEATURE: Health check endpoint ----
|
||||||
|
router.get("/health", (req, res) => {
|
||||||
|
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const hours = Math.floor(uptime / 3600);
|
||||||
|
const minutes = Math.floor((uptime % 3600) / 60);
|
||||||
|
const seconds = uptime % 60;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
results: {
|
||||||
|
status: "healthy",
|
||||||
|
version: "1.2.0",
|
||||||
|
uptime: `${hours}h ${minutes}m ${seconds}s`,
|
||||||
|
uptimeSeconds: uptime,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
node: process.version,
|
||||||
|
memory: {
|
||||||
|
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB",
|
||||||
|
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + "MB",
|
||||||
|
},
|
||||||
|
endpoints: 16,
|
||||||
|
providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: Cache & API statistics ----
|
||||||
|
router.get("/stats", (req, res) => {
|
||||||
|
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
results: {
|
||||||
|
uptime: uptime + "s",
|
||||||
|
requests: {
|
||||||
|
total: requestCount,
|
||||||
|
errors: errorCount,
|
||||||
|
successRate: requestCount > 0
|
||||||
|
? ((1 - errorCount / requestCount) * 100).toFixed(1) + "%"
|
||||||
|
: "100%",
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
type: "in-memory",
|
||||||
|
ttl: "1 minute (default), 2-5 minutes for collections",
|
||||||
|
maxSize: 100,
|
||||||
|
description: "Map-based cache with TTL expiration and FIFO eviction",
|
||||||
|
},
|
||||||
|
endpoints: 16,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- FEATURE: OpenAPI specification ----
|
||||||
|
router.get("/openapi", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "MiruroAPI",
|
||||||
|
description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers",
|
||||||
|
version: "1.2.0",
|
||||||
|
contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" },
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{ url: "https://mirurotvapi.vercel.app/api", description: "Production" },
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
"/search": { get: { summary: "Search anime", tags: ["Search"] } },
|
||||||
|
"/suggestions": { get: { summary: "Autocomplete suggestions", tags: ["Search"] } },
|
||||||
|
"/filter": { get: { summary: "Advanced filter", tags: ["Search"] } },
|
||||||
|
"/trending": { get: { summary: "Trending anime", tags: ["Collections"] } },
|
||||||
|
"/popular": { get: { summary: "Most popular", tags: ["Collections"] } },
|
||||||
|
"/upcoming": { get: { summary: "Upcoming anime", tags: ["Collections"] } },
|
||||||
|
"/recent": { get: { summary: "Recently aired", tags: ["Collections"] } },
|
||||||
|
"/spotlight": { get: { summary: "Spotlight anime", tags: ["Collections"] } },
|
||||||
|
"/schedule": { get: { summary: "Airing schedule", tags: ["Collections"] } },
|
||||||
|
"/info/{id}": { get: { summary: "Full anime info", tags: ["Anime Details"] } },
|
||||||
|
"/anime/{id}/characters": { get: { summary: "Characters", tags: ["Anime Details"] } },
|
||||||
|
"/anime/{id}/relations": { get: { summary: "Relations", tags: ["Anime Details"] } },
|
||||||
|
"/anime/{id}/recommendations": { get: { summary: "Recommendations", tags: ["Anime Details"] } },
|
||||||
|
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Streaming"] } },
|
||||||
|
"/sources": { get: { summary: "Streaming sources (detailed)", tags: ["Streaming"] } },
|
||||||
|
"/watch/{provider}/{anilistId}/{category}/{slug}": { get: { summary: "Streaming sources (simple)", tags: ["Streaming"] } },
|
||||||
|
"/health": { get: { summary: "Health check", tags: ["System"] } },
|
||||||
|
"/stats": { get: { summary: "API statistics", tags: ["System"] } },
|
||||||
|
"/openapi": { get: { summary: "OpenAPI spec", tags: ["System"] } },
|
||||||
|
},
|
||||||
|
tags: [
|
||||||
|
{ name: "Search", description: "Search & filter" },
|
||||||
|
{ name: "Collections", description: "Trending, popular, upcoming, schedule" },
|
||||||
|
{ name: "Anime Details", description: "Info, characters, relations, recommendations" },
|
||||||
|
{ name: "Streaming", description: "Episodes, sources, watch" },
|
||||||
|
{ name: "System", description: "Health, stats, OpenAPI" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = createApiRoutes;
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════ END: apiRoutes.js
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
* MiruroAPI — test.js
|
||||||
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Integration test suite for MiruroAPI endpoints.
|
||||||
|
* Tests all major endpoints for correct response format.
|
||||||
|
*
|
||||||
|
* @author Shinei Nouzen
|
||||||
|
* @license MIT
|
||||||
|
* ======= • ======= • ======= • ======= • =======• =======
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = process.env.API_URL || "https://mirurotvapi.vercel.app/api";
|
||||||
|
|
||||||
|
const tests = [
|
||||||
|
{ name: "Health", url: "/health" },
|
||||||
|
{ name: "Stats", url: "/stats" },
|
||||||
|
{ name: "OpenAPI", url: "/openapi" },
|
||||||
|
{ name: "Search", url: "/search?query=naruto" },
|
||||||
|
{ name: "Suggestions", url: "/suggestions?query=naruto" },
|
||||||
|
{ name: "Filter", url: "/filter?genre=Action&per_page=1" },
|
||||||
|
{ name: "Trending", url: "/trending?per_page=1" },
|
||||||
|
{ name: "Popular", url: "/popular?per_page=1" },
|
||||||
|
{ name: "Upcoming", url: "/upcoming?per_page=1" },
|
||||||
|
{ name: "Recent", url: "/recent?per_page=1" },
|
||||||
|
{ name: "Spotlight", url: "/spotlight" },
|
||||||
|
{ name: "Schedule", url: "/schedule" },
|
||||||
|
{ name: "Info", url: "/info/20" },
|
||||||
|
{ name: "Characters", url: "/anime/20/characters" },
|
||||||
|
{ name: "Relations", url: "/anime/20/relations" },
|
||||||
|
{ name: "Recommendations", url: "/anime/20/recommendations" },
|
||||||
|
{ name: "Episodes", url: "/episodes/20" },
|
||||||
|
{ name: "Watch", url: "/watch/kiwi/20/sub/animepahe-1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
async function runTest(test) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}${test.url}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log(`❌ ${test.name} - HTTP ${res.status}`);
|
||||||
|
failed++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success === true && data.results) {
|
||||||
|
console.log(`✅ ${test.name}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${test.name} - Invalid response format`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ ${test.name} - ${error.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAll() {
|
||||||
|
console.log(`\n🧪 Running ${tests.length} tests...\n`);
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
await runTest(test);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📊 Results: ${passed} passed, ${failed} failed, ${tests.length} total\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
runAll();
|
||||||