Fix gacha portraits: switch to SchaleDB CDN (joexyz is dead)

This commit is contained in:
esef committed 2026-09-15 16:07:44 +03:00
1 parent 437fdccfbc
commit e4b5f0d849
3 files changed
+11 -71

No files matched your search

+1 -40
View File
@@ -16,7 +16,7 @@ GACHA_ANIM_DURATION = 3.5 # 195 frames × 100 ms (used to be 19.5 will be chang
# ── APIs ──────────────────────────────────────────────────────────────────── # ── APIs ────────────────────────────────────────────────────────────────────
BANNER_API = "https://api.ennead.cc/buruaka/banner" BANNER_API = "https://api.ennead.cc/buruaka/banner"
CHARACTER_API = "https://api.ennead.cc/buruaka/character" CHARACTER_API = "https://api.ennead.cc/buruaka/character"
JOEXYZ_CDN = "https://ba.joexyz.online/cdn/v2/images" PORTRAIT_CDN = "https://schaledb.com/images"
# ── Gacha rates (matching real Blue Archive) ──────────────────────────────── # ── Gacha rates (matching real Blue Archive) ────────────────────────────────
# Regular/Pickup: 3★ = 3.0%, 2★ = 18.5%, 1★ = 78.5% # Regular/Pickup: 3★ = 3.0%, 2★ = 18.5%, 1★ = 78.5%
@@ -72,45 +72,6 @@ PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
PAGE_SIZE = 10 PAGE_SIZE = 10
FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★": 1} FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★": 1}
# ── CDN icon slug mapping (SchaleDB variant suffix → joexyz CDN slug suffix) ─
# Base names without a parenthesised variant use the lowercased name directly.
# "Aris" is a special case — the CDN uses "aris", not "arisu".
CDN_SUFFIX_MAP: dict[str, str] = {
"New Year": "newyear",
"Hot Spring": "onsen",
"Bunny": "bunnygirl",
"Cheer Squad": "cheerleader",
"Camp": "camp",
"Cycling": "cycling",
"Dress": "dress",
"Small": "small",
"Maid": "maid",
"Track": "track",
"Swimsuit": "swimsuit",
"Band": "band",
"Christmas": "christmas",
"Casual": "casual",
"Guide": "guide",
}
def cdn_icon_slug(name: str) -> str:
"""Convert a SchaleDB student Name to the joexyz CDN icon filename slug."""
# Normalise "Name Terror" into the same parenthesised path
if name.endswith(" Terror"):
name = name[:-7] + " (Terror)"
# "Aris" is "aris" on the CDN, not "arisu"
if name == "Aris":
return "aris"
if "(" in name:
base, rest = name.split("(", 1)
base = base.strip().lower().replace(" ", "_")
variant = rest.rstrip(")").strip()
suffix = CDN_SUFFIX_MAP.get(variant, variant.lower().replace(" ", "_"))
return f"{base}_{suffix}"
return name.lower().replace(" ", "_")
# ── Font paths (tried in order; Pillow default used as last resort) ───────── # ── Font paths (tried in order; Pillow default used as last resort) ─────────
FONT_PATHS = [ FONT_PATHS = [
"/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf",
+4 -13
View File
@@ -14,8 +14,7 @@ from .constants import (
BUNDLED_DB, BUNDLED_DB,
BANNER_API, BANNER_API,
CHARACTER_API, CHARACTER_API,
JOEXYZ_CDN, PORTRAIT_CDN,
cdn_icon_slug,
BANNER_RATES, BANNER_RATES,
DEFAULT_RATES, DEFAULT_RATES,
PULL10_RATES, PULL10_RATES,
@@ -304,17 +303,9 @@ class StudentDB:
pullable = [s for s in self.students if s.get("IsLimited", 0) < 2] pullable = [s for s in self.students if s.get("IsLimited", 0) < 2]
return random.choice(pullable or self.students) return random.choice(pullable or self.students)
def cdn_icon(self, student: dict) -> str: def cdn_portrait(self, student: dict) -> str:
"""joexyz CDN icon URL for a student (face/upper-body).""" """SchaleDB CDN full-body portrait URL for a student (keyed by ID)."""
slug = cdn_icon_slug(student["Name"]) return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp"
return f"{JOEXYZ_CDN}/students/icons/{slug}.png"
def cdn_skill_portrait(self, student: dict) -> str:
"""joexyz CDN skill-portrait URL (full-body fallback)."""
dev = student.get("DevName", "")
if not dev:
return ""
return f"{JOEXYZ_CDN}/skill-portraits/Skill_Portrait_{dev}.png"
# Global singleton # Global singleton
+6 -18
View File
@@ -48,36 +48,24 @@ FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ───────────────────────────────────────────────────────── # ── Image fetching ─────────────────────────────────────────────────────────
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]: async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
"""Download a student's face icon from the joexyz CDN. """Download a student's full-body portrait and crop it to the card area.
Uses the student icon CDN (face/upper-body crop). Falls back to the Portraits come from the SchaleDB image CDN, keyed by student ID.
skill-portrait (full-body art) if the icon is unavailable.
""" """
# 1. Try the face icon url = db.cdn_portrait(student)
icon_url = db.cdn_icon(student)
try: try:
async with session.get(icon_url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.read()
return Image.open(io.BytesIO(data)).convert("RGBA")
except Exception:
log.debug("CDN icon failed for %s, trying skill portrait fallback", student["Name"])
# 2. Fallback: skill portrait (full-body character art)
sp_url = db.cdn_skill_portrait(student)
try:
async with session.get(sp_url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.read() data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGBA") img = Image.open(io.BytesIO(data)).convert("RGBA")
# Skill portraits are full-body; crop upper portion for card fit # Full-body art; crop the upper portion to fit the card.
p_w, p_h = img.size p_w, p_h = img.size
crop_h = int(p_w * 1.20) crop_h = int(p_w * 1.20)
if crop_h < p_h: if crop_h < p_h:
img = img.crop((0, 0, p_w, crop_h)) img = img.crop((0, 0, p_w, crop_h))
return img return img
except Exception: except Exception:
log.exception("Failed to fetch any portrait for %s", student["Name"]) log.exception("Failed to fetch portrait for %s", student["Name"])
return None return None