-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgithub_api_py.json
More file actions
20 lines (20 loc) · 46.7 KB
/
Copy pathgithub_api_py.json
File metadata and controls
20 lines (20 loc) · 46.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"code": 200,
"data": {
"description": "",
"html": "<html><head><meta name=\"color-scheme\" content=\"light dark\"></head><body><pre style=\"word-wrap: break-word; white-space: pre-wrap;\">import base64, json, gzip, httpx, os\r\nfrom curl_cffi.requests import AsyncSession\r\nfrom fastapi import FastAPI, HTTPException, Query\r\nfrom fastapi.responses import HTMLResponse, JSONResponse\r\nfrom fastapi.middleware.cors import CORSMiddleware\r\nfrom typing import Optional\r\nfrom dotenv import load_dotenv\r\n\r\nload_dotenv()\r\n\r\napp = FastAPI(title=\"Miruro API\", version=\"2.0\")\r\n\r\napp.add_middleware(\r\n CORSMiddleware,\r\n allow_origins=[\"*\"],\r\n allow_credentials=True,\r\n allow_methods=[\"*\"],\r\n allow_headers=[\"*\"],\r\n)\r\n\r\nHEADERS = {\r\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36\",\r\n \"Referer\": \"https://www.miruro.tv/\",\r\n \"Origin\": \"https://www.miruro.tv\",\r\n \"Accept\": \"*/*\",\r\n \"Accept-Language\": \"en-US,en;q=0.9\",\r\n \"Accept-Encoding\": \"gzip, deflate, br\",\r\n \"sec-fetch-site\": \"same-origin\",\r\n \"sec-fetch-mode\": \"cors\",\r\n \"sec-fetch-dest\": \"empty\",\r\n \"sec-ch-ua\": '\"Chromium\";v=\"110\", \"Not A(Brand\";v=\"24\", \"Google Chrome\";v=\"110\"',\r\n \"sec-ch-ua-mobile\": \"?0\",\r\n \"sec-ch-ua-platform\": '\"Windows\"',\r\n}\r\nANILIST_URL = \"https://graphql.anilist.co\"\r\nMIRURO_PIPE_URL = \"https://www.miruro.tv/api/secure/pipe\"\r\n\r\ndef _proxy_img(url: str) -> str:\r\n return url\r\n\r\ndef _proxy_deep_images(obj):\r\n return obj\r\n\r\ndef _inject_source_slugs(data: dict, anilist_id: int):\r\n providers = data.get(\"providers\", {})\r\n for provider_name, provider_data in providers.items():\r\n if not isinstance(provider_data, dict):\r\n continue\r\n episodes = provider_data.get(\"episodes\", {})\r\n if not isinstance(episodes, dict):\r\n if isinstance(episodes, list):\r\n provider_data[\"episodes\"] = {\"sub\": episodes}\r\n episodes = provider_data[\"episodes\"]\r\n else:\r\n continue\r\n for category, ep_list in episodes.items():\r\n if not isinstance(ep_list, list):\r\n continue\r\n for ep in ep_list:\r\n if not isinstance(ep, dict):\r\n continue\r\n if \"id\" in ep and \"number\" in ep:\r\n orig_id = ep[\"id\"]\r\n prefix = orig_id.split(\":\")[0] if \":\" in orig_id else orig_id\r\n ep[\"id\"] = f\"watch/{provider_name}/{anilist_id}/{category}/{prefix}-{ep['number']}\"\r\n return data\r\n\r\nasync def _fetch_raw_episodes(anilist_id: int) -> dict:\r\n payload = {\r\n \"path\": \"episodes\",\r\n \"method\": \"GET\",\r\n \"query\": {\"anilistId\": anilist_id},\r\n \"body\": None,\r\n \"version\": \"0.1.0\",\r\n }\r\n encoded_req = _encode_pipe_request(payload)\r\n async with AsyncSession(impersonate=\"chrome110\") as client:\r\n res = await client.get(f\"{MIRURO_PIPE_URL}?e={encoded_req}\", headers=HEADERS)\r\n if res.status_code != 200:\r\n raise HTTPException(status_code=res.status_code, detail={\"status\": res.status_code, \"body\": res.text[:500], \"headers\": dict(res.headers)})\r\n data = _decode_pipe_response(res.text.strip())\r\n _deep_translate(data)\r\n return data\r\n\r\nMEDIA_LIST_FIELDS = \"\"\"\r\n id\r\n title { romaji english native }\r\n coverImage { large extraLarge }\r\n bannerImage\r\n format\r\n season\r\n seasonYear\r\n episodes\r\n duration\r\n status\r\n averageScore\r\n meanScore\r\n popularity\r\n favourites\r\n genres\r\n source\r\n countryOfOrigin\r\n isAdult\r\n studios(isMain: true) { nodes { name isAnimationStudio } }\r\n nextAiringEpisode { episode airingAt timeUntilAiring }\r\n startDate { year month day }\r\n endDate { year month day }\r\n\"\"\"\r\n\r\nMEDIA_FULL_FIELDS = \"\"\"\r\n id\r\n idMal\r\n title { romaji english native }\r\n description(asHtml: false)\r\n coverImage { large extraLarge color }\r\n bannerImage\r\n format\r\n season\r\n seasonYear\r\n episodes\r\n duration\r\n status\r\n averageScore\r\n meanScore\r\n popularity\r\n favourites\r\n trending\r\n genres\r\n tags { name rank isMediaSpoiler }\r\n source\r\n countryOfOrigin\r\n isAdult\r\n hashtag\r\n synonyms\r\n siteUrl\r\n trailer { id site thumbnail }\r\n studios { nodes { id name isAnimationStudio siteUrl } }\r\n nextAiringEpisode { episode airingAt timeUntilAiring }\r\n startDate { year month day }\r\n endDate { year month day }\r\n characters(sort: [ROLE, RELEVANCE], perPage: 25) {\r\n edges {\r\n role\r\n node { id name { full native } image { large } }\r\n voiceActors(language: JAPANESE) { id name { full native } image { large } languageV2 }\r\n }\r\n }\r\n staff(sort: RELEVANCE, perPage: 25) {\r\n edges {\r\n role\r\n node { id name { full native } image { large } }\r\n }\r\n }\r\n relations {\r\n edges {\r\n relationType(version: 2)\r\n node {\r\n id\r\n title { romaji english native }\r\n coverImage { large }\r\n format\r\n type\r\n status\r\n episodes\r\n meanScore\r\n }\r\n }\r\n }\r\n recommendations(sort: RATING_DESC, perPage: 10) {\r\n nodes {\r\n rating\r\n mediaRecommendation {\r\n id\r\n title { romaji english native }\r\n coverImage { large }\r\n format\r\n episodes\r\n status\r\n meanScore\r\n averageScore\r\n }\r\n }\r\n }\r\n externalLinks { url site type }\r\n streamingEpisodes { title thumbnail url site }\r\n stats {\r\n scoreDistribution { score amount }\r\n statusDistribution { status amount }\r\n }\r\n\"\"\"\r\n\r\ndef _translate_id(encoded_id: str) -> str:\r\n try:\r\n decoded = base64.urlsafe_b64decode(encoded_id + '=' * (4 - len(encoded_id) % 4)).decode()\r\n if ':' in decoded:\r\n return decoded\r\n return encoded_id\r\n except Exception:\r\n return encoded_id\r\n\r\ndef _deep_translate(obj):\r\n if isinstance(obj, dict):\r\n for key, value in obj.items():\r\n if key == 'id' and isinstance(value, str):\r\n obj[key] = _translate_id(value)\r\n elif isinstance(value, (dict, list)):\r\n _deep_translate(value)\r\n elif isinstance(obj, list):\r\n for item in obj:\r\n if isinstance(item, (dict, list)):\r\n _deep_translate(item)\r\n\r\ndef _decode_pipe_response(encoded_str: str) -> dict:\r\n try:\r\n encoded_str += '=' * (4 - len(encoded_str) % 4)\r\n compressed = base64.urlsafe_b64decode(encoded_str)\r\n return json.loads(gzip.decompress(compressed).decode('utf-8'))\r\n except Exception:\r\n raise ValueError(\"Failed to decode pipe response\")\r\n\r\ndef _encode_pipe_request(payload: dict) -> str:\r\n return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')\r\n\r\nasync def _anilist_query(query: str, variables: dict = None):\r\n body = {\"query\": query}\r\n if variables:\r\n body[\"variables\"] = variables\r\n async with httpx.AsyncClient(timeout=15.0) as client:\r\n res = await client.post(ANILIST_URL, json=body)\r\n if res.status_code != 200:\r\n raise HTTPException(status_code=500, detail=\"AniList query failed\")\r\n return res.json().get(\"data\", {})\r\n\r\n@app.get(\"/\", response_class=HTMLResponse)\r\nasync def home():\r\n return \"\"\"<!DOCTYPE html>\r\n<html lang=\"en\">\r\n<head>\r\n<meta charset=\"UTF-8\">\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\r\n<title>Miruro API v3.0</title>\r\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\r\n<style>\r\n*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}\r\n:root{\r\n --bg:#03040a;--surface:rgba(255,255,255,0.03);--border:rgba(255,255,255,0.07);\r\n --blue:#38bdf8;--purple:#818cf8;--green:#34d399;--amber:#fbbf24;\r\n --text:#e2e8f0;--muted:#64748b;--dim:#334155;\r\n --font:'Inter',sans-serif;--mono:'JetBrains Mono',monospace;\r\n}\r\nhtml{scroll-behavior:smooth}\r\nbody{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;overflow-x:hidden;-webkit-font-smoothing:antialiased}\r\n\r\n/* ── canvas bg ── */\r\n#bg{position:fixed;inset:0;z-index:0;pointer-events:none}\r\n\r\n/* ── notice banner ── */\r\n.notice{position:relative;z-index:10;background:linear-gradient(90deg,rgba(251,191,36,.12),rgba(251,191,36,.06));border-bottom:1px solid rgba(251,191,36,.2);padding:11px 20px;text-align:center;font-size:.82em;color:#fde68a;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}\r\n.notice strong{color:#fbbf24}\r\n.notice-icon{font-size:1em;flex-shrink:0}\r\n\r\n/* ── layout ── */\r\n.wrap{position:relative;z-index:1;max-width:860px;margin:0 auto;padding:60px 20px 80px}\r\n\r\n/* ── hero ── */\r\n.hero{text-align:center;padding:50px 0 60px;perspective:1000px}\r\n.logo-wrap{display:inline-block;margin-bottom:28px;animation:float 6s ease-in-out infinite}\r\n.logo-wrap img{width:88px;border-radius:22px;box-shadow:0 0 0 1px var(--border),0 20px 60px rgba(56,189,248,.2);display:block}\r\n@keyframes float{0%,100%{transform:translateY(0) rotateY(0deg)}50%{transform:translateY(-8px) rotateY(6deg)}}\r\nh1{font-size:clamp(2rem,6vw,3.2rem);font-weight:700;letter-spacing:-.03em;line-height:1.1;margin-bottom:14px}\r\n.grad{background:linear-gradient(135deg,#fff 0%,var(--blue) 50%,var(--purple) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}\r\n.sub{color:var(--muted);font-size:1em;font-weight:400;max-width:480px;margin:0 auto 20px;line-height:1.6}\r\n.chip{display:inline-flex;align-items:center;gap:6px;background:rgba(56,189,248,.08);color:var(--blue);border:1px solid rgba(56,189,248,.18);border-radius:999px;padding:5px 14px;font-size:.78em;font-weight:500;letter-spacing:.04em}\r\n.chip::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}\r\n@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.8)}}\r\n\r\n/* ── section ── */\r\n.section{margin-top:56px}\r\n.section-head{display:flex;align-items:center;gap:10px;margin-bottom:20px}\r\n.section-head h2{font-size:.7em;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}\r\n.section-line{flex:1;height:1px;background:var(--border)}\r\n\r\n/* ── card ── */\r\n.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:22px 24px;margin-bottom:10px;cursor:default;will-change:transform;transition:transform .2s cubic-bezier(.23,1,.32,1),box-shadow .2s cubic-bezier(.23,1,.32,1),border-color .2s;transform-style:preserve-3d}\r\n.card:hover{transform:translateY(-3px) scale(1.005);box-shadow:0 20px 60px rgba(0,0,0,.5),0 0 0 1px rgba(56,189,248,.12);border-color:rgba(56,189,248,.18)}\r\n.card-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap}\r\n.method{font-family:var(--mono);font-size:.72em;font-weight:500;background:rgba(52,211,153,.1);color:var(--green);border:1px solid rgba(52,211,153,.2);padding:3px 9px;border-radius:6px;letter-spacing:.04em}\r\n.path{font-family:var(--mono);font-size:.92em;color:var(--text);font-weight:500}\r\n.badge{font-size:.65em;padding:2px 7px;border-radius:5px;font-weight:600;letter-spacing:.05em}\r\n.b-new{background:rgba(52,211,153,.12);color:var(--green);border:1px solid rgba(52,211,153,.25)}\r\n.b-hot{background:rgba(251,191,36,.1);color:var(--amber);border:1px solid rgba(251,191,36,.2)}\r\n.b-rec{background:rgba(129,140,248,.12);color:var(--purple);border:1px solid rgba(129,140,248,.25)}\r\n.desc{color:var(--muted);font-size:.87em;line-height:1.65;margin-top:12px}\r\n.desc b{color:var(--text);font-weight:500}\r\n.params{font-family:var(--mono);font-size:.78em;color:var(--dim);margin-top:10px;line-height:1.9}\r\n.params em{color:var(--blue);font-style:normal}\r\n.try{display:inline-flex;align-items:center;gap:5px;margin-top:12px;font-size:.8em;color:var(--blue);text-decoration:none;border:1px solid rgba(56,189,248,.15);border-radius:7px;padding:4px 10px;transition:background .15s,border-color .15s}\r\n.try:hover{background:rgba(56,189,248,.08);border-color:rgba(56,189,248,.3)}\r\n.try::after{content:'↗';font-size:.9em}\r\n.returns{font-size:.8em;color:var(--dim);margin-top:10px;line-height:1.7}\r\n.returns b{color:#a5b4fc;font-weight:500}\r\n\r\n/* ── step cards ── */\r\n.step-card{border-radius:14px;padding:22px 24px;margin-bottom:10px;border:1px solid var(--border);will-change:transform;transition:transform .2s cubic-bezier(.23,1,.32,1),box-shadow .2s}\r\n.step-card:hover{transform:translateY(-3px);box-shadow:0 20px 60px rgba(0,0,0,.5)}\r\n.step1{background:linear-gradient(135deg,rgba(56,189,248,.05),rgba(56,189,248,.02))}\r\n.step2{background:linear-gradient(135deg,rgba(52,211,153,.05),rgba(52,211,153,.02));border-color:rgba(52,211,153,.15)}\r\n.step3{background:linear-gradient(135deg,rgba(129,140,248,.05),rgba(129,140,248,.02));border-color:rgba(129,140,248,.15)}\r\n.step-num{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-size:.78em;font-weight:700;margin-right:8px;flex-shrink:0}\r\n.s1{background:rgba(56,189,248,.12);color:var(--blue);border:1px solid rgba(56,189,248,.25)}\r\n.s2{background:rgba(52,211,153,.12);color:var(--green);border:1px solid rgba(52,211,153,.25)}\r\n.s3{background:rgba(129,140,248,.12);color:var(--purple);border:1px solid rgba(129,140,248,.25)}\r\n\r\n/* ── code ── */\r\npre{background:rgba(0,0,0,.4);border:1px solid var(--border);border-radius:10px;padding:16px;font-family:var(--mono);font-size:.76em;color:#94a3b8;overflow-x:auto;margin-top:14px;line-height:1.7;tab-size:2}\r\ncode{font-family:var(--mono);font-size:.85em;color:#a5b4fc;background:rgba(165,180,252,.07);padding:1px 5px;border-radius:4px}\r\n\r\n/* ── param table ── */\r\n.ptable{width:100%;margin-top:14px;border-collapse:collapse;font-size:.8em}\r\n.ptable th{text-align:left;color:var(--purple);font-weight:500;padding:7px 10px;border-bottom:1px solid var(--border);font-size:.9em}\r\n.ptable td{padding:7px 10px;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.025)}\r\n.ptable td:first-child{font-family:var(--mono);color:#a5b4fc;white-space:nowrap}\r\n\r\n/* ── alert ── */\r\n.alert{border-radius:10px;padding:13px 16px;font-size:.83em;line-height:1.6;margin-top:14px}\r\n.alert-yellow{background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.15);color:#fde68a}\r\n.alert-yellow b{color:var(--amber)}\r\n.alert-green{background:rgba(52,211,153,.06);border:1px solid rgba(52,211,153,.15);color:#6ee7b7}\r\n.alert-green b{color:var(--green)}\r\n\r\n/* ── footer ── */\r\n.footer{text-align:center;margin-top:72px;padding-top:28px;border-top:1px solid var(--border);color:var(--dim);font-size:.82em;line-height:2}\r\n.footer a{color:var(--blue);text-decoration:none;font-weight:500}\r\n.footer a:hover{color:var(--purple)}\r\n\r\n@media(max-width:600px){\r\n .wrap{padding:40px 14px 60px}\r\n .hero{padding:36px 0 44px}\r\n .card,.step-card{padding:18px}\r\n pre{font-size:.7em}\r\n}\r\n</style>\r\n</head>\r\n<body>\r\n\r\n<canvas id=\"bg\"></canvas>\r\n\r\n<!-- ── notice ── -->\r\n<div class=\"notice\">\r\n <span class=\"notice-icon\">⚠️</span>\r\n <span><strong>Hosting Notice:</strong> Miruro now has Cloudflare protection on the pipe endpoint. <strong>Do not deploy on Vercel</strong> — its IPs are datacenter-blocked by CF. Use a <strong>VPS with a residential or non-datacenter IP</strong> instead.</span>\r\n</div>\r\n\r\n<div class=\"wrap\">\r\n\r\n <!-- ── hero ── -->\r\n <div class=\"hero\">\r\n <div class=\"logo-wrap\">\r\n <img src=\"https://www.miruro.to/assets/logo-Dnw3w3dS.png?v=1.12.0\" alt=\"Miruro\">\r\n </div>\r\n <h1><span class=\"grad\">Miruro API</span></h1>\r\n <p class=\"sub\">Reverse-engineered anime streaming API. Episodes, sources, metadata — all in one place.</p>\r\n <div class=\"chip\">v3.0 · Live</div>\r\n </div>\r\n\r\n <!-- ── search ── -->\r\n <div class=\"section\">\r\n <div class=\"section-head\"><h2>Search & Discovery</h2><div class=\"section-line\"></div></div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/search</span></div>\r\n <p class=\"desc\">Search anime by name. Returns full metadata — title, cover, banner, genres, studios, scores, airing status, and more.</p>\r\n <div class=\"params\">Params: <em>query</em> (required) · <em>page</em>=1 · <em>per_page</em>=20</div>\r\n <div class=\"returns\">Returns: <b>page</b>, <b>perPage</b>, <b>total</b>, <b>hasNextPage</b>, <b>results[]</b> (20+ fields each)</div>\r\n <a class=\"try\" href=\"/search?query=naruto&page=1&per_page=5\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/suggestions</span><span class=\"badge b-new\">NEW</span></div>\r\n <p class=\"desc\">Lightweight autocomplete search. Returns only essentials: id, title, poster, format, status, year, episode count. Max 8 results.</p>\r\n <div class=\"params\">Params: <em>query</em> (required)</div>\r\n <a class=\"try\" href=\"/suggestions?query=one piece\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/spotlight</span><span class=\"badge b-hot\">HOT</span></div>\r\n <p class=\"desc\">Top 10 anime currently trending and popular globally. Perfect for hero banners and home carousels.</p>\r\n <a class=\"try\" href=\"/spotlight\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/filter</span><span class=\"badge b-new\">NEW</span></div>\r\n <p class=\"desc\">Advanced filter and browse. All params are optional — combine freely.</p>\r\n <table class=\"ptable\">\r\n <tr><th>Param</th><th>Values</th></tr>\r\n <tr><td>genre</td><td>Action, Romance, Comedy, Drama, Fantasy, Sci-Fi …</td></tr>\r\n <tr><td>tag</td><td>Isekai, Time Skip, Reincarnation …</td></tr>\r\n <tr><td>year</td><td>2025, 2024 …</td></tr>\r\n <tr><td>season</td><td>WINTER · SPRING · SUMMER · FALL</td></tr>\r\n <tr><td>format</td><td>TV · MOVIE · OVA · ONA · SPECIAL</td></tr>\r\n <tr><td>status</td><td>RELEASING · FINISHED · NOT_YET_RELEASED · CANCELLED</td></tr>\r\n <tr><td>sort</td><td>SCORE_DESC · POPULARITY_DESC · TRENDING_DESC · START_DATE_DESC</td></tr>\r\n <tr><td>page / per_page</td><td>Pagination (default 1 / 20)</td></tr>\r\n </table>\r\n <a class=\"try\" href=\"/filter?genre=Action&format=TV&sort=SCORE_DESC&per_page=5\" target=\"_blank\">Try it</a>\r\n </div>\r\n </div>\r\n\r\n <!-- ── collections ── -->\r\n <div class=\"section\">\r\n <div class=\"section-head\"><h2>Collections</h2><div class=\"section-line\"></div></div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/trending</span></div>\r\n <p class=\"desc\">Currently trending anime across the community.</p>\r\n <div class=\"params\">Params: <em>page</em>=1 · <em>per_page</em>=20</div>\r\n <a class=\"try\" href=\"/trending?per_page=5\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/popular</span></div>\r\n <p class=\"desc\">Most popular anime of all time by total user count.</p>\r\n <div class=\"params\">Params: <em>page</em>=1 · <em>per_page</em>=20</div>\r\n <a class=\"try\" href=\"/popular\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/upcoming</span></div>\r\n <p class=\"desc\">Most anticipated anime not yet aired.</p>\r\n <div class=\"params\">Params: <em>page</em>=1 · <em>per_page</em>=20</div>\r\n <a class=\"try\" href=\"/upcoming\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/recent</span></div>\r\n <p class=\"desc\">Currently airing / this season's anime.</p>\r\n <div class=\"params\">Params: <em>page</em>=1 · <em>per_page</em>=20</div>\r\n <a class=\"try\" href=\"/recent\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/schedule</span></div>\r\n <p class=\"desc\">Next episodes airing soon — includes <b>airingAt</b> (UNIX), <b>timeUntilAiring</b> (seconds), and <b>next_episode</b> number.</p>\r\n <div class=\"params\">Params: <em>page</em>=1 · <em>per_page</em>=20</div>\r\n <a class=\"try\" href=\"/schedule\" target=\"_blank\">Try it</a>\r\n </div>\r\n </div>\r\n\r\n <!-- ── details ── -->\r\n <div class=\"section\">\r\n <div class=\"section-head\"><h2>Anime Details</h2><div class=\"section-line\"></div></div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/info/{anilist_id}</span></div>\r\n <p class=\"desc\">Complete anime detail page in one request — title, description, cover, banner, format, season, scores, genres, <b>tags</b>, <b>studios</b>, <b>characters</b>, <b>staff</b>, <b>relations</b>, <b>recommendations</b>, <b>trailer</b>, <b>external links</b>, stats, and more.</p>\r\n <a class=\"try\" href=\"/info/20\" target=\"_blank\">Try /info/20</a>\r\n <a class=\"try\" href=\"/info/21\" target=\"_blank\" style=\"margin-left:6px\">Try /info/21</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/anime/{id}/characters</span></div>\r\n <p class=\"desc\">Paginated character list — name, image, role (MAIN / SUPPORTING), Japanese voice actors with images.</p>\r\n <div class=\"params\">Params: <em>page</em>=1 · <em>per_page</em>=25</div>\r\n <a class=\"try\" href=\"/anime/20/characters\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/anime/{id}/relations</span></div>\r\n <p class=\"desc\">All related media — sequels, prequels, side stories, spin-offs, source material. Each entry includes relation type and basic metadata.</p>\r\n <a class=\"try\" href=\"/anime/20/relations\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"card\">\r\n <div class=\"card-top\"><span class=\"method\">GET</span><span class=\"path\">/anime/{id}/recommendations</span></div>\r\n <p class=\"desc\">Community \"if you liked X, try Y\" recommendations sorted by rating.</p>\r\n <div class=\"params\">Params: <em>page</em>=1 · <em>per_page</em>=10</div>\r\n <a class=\"try\" href=\"/anime/20/recommendations\" target=\"_blank\">Try it</a>\r\n </div>\r\n </div>\r\n\r\n <!-- ── streaming ── -->\r\n <div class=\"section\">\r\n <div class=\"section-head\"><h2>Streaming — 3-Step Flow</h2><div class=\"section-line\"></div></div>\r\n\r\n <div class=\"alert alert-yellow\" style=\"margin-bottom:16px\">\r\n <b>How it works:</b> Follow these 3 steps in order. Each step's output feeds directly into the next.\r\n </div>\r\n\r\n <div class=\"step-card step1\">\r\n <div class=\"card-top\"><span class=\"step-num s1\">1</span><span class=\"method\">GET</span><span class=\"path\">/episodes/{anilist_id}</span></div>\r\n <p class=\"desc\">Get all available episodes across multiple providers (kiwi, arc, zoro, hop …) organised by audio type (sub / dub).</p>\r\n <div class=\"returns\">Returns: <b>mappings</b> (AniList / MAL / Kitsu IDs) + <b>providers</b> (episode lists per provider)</div>\r\n<pre>{\r\n \"mappings\": { \"anilistId\": 178005, \"malId\": 56885, ... },\r\n \"providers\": {\r\n \"kiwi\": {\r\n \"episodes\": {\r\n \"sub\": [\r\n {\r\n \"id\": \"watch/kiwi/178005/sub/animepahe-1\",\r\n \"number\": 1,\r\n \"title\": \"Episode Title\",\r\n \"image\": \"https://...\",\r\n \"airDate\": \"2026-01-04\",\r\n \"duration\": 1420,\r\n \"filler\": false\r\n }\r\n ]\r\n }\r\n }\r\n }\r\n}</pre>\r\n <a class=\"try\" href=\"/episodes/178005\" target=\"_blank\">Try it</a>\r\n </div>\r\n\r\n <div class=\"step-card step2\">\r\n <div class=\"card-top\"><span class=\"step-num s2\">2</span><span class=\"path\">/watch/{provider}/{anilistId}/{category}/{slug}</span><span class=\"badge b-rec\">RECOMMENDED</span></div>\r\n <p class=\"desc\">Take the <b>id</b> from Step 1 and use it directly as the URL — no extra params needed.</p>\r\n <a class=\"try\" href=\"/watch/kiwi/178005/sub/animepahe-1\" target=\"_blank\">Try it</a>\r\n<pre>{\r\n \"streams\": [\r\n { \"url\": \"https://.../master.m3u8\", \"type\": \"hls\", \"quality\": \"1080p\" }\r\n ],\r\n \"subtitles\": [ { \"file\": \"...\", \"label\": \"English\" } ],\r\n \"intro\": { \"start\": 0, \"end\": 90 },\r\n \"outro\": { \"start\": 1300, \"end\": 1420 }\r\n}</pre>\r\n <div class=\"alert alert-green\" style=\"margin-top:14px;font-size:.78em\">\r\n <b>Alternative:</b> <code>GET /sources?episodeId=...&provider=...&anilistId=...&category=...</code>\r\n </div>\r\n </div>\r\n\r\n <div class=\"step-card step3\">\r\n <div class=\"card-top\"><span class=\"step-num s3\">3</span><span class=\"path\" style=\"color:var(--purple)\">Play the stream</span></div>\r\n <p class=\"desc\">Feed <b>streams[0].url</b> into any HLS player — Video.js, hls.js, VLC, mpv. Subtitles are hard-subbed (kiwi/pahe) or in the <b>subtitles[]</b> array (zoro/arc). Use <b>intro/outro</b> timestamps for skip buttons.</p>\r\n </div>\r\n </div>\r\n\r\n <div class=\"footer\">\r\n All collection endpoints return <code>{ page, perPage, total, hasNextPage, results[] }</code>\r\n <br>\r\n Built by Walter · <a href=\"https://github.com/walterwhite-69\" target=\"_blank\">github.com/walterwhite-69</a>\r\n </div>\r\n\r\n</div>\r\n\r\n<script>\r\n(function(){\r\n const c=document.getElementById('bg'),x=c.getContext('2d');\r\n let W,H,pts=[];\r\n const N=60,COLOR='rgba(56,189,248,';\r\n function resize(){W=c.width=innerWidth;H=c.height=innerHeight;pts=Array.from({length:N},()=>({x:Math.random()*W,y:Math.random()*H,vx:(Math.random()-.5)*.3,vy:(Math.random()-.5)*.3,r:Math.random()*1.5+.5}))}\r\n function draw(){\r\n x.clearRect(0,0,W,H);\r\n for(let i=0;i<N;i++){\r\n const p=pts[i];\r\n p.x+=p.vx;p.y+=p.vy;\r\n if(p.x<0||p.x>W)p.vx*=-1;\r\n if(p.y<0||p.y>H)p.vy*=-1;\r\n x.beginPath();x.arc(p.x,p.y,p.r,0,6.28);x.fillStyle=COLOR+'.4)';x.fill();\r\n for(let j=i+1;j<N;j++){\r\n const q=pts[j],dx=p.x-q.x,dy=p.y-q.y,d=Math.sqrt(dx*dx+dy*dy);\r\n if(d<140){x.beginPath();x.moveTo(p.x,p.y);x.lineTo(q.x,q.y);x.strokeStyle=COLOR+(1-d/140)*.08+')';x.lineWidth=.6;x.stroke()}\r\n }\r\n }\r\n requestAnimationFrame(draw);\r\n }\r\n window.addEventListener('resize',resize);resize();draw();\r\n\r\n /* 3-D tilt on cards */\r\n document.querySelectorAll('.card,.step-card').forEach(el=>{\r\n el.addEventListener('mousemove',e=>{\r\n const r=el.getBoundingClientRect(),cx=r.left+r.width/2,cy=r.top+r.height/2;\r\n const rx=((e.clientY-cy)/r.height)*6,ry=-((e.clientX-cx)/r.width)*6;\r\n el.style.transform=`perspective(800px) rotateX(${rx}deg) rotateY(${ry}deg) translateY(-3px)`;\r\n });\r\n el.addEventListener('mouseleave',()=>el.style.transform='');\r\n });\r\n})();\r\n</script>\r\n</body>\r\n</html>\"\"\"\r\n\r\n@app.get(\"/search\")\r\nasync def search_anime(\r\n query: str,\r\n page: int = Query(1, ge=1, description=\"Page number\"),\r\n per_page: int = Query(20, ge=1, le=50, description=\"Results per page\"),\r\n):\r\n gql = f\"\"\"\r\n query ($search: String, $page: Int, $perPage: Int) {{\r\n Page(page: $page, perPage: $perPage) {{\r\n pageInfo {{ total currentPage lastPage hasNextPage perPage }}\r\n media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{\r\n {MEDIA_LIST_FIELDS}\r\n }}\r\n }}\r\n }}\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"search\": query, \"page\": page, \"perPage\": per_page})\r\n page_data = data.get(\"Page\", {})\r\n page_info = page_data.get(\"pageInfo\", {})\r\n response = {\r\n \"page\": page_info.get(\"currentPage\", page),\r\n \"perPage\": page_info.get(\"perPage\", per_page),\r\n \"total\": page_info.get(\"total\", 0),\r\n \"hasNextPage\": page_info.get(\"hasNextPage\", False),\r\n \"results\": page_data.get(\"media\", []),\r\n }\r\n return _proxy_deep_images(response)\r\n\r\n@app.get(\"/suggestions\")\r\nasync def search_suggestions(\r\n query: str = Query(..., min_length=1, description=\"Search query for autocomplete\"),\r\n):\r\n gql = \"\"\"\r\n query ($search: String) {\r\n Page(page: 1, perPage: 8) {\r\n media(search: $search, type: ANIME, sort: SEARCH_MATCH) {\r\n id\r\n title { romaji english }\r\n coverImage { large }\r\n format\r\n status\r\n startDate { year }\r\n episodes\r\n }\r\n }\r\n }\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"search\": query})\r\n results = []\r\n for item in data.get(\"Page\", {}).get(\"media\", []):\r\n results.append({\r\n \"id\": item[\"id\"],\r\n \"title\": item[\"title\"].get(\"english\") or item[\"title\"].get(\"romaji\"),\r\n \"title_romaji\": item[\"title\"].get(\"romaji\"),\r\n \"poster\": item[\"coverImage\"][\"large\"],\r\n \"format\": item.get(\"format\"),\r\n \"status\": item.get(\"status\"),\r\n \"year\": (item.get(\"startDate\") or {}).get(\"year\"),\r\n \"episodes\": item.get(\"episodes\"),\r\n })\r\n return _proxy_deep_images({\"suggestions\": results})\r\n\r\nSORT_MAP = {\r\n \"SCORE_DESC\": \"SCORE_DESC\",\r\n \"POPULARITY_DESC\": \"POPULARITY_DESC\",\r\n \"TRENDING_DESC\": \"TRENDING_DESC\",\r\n \"START_DATE_DESC\": \"START_DATE_DESC\",\r\n \"FAVOURITES_DESC\": \"FAVOURITES_DESC\",\r\n \"UPDATED_AT_DESC\": \"UPDATED_AT_DESC\",\r\n}\r\n\r\n@app.get(\"/filter\")\r\nasync def filter_anime(\r\n genre: Optional[str] = Query(None, description=\"Genre name, e.g. Action, Romance\"),\r\n tag: Optional[str] = Query(None, description=\"Tag name, e.g. Isekai, Time Skip\"),\r\n year: Optional[int] = Query(None, description=\"Season year, e.g. 2025\"),\r\n season: Optional[str] = Query(None, description=\"WINTER, SPRING, SUMMER, or FALL\"),\r\n format: Optional[str] = Query(None, description=\"TV, MOVIE, OVA, ONA, SPECIAL, MUSIC\"),\r\n status: Optional[str] = Query(None, description=\"RELEASING, FINISHED, NOT_YET_RELEASED, CANCELLED, HIATUS\"),\r\n sort: str = Query(\"POPULARITY_DESC\", description=\"Sort order\"),\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(20, ge=1, le=50),\r\n):\r\n args = [\"type: ANIME\", f\"sort: [{SORT_MAP.get(sort, 'POPULARITY_DESC')}]\"]\r\n variables = {\"page\": page, \"perPage\": per_page}\r\n\r\n if genre:\r\n args.append(\"genre: $genre\")\r\n variables[\"genre\"] = genre\r\n if tag:\r\n args.append(\"tag: $tag\")\r\n variables[\"tag\"] = tag\r\n if year:\r\n args.append(\"seasonYear: $seasonYear\")\r\n variables[\"seasonYear\"] = year\r\n if season:\r\n args.append(\"season: $season\")\r\n variables[\"season\"] = season.upper()\r\n if format:\r\n args.append(\"format: $format\")\r\n variables[\"format\"] = format.upper()\r\n if status:\r\n args.append(\"status: $status\")\r\n variables[\"status\"] = status.upper()\r\n\r\n var_types = [\"$page: Int\", \"$perPage: Int\"]\r\n if genre:\r\n var_types.append(\"$genre: String\")\r\n if tag:\r\n var_types.append(\"$tag: String\")\r\n if year:\r\n var_types.append(\"$seasonYear: Int\")\r\n if season:\r\n var_types.append(\"$season: MediaSeason\")\r\n if format:\r\n var_types.append(\"$format: MediaFormat\")\r\n if status:\r\n var_types.append(\"$status: MediaStatus\")\r\n\r\n gql = f\"\"\"\r\n query ({', '.join(var_types)}) {{\r\n Page(page: $page, perPage: $perPage) {{\r\n pageInfo {{ total currentPage lastPage hasNextPage perPage }}\r\n media({', '.join(args)}) {{\r\n {MEDIA_LIST_FIELDS}\r\n }}\r\n }}\r\n }}\r\n \"\"\"\r\n data = await _anilist_query(gql, variables)\r\n page_data = data.get(\"Page\", {})\r\n page_info = page_data.get(\"pageInfo\", {})\r\n response = {\r\n \"page\": page_info.get(\"currentPage\", page),\r\n \"perPage\": page_info.get(\"perPage\", per_page),\r\n \"total\": page_info.get(\"total\", 0),\r\n \"hasNextPage\": page_info.get(\"hasNextPage\", False),\r\n \"results\": page_data.get(\"media\", []),\r\n }\r\n return _proxy_deep_images(response)\r\n\r\nasync def _fetch_collection(sort_type: str, status: str = None, page: int = 1, per_page: int = 20):\r\n status_filter = f\", status: {status}\" if status else \"\"\r\n gql = f\"\"\"\r\n query ($page: Int, $perPage: Int) {{\r\n Page(page: $page, perPage: $perPage) {{\r\n pageInfo {{ total currentPage lastPage hasNextPage perPage }}\r\n media(type: ANIME, sort: [{sort_type}]{status_filter}) {{\r\n {MEDIA_LIST_FIELDS}\r\n }}\r\n }}\r\n }}\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"page\": page, \"perPage\": per_page})\r\n page_data = data.get(\"Page\", {})\r\n page_info = page_data.get(\"pageInfo\", {})\r\n response = {\r\n \"page\": page_info.get(\"currentPage\", page),\r\n \"perPage\": page_info.get(\"perPage\", per_page),\r\n \"total\": page_info.get(\"total\", 0),\r\n \"hasNextPage\": page_info.get(\"hasNextPage\", False),\r\n \"results\": page_data.get(\"media\", []),\r\n }\r\n return _proxy_deep_images(response)\r\n\r\n@app.get(\"/spotlight\")\r\nasync def get_spotlight():\r\n gql = f\"\"\"\r\n query {{\r\n Page(page: 1, perPage: 10) {{\r\n media(sort: [TRENDING_DESC, POPULARITY_DESC], type: ANIME) {{\r\n {MEDIA_LIST_FIELDS}\r\n }}\r\n }}\r\n }}\r\n \"\"\"\r\n data = await _anilist_query(gql)\r\n media = data.get(\"Page\", {}).get(\"media\", [])\r\n return _proxy_deep_images({\"results\": media})\r\n\r\n@app.get(\"/trending\")\r\nasync def get_trending(\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(20, ge=1, le=50),\r\n):\r\n return await _fetch_collection(\"TRENDING_DESC\", page=page, per_page=per_page)\r\n\r\n@app.get(\"/popular\")\r\nasync def get_popular(\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(20, ge=1, le=50),\r\n):\r\n return await _fetch_collection(\"POPULARITY_DESC\", page=page, per_page=per_page)\r\n\r\n@app.get(\"/upcoming\")\r\nasync def get_upcoming(\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(20, ge=1, le=50),\r\n):\r\n return await _fetch_collection(\"POPULARITY_DESC\", \"NOT_YET_RELEASED\", page=page, per_page=per_page)\r\n\r\n@app.get(\"/recent\")\r\nasync def get_recent(\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(20, ge=1, le=50),\r\n):\r\n return await _fetch_collection(\"START_DATE_DESC\", \"RELEASING\", page=page, per_page=per_page)\r\n\r\n@app.get(\"/schedule\")\r\nasync def get_schedule(\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(20, ge=1, le=50),\r\n):\r\n gql = f\"\"\"\r\n query ($page: Int, $perPage: Int) {{\r\n Page(page: $page, perPage: $perPage) {{\r\n pageInfo {{ total currentPage lastPage hasNextPage perPage }}\r\n airingSchedules(notYetAired: true, sort: TIME) {{\r\n episode\r\n airingAt\r\n timeUntilAiring\r\n media {{\r\n {MEDIA_LIST_FIELDS}\r\n }}\r\n }}\r\n }}\r\n }}\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"page\": page, \"perPage\": per_page})\r\n page_data = data.get(\"Page\", {})\r\n page_info = page_data.get(\"pageInfo\", {})\r\n results = []\r\n for item in page_data.get(\"airingSchedules\", []):\r\n entry = item.get(\"media\", {})\r\n entry[\"next_episode\"] = item.get(\"episode\")\r\n entry[\"airingAt\"] = item.get(\"airingAt\")\r\n entry[\"timeUntilAiring\"] = item.get(\"timeUntilAiring\")\r\n results.append(entry)\r\n response = {\r\n \"page\": page_info.get(\"currentPage\", page),\r\n \"perPage\": page_info.get(\"perPage\", per_page),\r\n \"total\": page_info.get(\"total\", 0),\r\n \"hasNextPage\": page_info.get(\"hasNextPage\", False),\r\n \"results\": results,\r\n }\r\n return _proxy_deep_images(response)\r\n\r\n@app.get(\"/info/{anilist_id}\")\r\nasync def get_anime_info(anilist_id: int):\r\n gql = f\"\"\"\r\n query ($id: Int) {{\r\n Media(id: $id, type: ANIME) {{\r\n {MEDIA_FULL_FIELDS}\r\n }}\r\n }}\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"id\": anilist_id})\r\n media = data.get(\"Media\")\r\n if not media:\r\n raise HTTPException(status_code=404, detail=\"Anime not found\")\r\n return _proxy_deep_images(media)\r\n\r\n@app.get(\"/anime/{anilist_id}/characters\")\r\nasync def get_anime_characters(\r\n anilist_id: int,\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(25, ge=1, le=50),\r\n):\r\n gql = \"\"\"\r\n query ($id: Int, $page: Int, $perPage: Int) {\r\n Media(id: $id, type: ANIME) {\r\n id\r\n title { romaji english }\r\n characters(sort: [ROLE, RELEVANCE], page: $page, perPage: $perPage) {\r\n pageInfo { total currentPage lastPage hasNextPage perPage }\r\n edges {\r\n role\r\n node {\r\n id\r\n name { full native userPreferred }\r\n image { large medium }\r\n description\r\n gender\r\n dateOfBirth { year month day }\r\n age\r\n favourites\r\n siteUrl\r\n }\r\n voiceActors {\r\n id\r\n name { full native }\r\n image { large }\r\n languageV2\r\n }\r\n }\r\n }\r\n }\r\n }\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"id\": anilist_id, \"page\": page, \"perPage\": per_page})\r\n media = data.get(\"Media\")\r\n if not media:\r\n raise HTTPException(status_code=404, detail=\"Anime not found\")\r\n chars = media.get(\"characters\", {})\r\n page_info = chars.get(\"pageInfo\", {})\r\n response = {\r\n \"page\": page_info.get(\"currentPage\", page),\r\n \"perPage\": page_info.get(\"perPage\", per_page),\r\n \"total\": page_info.get(\"total\", 0),\r\n \"hasNextPage\": page_info.get(\"hasNextPage\", False),\r\n \"characters\": chars.get(\"edges\", []),\r\n }\r\n return _proxy_deep_images(response)\r\n\r\n@app.get(\"/anime/{anilist_id}/relations\")\r\nasync def get_anime_relations(anilist_id: int):\r\n gql = \"\"\"\r\n query ($id: Int) {\r\n Media(id: $id, type: ANIME) {\r\n id\r\n title { romaji english }\r\n relations {\r\n edges {\r\n relationType(version: 2)\r\n node {\r\n id\r\n title { romaji english native }\r\n coverImage { large }\r\n bannerImage\r\n format\r\n type\r\n status\r\n episodes\r\n chapters\r\n meanScore\r\n averageScore\r\n popularity\r\n startDate { year month day }\r\n }\r\n }\r\n }\r\n }\r\n }\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"id\": anilist_id})\r\n media = data.get(\"Media\")\r\n if not media:\r\n raise HTTPException(status_code=404, detail=\"Anime not found\")\r\n response = {\r\n \"id\": media[\"id\"],\r\n \"title\": media[\"title\"],\r\n \"relations\": media.get(\"relations\", {}).get(\"edges\", []),\r\n }\r\n return _proxy_deep_images(response)\r\n\r\n@app.get(\"/anime/{anilist_id}/recommendations\")\r\nasync def get_anime_recommendations(\r\n anilist_id: int,\r\n page: int = Query(1, ge=1),\r\n per_page: int = Query(10, ge=1, le=25),\r\n):\r\n gql = \"\"\"\r\n query ($id: Int, $page: Int, $perPage: Int) {\r\n Media(id: $id, type: ANIME) {\r\n id\r\n title { romaji english }\r\n recommendations(sort: RATING_DESC, page: $page, perPage: $perPage) {\r\n pageInfo { total currentPage lastPage hasNextPage perPage }\r\n nodes {\r\n rating\r\n mediaRecommendation {\r\n id\r\n title { romaji english native }\r\n coverImage { large extraLarge }\r\n bannerImage\r\n format\r\n episodes\r\n status\r\n meanScore\r\n averageScore\r\n popularity\r\n genres\r\n startDate { year }\r\n }\r\n }\r\n }\r\n }\r\n }\r\n \"\"\"\r\n data = await _anilist_query(gql, {\"id\": anilist_id, \"page\": page, \"perPage\": per_page})\r\n media = data.get(\"Media\")\r\n if not media:\r\n raise HTTPException(status_code=404, detail=\"Anime not found\")\r\n recs = media.get(\"recommendations\", {})\r\n page_info = recs.get(\"pageInfo\", {})\r\n response = {\r\n \"page\": page_info.get(\"currentPage\", page),\r\n \"perPage\": page_info.get(\"perPage\", per_page),\r\n \"total\": page_info.get(\"total\", 0),\r\n \"hasNextPage\": page_info.get(\"hasNextPage\", False),\r\n \"recommendations\": recs.get(\"nodes\", []),\r\n }\r\n return _proxy_deep_images(response)\r\n\r\n@app.get(\"/episodes/{anilist_id}\")\r\nasync def get_episodes(anilist_id: int):\r\n data = await _fetch_raw_episodes(anilist_id)\r\n return _proxy_deep_images(_inject_source_slugs(data, anilist_id))\r\n\r\n@app.get(\"/sources\")\r\nasync def get_sources(\r\n episodeId: str = Query(..., description=\"Plain-text episode ID from /episodes response\"),\r\n provider: str = Query(..., description=\"Provider name, e.g. kiwi, arc, telli\"),\r\n anilistId: int = Query(..., description=\"AniList anime ID\"),\r\n category: str = Query(\"sub\", description=\"sub or dub\"),\r\n):\r\n enc_id = base64.urlsafe_b64encode(episodeId.encode()).decode().rstrip('=')\r\n payload = {\r\n \"path\": \"sources\",\r\n \"method\": \"GET\",\r\n \"query\": {\r\n \"episodeId\": enc_id,\r\n \"provider\": provider,\r\n \"category\": category,\r\n \"anilistId\": anilistId,\r\n },\r\n \"body\": None,\r\n \"version\": \"0.1.0\",\r\n }\r\n encoded_req = _encode_pipe_request(payload)\r\n async with AsyncSession(impersonate=\"chrome110\") as client:\r\n res = await client.get(f\"{MIRURO_PIPE_URL}?e={encoded_req}\", headers=HEADERS)\r\n if res.status_code != 200:\r\n raise HTTPException(status_code=res.status_code, detail={\"status\": res.status_code, \"body\": res.text[:500], \"headers\": dict(res.headers)})\r\n return _proxy_deep_images(_decode_pipe_response(res.text.strip()))\r\n\r\n@app.get(\"/watch/{provider}/{anilist_id}/{category}/{slug}\")\r\nasync def get_watch_sources(provider: str, anilist_id: int, category: str, slug: str):\r\n data = await _fetch_raw_episodes(anilist_id)\r\n prov_data = data.get(\"providers\", {}).get(provider, {})\r\n ep_list = prov_data.get(\"episodes\", {}).get(category, [])\r\n \r\n target_id = None\r\n for ep in ep_list:\r\n orig_id = ep.get(\"id\", \"\")\r\n prefix = orig_id.split(\":\")[0] if \":\" in orig_id else orig_id\r\n generated = f\"{prefix}-{ep.get('number')}\"\r\n if generated == slug:\r\n target_id = orig_id\r\n break\r\n \r\n if not target_id:\r\n raise HTTPException(status_code=404, detail=f\"Episode slug '{slug}' not found for provider {provider}\")\r\n \r\n return await get_sources(episodeId=target_id, provider=provider, anilistId=anilist_id, category=category)\r\n</pre></body></html>",
"httpStatus": 200,
"httpStatusText": "",
"title": "",
"url": "https://raw.githubusercontent.com/walterwhite-69/Miruro-API/main/api.py",
"usage": {
"tokens": 12096
}
},
"meta": {
"usage": {
"tokens": 12096
}
},
"status": 20000
}