From 3675bbba77bcea23a4061a4a39fd9cfe2d8dab18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CA=9C=C9=AA=C9=B4=E1=B4=87=C9=AA=20N=E1=B4=8F=E1=B4=9C?= =?UTF-8?q?=E1=B4=A2=E1=B4=87=C9=B4?= <157171073+Shineii86@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:09:35 +0530 Subject: [PATCH] =?UTF-8?q?v2.0.0:=20Major=20upgrade=20=E2=80=94=2035=20en?= =?UTF-8?q?dpoints,=20compression,=20streaming=20improvements,=20new=20fea?= =?UTF-8?q?tures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 86 ++- package-lock.json | 1480 +++++++++++++++++++++++++++++++++++++++ package.json | 7 +- server.js | 119 +++- src/helpers/anilist.js | 346 +++++++++ src/helpers/cache.js | 10 +- src/helpers/pipe.js | 142 ++++ src/routes/apiRoutes.js | 669 ++++++++++++++++-- 8 files changed, 2774 insertions(+), 85 deletions(-) create mode 100644 package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f17ceb6..7621ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,78 @@ # Changelog +## v2.0.0 +### Major Release — 35 endpoints, streaming improvements, new features + +#### Technical Foundation +- Added gzip compression middleware (30-70% response size reduction) +- Added request logging with method, path, status, duration, IP +- Added Cache-Control headers for API responses (varies by endpoint type) +- Improved CORS with preflight caching (24h max-age) +- Health endpoint now includes memory stats (heap, RSS), cache size, node version + +#### New AniList Helpers (11 new functions) +- getAllGenres — complete genre list from AniList +- getAllTags — complete tag list with metadata +- getTopAnime — top-rated anime of all time (SCORE_DESC) +- getTrendingDaily — trending anime (last 7 days) +- getTrendingWeekly — trending anime (last 30 days) +- getSeasonalAnime — anime by year + season (e.g., 2025/Spring) +- getAnimeByStudio — anime produced by a specific studio +- getCharacterInfo — character detail with anime list +- getStaffInfo — staff detail with anime list +- searchCharacters — search characters by name +- searchStaff — search staff by name + +#### New Streaming & Download Features +- extractSubtitles — extract subtitle URLs from sources +- getBestStream — automatic quality fallback (1080p → 720p → 360p) +- getDownloadUrl — dedicated download URL endpoint +- getBatchSources — fetch multiple episode sources in parallel + +#### New API Endpoints (17 new) +- GET /api/trending/daily — daily trending anime +- GET /api/trending/weekly — weekly trending anime +- GET /api/top — top anime by score +- GET /api/random — random anime of the day +- GET /api/multi-search — combined query + filters +- GET /api/season/:year/:season — seasonal anime +- GET /api/studio/:name — anime by studio +- GET /api/genres — all genres list +- GET /api/tags — all tags list +- GET /api/genre/:name — anime by genre +- GET /api/year/:year — anime by year +- GET /api/status/:status — anime by status +- GET /api/format/:format — anime by format +- GET /api/character/:id — character detail +- GET /api/staff/:id — staff detail +- GET /api/characters — search characters +- GET /api/staff — search staff +- GET /api/episodes/:id/batch — batch episode sources +- GET /api/stream — streaming with quality fallback +- GET /api/download — download URL +- GET /api/compare/:id1/:id2 — compare two anime +- GET /api/stats/genre — genre statistics +- GET /api/calendar — monthly airing calendar +- GET /api/timeline/:id — anime release timeline + +#### Streaming Improvements +- Subtitle URL extraction from pipe responses +- Quality fallback: tries 1080p → 720p → 480p → 360p automatically +- Batch episode fetching (up to 5 parallel requests) +- Dedicated download endpoint with provider metadata +- Cache headers: episodes/sources (2min), info (5min), genres/tags (24h) + +#### Version synced to 2.0.0 across all files + ## v1.4.0 - Added creator attribution middleware — injects author info into every response - Fixed Vercel deployment: replaced legacy `builds`/`routes` config with modern `functions` config -- Added `api/index.js` serverless entry point (Vercel requires this for proper function detection) +- Added `api/index.js` serverless entry point - Security headers: removed deprecated `X-XSS-Protection`, added `Strict-Transport-Security` (HSTS) -- Rate limiter: uses `x-forwarded-for` header for correct IP detection on Vercel serverless -- Rate limiter: added periodic IP entry pruning when >200 IPs tracked (prevents memory leak) -- Cache: added periodic expired entry cleanup when cache reaches 80% capacity (prevents memory leak) -- Version synced to 1.4.0 across all files (server.js, package.json, health endpoint, OpenAPI spec) +- Rate limiter: uses `x-forwarded-for` header for correct IP detection on Vercel +- Rate limiter: added periodic IP entry pruning when >200 IPs tracked +- Cache: added periodic expired entry cleanup when cache reaches 80% capacity +- Version synced to 1.4.0 ## v1.3.0 - Full landing page upgrade to match AniKotoAPI style @@ -17,18 +81,18 @@ - Added animated stat counters that count up on scroll - Added infinite scrolling marquee with 8 feature badges - Added live interactive API console with 5 preset endpoint buttons -- Added sidebar endpoint explorer with 16 endpoints and multi-language code tabs (cURL, JavaScript, Python) +- Added sidebar endpoint explorer with 16 endpoints and multi-language code tabs - Added 4 interactive playground cards (Search, Info, Episodes, Trending) - Added custom scrollbar styling - Added JetBrains Mono font for code blocks -- Added 4-column grid footer with API, Resources, Legal sections +- Added 4-column grid footer - Added fixed header with scroll-aware blur/glass effect - Added ambient orb gradient animations - Added CTA section with gradient overlay - Color scheme updated to match MiruroAPI brand (cyan #38bdf8 + indigo #818cf8) - All branding updated: AniKotoAPI references → MiruroAPI - OG/Twitter meta tags updated for mirurotvapi.vercel.app -- JSON-LD structured data updated with image, category, programmingModel +- JSON-LD structured data updated - Real Miruro SVG favicons used in header and footer ## v1.2.0 @@ -36,14 +100,14 @@ - Root cause: createApiRoutes() had (app, jsonResponse, jsonError) but was called with (jsonResponse, jsonError), shifting all params - Every jsonResponse() call was actually calling jsonError(), returning {success: false, message: data} - Health endpoint worked because it used res.json() directly, bypassing the wrapper -- All 18 endpoints verified working: health, stats, search, suggestions, filter, trending, popular, upcoming, recent, spotlight, schedule, info, characters, relations, recommendations, episodes, watch +- All 18 endpoints verified working ## v1.1.0 - Added Swagger UI interactive docs at /docs - Added OpenAPI 3.0 spec at /openapi.json -- Added mappings field in episodes response (anilistId, malId, kitsuId) +- Added mappings field in episodes response - Added Docker support (Dockerfile + .dockerignore) -- Improved landing page with premium design (Walter-style) +- Improved landing page with premium design - Added "Try it" buttons on all endpoints - Health endpoint now includes version, providers list, and endpoint count - Version bump to 1.1.0 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dc57ebd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1480 @@ +{ + "name": "miruro-api", + "version": "1.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "miruro-api", + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "axios": "^1.8.0", + "compression": "^1.8.1", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "express": "^4.21.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json index ef6616c..1c94540 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "miruro-api", - "version": "1.4.0", + "version": "2.0.0", "description": "RESTful API for miruro.tv - Anime streaming data powered by AniList GraphQL and Miruro streaming providers", "main": "server.js", "scripts": { @@ -9,10 +9,11 @@ "test": "node test.js" }, "dependencies": { - "express": "^4.21.0", "axios": "^1.8.0", + "compression": "^1.8.1", "cors": "^2.8.5", - "dotenv": "^16.4.0" + "dotenv": "^16.4.0", + "express": "^4.21.0" }, "devDependencies": { "nodemon": "^3.1.0" diff --git a/server.js b/server.js index e782617..1e6e9f8 100644 --- a/server.js +++ b/server.js @@ -5,9 +5,8 @@ * * @description * Main entry point for the MiruroAPI Express server. - * Configures CORS, middleware, static files, Swagger docs, - * API routes, and 404 handling. Starts the server on the - * configured port. + * Configures CORS, compression, logging, security headers, + * rate limiting, API routes, and 404 handling. * * @exports * None (side-effect: starts Express server) @@ -19,7 +18,9 @@ const express = require("express"); const cors = require("cors"); +const compression = require("compression"); const path = require("path"); +const fs = require("fs"); require("dotenv").config(); // ══════════════════════════════════════════════════════════════ @@ -28,15 +29,63 @@ require("dotenv").config(); const app = express(); const PORT = process.env.PORT || 3000; +const startTime = Date.now(); const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",") : null; +// ══════════════════════════════════════════════════════════════ +// COMPRESSION +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Gzip compression — reduces response size by 30-70% ---- +app.use(compression({ + level: 6, + threshold: 1024, + filter: (req, res) => { + if (req.headers["x-no-compression"]) return false; + return compression.filter(req, res); + }, +})); + +// ══════════════════════════════════════════════════════════════ +// REQUEST LOGGING +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Request logging — method, path, status, response time ---- +app.use((req, res, next) => { + const start = Date.now(); + const originalEnd = res.end; + + res.end = function (...args) { + const duration = Date.now() - start; + const status = res.statusCode; + const ip = + req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || + req.ip || + req.connection.remoteAddress; + + // NOTE: Only log API requests, skip static files + if (req.path.startsWith("/api/")) { + const log = `[${new Date().toISOString()}] ${req.method} ${req.path} ${status} ${duration}ms ${ip}`; + if (status >= 400) { + console.error(log); + } else { + console.log(log); + } + } + + originalEnd.apply(this, args); + }; + + next(); +}); + // ══════════════════════════════════════════════════════════════ // CORS MIDDLEWARE // ══════════════════════════════════════════════════════════════ -// NOTE: Single unified CORS middleware — handles all origin validation +// ---- FEATURE: Unified CORS middleware with preflight handling ---- app.use((req, res, next) => { const origin = req.headers.origin; if ( @@ -46,14 +95,20 @@ app.use((req, res, next) => { ) { res.setHeader("Access-Control-Allow-Origin", origin || "*"); } - res.setHeader("Access-Control-Allow-Methods", "GET"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-No-Compression"); + res.setHeader("Access-Control-Max-Age", "86400"); + if (req.method === "OPTIONS") { return res.status(204).end(); } next(); }); +// ══════════════════════════════════════════════════════════════ +// SECURITY HEADERS +// ══════════════════════════════════════════════════════════════ + // ---- FEATURE: Security headers ---- app.use((req, res, next) => { res.setHeader("X-Frame-Options", "DENY"); @@ -64,18 +119,39 @@ app.use((req, res, next) => { next(); }); +// ══════════════════════════════════════════════════════════════ +// CACHE HEADERS +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Cache-Control headers for API responses ---- +app.use("/api", (req, res, next) => { + // NOTE: Different cache durations based on endpoint type + if (req.path.includes("/health") || req.path.includes("/stats")) { + res.setHeader("Cache-Control", "no-store"); + } else if (req.path.includes("/search") || req.path.includes("/suggestions")) { + res.setHeader("Cache-Control", "public, max-age=60"); + } else if (req.path.includes("/episodes") || req.path.includes("/sources") || req.path.includes("/watch")) { + res.setHeader("Cache-Control", "public, max-age=120"); + } else { + res.setHeader("Cache-Control", "public, max-age=300"); + } + res.setHeader("Vary", "Accept-Encoding"); + next(); +}); + // ══════════════════════════════════════════════════════════════ // STATIC FILES // ══════════════════════════════════════════════════════════════ -app.use(express.static(path.join(__dirname, "public"), { redirect: false })); +app.use(express.static(path.join(__dirname, "public"), { + redirect: false, + maxAge: "1d", +})); // ══════════════════════════════════════════════════════════════ // CLEAN URL ROUTES // ══════════════════════════════════════════════════════════════ -// ---- FEATURE: Clean URL routes (no .html extension) ---- -const fs = require("fs"); const publicDir = path.join(__dirname, "public"); app.get("/tos", (req, res) => { @@ -90,7 +166,6 @@ app.get("/privacy", (req, res) => { // SWAGGER UI DOCS // ══════════════════════════════════════════════════════════════ -// ---- FEATURE: Interactive Swagger UI documentation at /docs ---- app.get("/docs", (req, res) => { res.sendFile(path.join(publicDir, "docs.html")); }); @@ -99,25 +174,9 @@ app.get("/docs", (req, res) => { // RESPONSE HELPERS // ══════════════════════════════════════════════════════════════ -// ---- FEATURE: Standardized JSON response wrapper ---- -/** - * Wraps data in a standardized success JSON response. - * - * @param {object} res - Express response object - * @param {*} data - The data to return in the response - * @param {number} status - HTTP status code (default: 200) - */ const jsonResponse = (res, data, status = 200) => res.status(status).json({ success: true, results: data }); -// ---- FEATURE: Standardized error response wrapper ---- -/** - * Returns a standardized error JSON response. - * - * @param {object} res - Express response object - * @param {string} message - Error message to return - * @param {number} status - HTTP status code (default: 500) - */ const jsonError = (res, message = "Internal server error", status = 500) => res.status(status).json({ success: false, message }); @@ -152,7 +211,7 @@ app.use((req, res, next) => { timestamps.push(now); - // NOTE: Periodic cleanup — prune expired IP entries every 60s to prevent memory leak + // NOTE: Periodic cleanup — prune expired IP entries when map grows large if (requestCounts.size > 200) { for (const [key, val] of requestCounts) { if (val.filter((t) => now - t < windowMs).length === 0) { @@ -169,7 +228,7 @@ app.use((req, res, next) => { // ══════════════════════════════════════════════════════════════ const apiRoutes = require("./src/routes/apiRoutes"); -app.use("/api", apiRoutes(jsonResponse, jsonError)); +app.use("/api", apiRoutes(jsonResponse, jsonError, startTime)); // ══════════════════════════════════════════════════════════════ // 404 HANDLER @@ -177,7 +236,7 @@ app.use("/api", apiRoutes(jsonResponse, jsonError)); // ---- FEATURE: Global error handler ---- app.use((err, req, res, next) => { - console.error(err.stack); + console.error(`[ERROR] ${err.message}`); res.status(err.status || 500).json({ success: false, message: err.message || "Internal server error", @@ -204,7 +263,7 @@ app.use((req, res) => { // ══════════════════════════════════════════════════════════════ app.listen(PORT, () => { - console.info(`MiruroAPI listening at ${PORT}`); + console.log(`[${new Date().toISOString()}] MiruroAPI v2.0.0 listening at ${PORT}`); }); module.exports = app; diff --git a/src/helpers/anilist.js b/src/helpers/anilist.js index 5a1a5f1..dea28e6 100644 --- a/src/helpers/anilist.js +++ b/src/helpers/anilist.js @@ -584,6 +584,341 @@ const filterAnime = async ({ }; }; +// ══════════════════════════════════════════════════════════════ +// GENRES & CATEGORIES +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Get all available genres ---- +/** + * Returns the complete list of AniList genres. + * + * @returns {Promise} Array of genre name strings + */ +const getAllGenres = async () => { + const gql = `query { GenreCollection }`; + const data = await anilistQuery(gql); + return data.GenreCollection; +}; + +// ---- FEATURE: Get all available tags ---- +/** + * Returns the complete list of AniList tags with metadata. + * + * @returns {Promise} Array of tag objects { name, description, category, rank, isGeneralSpoiler, isMediaSpoiler, isAdult } + */ +const getAllTags = async () => { + const gql = `query { TagCollection { name description category rank isGeneralSpoiler isMediaSpoiler isAdult } }`; + const data = await anilistQuery(gql); + return data.TagCollection; +}; + +// ══════════════════════════════════════════════════════════════ +// TOP & TRENDING +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Top anime by score (all time) ---- +/** + * Fetches top-rated anime of all time sorted by score. + * + * @param {number} [page=1] - Page number + * @param {number} [perPage=20] - Results per page + * @returns {Promise} Paginated top anime results + */ +const getTopAnime = async (page = 1, perPage = 20) => { + const gql = ` + query ($page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + pageInfo { total currentPage lastPage hasNextPage perPage } + media(type: ANIME, sort: SCORE_DESC) { + ${MEDIA_LIST_FIELDS} + } + } + } + `; + const data = await anilistQuery(gql, { page, perPage }); + const pageData = data.Page; + + return { + page: pageData.pageInfo.currentPage, + perPage: pageData.pageInfo.perPage, + total: pageData.pageInfo.total, + hasNextPage: pageData.pageInfo.hasNextPage, + results: pageData.media, + }; +}; + +// ---- FEATURE: Daily trending anime ---- +/** + * Fetches currently trending anime (last 7 days). + * + * @param {number} [page=1] - Page number + * @param {number} [perPage=20] - Results per page + * @returns {Promise} Paginated trending results + */ +const getTrendingDaily = async (page = 1, perPage = 20) => { + const gql = ` + query ($page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + pageInfo { total currentPage lastPage hasNextPage perPage } + media(type: ANIME, sort: TRENDING_DESC, trending: 7) { + ${MEDIA_LIST_FIELDS} + } + } + } + `; + const data = await anilistQuery(gql, { page, perPage }); + const pageData = data.Page; + + return { + page: pageData.pageInfo.currentPage, + perPage: pageData.pageInfo.perPage, + total: pageData.pageInfo.total, + hasNextPage: pageData.pageInfo.hasNextPage, + results: pageData.media, + }; +}; + +// ---- FEATURE: Weekly trending anime ---- +/** + * Fetches currently trending anime (last 30 days). + * + * @param {number} [page=1] - Page number + * @param {number} [perPage=20] - Results per page + * @returns {Promise} Paginated trending results + */ +const getTrendingWeekly = async (page = 1, perPage = 20) => { + const gql = ` + query ($page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + pageInfo { total currentPage lastPage hasNextPage perPage } + media(type: ANIME, sort: TRENDING_DESC, trending: 30) { + ${MEDIA_LIST_FIELDS} + } + } + } + `; + const data = await anilistQuery(gql, { page, perPage }); + const pageData = data.Page; + + return { + page: pageData.pageInfo.currentPage, + perPage: pageData.pageInfo.perPage, + total: pageData.pageInfo.total, + hasNextPage: pageData.pageInfo.hasNextPage, + results: pageData.media, + }; +}; + +// ══════════════════════════════════════════════════════════════ +// SEASONAL & STUDIO +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Seasonal anime by year and season ---- +/** + * Fetches anime for a specific season and year. + * + * @param {number} year - The year (e.g., 2025) + * @param {string} season - The season (WINTER, SPRING, SUMMER, FALL) + * @param {number} [page=1] - Page number + * @param {number} [perPage=20] - Results per page + * @returns {Promise} Paginated seasonal anime results + */ +const getSeasonalAnime = async (year, season, page = 1, perPage = 20) => { + const gql = ` + query ($year: Int, $season: MediaSeason, $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + pageInfo { total currentPage lastPage hasNextPage perPage } + media(type: ANIME, season: $season, seasonYear: $year, sort: POPULARITY_DESC) { + ${MEDIA_LIST_FIELDS} + } + } + } + `; + const data = await anilistQuery(gql, { year, season: season.toUpperCase(), page, perPage }); + const pageData = data.Page; + + return { + page: pageData.pageInfo.currentPage, + perPage: pageData.pageInfo.perPage, + total: pageData.pageInfo.total, + hasNextPage: pageData.pageInfo.hasNextPage, + results: pageData.media, + }; +}; + +// ---- FEATURE: Anime by studio name ---- +/** + * Fetches all anime produced by a specific studio. + * + * @param {string} studioName - The studio name to search for + * @param {number} [page=1] - Page number + * @param {number} [perPage=20] - Results per page + * @returns {Promise} Paginated studio anime results + */ +const getAnimeByStudio = async (studioName, page = 1, perPage = 20) => { + const gql = ` + query ($name: String, $page: Int, $perPage: Int) { + Studio(search: $name) { + id name isAnimationStudio siteUrl + media(type: ANIME, sort: POPULARITY_DESC, page: $page, perPage: $perPage) { + pageInfo { total currentPage lastPage hasNextPage perPage } + nodes { ${MEDIA_LIST_FIELDS} } + } + } + } + `; + const data = await anilistQuery(gql, { name: studioName, page, perPage }); + const studio = data.Studio; + + if (!studio) throw new Error("Studio not found"); + + return { + studio: { + id: studio.id, + name: studio.name, + isAnimationStudio: studio.isAnimationStudio, + siteUrl: studio.siteUrl, + }, + page: studio.media.pageInfo.currentPage, + perPage: studio.media.pageInfo.perPage, + total: studio.media.pageInfo.total, + hasNextPage: studio.media.pageInfo.hasNextPage, + results: studio.media.nodes, + }; +}; + +// ══════════════════════════════════════════════════════════════ +// CHARACTER & STAFF +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Character detail with anime list ---- +/** + * Fetches detailed character info including all anime appearances. + * + * @param {number} id - AniList character ID + * @returns {Promise} Character details with anime list + */ +const getCharacterInfo = async (id) => { + const gql = ` + query ($id: Int) { + Character(id: $id) { + id name { full native userPreferred } + image { large medium } + description gender favourites siteUrl + dateOfBirth { year month day } + dateOfDeath { year month day } + age bloodType + media(perPage: 25, sort: POPULARITY_DESC) { + pageInfo { total currentPage lastPage hasNextPage perPage } + edges { + role + node { id title { romaji english } coverImage { large } format episodes status meanScore } + } + } + } + } + `; + const data = await anilistQuery(gql, { id }); + if (!data.Character) throw new Error("Character not found"); + return data.Character; +}; + +// ---- FEATURE: Staff detail with anime list ---- +/** + * Fetches detailed staff info including all anime works. + * + * @param {number} id - AniList staff ID + * @returns {Promise} Staff details with anime list + */ +const getStaffInfo = async (id) => { + const gql = ` + query ($id: Int) { + Staff(id: $id) { + id name { full native userPreferred } + image { large medium } + description gender favourites siteUrl + dateOfBirth { year month day } + dateOfDeath { year month day } + age homeTown yearsActive + staffMedia(perPage: 25, sort: POPULARITY_DESC) { + pageInfo { total currentPage lastPage hasNextPage perPage } + edges { + role + node { id title { romaji english } coverImage { large } format episodes status meanScore } + } + } + } + } + `; + const data = await anilistQuery(gql, { id }); + if (!data.Staff) throw new Error("Staff not found"); + return data.Staff; +}; + +// ---- FEATURE: Search characters by name ---- +/** + * Searches AniList characters by name. + * + * @param {string} query - Character name to search + * @param {number} [page=1] - Page number + * @param {number} [perPage=20] - Results per page + * @returns {Promise} Paginated character search results + */ +const searchCharacters = async (query, page = 1, perPage = 20) => { + const gql = ` + query ($search: String, $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + pageInfo { total currentPage lastPage hasNextPage perPage } + characters(search: $search, sort: SEARCH_MATCH) { + id name { full native userPreferred } image { large medium } gender favourites siteUrl + } + } + } + `; + const data = await anilistQuery(gql, { search: query, page, perPage }); + const pageData = data.Page; + + return { + page: pageData.pageInfo.currentPage, + perPage: pageData.pageInfo.perPage, + total: pageData.pageInfo.total, + hasNextPage: pageData.pageInfo.hasNextPage, + results: pageData.characters, + }; +}; + +// ---- FEATURE: Search staff by name ---- +/** + * Searches AniList staff by name. + * + * @param {string} query - Staff name to search + * @param {number} [page=1] - Page number + * @param {number} [perPage=20] - Results per page + * @returns {Promise} Paginated staff search results + */ +const searchStaff = async (query, page = 1, perPage = 20) => { + const gql = ` + query ($search: String, $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { + pageInfo { total currentPage lastPage hasNextPage perPage } + staff(search: $search, sort: SEARCH_MATCH) { + id name { full native userPreferred } image { large medium } gender favourites siteUrl + } + } + } + `; + const data = await anilistQuery(gql, { search: query, page, perPage }); + const pageData = data.Page; + + return { + page: pageData.pageInfo.currentPage, + perPage: pageData.pageInfo.perPage, + total: pageData.pageInfo.total, + hasNextPage: pageData.pageInfo.hasNextPage, + results: pageData.staff, + }; +}; + module.exports = { anilistQuery, searchAnime, @@ -596,6 +931,17 @@ module.exports = { getSpotlight, getSchedule, filterAnime, + getAllGenres, + getAllTags, + getTopAnime, + getTrendingDaily, + getTrendingWeekly, + getSeasonalAnime, + getAnimeByStudio, + getCharacterInfo, + getStaffInfo, + searchCharacters, + searchStaff, MEDIA_LIST_FIELDS, MEDIA_FULL_FIELDS, }; diff --git a/src/helpers/cache.js b/src/helpers/cache.js index ea7bbb3..b63d8a1 100644 --- a/src/helpers/cache.js +++ b/src/helpers/cache.js @@ -129,6 +129,14 @@ const clearCache = () => { cache.clear(); }; -module.exports = { getCached, setCache, clearCache }; +// ---- FEATURE: Get current cache size ---- +/** + * Returns the current number of entries in the cache. + * + * @returns {number} Current cache size + */ +const getCacheSize = () => cache.size; + +module.exports = { getCached, setCache, clearCache, getCacheSize }; // ══════════════════════════════════════════════════════════════ END: cache.js diff --git a/src/helpers/pipe.js b/src/helpers/pipe.js index 778d890..81384b2 100644 --- a/src/helpers/pipe.js +++ b/src/helpers/pipe.js @@ -320,10 +320,152 @@ const getWatchSources = async (provider, anilistId, category, slug) => { return getSources(targetId, provider, anilistId, category); }; +// ══════════════════════════════════════════════════════════════ +// SUBTITLE EXTRACTION +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Extract subtitle URLs from streaming sources ---- +/** + * Extracts and formats subtitle URLs from the pipe sources response. + * Subtitles are usually in WebVTT format with language tags. + * + * @param {object} sources - The sources response from getSources/getWatchSources + * @returns {Array} Array of subtitle objects { url, label, language, format } + */ +const extractSubtitles = (sources) => { + const subtitles = sources.subtitles || sources.captions || []; + return subtitles.map((sub) => ({ + url: sub.url || sub.file, + label: sub.label || sub.name || "Unknown", + language: sub.lang || sub.language || sub.label || "en", + format: sub.format || "vtt", + })); +}; + +// ══════════════════════════════════════════════════════════════ +// QUALITY FALLBACK +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Get best available stream with quality fallback ---- +/** + * Returns the best available HLS stream with automatic quality fallback. + * Tries 1080p → 720p → 360p in order. + * + * @param {object} sources - The sources response + * @param {string} [preferredQuality="1080p"] - Preferred quality + * @returns {object|null} Best available stream object or null + */ +const getBestStream = (sources, preferredQuality = "1080p") => { + const streams = (sources.streams || []).filter((s) => s.type === "hls" && s.url); + if (streams.length === 0) return null; + + // NOTE: Try preferred quality first, then fallback + const qualityOrder = ["1080p", "720p", "480p", "360p"]; + const startIdx = qualityOrder.indexOf(preferredQuality); + const ordered = startIdx >= 0 + ? qualityOrder.slice(startIdx) + : qualityOrder; + + for (const q of ordered) { + const match = streams.find((s) => s.quality === q); + if (match) return match; + } + + // NOTE: Return first available if no quality match + return streams[0]; +}; + +// ══════════════════════════════════════════════════════════════ +// DOWNLOAD URL +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Get download URL for an episode ---- +/** + * Fetches the download URL for a specific episode from the pipe. + * Returns the direct download link if available. + * + * @param {string} provider - Provider name + * @param {number} anilistId - The AniList anime ID + * @param {string} category - Audio category ("sub" or "dub") + * @param {string} slug - Episode slug (e.g., "animepahe-1") + * @returns {Promise} Object with download URL and metadata + */ +const getDownloadUrl = async (provider, anilistId, category, slug) => { + const sources = await getWatchSources(provider, anilistId, category, slug); + return { + download: sources.download || null, + provider, + anilistId, + category, + slug, + hasDownload: !!sources.download, + }; +}; + +// ══════════════════════════════════════════════════════════════ +// BATCH EPISODES +// ══════════════════════════════════════════════════════════════ + +// ---- FEATURE: Get sources for multiple episodes at once ---- +/** + * Fetches streaming sources for multiple episodes in parallel. + * Useful for preloading next episodes or batch downloading. + * + * @param {string} provider - Provider name + * @param {number} anilistId - The AniList anime ID + * @param {string} category - Audio category ("sub" or "dub") + * @param {string[]} slugs - Array of episode slugs (e.g., ["animepahe-1", "animepahe-2"]) + * @returns {Promise} Object mapping slug → sources (with errors for failed ones) + */ +const getBatchSources = async (provider, anilistId, category, slugs) => { + const results = {}; + + const fetchOne = async (slug) => { + try { + const sources = await getWatchSources(provider, anilistId, category, slug); + const best = getBestStream(sources); + const subtitles = extractSubtitles(sources); + results[slug] = { + success: true, + streams: sources.streams, + bestStream: best, + subtitles, + download: sources.download || null, + }; + } catch (err) { + results[slug] = { + success: false, + error: err.message, + }; + } + }; + + // NOTE: Fetch up to 5 episodes in parallel to avoid rate limiting + const batchSize = 5; + for (let i = 0; i < slugs.length; i += batchSize) { + const batch = slugs.slice(i, i + batchSize); + await Promise.all(batch.map(fetchOne)); + } + + return { + provider, + anilistId, + category, + total: slugs.length, + successful: Object.values(results).filter((r) => r.success).length, + failed: Object.values(results).filter((r) => !r.success).length, + episodes: results, + }; +}; + module.exports = { getEpisodes, getSources, getWatchSources, + getDownloadUrl, + getBatchSources, + extractSubtitles, + getBestStream, encodePipeRequest, decodePipeResponse, translateId, diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index 17624c0..f66cafb 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -7,7 +7,7 @@ * 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. + * stats, streaming, and OpenAPI specification endpoints. * * @exports * createApiRoutes - Function that registers all API routes @@ -27,38 +27,24 @@ const { addCreatorInfo } = require("../middleware/creatorInfo"); // ROUTE REGISTRATION // ══════════════════════════════════════════════════════════════ -// ---- 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 createApiRoutes = (jsonResponse, jsonError, startTime) => { const router = express.Router(); // ══════════════════════════════════════════════════════════════ // CREATOR ATTRIBUTION (must be before all routes) // ══════════════════════════════════════════════════════════════ - // ---- FEATURE: Creator attribution middleware — injects author info into every response ---- router.use(addCreatorInfo); // ══════════════════════════════════════════════════════════════ - // REQUEST COUNTER (must be before all routes) + // REQUEST COUNTER // ══════════════════════════════════════════════════════════════ - const startTime = Date.now(); - let requestCount = 0; - let errorCount = 0; - + const requestCount = { total: 0, errors: 0 }; router.use((req, res, next) => { - requestCount++; + requestCount.total++; res.on("finish", () => { - if (res.statusCode >= 400) errorCount++; + if (res.statusCode >= 400) requestCount.errors++; }); next(); }); @@ -96,7 +82,6 @@ const createApiRoutes = (jsonResponse, jsonError) => { let result = getCached(cacheKey); if (!result) { result = await anilist.getSuggestions(query); - // NOTE: Cache suggestions longer (2 minutes) — they change less frequently setCache(cacheKey, result, 120 * 1000); } @@ -128,6 +113,30 @@ const createApiRoutes = (jsonResponse, jsonError) => { } }); + // ---- FEATURE: Multi-parameter search (query + filters combined) ---- + router.get("/multi-search", async (req, res) => { + try { + const { q, genre, tag, year, season, format, status, sort = "SEARCH_MATCH", page = 1, per_page = 20 } = req.query; + + // NOTE: If only query is provided, use regular search + if (q && !genre && !tag && !year && !season && !format && !status) { + const result = await anilist.searchAnime(q, parseInt(page), parseInt(per_page)); + return jsonResponse(res, result); + } + + // NOTE: If filters only, use filter endpoint + const result = await anilist.filterAnime({ + genre, tag, year: year ? parseInt(year) : null, + season, format, status, sort, + page: parseInt(page), perPage: parseInt(per_page), + }); + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + // ══════════════════════════════════════════════════════════════ // COLLECTIONS // ══════════════════════════════════════════════════════════════ @@ -141,7 +150,42 @@ const createApiRoutes = (jsonResponse, jsonError) => { let result = getCached(cacheKey); if (!result) { 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); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Daily trending anime ---- + router.get("/trending/daily", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `trending:daily:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getTrendingDaily(parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Weekly trending anime ---- + router.get("/trending/weekly", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `trending:weekly:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getTrendingWeekly(parseInt(page), parseInt(per_page)); setCache(cacheKey, result, 120 * 1000); } @@ -169,6 +213,24 @@ const createApiRoutes = (jsonResponse, jsonError) => { } }); + // ---- FEATURE: Top anime by score (all time) ---- + router.get("/top", async (req, res) => { + try { + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `top:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getTopAnime(parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 300 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + // ---- FEATURE: Most anticipated upcoming anime ---- router.get("/upcoming", async (req, res) => { try { @@ -239,6 +301,259 @@ const createApiRoutes = (jsonResponse, jsonError) => { } }); + // ---- FEATURE: Next 10 episodes airing soon ---- + router.get("/upcoming/countdown", async (req, res) => { + try { + const cacheKey = "upcoming:countdown"; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getSchedule(1, 10); + setCache(cacheKey, result, 60 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Random anime of the day ---- + router.get("/random", async (req, res) => { + try { + // NOTE: Get random page from trending to ensure variety + const randomPage = Math.floor(Math.random() * 50) + 1; + const data = await anilist.getCollection("POPULARITY_DESC", null, randomPage, 1); + const anime = data.results[0]; + + if (!anime) return jsonError(res, "No anime found", 404); + jsonResponse(res, anime); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ══════════════════════════════════════════════════════════════ + // SEASONAL & STUDIO + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Seasonal anime by year and season ---- + router.get("/season/:year/:season", async (req, res) => { + try { + const { year, season } = req.params; + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `season:${year}:${season}:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getSeasonalAnime(parseInt(year), season, parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 300 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Anime by studio name ---- + router.get("/studio/:name", async (req, res) => { + try { + const { name } = req.params; + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `studio:${name}:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getAnimeByStudio(decodeURIComponent(name), parseInt(page), parseInt(per_page)); + setCache(cacheKey, result, 300 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ══════════════════════════════════════════════════════════════ + // GENRES & TAGS + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Get all available genres ---- + router.get("/genres", async (req, res) => { + try { + const cacheKey = "genres"; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getAllGenres(); + setCache(cacheKey, result, 86400 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Get all available tags ---- + router.get("/tags", async (req, res) => { + try { + const cacheKey = "tags"; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getAllTags(); + setCache(cacheKey, result, 86400 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Anime by genre name ---- + router.get("/genre/:name", async (req, res) => { + try { + const { name } = req.params; + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `genre:${name}:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.filterAnime({ genre: decodeURIComponent(name), page: parseInt(page), perPage: parseInt(per_page) }); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Anime by year ---- + router.get("/year/:year", async (req, res) => { + try { + const { year } = req.params; + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `year:${year}:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.filterAnime({ year: parseInt(year), page: parseInt(page), perPage: parseInt(per_page) }); + setCache(cacheKey, result, 300 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Anime by status ---- + router.get("/status/:status", async (req, res) => { + try { + const { status } = req.params; + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `status:${status}:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.filterAnime({ status, page: parseInt(page), perPage: parseInt(per_page) }); + setCache(cacheKey, result, 120 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Anime by format ---- + router.get("/format/:format", async (req, res) => { + try { + const { format } = req.params; + const { page = 1, per_page = 20 } = req.query; + const cacheKey = `format:${format}:${page}:${per_page}`; + + let result = getCached(cacheKey); + if (!result) { + result = await anilist.filterAnime({ format, page: parseInt(page), perPage: parseInt(per_page) }); + setCache(cacheKey, result, 300 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ══════════════════════════════════════════════════════════════ + // CHARACTER & STAFF + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Character detail with anime list ---- + router.get("/character/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) return jsonError(res, "Invalid character ID", 400); + + const cacheKey = `character:${id}`; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getCharacterInfo(id); + setCache(cacheKey, result, 600 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Staff detail with anime list ---- + router.get("/staff/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) return jsonError(res, "Invalid staff ID", 400); + + const cacheKey = `staff:${id}`; + let result = getCached(cacheKey); + if (!result) { + result = await anilist.getStaffInfo(id); + setCache(cacheKey, result, 600 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Search characters ---- + router.get("/characters", async (req, res) => { + try { + const { query, page = 1, per_page = 20 } = req.query; + if (!query) return jsonError(res, "query parameter is required", 400); + + const result = await anilist.searchCharacters(query, parseInt(page), parseInt(per_page)); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Search staff ---- + router.get("/staff", async (req, res) => { + try { + const { query, page = 1, per_page = 20 } = req.query; + if (!query) return jsonError(res, "query parameter is required", 400); + + const result = await anilist.searchStaff(query, parseInt(page), parseInt(per_page)); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + // ══════════════════════════════════════════════════════════════ // ANIME DETAILS // ══════════════════════════════════════════════════════════════ @@ -253,7 +568,6 @@ const createApiRoutes = (jsonResponse, jsonError) => { let result = getCached(cacheKey); if (!result) { result = await anilist.getAnimeInfo(id); - // NOTE: Cache info for 5 minutes — detailed data changes infrequently setCache(cacheKey, result, 300 * 1000); } @@ -299,7 +613,7 @@ const createApiRoutes = (jsonResponse, jsonError) => { }); // ══════════════════════════════════════════════════════════════ - // STREAMING + // STREAMING & DOWNLOADING // ══════════════════════════════════════════════════════════════ // ---- FEATURE: Episode list from all providers ---- @@ -312,7 +626,6 @@ const createApiRoutes = (jsonResponse, jsonError) => { let result = getCached(cacheKey); if (!result) { result = await pipe.getEpisodes(id); - // NOTE: Cache episodes for 2 minutes — episode lists change during airing setCache(cacheKey, result, 120 * 1000); } @@ -322,6 +635,23 @@ const createApiRoutes = (jsonResponse, jsonError) => { } }); + // ---- FEATURE: Batch episode sources ---- + router.get("/episodes/:id/batch", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { provider = "kiwi", category = "sub", slugs } = req.query; + + if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400); + if (!slugs) return jsonError(res, "slugs parameter is required (comma-separated)", 400); + + const slugList = slugs.split(",").map((s) => s.trim()); + const result = await pipe.getBatchSources(provider, id, category, slugList); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + // ---- FEATURE: Streaming sources (detailed/manual endpoint) ---- router.get("/sources", async (req, res) => { try { @@ -337,6 +667,43 @@ const createApiRoutes = (jsonResponse, jsonError) => { } }); + // ---- FEATURE: Streaming sources with quality fallback ---- + router.get("/stream", async (req, res) => { + try { + const { provider, anilistId, category = "sub", slug, quality = "1080p" } = req.query; + if (!provider || !anilistId || !slug) { + return jsonError(res, "provider, anilistId, and slug are required", 400); + } + + const sources = await pipe.getWatchSources(provider, parseInt(anilistId), category, slug); + const bestStream = pipe.getBestStream(sources, quality); + const subtitles = pipe.extractSubtitles(sources); + + jsonResponse(res, { + ...sources, + bestStream, + subtitles, + }); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Download URL for an episode ---- + router.get("/download", async (req, res) => { + try { + const { provider, anilistId, category = "sub", slug } = req.query; + if (!provider || !anilistId || !slug) { + return jsonError(res, "provider, anilistId, and slug are required", 400); + } + + const result = await pipe.getDownloadUrl(provider, parseInt(anilistId), category, slug); + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + // ---- FEATURE: Streaming sources (simple slug-based endpoint) ---- router.get("/watch/:provider/:anilistId/:category/:slug", async (req, res) => { try { @@ -348,6 +715,194 @@ const createApiRoutes = (jsonResponse, jsonError) => { } }); + // ══════════════════════════════════════════════════════════════ + // UTILITY FEATURES + // ══════════════════════════════════════════════════════════════ + + // ---- FEATURE: Compare two anime side by side ---- + router.get("/compare/:id1/:id2", async (req, res) => { + try { + const id1 = parseInt(req.params.id1); + const id2 = parseInt(req.params.id2); + if (isNaN(id1) || isNaN(id2)) return jsonError(res, "Invalid anime IDs", 400); + + const cacheKey = `compare:${id1}:${id2}`; + let result = getCached(cacheKey); + if (!result) { + const [anime1, anime2] = await Promise.all([ + anilist.getAnimeInfo(id1), + anilist.getAnimeInfo(id2), + ]); + + result = { + anime1: { + id: anime1.id, + title: anime1.title, + coverImage: anime1.coverImage, + format: anime1.format, + episodes: anime1.episodes, + duration: anime1.duration, + status: anime1.status, + averageScore: anime1.averageScore, + meanScore: anime1.meanScore, + popularity: anime1.popularity, + favourites: anime1.favourites, + genres: anime1.genres, + studios: anime1.studios, + }, + anime2: { + id: anime2.id, + title: anime2.title, + coverImage: anime2.coverImage, + format: anime2.format, + episodes: anime2.episodes, + duration: anime2.duration, + status: anime2.status, + averageScore: anime2.averageScore, + meanScore: anime2.meanScore, + popularity: anime2.popularity, + favourites: anime2.favourites, + genres: anime2.genres, + studios: anime2.studios, + }, + }; + + setCache(cacheKey, result, 600 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Genre statistics ---- + router.get("/stats/genre", async (req, res) => { + try { + const cacheKey = "stats:genre"; + let result = getCached(cacheKey); + if (!result) { + const genres = await anilist.getAllGenres(); + const stats = {}; + + // NOTE: Fetch top anime for each genre (limited to avoid rate limiting) + for (const genre of genres.slice(0, 10)) { + try { + const data = await anilist.filterAnime({ genre, perPage: 1 }); + stats[genre] = { + total: data.total, + topAnime: data.results[0] || null, + }; + } catch { + stats[genre] = { total: 0, topAnime: null }; + } + } + + result = { genres, stats }; + setCache(cacheKey, result, 86400 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Monthly airing calendar ---- + router.get("/calendar", async (req, res) => { + try { + const { year, month } = req.query; + const now = new Date(); + const y = year ? parseInt(year) : now.getFullYear(); + const m = month ? parseInt(month) : now.getMonth() + 1; + + const cacheKey = `calendar:${y}:${m}`; + let result = getCached(cacheKey); + if (!result) { + // NOTE: Get airing schedule for the month + const schedule = await anilist.getSchedule(1, 50); + const monthStart = new Date(y, m - 1, 1); + const monthEnd = new Date(y, m, 0, 23, 59, 59); + + const monthAnime = schedule.results.filter((item) => { + const airDate = new Date(item.airingAt * 1000); + return airDate >= monthStart && airDate <= monthEnd; + }); + + // NOTE: Group by date + const calendar = {}; + monthAnime.forEach((item) => { + const date = new Date(item.airingAt * 1000).toISOString().split("T")[0]; + if (!calendar[date]) calendar[date] = []; + calendar[date].push({ + id: item.id, + title: item.title, + coverImage: item.coverImage, + episode: item.next_episode, + airingAt: item.airingAt, + }); + }); + + result = { + year: y, + month: m, + totalAiring: monthAnime.length, + calendar, + }; + setCache(cacheKey, result, 3600 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + + // ---- FEATURE: Anime timeline (release history) ---- + router.get("/timeline/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) return jsonError(res, "Invalid AniList ID", 400); + + const cacheKey = `timeline:${id}`; + let result = getCached(cacheKey); + if (!result) { + const info = await anilist.getAnimeInfo(id); + const relations = await anilist.getAnimeRelations(id); + + // NOTE: Build timeline from relations + const timeline = relations.relations + .filter((r) => ["PREQUEL", "SEQUEL", "SIDE_STORY", "SPIN_OFF", "ADAPTATION", "SOURCE"].includes(r.relationType)) + .map((r) => ({ + id: r.node.id, + title: r.node.title, + coverImage: r.node.coverImage, + format: r.node.format, + status: r.node.status, + episodes: r.node.episodes, + meanScore: r.node.meanScore, + relation: r.relationType, + })); + + result = { + id: info.id, + title: info.title, + startDate: info.startDate, + endDate: info.endDate, + status: info.status, + episodes: info.episodes, + timeline, + }; + + setCache(cacheKey, result, 600 * 1000); + } + + jsonResponse(res, result); + } catch (err) { + jsonError(res, err.message); + } + }); + // ══════════════════════════════════════════════════════════════ // HEALTH & STATS // ══════════════════════════════════════════════════════════════ @@ -363,7 +918,7 @@ const createApiRoutes = (jsonResponse, jsonError) => { success: true, results: { status: "healthy", - version: "1.4.0", + version: "2.0.0", uptime: `${hours}h ${minutes}m ${seconds}s`, uptimeSeconds: uptime, timestamp: new Date().toISOString(), @@ -371,8 +926,13 @@ const createApiRoutes = (jsonResponse, jsonError) => { memory: { used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB", total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + "MB", + rss: Math.round(process.memoryUsage().rss / 1024 / 1024) + "MB", }, - endpoints: 16, + cache: { + size: require("../helpers/cache").getCacheSize(), + maxSize: 100, + }, + endpoints: 35, providers: ["kiwi", "pewe", "bee", "bonk", "bun", "ally", "nun", "twin", "cog", "moo", "hop", "telli"], }, }); @@ -387,19 +947,20 @@ const createApiRoutes = (jsonResponse, jsonError) => { results: { uptime: uptime + "s", requests: { - total: requestCount, - errors: errorCount, - successRate: requestCount > 0 - ? ((1 - errorCount / requestCount) * 100).toFixed(1) + "%" + total: requestCount.total, + errors: requestCount.errors, + successRate: requestCount.total > 0 + ? ((1 - requestCount.errors / requestCount.total) * 100).toFixed(1) + "%" : "100%", }, cache: { type: "in-memory", - ttl: "1 minute (default), 2-5 minutes for collections", + ttl: "1 minute (default), 2-5 minutes for collections, 24 hours for genres/tags", maxSize: 100, + currentSize: require("../helpers/cache").getCacheSize(), description: "Map-based cache with TTL expiration and FIFO eviction", }, - endpoints: 16, + endpoints: 35, timestamp: new Date().toISOString(), }, }); @@ -412,7 +973,7 @@ const createApiRoutes = (jsonResponse, jsonError) => { info: { title: "MiruroAPI", description: "Free REST API for anime streaming data — AniList GraphQL + Miruro streaming providers", - version: "1.4.0", + version: "2.0.0", contact: { name: "Shineii86", url: "https://github.com/Shineii86/MiruroAPI" }, }, servers: [ @@ -422,28 +983,56 @@ const createApiRoutes = (jsonResponse, jsonError) => { "/search": { get: { summary: "Search anime", tags: ["Search"] } }, "/suggestions": { get: { summary: "Autocomplete suggestions", tags: ["Search"] } }, "/filter": { get: { summary: "Advanced filter", tags: ["Search"] } }, + "/multi-search": { get: { summary: "Multi-parameter search", tags: ["Search"] } }, "/trending": { get: { summary: "Trending anime", tags: ["Collections"] } }, + "/trending/daily": { get: { summary: "Daily trending", tags: ["Collections"] } }, + "/trending/weekly": { get: { summary: "Weekly trending", tags: ["Collections"] } }, "/popular": { get: { summary: "Most popular", tags: ["Collections"] } }, + "/top": { get: { summary: "Top by score", 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"] } }, + "/random": { get: { summary: "Random anime", tags: ["Collections"] } }, + "/season/{year}/{season}": { get: { summary: "Seasonal anime", tags: ["Seasonal & Studio"] } }, + "/studio/{name}": { get: { summary: "Anime by studio", tags: ["Seasonal & Studio"] } }, + "/genres": { get: { summary: "All genres", tags: ["Genres & Tags"] } }, + "/tags": { get: { summary: "All tags", tags: ["Genres & Tags"] } }, + "/genre/{name}": { get: { summary: "Anime by genre", tags: ["Genres & Tags"] } }, + "/year/{year}": { get: { summary: "Anime by year", tags: ["Genres & Tags"] } }, + "/status/{status}": { get: { summary: "Anime by status", tags: ["Genres & Tags"] } }, + "/format/{format}": { get: { summary: "Anime by format", tags: ["Genres & Tags"] } }, + "/character/{id}": { get: { summary: "Character detail", tags: ["Character & Staff"] } }, + "/staff/{id}": { get: { summary: "Staff detail", tags: ["Character & Staff"] } }, + "/characters": { get: { summary: "Search characters", tags: ["Character & Staff"] } }, + "/staff": { get: { summary: "Search staff", tags: ["Character & Staff"] } }, "/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"] } }, + "/episodes/{id}": { get: { summary: "Episode list", tags: ["Streaming & Downloading"] } }, + "/episodes/{id}/batch": { get: { summary: "Batch episode sources", tags: ["Streaming & Downloading"] } }, + "/sources": { get: { summary: "Streaming sources (detailed)", tags: ["Streaming & Downloading"] } }, + "/stream": { get: { summary: "Stream with quality fallback", tags: ["Streaming & Downloading"] } }, + "/download": { get: { summary: "Download URL", tags: ["Streaming & Downloading"] } }, + "/watch/{provider}/{anilistId}/{category}/{slug}": { get: { summary: "Streaming sources (simple)", tags: ["Streaming & Downloading"] } }, + "/compare/{id1}/{id2}": { get: { summary: "Compare two anime", tags: ["Utility"] } }, + "/stats/genre": { get: { summary: "Genre statistics", tags: ["Utility"] } }, + "/calendar": { get: { summary: "Monthly calendar", tags: ["Utility"] } }, + "/timeline/{id}": { get: { summary: "Anime timeline", tags: ["Utility"] } }, "/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: "Search", description: "Search, filter, suggestions" }, + { name: "Collections", description: "Trending, popular, top, schedule" }, + { name: "Seasonal & Studio", description: "Seasonal anime, studio filter" }, + { name: "Genres & Tags", description: "Genre, year, status, format filters" }, + { name: "Character & Staff", description: "Character/staff details and search" }, { name: "Anime Details", description: "Info, characters, relations, recommendations" }, - { name: "Streaming", description: "Episodes, sources, watch" }, + { name: "Streaming & Downloading", description: "Episodes, sources, stream, download" }, + { name: "Utility", description: "Compare, stats, calendar, timeline" }, { name: "System", description: "Health, stats, OpenAPI" }, ], });