Fix gacha portraits: switch to SchaleDB CDN (joexyz is dead)
This commit is contained in:
1 parent
437fdccfbc
commit
e4b5f0d849
3 files changed
+11
-71
No files matched your search
@@ -16,7 +16,7 @@ GACHA_ANIM_DURATION = 3.5 # 195 frames × 100 ms (used to be 19.5 will be chang
|
||||
# ── APIs ────────────────────────────────────────────────────────────────────
|
||||
BANNER_API = "https://api.ennead.cc/buruaka/banner"
|
||||
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) ────────────────────────────────
|
||||
# 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
|
||||
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 = [
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
|
||||
+4
-13
@@ -14,8 +14,7 @@ from .constants import (
|
||||
BUNDLED_DB,
|
||||
BANNER_API,
|
||||
CHARACTER_API,
|
||||
JOEXYZ_CDN,
|
||||
cdn_icon_slug,
|
||||
PORTRAIT_CDN,
|
||||
BANNER_RATES,
|
||||
DEFAULT_RATES,
|
||||
PULL10_RATES,
|
||||
@@ -304,17 +303,9 @@ class StudentDB:
|
||||
pullable = [s for s in self.students if s.get("IsLimited", 0) < 2]
|
||||
return random.choice(pullable or self.students)
|
||||
|
||||
def cdn_icon(self, student: dict) -> str:
|
||||
"""joexyz CDN icon URL for a student (face/upper-body)."""
|
||||
slug = cdn_icon_slug(student["Name"])
|
||||
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"
|
||||
def cdn_portrait(self, student: dict) -> str:
|
||||
"""SchaleDB CDN full-body portrait URL for a student (keyed by ID)."""
|
||||
return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp"
|
||||
|
||||
|
||||
# Global singleton
|
||||
|
||||
@@ -48,36 +48,24 @@ FOOTER_FONT = _get_font(18, bold=False)
|
||||
# ── Image fetching ─────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
skill-portrait (full-body art) if the icon is unavailable.
|
||||
Portraits come from the SchaleDB image CDN, keyed by student ID.
|
||||
"""
|
||||
# 1. Try the face icon
|
||||
icon_url = db.cdn_icon(student)
|
||||
url = db.cdn_portrait(student)
|
||||
try:
|
||||
async with session.get(icon_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:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.read()
|
||||
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
|
||||
crop_h = int(p_w * 1.20)
|
||||
if crop_h < p_h:
|
||||
img = img.crop((0, 0, p_w, crop_h))
|
||||
return img
|
||||
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user