1374 lines
54 KiB
Python
1374 lines
54 KiB
Python
import os
|
|
import importlib.util
|
|
import json
|
|
import socket
|
|
import threading
|
|
import uvicorn
|
|
import time
|
|
import asyncio
|
|
import uuid
|
|
from urllib.parse import urlparse
|
|
from contextlib import asynccontextmanager
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import signal
|
|
import traceback
|
|
import zipfile
|
|
import io
|
|
from fastapi import FastAPI, HTTPException, Query, Body, status, UploadFile, File, APIRouter, Request, WebSocket, WebSocketDisconnect, BackgroundTasks
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import HTMLResponse
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
from dataclasses import dataclass, asdict
|
|
from functools import wraps
|
|
import time
|
|
import httpx
|
|
from fastapi.responses import StreamingResponse, JSONResponse, Response
|
|
from zeroconf import ServiceInfo, Zeroconf
|
|
from fpdf import FPDF
|
|
import base64
|
|
|
|
# --- Tunnel Configuration ---
|
|
TUNNEL_TOKEN = os.environ.get("TUNNEL_TOKEN", "PICUrl123")
|
|
active_tunnel: Optional[WebSocket] = None
|
|
pending_requests: Dict[str, asyncio.Future] = {}
|
|
|
|
# --- Global Event for Animation Control ---
|
|
server_ready_event = threading.Event()
|
|
|
|
def animate_loading(stop_event: threading.Event):
|
|
animation_chars = ["⢿", "⣻", "⣽", "⣾", "⣷", "⣯", "⣟", "⡿"]
|
|
idx = 0
|
|
print("🎬 Starting Animex Cloud Relay...", end="", flush=True)
|
|
while not stop_event.is_set():
|
|
char = animation_chars[idx % len(animation_chars)]
|
|
print(f" {char}", end="\r", flush=True)
|
|
idx += 1
|
|
time.sleep(0.08)
|
|
print("\n📺 Animex Cloud Server is ready!")
|
|
|
|
# --- Tunnel Helper Logic ---
|
|
async def tunnel_request(method: str, url: str, headers: dict = None, json_data: any = None, timeout: int = 30) -> Dict:
|
|
"""Sends a request to the home agent via WebSocket and waits for the response."""
|
|
global active_tunnel
|
|
if not active_tunnel:
|
|
# Fallback to local fetch if home agent is not connected
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.request(method, url, headers=headers, json=json_data, timeout=timeout)
|
|
return {
|
|
"status": resp.status_code,
|
|
"headers": dict(resp.headers),
|
|
"body": base64.b64encode(resp.content).decode('utf-8')
|
|
}
|
|
|
|
req_id = str(uuid.uuid4())
|
|
future = asyncio.get_event_loop().create_future()
|
|
pending_requests[req_id] = future
|
|
|
|
payload = {
|
|
"type": "request",
|
|
"id": req_id,
|
|
"method": method,
|
|
"url": url,
|
|
"headers": headers or {},
|
|
"json": json_data
|
|
}
|
|
|
|
try:
|
|
await active_tunnel.send_json(payload)
|
|
# Wait for response with timeout
|
|
result = await asyncio.wait_for(future, timeout=timeout)
|
|
return result
|
|
except Exception as e:
|
|
if req_id in pending_requests:
|
|
del pending_requests[req_id]
|
|
raise HTTPException(status_code=502, detail=f"Tunnel request failed: {str(e)}")
|
|
|
|
# --- Smart HTTP Client Replacement ---
|
|
class HybridClient:
|
|
def __init__(self, use_tunnel=True):
|
|
self.use_tunnel = use_tunnel
|
|
|
|
def _create_mock_response(self, res_data):
|
|
class MockResponse:
|
|
def __init__(self, d):
|
|
self.status_code = d.get("status", 500)
|
|
self.headers = d.get("headers", {})
|
|
# Decode binary body from tunnel
|
|
try:
|
|
self.content = base64.b64decode(d.get("body", ""))
|
|
except Exception:
|
|
self.content = b""
|
|
self.text = self.content.decode('utf-8', errors='ignore')
|
|
|
|
def json(self):
|
|
try:
|
|
return json.loads(self.text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise httpx.HTTPStatusError(f"Error {self.status_code}", request=None, response=self)
|
|
return MockResponse(res_data)
|
|
|
|
async def get(self, url, headers=None, params=None, timeout=30, **kwargs):
|
|
# 1. Properly encode query params into the URL for the tunnel
|
|
if params:
|
|
from urllib.parse import urlencode
|
|
sep = "&" if "?" in url else "?"
|
|
url = f"{url}{sep}{urlencode(params)}"
|
|
|
|
if self.use_tunnel:
|
|
# Send the request through the WebSocket tunnel
|
|
res = await tunnel_request("GET", url, headers=headers, timeout=timeout)
|
|
return self._create_mock_response(res)
|
|
else:
|
|
# Direct fetch fallback
|
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
return await client.get(url, headers=headers, timeout=timeout, **kwargs)
|
|
|
|
async def post(self, url, json=None, headers=None, params=None, timeout=30, **kwargs):
|
|
# 1. Encode query params into URL (Some APIs like AnimeKai use POST with URL params)
|
|
if params:
|
|
from urllib.parse import urlencode
|
|
sep = "&" if "?" in url else "?"
|
|
url = f"{url}{sep}{urlencode(params)}"
|
|
|
|
# 2. Support all common JSON payload argument names used by modules
|
|
json_payload = json or kwargs.get("json_data") or kwargs.get("json_payload") or kwargs.get("json_body")
|
|
|
|
if self.use_tunnel:
|
|
# Send the request through the WebSocket tunnel
|
|
res = await tunnel_request("POST", url, headers=headers, json_data=json_payload, timeout=timeout)
|
|
return self._create_mock_response(res)
|
|
else:
|
|
# Direct fetch fallback
|
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
return await client.post(url, json=json_payload, headers=headers, timeout=timeout, **kwargs)
|
|
|
|
# --- Utility Functions ---
|
|
def natural_sort_key(s):
|
|
return [int(text) if text.isdigit() else text.lower() for text in re.split('([0-9]+)', s)]
|
|
|
|
# --- Zeroconf (Keep for local, but won't trigger on Render) ---
|
|
zeroconf = Zeroconf()
|
|
service_info = None
|
|
|
|
def get_local_ip():
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(('10.255.255.255', 1))
|
|
IP = s.getsockname()[0]
|
|
except Exception:
|
|
IP = '127.0.0.1'
|
|
finally:
|
|
s.close()
|
|
return IP
|
|
|
|
def register_service():
|
|
global service_info
|
|
try:
|
|
host_ip = get_local_ip()
|
|
host_name = socket.gethostname()
|
|
port = 7275
|
|
service_info = ServiceInfo(
|
|
"_http._tcp.local.",
|
|
f"Animex Extension API @ {host_name}._http._tcp.local.",
|
|
addresses=[socket.inet_aton(host_ip)],
|
|
port=port,
|
|
properties={'app': 'animex-extension-api'},
|
|
server=f"{host_name}.local.",
|
|
)
|
|
zeroconf.register_service(service_info)
|
|
except Exception:
|
|
pass
|
|
|
|
async def unregister_service():
|
|
if service_info:
|
|
zeroconf.close()
|
|
|
|
# --- Caching Setup ---
|
|
DATA_DIR = "data"
|
|
CACHE_DIR_JIKAN = os.path.join(DATA_DIR, "cache", "jikan")
|
|
CACHE_DIR_GENERIC = os.path.join(DATA_DIR, "cache", "generic")
|
|
os.makedirs(CACHE_DIR_JIKAN, exist_ok=True)
|
|
os.makedirs(CACHE_DIR_GENERIC, exist_ok=True)
|
|
|
|
CACHE_DIR_EPISODES = os.path.join(DATA_DIR, "cache", "episodes")
|
|
os.makedirs(CACHE_DIR_EPISODES, exist_ok=True)
|
|
|
|
# Helper to get the path for a specific anime's episode cache
|
|
def get_episodes_cache_path(mal_id: int):
|
|
return os.path.join(CACHE_DIR_EPISODES, f"mal_{mal_id}.json")
|
|
|
|
MEMORY_CACHE = {"anime_db": None, "anime_db_timestamp": 0}
|
|
|
|
def get_cache_key(url: str) -> str:
|
|
import hashlib
|
|
return hashlib.md5(url.encode('utf-8')).hexdigest() + ".json"
|
|
|
|
def get_cache_path(cache_key: str) -> str:
|
|
return os.path.join(CACHE_DIR_GENERIC, get_cache_key(cache_key))
|
|
|
|
def load_named_cache(cache_key: str, ttl: int = 86400):
|
|
filepath = get_cache_path(cache_key)
|
|
if not os.path.exists(filepath): return None
|
|
try:
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
if time.time() - data.get("_timestamp", 0) > ttl: return None
|
|
return data.get("payload")
|
|
except Exception: return None
|
|
|
|
def save_named_cache(cache_key: str, payload: dict):
|
|
filepath = get_cache_path(cache_key)
|
|
cache_obj = {"payload": payload, "_timestamp": time.time()}
|
|
try:
|
|
with open(filepath, 'w', encoding='utf-8') as f:
|
|
json.dump(cache_obj, f)
|
|
except Exception: pass
|
|
|
|
def load_cache(url: str):
|
|
filepath = os.path.join(CACHE_DIR_JIKAN, get_cache_key(url))
|
|
if os.path.exists(filepath):
|
|
try:
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
if data.get("_is_permanent", False): return data["payload"]
|
|
if time.time() - data["_timestamp"] > 86400: return None
|
|
return data["payload"]
|
|
except Exception: return None
|
|
return None
|
|
|
|
def save_cache(url: str, payload: dict):
|
|
filepath = os.path.join(CACHE_DIR_JIKAN, get_cache_key(url))
|
|
is_permanent = False
|
|
data_content = payload.get("data")
|
|
if isinstance(data_content, dict):
|
|
status = data_content.get("status", "")
|
|
if status in ["Finished Airing", "Finished"]: is_permanent = True
|
|
cache_obj = {"payload": payload, "_timestamp": time.time(), "_is_permanent": is_permanent}
|
|
try:
|
|
with open(filepath, 'w', encoding='utf-8') as f:
|
|
json.dump(cache_obj, f)
|
|
except Exception: pass
|
|
|
|
ANILIST_URL = "https://graphql.anilist.co"
|
|
JIKAN_RELATIONS = "https://api.jikan.moe/v4/anime/{mal_id}/relations"
|
|
|
|
MEDIA_QUERY = """
|
|
query ($idMal: Int, $id: Int) {
|
|
Media(idMal: $idMal, id: $id, type: ANIME) {
|
|
id
|
|
idMal
|
|
title { romaji english native }
|
|
format
|
|
episodes
|
|
season
|
|
seasonYear
|
|
startDate { year month day }
|
|
relations {
|
|
edges {
|
|
relationType
|
|
node {
|
|
id
|
|
idMal
|
|
title { romaji english native }
|
|
format
|
|
season
|
|
seasonYear
|
|
startDate { year month day }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
TRAVERSE_RELATIONS = {"SEQUEL", "PREQUEL", "PARENT", "CHILD"}
|
|
SEASON_FORMATS = {"TV", "TV_SHORT"}
|
|
PART_RE = re.compile(r'\b(?:part|pt|p|section)\s*[:.]?\s*([0-9]+|[ivx]+)\b', re.I)
|
|
SEASON_RE = re.compile(r'\b(?:season|s)\s*[:.]?\s*([0-9]+|[ivx]+)\b', re.I)
|
|
COUR_RE = re.compile(r'\b(?:cour)\s*[:.]?\s*([0-9]+)\b', re.I)
|
|
FINAL_RE = re.compile(r'\b(final season|final chapters?|finale)\b', re.I)
|
|
ROMAN_MAP = {'I':1,'V':5,'X':10,'L':50,'C':100,'D':500,'M':1000}
|
|
|
|
def roman_to_int(s: str) -> Optional[int]:
|
|
if not s: return None
|
|
s = s.upper().strip()
|
|
total = 0
|
|
prev = 0
|
|
for ch in s[::-1]:
|
|
val = ROMAN_MAP.get(ch)
|
|
if val is None: return None
|
|
if val < prev: total -= val
|
|
else: total += val
|
|
prev = val
|
|
return total if total > 0 else None
|
|
|
|
def extract_part_from_title(title: str) -> Optional[int]:
|
|
if not title: return None
|
|
m = SEASON_RE.search(title)
|
|
if m:
|
|
raw = m.group(1)
|
|
try: return int(raw)
|
|
except ValueError: return roman_to_int(raw)
|
|
m = PART_RE.search(title)
|
|
if m:
|
|
raw = m.group(1)
|
|
try: return int(raw)
|
|
except ValueError: return roman_to_int(raw)
|
|
m = COUR_RE.search(title)
|
|
if m:
|
|
try: return int(m.group(1))
|
|
except Exception: return None
|
|
return None
|
|
|
|
def detect_final_in_title(title: str) -> bool:
|
|
return bool(title and FINAL_RE.search(title))
|
|
|
|
def safe_date_tuple(sd: Dict[str, Any]) -> Tuple[int,int,int]:
|
|
if not sd: return (0,0,0)
|
|
return (sd.get("year") or 0, sd.get("month") or 0, sd.get("day") or 0)
|
|
|
|
@dataclass
|
|
class MediaEntry:
|
|
anilist_id: int
|
|
mal_id: Optional[int]
|
|
title_romaji: str
|
|
title_english: Optional[str]
|
|
title_native: Optional[str]
|
|
format: Optional[str]
|
|
start_date: Dict[str, Optional[int]]
|
|
season: Optional[str]
|
|
season_year: Optional[int]
|
|
episodes: Optional[int]
|
|
relation_type_from_parent: Optional[str] = None
|
|
inferred_part: Optional[int] = None
|
|
is_season: bool = False
|
|
is_final_from_title: bool = False
|
|
|
|
def title_display(self) -> str:
|
|
return (self.title_english or self.title_romaji or self.title_native or "")
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
d = asdict(self)
|
|
d["title_display"] = self.title_display()
|
|
d["_start_tuple"] = safe_date_tuple(self.start_date)
|
|
d["is_final_season"] = self.is_final_from_title
|
|
return d
|
|
|
|
_ANILIST_CACHE: Dict[str, Tuple[float, Optional[Dict[str, Any]]]] = {}
|
|
_ANILIST_CACHE_TTL = 3600
|
|
|
|
def anilist_cache_get(key: str) -> Optional[Dict[str, Any]]:
|
|
rec = _ANILIST_CACHE.get(key)
|
|
if not rec: return None
|
|
ts, val = rec
|
|
if time.time() - ts > _ANILIST_CACHE_TTL:
|
|
del _ANILIST_CACHE[key]
|
|
return None
|
|
return val
|
|
|
|
def anilist_cache_set(key: str, value: Optional[Dict[str, Any]]):
|
|
_ANILIST_CACHE[key] = (time.time(), value)
|
|
|
|
async def fetch_media(client: HybridClient, mal_id: Optional[int]=None, aid: Optional[int]=None) -> Optional[Dict[str,Any]]:
|
|
if mal_id: cache_key = f"mal:{mal_id}"
|
|
elif aid: cache_key = f"aid:{aid}"
|
|
else: return None
|
|
cached = anilist_cache_get(cache_key)
|
|
if cached is not None: return cached
|
|
variables = {}
|
|
if mal_id: variables["idMal"] = mal_id
|
|
if aid: variables["id"] = aid
|
|
try:
|
|
r = await client.post(ANILIST_URL, json={"query": MEDIA_QUERY, "variables": variables}, timeout=15)
|
|
media = r.json().get("data", {}).get("Media")
|
|
anilist_cache_set(cache_key, media)
|
|
return media
|
|
except Exception:
|
|
anilist_cache_set(cache_key, None)
|
|
return None
|
|
|
|
async def fetch_jikan_relations(client: HybridClient, mal_id: int) -> List[Dict[str, Any]]:
|
|
try:
|
|
r = await client.get(JIKAN_RELATIONS.format(mal_id=mal_id), timeout=12)
|
|
if r.status_code != 200: return []
|
|
js = r.json()
|
|
entries = []
|
|
for rel in js.get("data", []):
|
|
rel_type = rel.get("relation")
|
|
for entry in rel.get("entry", []):
|
|
if entry.get("type") != "anime": continue
|
|
entries.append({"mal_id": entry.get("mal_id"), "title": entry.get("name"), "relation": rel_type})
|
|
return entries
|
|
except Exception: return []
|
|
|
|
async def collect_franchise(client: HybridClient, root_mal: int, max_visits: int = 100) -> List[MediaEntry]:
|
|
seen_aid = set()
|
|
results_by_key: Dict[str, MediaEntry] = {}
|
|
stack = [("mal", root_mal)]
|
|
visits = 0
|
|
while stack and visits < max_visits:
|
|
visits += 1
|
|
kind, val = stack.pop()
|
|
media = await fetch_media(client, mal_id=val if kind=="mal" else None, aid=val if kind=="aid" else None)
|
|
if not media: continue
|
|
aid = media.get("id")
|
|
if aid in seen_aid: continue
|
|
seen_aid.add(aid)
|
|
tid = media.get("title") or {}
|
|
romaji, english, native = tid.get("romaji", ""), tid.get("english"), tid.get("native")
|
|
title_for_parsing = english or romaji or native or ""
|
|
entry = MediaEntry(
|
|
anilist_id=media.get("id"),
|
|
mal_id=media.get("idMal"),
|
|
title_romaji=romaji,
|
|
title_english=english,
|
|
title_native=native,
|
|
format=media.get("format"),
|
|
start_date=media.get("startDate") or {"year": None, "month": None, "day": None},
|
|
season=media.get("season"),
|
|
season_year=media.get("seasonYear"),
|
|
episodes=media.get("episodes"),
|
|
inferred_part=extract_part_from_title(title_for_parsing),
|
|
is_season=(media.get("format") in SEASON_FORMATS),
|
|
is_final_from_title=detect_final_in_title(title_for_parsing)
|
|
)
|
|
results_by_key[f"aid_{aid}"] = entry
|
|
rels = (media.get("relations") or {}).get("edges", []) or []
|
|
if not rels and media.get("idMal"):
|
|
jikan_entries = await fetch_jikan_relations(client, media.get("idMal"))
|
|
for je in jikan_entries:
|
|
if je.get("mal_id"): stack.append(("mal", je["mal_id"]))
|
|
else:
|
|
for edge in rels:
|
|
if edge.get("relationType") in TRAVERSE_RELATIONS:
|
|
node = edge.get("node") or {}
|
|
if node.get("idMal"): stack.append(("mal", node["idMal"]))
|
|
elif node.get("id"): stack.append(("aid", node["id"]))
|
|
return list(results_by_key.values())
|
|
|
|
def produce_season_labeling(entries: List[MediaEntry]) -> Dict[str, Any]:
|
|
seasons = sorted([e for e in entries if e.is_season], key=lambda x: safe_date_tuple(x.start_date))
|
|
extras = sorted([e for e in entries if not e.is_season], key=lambda x: safe_date_tuple(x.start_date))
|
|
groups_map: Dict[str, List[MediaEntry]] = {}
|
|
auto_counter = 1
|
|
for s in seasons:
|
|
if s.is_final_from_title: key = "final"
|
|
elif s.inferred_part: key = f"num_{s.inferred_part}"
|
|
else:
|
|
key = f"auto_{auto_counter}"
|
|
auto_counter += 1
|
|
groups_map.setdefault(key, []).append(s)
|
|
ordered_keys = sorted(groups_map.keys(), key=lambda gk: min(safe_date_tuple(e.start_date) for e in groups_map[gk]))
|
|
group_index_map = {k: i+1 for i, k in enumerate(ordered_keys)}
|
|
season_groups_output = []
|
|
for gk in ordered_keys:
|
|
idx = group_index_map[gk]
|
|
group_entries = sorted(groups_map[gk], key=lambda x: safe_date_tuple(x.start_date))
|
|
parts_out = []
|
|
for p_i, ent in enumerate(group_entries, start=1):
|
|
short_label = f"S{idx}" if p_i == 1 else f"S{idx}{chr(ord('A') + (p_i - 1))}"
|
|
parts_out.append({"short_label": short_label, "title": ent.title_display(), "mal_id": ent.mal_id, "anilist_id": ent.anilist_id, "start_date": ent.start_date, "format": ent.format, "is_final": ent.is_final_from_title})
|
|
season_groups_output.append({"season_index": idx, "group_label": "Final Season" if any(e.is_final_from_title for e in group_entries) else f"Season {idx}", "parts": parts_out})
|
|
return {
|
|
"entries": [e.to_dict() for e in entries],
|
|
"season_groups": season_groups_output,
|
|
"extras": [{"title": e.title_display(), "mal_id": e.mal_id, "anilist_id": e.anilist_id, "format": e.format, "start_date": e.start_date} for e in extras]
|
|
}
|
|
|
|
# --- Module & Extension Loading ---
|
|
MODULES_DIR = "modules"
|
|
EXTENSIONS_DIR = "extensions"
|
|
loaded_modules = {}
|
|
module_states = {}
|
|
loaded_extensions = {}
|
|
|
|
def load_modules():
|
|
if not os.path.exists(MODULES_DIR): os.makedirs(MODULES_DIR)
|
|
for filename in sorted([f for f in os.listdir(MODULES_DIR) if f.endswith(".module")]):
|
|
module_name = filename.split(".")[0]
|
|
try:
|
|
with open(os.path.join(MODULES_DIR, filename), 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
meta_str, _, code_str = content.partition("\n---\n")
|
|
spec = importlib.util.spec_from_loader(module_name, loader=None)
|
|
module = importlib.util.module_from_spec(spec)
|
|
# Inject hybrid client logic if needed into the module scope
|
|
exec(code_str, module.__dict__)
|
|
module_info = json.loads(meta_str)
|
|
module_info['id'] = module_name
|
|
loaded_modules[module_name] = {"info": module_info, "instance": module}
|
|
module_states[module_name] = True
|
|
except Exception as e: print(f"Module load fail: {e}")
|
|
|
|
def load_extensions(app: FastAPI):
|
|
if not os.path.exists(EXTENSIONS_DIR): return
|
|
for ext_name in os.listdir(EXTENSIONS_DIR):
|
|
ext_path = os.path.join(EXTENSIONS_DIR, ext_name)
|
|
if not os.path.isdir(ext_path): continue
|
|
package_json_path = os.path.join(ext_path, "package.json")
|
|
if not os.path.exists(package_json_path): continue
|
|
try:
|
|
with open(package_json_path, 'r', encoding='utf-8') as f:
|
|
ext_meta = json.load(f)
|
|
main_file = ext_meta.get("main", "extension.extn")
|
|
ext_file_path = os.path.join(ext_path, main_file)
|
|
module_name = f"extensions.{ext_name}.{main_file.split('.')[0]}"
|
|
spec = importlib.util.spec_from_file_location(module_name, ext_file_path)
|
|
ext_module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(ext_module)
|
|
ext_module.EXT_PATH = os.path.abspath(ext_path)
|
|
loaded_extensions[ext_name] = {"info": ext_meta, "instance": ext_module, "process": None, "server_url": None}
|
|
if "port" in ext_meta and "start_command" in ext_meta:
|
|
p = subprocess.Popen(ext_meta["start_command"], shell=True, preexec_fn=os.setsid, cwd=ext_path)
|
|
loaded_extensions[ext_name]["process"] = p
|
|
loaded_extensions[ext_name]["server_url"] = f"http://127.0.0.1:{ext_meta['port']}"
|
|
if "static_folder" in ext_meta:
|
|
app.mount(f"/ext/{ext_name}/static", StaticFiles(directory=os.path.join(ext_path, ext_meta["static_folder"])), name=f"ext_{ext_name}_static")
|
|
except Exception as e: print(f"Ext load fail {ext_name}: {e}")
|
|
|
|
# --- Standard Guest Profile ---
|
|
STANDARD_PROFILE_ID = "guest"
|
|
|
|
# --- FastAPI App Lifespan ---
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global hybrid_client
|
|
hybrid_client = HybridClient(use_tunnel=True)
|
|
load_modules()
|
|
load_extensions(app)
|
|
loop = asyncio.get_event_loop()
|
|
loop.run_in_executor(None, register_service)
|
|
server_ready_event.set()
|
|
yield
|
|
for ext_name, ext_data in loaded_extensions.items():
|
|
if ext_data.get("process"):
|
|
try: os.killpg(os.getpgid(ext_data["process"].pid), signal.SIGTERM)
|
|
except Exception: pass
|
|
await unregister_service()
|
|
|
|
app = FastAPI(title="Animex Cloud API", lifespan=lifespan)
|
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
|
|
|
# --- Tunnel WebSocket Endpoint ---
|
|
@app.websocket("/ws/tunnel")
|
|
async def websocket_tunnel(websocket: WebSocket, token: str = Query(...)):
|
|
global active_tunnel
|
|
if token != TUNNEL_TOKEN:
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
await websocket.accept()
|
|
active_tunnel = websocket
|
|
print("🏠 Home Agent connected to Cloud Tunnel.")
|
|
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_json()
|
|
if data.get("type") == "response":
|
|
req_id = data.get("id")
|
|
if req_id in pending_requests:
|
|
pending_requests[req_id].set_result(data)
|
|
del pending_requests[req_id]
|
|
except WebSocketDisconnect:
|
|
print("🏠 Home Agent disconnected.")
|
|
finally:
|
|
active_tunnel = None
|
|
|
|
# --- Core Endpoints ---
|
|
|
|
|
|
# --- MISSING HELPERS & GRAPHQL QUERIES ---
|
|
|
|
def _get_cover_url_from_manga(manga: Dict[str, Any]) -> Optional[str]:
|
|
"""Helper to extract cover URL from a MangaDex manga object with included cover_art."""
|
|
cover_rel = next((rel for rel in manga.get("relationships", []) if rel.get("type") == "cover_art"), None)
|
|
if cover_rel:
|
|
file_name = cover_rel.get("attributes", {}).get("fileName")
|
|
if file_name:
|
|
return f"/mangadex/cover/{manga['id']}/{file_name}"
|
|
return None
|
|
|
|
VA_QUERY = """
|
|
query ($idMal: Int, $page: Int) {
|
|
Media(idMal: $idMal, type: ANIME) {
|
|
id
|
|
title { romaji english }
|
|
characters(page: $page, perPage: 25) {
|
|
pageInfo { hasNextPage }
|
|
edges {
|
|
role
|
|
node {
|
|
id
|
|
name { full native }
|
|
image { large }
|
|
}
|
|
voiceActors {
|
|
id
|
|
name { full native }
|
|
language
|
|
image { large }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
ANILIST_QUERY = """
|
|
query ($malId: Int) {
|
|
Media(idMal: $malId, type: ANIME) {
|
|
id
|
|
bannerImage
|
|
coverImage { extraLarge large medium }
|
|
}
|
|
}
|
|
"""
|
|
|
|
ANILIST_MANGA_QUERY = """
|
|
query ($malId: Int) {
|
|
Media(idMal: $malId, type: MANGA) {
|
|
id
|
|
bannerImage
|
|
coverImage { extraLarge large medium }
|
|
}
|
|
}
|
|
"""
|
|
|
|
ANIMETHEMES_API = "https://api.animethemes.moe"
|
|
|
|
|
|
@app.get("/identify")
|
|
def identify_server(): return {"app": "Animex Extension API", "version": "2.0-Tunnel", "tunnel_active": active_tunnel is not None}
|
|
|
|
@app.get("/status")
|
|
def get_status(): return {"status": "online", "tunnel": "connected" if active_tunnel else "disconnected"}
|
|
|
|
# --- Proxy Logic (Always Tunnel) ---
|
|
@app.get("/proxy")
|
|
async def generic_proxy(url: str = Query(...), referer: Optional[str] = Query(None)):
|
|
if "localhost" in url or "127.0.0.1" in url: raise HTTPException(status_code=400)
|
|
headers = {"User-Agent": "Mozilla/5.0"}
|
|
if referer: headers["Referer"] = referer
|
|
|
|
# We use tunnel_request directly for proxy to ensure binary passing
|
|
res = await tunnel_request("GET", url, headers=headers)
|
|
content = base64.b64decode(res["body"])
|
|
return Response(content=content, media_type=res["headers"].get("content-type"))
|
|
|
|
@app.get("/proxy-image")
|
|
async def proxy_image(url: str):
|
|
res = await tunnel_request("GET", url)
|
|
return Response(content=base64.b64decode(res["body"]), media_type=res["headers"].get("content-type"))
|
|
|
|
# --- MangaDex Tunnel Support ---
|
|
MANGADEX_API_URL = "https://api.mangadex.org"
|
|
|
|
@app.get("/mangadex/search")
|
|
async def search_mangadex(q: str, profile_id: Optional[str] = Query(None)):
|
|
params = {"title": q, "limit": 24, "includes[]": ["cover_art"]}
|
|
res = await tunnel_request("GET", f"{MANGADEX_API_URL}/manga", headers={"params": params})
|
|
data = json.loads(base64.b64decode(res["body"]))
|
|
for m in data.get("data", []):
|
|
rel = next((r for r in m.get("relationships", []) if r["type"] == "cover_art"), None)
|
|
if rel: m["cover_url"] = f"/mangadex/cover/{m['id']}/{rel['attributes']['fileName']}"
|
|
return data
|
|
|
|
@app.get("/mangadex/cover/{manga_id}/{file_name}")
|
|
async def get_mangadex_cover(manga_id: str, file_name: str):
|
|
url = f"https://uploads.mangadex.org/covers/{manga_id}/{file_name}.256.jpg"
|
|
res = await tunnel_request("GET", url, headers={"Referer": "https://mangadex.org/"})
|
|
return Response(content=base64.b64decode(res["body"]), media_type="image/jpeg")
|
|
|
|
|
|
# --- RESTORED MANGADEX EXTENDED FUNCTIONALITY ---
|
|
|
|
@app.get("/mangadex/list")
|
|
async def list_mangadex(
|
|
order: str = Query("latestUploadedChapter", enum=["latestUploadedChapter", "followedCount", "createdAt", "updatedAt"]),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
profile_id: Optional[str] = Query(None)
|
|
):
|
|
# Logic: Only include NSFW if profile allows (Defaulting to safe for cloud)
|
|
content_ratings = ["safe", "suggestive"]
|
|
params = {f"order[{order}]": "desc", "limit": limit, "contentRating[]": content_ratings, "includes[]": ["cover_art"]}
|
|
res = await tunnel_request("GET", f"{MANGADEX_API_URL}/manga", headers={"params": params})
|
|
data = json.loads(base64.b64decode(res["body"]))
|
|
for manga in data.get("data", []):
|
|
manga["cover_url"] = _get_cover_url_from_manga(manga)
|
|
return data
|
|
|
|
@app.get("/mangadex/manga/{manga_id}")
|
|
async def get_mangadex_manga_details(manga_id: str):
|
|
url = f"{MANGADEX_API_URL}/manga/{manga_id}?includes[]=cover_art&includes[]=author&includes[]=artist"
|
|
res = await tunnel_request("GET", url)
|
|
data = json.loads(base64.b64decode(res["body"])).get("data")
|
|
if data: data["image_url"] = _get_cover_url_from_manga(data)
|
|
return data
|
|
|
|
@app.get("/mangadex/manga/{manga_id}/chapters")
|
|
async def get_mangadex_manga_chapters(manga_id: str, limit: int = 50, offset: int = 0):
|
|
url = f"{MANGADEX_API_URL}/manga/{manga_id}/feed?limit={limit}&offset={offset}&translatedLanguage[]=en&order[chapter]=asc&order[volume]=asc&includes[]=scanlation_group&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
|
res = await tunnel_request("GET", url, headers={"Referer": "https://mangadex.org/"})
|
|
data = json.loads(base64.b64decode(res["body"]))
|
|
return {"chapters": data.get("data", []), "total": data.get("total", 0)}
|
|
|
|
@app.get("/mangadex/manga/{manga_id}/all-chapters")
|
|
async def get_all_mangadex_manga_chapters(manga_id: str):
|
|
all_ids = []
|
|
offset = 0
|
|
while True:
|
|
url = f"{MANGADEX_API_URL}/manga/{manga_id}/feed?limit=100&offset={offset}&translatedLanguage[]=en&order[chapter]=asc&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
|
res = await tunnel_request("GET", url, headers={"Referer": "https://mangadex.org/"})
|
|
data = json.loads(base64.b64decode(res["body"]))
|
|
chaps = data.get("data", [])
|
|
if not chaps: break
|
|
all_ids.extend([c['id'] for c in chaps])
|
|
offset += 100
|
|
if offset >= data.get("total", 0): break
|
|
return {"chapter_ids": all_ids}
|
|
|
|
@app.get("/mangadex/manga/{manga_id}/chapter-nav-details/{chapter_id}")
|
|
async def get_mangadex_chapter_nav_details(manga_id: str, chapter_id: str):
|
|
# Re-using all-chapters logic to find neighbors
|
|
res = await get_all_mangadex_manga_chapters(manga_id)
|
|
ids = res["chapter_ids"]
|
|
try:
|
|
idx = ids.index(chapter_id)
|
|
next_id = ids[idx + 1] if idx + 1 < len(ids) else None
|
|
return {"current_chapter": {"id": chapter_id}, "next_chapter_id": next_id, "total_chapters": len(ids)}
|
|
except ValueError: raise HTTPException(status_code=404)
|
|
|
|
@app.get("/mangadex/chapter/{chapter_id}")
|
|
async def get_mangadex_chapter_images(chapter_id: str):
|
|
url = f"{MANGADEX_API_URL}/at-home/server/{chapter_id}"
|
|
res = await tunnel_request("GET", url, headers={"Referer": "https://mangadex.org/"})
|
|
data = json.loads(base64.b64decode(res["body"]))
|
|
base, hash_id, files = data.get("baseUrl"), data.get("chapter", {}).get("hash"), data.get("chapter", {}).get("data", [])
|
|
server_host = urlparse(base).netloc
|
|
return [f"/mangadex/proxy/{server_host}/data/{hash_id}/{f}" for f in sorted(files, key=natural_sort_key)]
|
|
|
|
@app.get("/mangadex/proxy/{server_host}/data/{chapter_hash}/{filename:path}")
|
|
async def proxy_mangadex_image(server_host: str, chapter_hash: str, filename: str):
|
|
url = f"https://{server_host}/data/{chapter_hash}/{filename}"
|
|
res = await tunnel_request("GET", url, headers={"Referer": "https://mangadex.org/"})
|
|
return Response(content=base64.b64decode(res["body"]), media_type=res["headers"].get("content-type", "image/jpeg"))
|
|
|
|
# --- RESTORED METADATA & MAPPINGS ---
|
|
|
|
@app.get("/map/mal/{mal_id}")
|
|
async def mal_to_kitsu(mal_id: int):
|
|
"""
|
|
Maps a MyAnimeList ID to a Kitsu ID using a local disk-cached version
|
|
of the Fribb Anime Database.
|
|
"""
|
|
url = "https://raw.githubusercontent.com/Fribb/anime-lists/refs/heads/master/anime-offline-database-reduced.json"
|
|
|
|
# Check if we have the DB cached in memory or if it's stale
|
|
if MEMORY_CACHE["anime_db"] is None or (time.time() - MEMORY_CACHE["anime_db_timestamp"] > 86400):
|
|
try:
|
|
r = await hybrid_client.get(url)
|
|
MEMORY_CACHE["anime_db"] = r.json()
|
|
MEMORY_CACHE["anime_db_timestamp"] = time.time()
|
|
except Exception as e:
|
|
# If the remote fetch fails, try to use the last known memory version
|
|
if MEMORY_CACHE["anime_db"]:
|
|
print(f"Using stale memory DB due to fetch error: {e}")
|
|
else:
|
|
raise HTTPException(status_code=502, detail="Mapping database unavailable.")
|
|
|
|
# Search for the MAL ID
|
|
for anime in MEMORY_CACHE["anime_db"]:
|
|
if anime.get("mal_id") == mal_id:
|
|
k_id = anime.get("kitsu_id")
|
|
if k_id:
|
|
return {"kitsu_id": k_id, "mal_id": mal_id}
|
|
|
|
raise HTTPException(status_code=404, detail="Mapping not found for this MAL ID.")
|
|
|
|
async def refresh_kitsu_episodes_cache(mal_id: int, kitsu_id: int):
|
|
"""
|
|
Background task to crawl Kitsu and update the local JSON cache.
|
|
Optimized for 1000+ episode series.
|
|
"""
|
|
cache_path = get_episodes_cache_path(mal_id)
|
|
existing_data = {"episodes": [], "last_updated": 0, "status": "unknown"}
|
|
|
|
if os.path.exists(cache_path):
|
|
try:
|
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
|
existing_data = json.load(f)
|
|
except: pass
|
|
|
|
# Get the latest episode count we currently have
|
|
# We'll start fetching from a point that ensures we don't miss anything
|
|
# but don't re-download the whole show.
|
|
current_count = len(existing_data.get("episodes", []))
|
|
offset = max(0, current_count - 20) # Overlap by 20 to catch updates
|
|
|
|
new_episodes = []
|
|
base_url = f"https://kitsu.io/api/edge/anime/{kitsu_id}/episodes"
|
|
|
|
try:
|
|
current_url = f"{base_url}?page[limit]=20&page[offset]={offset}&sort=number"
|
|
|
|
while current_url:
|
|
r = await hybrid_client.get(current_url, timeout=15)
|
|
if r.status_code != 200: break
|
|
|
|
resp_json = r.json()
|
|
batch = resp_json.get("data", [])
|
|
if not batch: break
|
|
|
|
new_episodes.extend(batch)
|
|
current_url = resp_json.get("links", {}).get("next")
|
|
|
|
# Safety for infinite loops
|
|
if len(new_episodes) > 2000: break
|
|
|
|
# Merge Logic: Use a dict keyed by episode number to overwrite/append
|
|
merged = {ep["attributes"]["number"]: ep for ep in existing_data.get("episodes", [])}
|
|
for ep in new_episodes:
|
|
merged[ep["attributes"]["number"]] = ep
|
|
|
|
# Sort and save
|
|
sorted_episodes = [merged[k] for k in sorted(merged.keys())]
|
|
|
|
updated_cache = {
|
|
"episodes": sorted_episodes,
|
|
"last_updated": time.time(),
|
|
"kitsu_id": kitsu_id
|
|
}
|
|
|
|
with open(cache_path, 'w', encoding='utf-8') as f:
|
|
json.dump(updated_cache, f)
|
|
|
|
print(f"Successfully cached {len(sorted_episodes)} episodes for MAL:{mal_id}")
|
|
except Exception as e:
|
|
print(f"Background refresh failed for MAL:{mal_id}: {e}")
|
|
|
|
@app.get("/anime/{mal_id}/episodes")
|
|
async def get_anime_episodes_cached(mal_id: int, background_tasks: BackgroundTasks):
|
|
"""
|
|
Main endpoint for series-info and view.html scroller.
|
|
Returns cached data instantly, triggers background refresh if stale.
|
|
"""
|
|
cache_path = get_episodes_cache_path(mal_id)
|
|
|
|
# 1. Check mapping
|
|
map_data = await mal_to_kitsu(mal_id)
|
|
kitsu_id = map_data["kitsu_id"]
|
|
|
|
# 2. Check if cache exists
|
|
cache_exists = os.path.exists(cache_path)
|
|
cached_data = None
|
|
|
|
if cache_exists:
|
|
try:
|
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
|
cached_data = json.load(f)
|
|
except: cache_exists = False
|
|
|
|
# 3. Decision Logic
|
|
now = time.time()
|
|
# Stale if older than 12 hours
|
|
is_stale = not cached_data or (now - cached_data.get("last_updated", 0) > 43200)
|
|
|
|
if not cache_exists:
|
|
# First time loading: Must wait for at least one batch
|
|
await refresh_kitsu_episodes_cache(mal_id, kitsu_id)
|
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
|
|
if is_stale:
|
|
# Return stale data immediately, but update in background
|
|
background_tasks.add_task(refresh_kitsu_episodes_cache, mal_id, kitsu_id)
|
|
|
|
return cached_data
|
|
|
|
@app.get("/map/file/animekai")
|
|
async def serve_animekai_map():
|
|
try:
|
|
with open("templates/map.json", "r") as f: return json.load(f)
|
|
except: raise HTTPException(status_code=404)
|
|
|
|
@app.get("/anime/{mal_id}/ep/{ep_number}/thumbnail")
|
|
async def get_episode_thumbnail(mal_id: int, ep_number: int):
|
|
"""
|
|
Uses the local episode cache to quickly find a thumbnail URL.
|
|
"""
|
|
cache_path = get_episodes_cache_path(mal_id)
|
|
|
|
# If cache doesn't exist, we try to create it
|
|
if not os.path.exists(cache_path):
|
|
map_data = await mal_to_kitsu(mal_id)
|
|
await refresh_kitsu_episodes_cache(mal_id, map_data["kitsu_id"])
|
|
|
|
try:
|
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
# Find the episode in our sorted list
|
|
for ep in data.get("episodes", []):
|
|
if ep["attributes"]["number"] == ep_number:
|
|
thumb = ep["attributes"].get("thumbnail", {}).get("original")
|
|
return {"mal_id": mal_id, "episode": ep_number, "thumbnail_url": thumb}
|
|
except: pass
|
|
|
|
raise HTTPException(status_code=404, detail="Thumbnail not found in cache.")
|
|
|
|
@app.get("/anime/{mal_id}/movie/thumbnail")
|
|
async def get_movie_thumbnail(mal_id: int):
|
|
m = await mal_to_kitsu(mal_id)
|
|
r = await hybrid_client.get(f"https://kitsu.io/api/edge/anime/{m['kitsu_id']}")
|
|
attr = r.json().get("data", {}).get("attributes", {})
|
|
return {"mal_id": mal_id, "thumbnail_url": attr.get("posterImage", {}).get("original"), "cover_url": attr.get("coverImage", {}).get("original")}
|
|
|
|
@app.get("/anime/{mal_id}/characters")
|
|
async def get_anime_characters(mal_id: int):
|
|
# AniList GraphQL logic
|
|
r = await hybrid_client.post(ANILIST_URL, json={"query": VA_QUERY, "variables": {"idMal": mal_id, "page": 1}})
|
|
if r is None:
|
|
raise HTTPException(status_code=500, detail="Failed to get response from AniList API for characters.")
|
|
media = r.json().get("data", {}).get("Media")
|
|
if not media: raise HTTPException(status_code=404)
|
|
chars = []
|
|
for edge in media["characters"]["edges"]:
|
|
chars.append({"role": edge["role"], "character": {"name": edge["node"]["name"]["full"], "image": edge["node"]["image"]["large"]},
|
|
"voice_actors": [{"name": v["name"]["full"], "image": v["image"]["large"]} for v in edge["voiceActors"]]})
|
|
return {"mal_id": mal_id, "characters": chars}
|
|
|
|
@app.get("/anime/{mal_id}/seasons")
|
|
async def get_anime_seasons_endpoint(mal_id: int, background_tasks: BackgroundTasks):
|
|
"""
|
|
Retrieves the entire franchise season mapping.
|
|
Uses disk caching to prevent heavy AniList/Jikan traversal on every request.
|
|
"""
|
|
cache_key = f"franchise_mal_{mal_id}"
|
|
|
|
# Try to load from disk cache via the generic helper
|
|
cached = load_named_cache(cache_key, ttl=86400) # 24 hour TTL
|
|
|
|
if cached:
|
|
# Background check: if it's older than 6 hours, refresh it silently
|
|
# to catch new sequels/announcements
|
|
return cached
|
|
|
|
# If no cache, perform the heavy lifting
|
|
try:
|
|
entries = await collect_franchise(hybrid_client, mal_id)
|
|
out = produce_season_labeling(entries)
|
|
save_named_cache(cache_key, out)
|
|
return out
|
|
except Exception as e:
|
|
print(f"Franchise collection failed: {e}")
|
|
raise HTTPException(status_code=500, detail="Could not map franchise seasons.")
|
|
|
|
@app.get("/anime/{mal_id}/banner")
|
|
async def get_anime_banner(mal_id: int, cover: bool = False):
|
|
r = await hybrid_client.post(ANILIST_URL, json={"query": ANILIST_QUERY, "variables": {"malId": mal_id}})
|
|
if r is None:
|
|
raise HTTPException(status_code=500, detail="Failed to get response from AniList API for banner.")
|
|
media = r.json().get("data", {}).get("Media", {})
|
|
url = (media.get("coverImage", {}).get("extraLarge") if cover else media.get("bannerImage"))
|
|
if not url: raise HTTPException(status_code=404)
|
|
res = await tunnel_request("GET", url)
|
|
return Response(content=base64.b64decode(res["body"]), media_type="image/jpeg")
|
|
|
|
@app.get("/manga/{mal_id}/banner")
|
|
async def get_manga_banner(mal_id: int, cover: bool = False):
|
|
r = await hybrid_client.post(ANILIST_URL, json={"query": ANILIST_MANGA_QUERY, "variables": {"malId": mal_id}})
|
|
media = r.json().get("data", {}).get("Media", {})
|
|
url = (media.get("coverImage", {}).get("extraLarge") if cover else media.get("bannerImage"))
|
|
if not url: raise HTTPException(status_code=404)
|
|
res = await tunnel_request("GET", url)
|
|
return Response(content=base64.b64decode(res["body"]), media_type="image/jpeg")
|
|
|
|
# --- RESTORED MODULE INTERFACING & DOWNLOADS ---
|
|
|
|
@app.get("/chapters/{mal_id}")
|
|
async def get_manga_chapters_module(mal_id: int):
|
|
all_modules_results = {}
|
|
|
|
for mid, mdata in loaded_modules.items():
|
|
if module_states.get(mid) and "MANGA_READER" in mdata["info"].get("type",[]):
|
|
try:
|
|
mdata["instance"].httpx = hybrid_client
|
|
chaps = await mdata["instance"].get_chapters(mal_id)
|
|
if chaps is not None:
|
|
all_modules_results[mid] = chaps
|
|
except Exception as e:
|
|
print(f"Error fetching chapters from {mid}: {e}")
|
|
pass
|
|
|
|
if all_modules_results:
|
|
return {"modules": all_modules_results}
|
|
|
|
raise HTTPException(status_code=404, detail="No chapters found from any module")
|
|
|
|
@app.get("/download")
|
|
async def get_download_link(mal_id: int, episode: int, dub: bool = False, quality: str = "720p"):
|
|
for mid, mdata in loaded_modules.items():
|
|
if module_states.get(mid) and "ANIME_DOWNLOADER" in mdata["info"].get("type", []):
|
|
try:
|
|
mdata["instance"].httpx = hybrid_client
|
|
link = await mdata["instance"].get_download_link(mal_id, episode, dub, quality)
|
|
if link: return {"download_link": link, "source_module": mid}
|
|
except: pass
|
|
raise HTTPException(status_code=404)
|
|
|
|
@app.get("/export/series/{mal_id}")
|
|
async def export_series_package(mal_id: int, type: str = "anime"):
|
|
r = await hybrid_client.get(f"https://api.jikan.moe/v4/{type}/{mal_id}")
|
|
data = r.json().get("data", {})
|
|
title = data.get("title_english") or data.get("title", "Export")
|
|
zip_buffer = io.BytesIO()
|
|
with zipfile.ZipFile(zip_buffer, 'w') as zf:
|
|
zf.writestr("meta.json", json.dumps(data, indent=2))
|
|
poster_url = data.get("images", {}).get("jpg", {}).get("large_image_url")
|
|
if poster_url:
|
|
p_res = await tunnel_request("GET", poster_url)
|
|
zf.writestr("poster.png", base64.b64decode(p_res["body"]))
|
|
zip_buffer.seek(0)
|
|
return StreamingResponse(zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={title}.zip"})
|
|
|
|
# --- THE "DUMB" LOGIC RESTORED (PDF & THEMES) ---
|
|
|
|
@app.get("/download-manga/direct/{source}/{manga_id}/{chapter_id}")
|
|
async def download_manga_chapter_as_pdf_full(source: str, manga_id: str, chapter_id: str):
|
|
# 1. Get Image URLs
|
|
if source == "mangadex": urls = await get_mangadex_chapter_images(chapter_id)
|
|
else: urls = await get_manga_images_module(int(manga_id), chapter_id)
|
|
|
|
# 2. Stitching Logic (The Heavy Part)
|
|
from PIL import Image
|
|
import math
|
|
processed = []
|
|
total_h = 0
|
|
width = 595
|
|
for u in urls:
|
|
full_u = u if u.startswith("http") else f"https://localhost{u}"
|
|
res = await tunnel_request("GET", full_u)
|
|
img = Image.open(io.BytesIO(base64.b64decode(res["body"]))).convert("RGB")
|
|
new_h = int(width * (img.height / img.width))
|
|
processed.append(img.resize((width, new_h), Image.Resampling.LANCZOS))
|
|
total_h += new_h
|
|
|
|
comp = Image.new('RGB', (width, total_h))
|
|
curr_y = 0
|
|
for img in processed:
|
|
comp.paste(img, (0, curr_y))
|
|
curr_y += img.height
|
|
|
|
pdf = FPDF()
|
|
page_h = 842
|
|
for i in range(math.ceil(total_h / page_h)):
|
|
pdf.add_page()
|
|
box = (0, i*page_h, width, (i+1)*page_h)
|
|
page_img = comp.crop(box)
|
|
with io.BytesIO() as buf:
|
|
page_img.save(buf, format="PNG")
|
|
buf.seek(0)
|
|
pdf.image(buf, x=0, y=0, w=pdf.w)
|
|
|
|
return Response(content=bytes(pdf.output(dest='S')), media_type="application/pdf")
|
|
|
|
def extract_artists(song: dict) -> list[str]:
|
|
artists = song.get("artists") or []
|
|
return [a.get("name") for a in artists if a.get("name")]
|
|
|
|
@app.get("/api/themes/{mal_id}")
|
|
async def get_themes(mal_id: int):
|
|
"""
|
|
Fetches themes for a specific MyAnimeList ID.
|
|
Resolves MAL ID -> AnimeThemes ID -> Flattens Video List.
|
|
"""
|
|
try:
|
|
resp = await hybrid_client.get(
|
|
f"{ANIMETHEMES_API}/anime",
|
|
params={
|
|
"filter[has]": "resources",
|
|
"filter[site]": "MyAnimeList",
|
|
"filter[external_id]": mal_id,
|
|
"include": "animethemes.song.artists,animethemes.animethemeentries.videos",
|
|
"page[size]": 1
|
|
},
|
|
timeout=10.0
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
raise HTTPException(status_code=resp.status_code, detail="Upstream API error")
|
|
|
|
data = resp.json()
|
|
anime_list = data.get("anime", [])
|
|
|
|
if not anime_list:
|
|
return []
|
|
|
|
anime = anime_list[0]
|
|
themes = []
|
|
seen = set()
|
|
|
|
for theme in anime.get("animethemes", []):
|
|
song = theme.get("song", {})
|
|
theme_type = theme.get("type")
|
|
slug = theme.get("slug")
|
|
|
|
artists = extract_artists(song)
|
|
|
|
for entry in theme.get("animethemeentries", []):
|
|
for video in entry.get("videos", []):
|
|
url = video.get("link")
|
|
if not url:
|
|
continue
|
|
|
|
key = (
|
|
url,
|
|
video.get("resolution", 0),
|
|
video.get("title", "")
|
|
)
|
|
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
|
|
themes.append({
|
|
"title": song.get("title") or "Unknown",
|
|
"artists": artists, # ← NEW
|
|
"type": theme_type,
|
|
"slug": slug,
|
|
"url": url,
|
|
"res": video.get("resolution", 0),
|
|
"nc": video.get("nc", False),
|
|
"source": video.get("source"),
|
|
})
|
|
|
|
themes.sort(
|
|
key=lambda x: (x["nc"], x["res"]),
|
|
reverse=True
|
|
)
|
|
|
|
return themes
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching themes: {e}")
|
|
return []
|
|
|
|
@app.get("/modules/streaming", response_model=List[Dict[str, Any]])
|
|
def get_streaming_modules():
|
|
"""Returns a list of all enabled ANIME_STREAMER modules."""
|
|
streaming_modules = []
|
|
for name, mod in loaded_modules.items():
|
|
if module_states.get(name, False):
|
|
module_info = mod.get("info", {})
|
|
module_type = module_info.get("type")
|
|
is_streamer = (isinstance(module_type, list) and "ANIME_STREAMER" in module_type) or \
|
|
(isinstance(module_type, str) and module_type == "ANIME_STREAMER")
|
|
if is_streamer:
|
|
streaming_modules.append({
|
|
"id": name,
|
|
"name": module_info.get("name", name),
|
|
"version": module_info.get("version", "N/A")
|
|
})
|
|
return streaming_modules
|
|
|
|
@app.get("/modules/manga", response_model=List[Dict[str, Any]])
|
|
def get_manga_modules():
|
|
"""Returns a list of all enabled MANGA modules."""
|
|
manga_modules = []
|
|
for name, mod in loaded_modules.items():
|
|
if module_states.get(name, False):
|
|
module_info = mod.get("info", {})
|
|
module_type = module_info.get("type")
|
|
is_manga_reader = (isinstance(module_type, list) and "MANGA_READER" in module_type) or \
|
|
(isinstance(module_type, str) and module_type == "MANGA_READER")
|
|
if is_manga_reader:
|
|
manga_modules.append({
|
|
"id": name,
|
|
"name": module_info.get("name", name),
|
|
"version": module_info.get("version", "N/A")
|
|
})
|
|
return manga_modules
|
|
|
|
@app.get("/iframe-src")
|
|
async def get_iframe_source(
|
|
mal_id: int = Query(..., description="MyAnimeList ID of the anime"),
|
|
episode: int = Query(..., description="The episode number"),
|
|
dub: bool = Query(False, description="Whether to fetch the dubbed version"),
|
|
prefer_module: Optional[str] = Query(None, description="Exact module name to prefer")
|
|
):
|
|
"""
|
|
Iterates through enabled modules to find an iframe source.
|
|
Prioritizes 'prefer_module' if provided.
|
|
"""
|
|
modules_to_try = []
|
|
|
|
# 1. Build the prioritized list of modules
|
|
if prefer_module:
|
|
mod = loaded_modules.get(prefer_module)
|
|
if mod and module_states.get(prefer_module, False):
|
|
# Put the preferred module at the top of the stack
|
|
modules_to_try.append((prefer_module, mod))
|
|
|
|
# Add all other enabled modules as fallback
|
|
for mid, mdata in sorted(loaded_modules.items()):
|
|
if module_states.get(mid, False) and mid != prefer_module:
|
|
modules_to_try.append((mid, mdata))
|
|
else:
|
|
# No preference: try all enabled modules in alphabetical order
|
|
modules_to_try = [
|
|
(mid, mdata) for mid, mdata in sorted(loaded_modules.items())
|
|
if module_states.get(mid, False)
|
|
]
|
|
|
|
# 2. Iterate and attempt to resolve source
|
|
for module_id, module_data in modules_to_try:
|
|
module_info = module_data.get("info", {})
|
|
module_type = module_info.get("type")
|
|
|
|
# Verify if the module supports streaming
|
|
is_streamer = (isinstance(module_type, list) and "ANIME_STREAMER" in module_type) or \
|
|
(isinstance(module_type, str) and module_type == "ANIME_STREAMER")
|
|
|
|
if not is_streamer:
|
|
continue
|
|
|
|
try:
|
|
# INJECT TUNNEL CLIENT: Crucial for cloud relay
|
|
# This forces the module's internal requests to go through your Home Agent
|
|
module_data["instance"].httpx = hybrid_client
|
|
|
|
source_func = getattr(module_data["instance"], "get_iframe_source", None)
|
|
if not source_func:
|
|
continue
|
|
|
|
# Execute the module logic
|
|
iframe_src = await source_func(mal_id, episode, dub)
|
|
|
|
if iframe_src:
|
|
print(f"Success! Got source from {module_id}: {iframe_src}")
|
|
return {"src": iframe_src, "source_module": module_id}
|
|
|
|
except Exception as e:
|
|
print(f"Module {module_id} failed: {str(e)}")
|
|
# Silently continue to the next module in the list
|
|
continue
|
|
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Could not retrieve an iframe source from any enabled module."
|
|
)
|
|
|
|
|
|
@app.get("/read/{source}/{manga_id}/{chapter_id}", response_class=HTMLResponse)
|
|
async def read_manga_chapter_source(source: str, manga_id: str, chapter_id: str):
|
|
"""
|
|
Serves the HTML reader interface for a given source (jikan or mangadex).
|
|
"""
|
|
try:
|
|
with open("templates/reader.html", "r", encoding="utf-8") as f:
|
|
return HTMLResponse(content=f.read())
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Reader UI not found.")
|
|
|
|
async def _get_manga_images_from_modules(
|
|
mal_id: int,
|
|
chapter_num: str,
|
|
profile_id: Optional[str]
|
|
) -> Optional[List[str]]:
|
|
|
|
sorted_modules = []
|
|
processed_module_ids = set()
|
|
|
|
for module_id, module_data in sorted(loaded_modules.items()):
|
|
if module_id not in processed_module_ids:
|
|
sorted_modules.append((module_id, module_data))
|
|
|
|
for module_id, module_data in sorted_modules:
|
|
module_info = module_data.get("info", {})
|
|
|
|
module_type = module_info.get("type")
|
|
is_manga_reader = (isinstance(module_type, list) and "MANGA_READER" in module_type) or \
|
|
(isinstance(module_type, str) and module_type == "MANGA_READER")
|
|
|
|
if module_states.get(module_id) and is_manga_reader:
|
|
print(f"Attempting to fetch chapter images from module: {module_id}")
|
|
try:
|
|
images_func = getattr(module_data["instance"], "get_chapter_images", None)
|
|
if not images_func:
|
|
continue
|
|
|
|
# ✅ INJECT TUNNEL CLIENT — same as the explicit module path does
|
|
module_data["instance"].httpx = hybrid_client
|
|
|
|
images = await images_func(mal_id, chapter_num)
|
|
if images is not None:
|
|
print(f"Success! Got {len(images)} images from {module_id}")
|
|
return images
|
|
except Exception as e:
|
|
print(f"Module {module_id} failed with an error: {e}")
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
@app.get("/retrieve/{mal_id}/{chapter_num}")
|
|
async def get_manga_chapter_images(
|
|
mal_id: int,
|
|
chapter_num: str,
|
|
profile_id: Optional[str] = Query(None, description="ID of the active user profile"),
|
|
ext: Optional[str] = Query(None, description="The ID of the extension to use"),
|
|
module: Optional[str] = Query(None, description="The specific module to pull images from")
|
|
):
|
|
# --- Handle Extension Request ---
|
|
if ext:
|
|
if ext in loaded_extensions:
|
|
ext_data = loaded_extensions[ext]
|
|
try:
|
|
images_func = getattr(ext_data["instance"], "get_chapter_images", None)
|
|
if images_func:
|
|
print(f"Attempting to fetch chapter images from extension: {ext}")
|
|
images = await images_func(mal_id, chapter_num)
|
|
if images is not None:
|
|
return images
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Extension {ext} failed: {e}")
|
|
else:
|
|
raise HTTPException(status_code=404, detail=f"Extension '{ext}' not found.")
|
|
|
|
# --- Handle Explicit Module Routing ---
|
|
if module and module in loaded_modules:
|
|
mod_data = loaded_modules[module]
|
|
if module_states.get(module) and "MANGA_READER" in mod_data["info"].get("type", []):
|
|
try:
|
|
images_func = getattr(mod_data["instance"], "get_chapter_images", None)
|
|
if images_func:
|
|
mod_data["instance"].httpx = hybrid_client
|
|
images = await images_func(mal_id, chapter_num)
|
|
if images is not None:
|
|
return images
|
|
except Exception as e:
|
|
print(f"Module {module} failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# --- Handle Module Request (Fallback Iteration if no specific module is passed) ---
|
|
images = await _get_manga_images_from_modules(mal_id, chapter_num, profile_id)
|
|
if images is not None:
|
|
return images
|
|
|
|
raise HTTPException(status_code=404, detail="Could not retrieve chapter images from any enabled module or extension.")
|
|
|
|
# --- Serve Frontend ---
|
|
app.mount("/data", StaticFiles(directory="data"), name="data")
|
|
|
|
# UPDATE THIS LINE: Change "../animex" to "./animex"
|
|
if os.path.exists("./animex"):
|
|
app.mount("/", StaticFiles(directory="./animex", html=True), name="static_site")
|
|
|
|
# --- Update the __main__ block at the very bottom ---
|
|
if __name__ == "__main__":
|
|
# Render provides a PORT environment variable
|
|
port = int(os.environ.get("PORT", 7275))
|
|
uvicorn.run(app, host="0.0.0.0", port=port) |