This commit is contained in:
2026-03-31 22:01:00 -05:00
parent c3e32d4666
commit 2658c72a38
11 changed files with 4426 additions and 2881 deletions

Binary file not shown.

View File

@@ -1644,7 +1644,7 @@
}">
<!-- Added seeked event and preload for caching and smooth seeking -->
<video x-ref="audioPlayer"
:src="currentTrack.url"
:src="currentTrack.url ? '/proxy?url=' + encodeURIComponent(currentTrack.url) : ''"
@timeupdate="onTimeUpdate"
@ended="onEnded"
@loadedmetadata="onMeta"
@@ -1861,6 +1861,8 @@
<!-- ALPINE.JS PLAYER LOGIC -->
<script>
// Add this helper function where your logic resides
function musicPlayer() {
return {
queue: [],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

198
app.py
View File

@@ -16,7 +16,7 @@ import signal
import traceback
import zipfile
import io
from fastapi import FastAPI, HTTPException, Query, Body, status, UploadFile, File, APIRouter, Request, WebSocket, WebSocketDisconnect
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
@@ -196,6 +196,13 @@ 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:
@@ -756,15 +763,133 @@ async def proxy_mangadex_image(server_host: str, chapter_hash: str, filename: st
@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):
r = await hybrid_client.get(url)
MEMORY_CACHE["anime_db"] = r.json()
MEMORY_CACHE["anime_db_timestamp"] = time.time()
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:
if anime.get("kitsu_id"): return {"kitsu_id": anime["kitsu_id"]}
raise HTTPException(status_code=404)
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():
@@ -774,13 +899,27 @@ async def serve_animekai_map():
@app.get("/anime/{mal_id}/ep/{ep_number}/thumbnail")
async def get_episode_thumbnail(mal_id: int, ep_number: int):
m = await mal_to_kitsu(mal_id)
url = f"https://kitsu.io/api/edge/anime/{m['kitsu_id']}/episodes?filter[number]={ep_number}"
r = await hybrid_client.get(url)
data = r.json().get("data", [])
if not data: raise HTTPException(status_code=404)
thumb = data[0].get("attributes", {}).get("thumbnail", {}).get("original")
return {"mal_id": mal_id, "episode": ep_number, "thumbnail_url": thumb}
"""
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):
@@ -802,15 +941,32 @@ async def get_anime_characters(mal_id: int):
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):
cache_key = f"seasons:{mal_id}"
cached = load_named_cache(cache_key)
if cached: return cached
entries = await collect_franchise(hybrid_client, mal_id)
out = produce_season_labeling(entries)
save_named_cache(cache_key, out)
return out
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):

View File

@@ -0,0 +1 @@
{"payload": {"entries": [{"anilist_id": 11179, "mal_id": 11179, "title_romaji": "Papa no Iukoto wo Kikinasai!", "title_english": "Listen to Me, Girls. I Am Your Father!", "title_native": "\u30d1\u30d1\u306e\u3044\u3046\u3053\u3068\u3092\u805e\u304d\u306a\u3055\u3044\uff01", "format": "TV", "start_date": {"year": 2012, "month": 1, "day": 11}, "season": "WINTER", "season_year": 2012, "episodes": 12, "relation_type_from_parent": null, "inferred_part": null, "is_season": true, "is_final_from_title": false, "title_display": "Listen to Me, Girls. I Am Your Father!", "_start_tuple": [2012, 1, 11], "is_final_season": false}, {"anilist_id": 12673, "mal_id": 12673, "title_romaji": "Papa no Iukoto wo Kikinasai!: Pokkapoka", "title_english": null, "title_native": "\u30d1\u30d1\u306e\u3044\u3046\u3053\u3068\u3092\u805e\u304d\u306a\u3055\u3044\uff01\u307d\u3063\u304b\u307d\u304b", "format": "SPECIAL", "start_date": {"year": 2012, "month": 7, "day": 11}, "season": "SUMMER", "season_year": 2012, "episodes": 1, "relation_type_from_parent": null, "inferred_part": null, "is_season": false, "is_final_from_title": false, "title_display": "Papa no Iukoto wo Kikinasai!: Pokkapoka", "_start_tuple": [2012, 7, 11], "is_final_season": false}, {"anilist_id": 17875, "mal_id": 17875, "title_romaji": "Papa no Iukoto wo Kikinasai! OVA", "title_english": null, "title_native": "\u30d1\u30d1\u306e\u3044\u3046\u3053\u3068\u3092\u805e\u304d\u306a\u3055\u3044! OVA", "format": "OVA", "start_date": {"year": 2013, "month": 6, "day": 25}, "season": "SUMMER", "season_year": 2013, "episodes": 2, "relation_type_from_parent": null, "inferred_part": null, "is_season": false, "is_final_from_title": false, "title_display": "Papa no Iukoto wo Kikinasai! OVA", "_start_tuple": [2013, 6, 25], "is_final_season": false}], "season_groups": [{"season_index": 1, "group_label": "Season 1", "parts": [{"short_label": "S1", "title": "Listen to Me, Girls. I Am Your Father!", "mal_id": 11179, "anilist_id": 11179, "start_date": {"year": 2012, "month": 1, "day": 11}, "format": "TV", "is_final": false}]}], "extras": [{"title": "Papa no Iukoto wo Kikinasai!: Pokkapoka", "mal_id": 12673, "anilist_id": 12673, "format": "SPECIAL", "start_date": {"year": 2012, "month": 7, "day": 11}}, {"title": "Papa no Iukoto wo Kikinasai! OVA", "mal_id": 17875, "anilist_id": 17875, "format": "OVA", "start_date": {"year": 2013, "month": 6, "day": 25}}]}, "_timestamp": 1775005857.750725}

View File

@@ -0,0 +1 @@
{"payload": {"entries": [{"anilist_id": 151807, "mal_id": 52299, "title_romaji": "Ore dake Level Up na Ken", "title_english": "Solo Leveling", "title_native": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6", "format": "TV", "start_date": {"year": 2024, "month": 1, "day": 7}, "season": "WINTER", "season_year": 2024, "episodes": 12, "relation_type_from_parent": null, "inferred_part": null, "is_season": true, "is_final_from_title": false, "title_display": "Solo Leveling", "_start_tuple": [2024, 1, 7], "is_final_season": false}, {"anilist_id": 176496, "mal_id": 58567, "title_romaji": "Ore dake Level Up na Ken: Season 2 - Arise from the Shadow", "title_english": "Solo Leveling Season 2 -Arise from the Shadow-", "title_native": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 Season 2 -Arise from the Shadow-", "format": "TV", "start_date": {"year": 2025, "month": 1, "day": 5}, "season": "WINTER", "season_year": 2025, "episodes": 13, "relation_type_from_parent": null, "inferred_part": 2, "is_season": true, "is_final_from_title": false, "title_display": "Solo Leveling Season 2 -Arise from the Shadow-", "_start_tuple": [2025, 1, 5], "is_final_season": false}, {"anilist_id": 184694, "mal_id": 59841, "title_romaji": "Ore dake Level Up na Ken: ReAwakening", "title_english": "Solo Leveling -ReAwakening-", "title_native": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 -ReAwakening-", "format": "MOVIE", "start_date": {"year": 2024, "month": 11, "day": 29}, "season": "FALL", "season_year": 2024, "episodes": 1, "relation_type_from_parent": null, "inferred_part": null, "is_season": false, "is_final_from_title": false, "title_display": "Solo Leveling -ReAwakening-", "_start_tuple": [2024, 11, 29], "is_final_season": false}], "season_groups": [{"season_index": 1, "group_label": "Season 1", "parts": [{"short_label": "S1", "title": "Solo Leveling", "mal_id": 52299, "anilist_id": 151807, "start_date": {"year": 2024, "month": 1, "day": 7}, "format": "TV", "is_final": false}]}, {"season_index": 2, "group_label": "Season 2", "parts": [{"short_label": "S2", "title": "Solo Leveling Season 2 -Arise from the Shadow-", "mal_id": 58567, "anilist_id": 176496, "start_date": {"year": 2025, "month": 1, "day": 5}, "format": "TV", "is_final": false}]}], "extras": [{"title": "Solo Leveling -ReAwakening-", "mal_id": 59841, "anilist_id": 184694, "format": "MOVIE", "start_date": {"year": 2024, "month": 11, "day": 29}}]}, "_timestamp": 1775000957.808718}

View File

@@ -0,0 +1 @@
{"payload": {"entries": [{"anilist_id": 153288, "mal_id": 52588, "title_romaji": "Kaijuu 8-gou", "title_english": "Kaiju No. 8", "title_native": "\u602a\u7363\uff18\u53f7", "format": "TV", "start_date": {"year": 2024, "month": 4, "day": 13}, "season": "SPRING", "season_year": 2024, "episodes": 12, "relation_type_from_parent": null, "inferred_part": null, "is_season": true, "is_final_from_title": false, "title_display": "Kaiju No. 8", "_start_tuple": [2024, 4, 13], "is_final_season": false}, {"anilist_id": 178754, "mal_id": 59177, "title_romaji": "Kaijuu 8-gou 2nd Season", "title_english": "Kaiju No. 8 Season 2", "title_native": "\u602a\u7363\uff18\u53f7 \u7b2c\uff12\u671f", "format": "TV", "start_date": {"year": 2025, "month": 7, "day": 19}, "season": "SUMMER", "season_year": 2025, "episodes": 11, "relation_type_from_parent": null, "inferred_part": 2, "is_season": true, "is_final_from_title": false, "title_display": "Kaiju No. 8 Season 2", "_start_tuple": [2025, 7, 19], "is_final_season": false}, {"anilist_id": 204362, "mal_id": 63136, "title_romaji": "Kaijuu 8-gou: Kanketsu-hen", "title_english": null, "title_native": "\u602a\u7363\uff18\u53f7 \u5b8c\u7d50\u7de8", "format": null, "start_date": {"year": null, "month": null, "day": null}, "season": null, "season_year": null, "episodes": null, "relation_type_from_parent": null, "inferred_part": null, "is_season": false, "is_final_from_title": false, "title_display": "Kaijuu 8-gou: Kanketsu-hen", "_start_tuple": [0, 0, 0], "is_final_season": false}, {"anilist_id": 179998, "mal_id": 59489, "title_romaji": "Kaijuu 8-gou Movie", "title_english": "Kaiju No. 8: Mission Recon", "title_native": "\u602a\u73638\u53f7 \u7b2c1\u671f\u7dcf\u96c6\u7de8", "format": "MOVIE", "start_date": {"year": 2025, "month": 3, "day": 28}, "season": "WINTER", "season_year": 2025, "episodes": 1, "relation_type_from_parent": null, "inferred_part": null, "is_season": false, "is_final_from_title": false, "title_display": "Kaiju No. 8: Mission Recon", "_start_tuple": [2025, 3, 28], "is_final_season": false}], "season_groups": [{"season_index": 1, "group_label": "Season 1", "parts": [{"short_label": "S1", "title": "Kaiju No. 8", "mal_id": 52588, "anilist_id": 153288, "start_date": {"year": 2024, "month": 4, "day": 13}, "format": "TV", "is_final": false}]}, {"season_index": 2, "group_label": "Season 2", "parts": [{"short_label": "S2", "title": "Kaiju No. 8 Season 2", "mal_id": 59177, "anilist_id": 178754, "start_date": {"year": 2025, "month": 7, "day": 19}, "format": "TV", "is_final": false}]}], "extras": [{"title": "Kaijuu 8-gou: Kanketsu-hen", "mal_id": 63136, "anilist_id": 204362, "format": null, "start_date": {"year": null, "month": null, "day": null}}, {"title": "Kaiju No. 8: Mission Recon", "mal_id": 59489, "anilist_id": 179998, "format": "MOVIE", "start_date": {"year": 2025, "month": 3, "day": 28}}]}, "_timestamp": 1775001076.475924}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"payload": {"entries": [{"anilist_id": 171627, "mal_id": 57555, "title_romaji": "Chainsaw Man: Reze-hen", "title_english": "Chainsaw Man \u2013 The Movie: Reze Arc", "title_native": "\u30c1\u30a7\u30f3\u30bd\u30fc\u30de\u30f3 \u30ec\u30bc\u7bc7", "format": "MOVIE", "start_date": {"year": 2025, "month": 9, "day": 19}, "season": "SUMMER", "season_year": 2025, "episodes": 1, "relation_type_from_parent": null, "inferred_part": null, "is_season": false, "is_final_from_title": false, "title_display": "Chainsaw Man \u2013 The Movie: Reze Arc", "_start_tuple": [2025, 9, 19], "is_final_season": false}, {"anilist_id": 204429, "mal_id": 63143, "title_romaji": "Chainsaw Man: Shikaku-hen", "title_english": null, "title_native": "\u30c1\u30a7\u30f3\u30bd\u30fc\u30de\u30f3 \u523a\u5ba2\u7bc7", "format": null, "start_date": {"year": null, "month": null, "day": null}, "season": null, "season_year": null, "episodes": null, "relation_type_from_parent": null, "inferred_part": null, "is_season": false, "is_final_from_title": false, "title_display": "Chainsaw Man: Shikaku-hen", "_start_tuple": [0, 0, 0], "is_final_season": false}, {"anilist_id": 127230, "mal_id": 44511, "title_romaji": "Chainsaw Man", "title_english": "Chainsaw Man", "title_native": "\u30c1\u30a7\u30f3\u30bd\u30fc\u30de\u30f3", "format": "TV", "start_date": {"year": 2022, "month": 10, "day": 12}, "season": "FALL", "season_year": 2022, "episodes": 12, "relation_type_from_parent": null, "inferred_part": null, "is_season": true, "is_final_from_title": false, "title_display": "Chainsaw Man", "_start_tuple": [2022, 10, 12], "is_final_season": false}], "season_groups": [{"season_index": 1, "group_label": "Season 1", "parts": [{"short_label": "S1", "title": "Chainsaw Man", "mal_id": 44511, "anilist_id": 127230, "start_date": {"year": 2022, "month": 10, "day": 12}, "format": "TV", "is_final": false}]}], "extras": [{"title": "Chainsaw Man: Shikaku-hen", "mal_id": 63143, "anilist_id": 204429, "format": null, "start_date": {"year": null, "month": null, "day": null}}, {"title": "Chainsaw Man \u2013 The Movie: Reze Arc", "mal_id": 57555, "anilist_id": 171627, "format": "MOVIE", "start_date": {"year": 2025, "month": 9, "day": 19}}]}, "_timestamp": 1775008693.3776262}

File diff suppressed because it is too large Load Diff