Compare commits
5
Commits
93e22a8364
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d1f6778c4 | ||
|
|
78c755a7a2 | ||
|
|
460f4bf78c | ||
|
|
ff579807f1 | ||
|
|
810a9a6b12 |
No files matched your search
@@ -28,3 +28,4 @@ _check_students.py
|
|||||||
_check_names.py
|
_check_names.py
|
||||||
_check_banner_api.py
|
_check_banner_api.py
|
||||||
|
|
||||||
|
credentials_test.txt
|
||||||
@@ -1 +1 @@
|
|||||||
[{"Id": 10000, "IsReleased": [true, true, true], "DefaultOrder": 0, "PathName": "Aru", "DevName": "Aru", "Name": "Aru", "School": "Gehenna", "Club": "Kohshinjo68", "StarGrade": 3, "SquadType": "Main", "TacticRole": "DamageDealer", "Summons": [], "Position": "Back", "BulletType": "Explosion", "ArmorType": "LightArmor", "StreetBattleAdaptation": 4, "OutdoorBattleAdaptation": 2, "IndoorBattleAdaptation": 0, "WeaponType": "SR", "WeaponImg": "weapon_icon_10000", "Cover": true, "Equipment": ["Hat", "Hairpin", "Watch"], "CollectionBG": "BG_Gehenna_Collection", "FamilyName": "Rikuhachima", "PersonalName": "Aru", "SchoolYear": "2nd Year", "CharacterAge": "16 years old", "Birthday": "March 12th", "CharacterSSRNew": "Heh heh. Good choice.", "ProfileIntroduction": "The self-proclaimed boss of Problem Solver 68.\n\nAru expanded Gehenna Academy's Problem Solver 68 club without permission to conduct illegal business activities. She aspires to be seen as a dashing and debonair villain, but her frequent blunders always expose her as a poser.", "Hobby": "Studying management", "CharacterVoice": "Reina Kondō", "BirthDay": "3/12", "Illustrator": "DoReMi", "Designer": "DoReMi", "CharHeightMetric": "160cm", "CharHeightImperial": "5'2\"", "StabilityPoint": 1988, "AttackPower1": 369, "AttackPower100": 3690, "MaxHP1": 2236, "MaxHP100": 19390, "DefensePower1": 19, "DefensePower100": 119, "HealPower1": 1408, "HealPower100": 4225, "DodgePoint": 201, "AccuracyPoint": 905, "CriticalPoint": 201, "CriticalDamageRate": 20000, "AmmoCount": 5, "AmmoCost": 1, "Range": 750, "RegenCost": 700, "Skills": [{"SkillType": "autoattack", "Effects": [{"Type": "DMGSingle", "Hits": [10000], "Scale": [10000], "Frames": {"AttackEnterDuration": 45, "AttackStartDuration": 15, "AttackEndDuration": 16, "AttackBurstRoundOverDelay": 50, "AttackIngDuration": 42, "AttackReloadDuration": 70}, "CriticalCheck": "Check"}]}, {"SkillType": "ex", "Name": "Hard-Boiled Shot", "Desc": "Fires a shot that deals <?1> damage to one enemy and explodes for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["274%", "315%", "397%", "438%", "521%"], ["292%", "335%", "423%", "467%", "554%"]], "Cost": [4, 4, 4, 4, 4], "Duration": 132, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 200}], "Icon": "COMMON_SKILLICON_CIRCLE", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [27438, 31554, 39781, 43898, 52125]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [29207, 33584, 42344, 46727, 55487]}]}, {"SkillType": "normal", "Name": "Noir Attack", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 50% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["152%", "160%", "168%", "198%", "206%", "213%", "244%", "251%", "259%", "290%"], ["251%", "263%", "276%", "326%", "338%", "351%", "401%", "414%", "426%", "476%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15270, 16035, 16801, 19851, 20617, 21376, 24433, 25198, 25958, 29014]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25102, 26355, 27608, 32631, 33884, 35137, 40155, 41414, 42667, 47685]}]}, {"SkillType": "gearnormal", "Name": "Noir Attack+", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 100% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["156%", "163%", "171%", "203%", "210%", "218%", "249%", "257%", "265%", "296%"], ["256%", "269%", "282%", "333%", "346%", "359%", "410%", "423%", "436%", "487%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15616, 16398, 17181, 20305, 21082, 21864, 24988, 25771, 26547, 29672]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25669, 26956, 28237, 33368, 34656, 35937, 41068, 42355, 43637, 48768]}]}, {"SkillType": "passive", "Name": "The Gravity of a Boss", "Desc": "Increases <b:CriticalDamage> by <?1>.", "Parameters": [["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Coefficient", "Scale": [1400, 1470, 1540, 1820, 1890, 1960, 2240, 2310, 2380, 2660]}]}, {"SkillType": "weaponpassive", "Name": "The Gravity of a Boss+", "Desc": "Increases <b:CriticalDamage> by <?1> plus a further <?2>.", "Parameters": [["2000", "2100", "2200", "2600", "2700", "2800", "3200", "3300", "3400", "3800"], ["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Base", "Scale": [2000, 2100, 2200, 2600, 2700,Line truncated
|
[{"Id": 10000, "IsReleased": [true, true, true], "DefaultOrder": 0, "PathName": "Aru", "DevName": "Aru", "Name": "Aru", "School": "Gehenna", "Club": "Kohshinjo68", "StarGrade": 3, "SquadType": "Main", "TacticRole": "DamageDealer", "Summons": [], "Position": "Back", "BulletType": "Explosion", "ArmorType": "LightArmor", "StreetBattleAdaptation": 4, "OutdoorBattleAdaptation": 2, "IndoorBattleAdaptation": 0, "WeaponType": "SR", "WeaponImg": "weapon_icon_10000", "Cover": true, "Equipment": ["Hat", "Hairpin", "Watch"], "CollectionBG": "BG_Gehenna_Collection", "FamilyName": "Rikuhachima", "PersonalName": "Aru", "SchoolYear": "2nd Year", "CharacterAge": "16 years old", "Birthday": "March 12th", "CharacterSSRNew": "Heh heh. Good choice.", "ProfileIntroduction": "The self-proclaimed boss of Problem Solver 68.\n\nAru expanded Gehenna Academy's Problem Solver 68 club without permission to conduct illegal business activities. She aspires to be seen as a dashing and debonair villain, but her frequent blunders always expose her as a poser.", "Hobby": "Studying management", "CharacterVoice": "Reina Kondō", "BirthDay": "3/12", "Illustrator": "DoReMi", "Designer": "DoReMi", "CharHeightMetric": "160cm", "CharHeightImperial": "5'2\"", "StabilityPoint": 1988, "AttackPower1": 369, "AttackPower100": 3690, "MaxHP1": 2236, "MaxHP100": 19390, "DefensePower1": 19, "DefensePower100": 119, "HealPower1": 1408, "HealPower100": 4225, "DodgePoint": 201, "AccuracyPoint": 905, "CriticalPoint": 201, "CriticalDamageRate": 20000, "AmmoCount": 5, "AmmoCost": 1, "Range": 750, "RegenCost": 700, "Skills": [{"SkillType": "autoattack", "Effects": [{"Type": "DMGSingle", "Hits": [10000], "Scale": [10000], "Frames": {"AttackEnterDuration": 45, "AttackStartDuration": 15, "AttackEndDuration": 16, "AttackBurstRoundOverDelay": 50, "AttackIngDuration": 42, "AttackReloadDuration": 70}, "CriticalCheck": "Check"}]}, {"SkillType": "ex", "Name": "Hard-Boiled Shot", "Desc": "Fires a shot that deals <?1> damage to one enemy and explodes for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["274%", "315%", "397%", "438%", "521%"], ["292%", "335%", "423%", "467%", "554%"]], "Cost": [4, 4, 4, 4, 4], "Duration": 132, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 200}], "Icon": "COMMON_SKILLICON_CIRCLE", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [27438, 31554, 39781, 43898, 52125]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [29207, 33584, 42344, 46727, 55487]}]}, {"SkillType": "normal", "Name": "Noir Attack", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 50% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["152%", "160%", "168%", "198%", "206%", "213%", "244%", "251%", "259%", "290%"], ["251%", "263%", "276%", "326%", "338%", "351%", "401%", "414%", "426%", "476%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15270, 16035, 16801, 19851, 20617, 21376, 24433, 25198, 25958, 29014]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25102, 26355, 27608, 32631, 33884, 35137, 40155, 41414, 42667, 47685]}]}, {"SkillType": "gearnormal", "Name": "Noir Attack+", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 100% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["156%", "163%", "171%", "203%", "210%", "218%", "249%", "257%", "265%", "296%"], ["256%", "269%", "282%", "333%", "346%", "359%", "410%", "423%", "436%", "487%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15616, 16398, 17181, 20305, 21082, 21864, 24988, 25771, 26547, 29672]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25669, 26956, 28237, 33368, 34656, 35937, 41068, 42355, 43637, 48768]}]}, {"SkillType": "passive", "Name": "The Gravity of a Boss", "Desc": "Increases <b:CriticalDamage> by <?1>.", "Parameters": [["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Coefficient", "Scale": [1400, 1470, 1540, 1820, 1890, 1960, 2240, 2310, 2380, 2660]}]}, {"SkillType": "weaponpassive", "Name": "The Gravity of a Boss+", "Desc": "Increases <b:CriticalDamage> by <?1> plus a further <?2>.", "Parameters": [["2000", "2100", "2200", "2600", "2700", "2800", "3200", "3300", "3400", "3800"], ["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Base", "Scale": [2000, 2100, 2200, 2600, 2700,Line truncated
|
||||||
@@ -8,6 +8,7 @@ _PKG_DIR = Path(__file__).parent
|
|||||||
_DATA_DIR = _PKG_DIR.parent / "data"
|
_DATA_DIR = _PKG_DIR.parent / "data"
|
||||||
BUNDLED_DB = _PKG_DIR / "ba_students.json"
|
BUNDLED_DB = _PKG_DIR / "ba_students.json"
|
||||||
BANNER_FILE = _DATA_DIR / "ba_banner_state.json"
|
BANNER_FILE = _DATA_DIR / "ba_banner_state.json"
|
||||||
|
PORTRAIT_CACHE_DIR = _DATA_DIR / "portraits"
|
||||||
ASSETS_DIR = _PKG_DIR / "assets"
|
ASSETS_DIR = _PKG_DIR / "assets"
|
||||||
BG_PATH = ASSETS_DIR / "gacha_bg.png"
|
BG_PATH = ASSETS_DIR / "gacha_bg.png"
|
||||||
GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif"
|
GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif"
|
||||||
@@ -17,6 +18,9 @@ GACHA_ANIM_DURATION = 3.5 # 195 frames × 100 ms (used to be 19.5 will be chang
|
|||||||
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"
|
||||||
PORTRAIT_CDN = "https://schaledb.com/images"
|
PORTRAIT_CDN = "https://schaledb.com/images"
|
||||||
|
# Wiki art is the nicest but its host goes down for days at a time. Once a
|
||||||
|
# student is cached from a lesser source, re-check the wiki this often.
|
||||||
|
WIKI_RETRY_HOURS = 24
|
||||||
WIKI_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/"
|
WIKI_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/"
|
||||||
WIKI_VARIANT_MAP: dict[str, str] = {
|
WIKI_VARIANT_MAP: dict[str, str] = {
|
||||||
"Bunny": "Bunny Girl",
|
"Bunny": "Bunny Girl",
|
||||||
@@ -80,6 +84,10 @@ STAR_BANNER_H = 36
|
|||||||
NAME_STRIP_H = 42
|
NAME_STRIP_H = 42
|
||||||
PORTRAIT_TOP = STAR_BANNER_H + 4
|
PORTRAIT_TOP = STAR_BANNER_H + 4
|
||||||
PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
|
PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
|
||||||
|
# Fraction of a full-body sprite kept when cropping it to a bust, and the
|
||||||
|
# fraction of it searched to locate the head horizontally.
|
||||||
|
BUST_HEIGHT_FRAC = 0.45
|
||||||
|
BUST_HEAD_FRAC = 0.25
|
||||||
|
|
||||||
# ── Inventory display ───────────────────────────────────────────────────────
|
# ── Inventory display ───────────────────────────────────────────────────────
|
||||||
PAGE_SIZE = 10
|
PAGE_SIZE = 10
|
||||||
|
|||||||
@@ -355,8 +355,12 @@ class StudentDB:
|
|||||||
name = f"{base.strip()} ({variant})"
|
name = f"{base.strip()} ({variant})"
|
||||||
return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="")
|
return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="")
|
||||||
|
|
||||||
|
def cdn_collection(self, student: dict) -> str:
|
||||||
|
"""SchaleDB CDN collection art URL (in-game card portrait, keyed by ID)."""
|
||||||
|
return f"{PORTRAIT_CDN}/student/collection/{student['Id']}.webp"
|
||||||
|
|
||||||
def cdn_sprite(self, student: dict) -> str:
|
def cdn_sprite(self, student: dict) -> str:
|
||||||
"""SchaleDB CDN full-body sprite URL (fallback, keyed by ID)."""
|
"""SchaleDB CDN full-body sprite URL (last resort, keyed by ID)."""
|
||||||
return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp"
|
return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import functools
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -21,7 +24,9 @@ from .constants import (
|
|||||||
HEADER_Y, FOOTER_Y,
|
HEADER_Y, FOOTER_Y,
|
||||||
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
|
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
|
||||||
RARITY_COLOR, RARITY_BG,
|
RARITY_COLOR, RARITY_BG,
|
||||||
|
BUST_HEIGHT_FRAC, BUST_HEAD_FRAC,
|
||||||
BG_PATH, SPARK_TARGET,
|
BG_PATH, SPARK_TARGET,
|
||||||
|
PORTRAIT_CACHE_DIR, WIKI_RETRY_HOURS,
|
||||||
FONT_PATHS,
|
FONT_PATHS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,20 +52,87 @@ FOOTER_FONT = _get_font(18, bold=False)
|
|||||||
|
|
||||||
# ── Image fetching ─────────────────────────────────────────────────────────
|
# ── Image fetching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _bust_crop(img: Image.Image) -> Image.Image:
|
||||||
|
"""Crop a full-body sprite to head-and-shoulders so it fills the card.
|
||||||
|
|
||||||
|
Anchored on the alpha box top (Kid variants sit lower in the frame) and on
|
||||||
|
the alpha-weighted centre of the top slice, so thin props like rifles and
|
||||||
|
parasols barely pull the crop off the face.
|
||||||
|
"""
|
||||||
|
alpha = img.getchannel("A")
|
||||||
|
box = alpha.getbbox()
|
||||||
|
if not box:
|
||||||
|
return img
|
||||||
|
left, top, right, bottom = box
|
||||||
|
fig_h = bottom - top
|
||||||
|
crop_h = min(img.height - top, max(1, int(fig_h * BUST_HEIGHT_FRAC)))
|
||||||
|
head = alpha.crop((left, top, right, top + max(1, int(fig_h * BUST_HEAD_FRAC))))
|
||||||
|
cols = head.resize((head.width, 1), Image.BOX).tobytes()
|
||||||
|
weight = sum(cols)
|
||||||
|
offset = sum(x * c for x, c in enumerate(cols)) // weight if weight else head.width // 2
|
||||||
|
cx = left + offset
|
||||||
|
crop_w = min(img.width, max(1, round(crop_h * (CARD_W - 12) / PORTRAIT_H)))
|
||||||
|
x0 = max(0, min(img.width - crop_w, cx - crop_w // 2))
|
||||||
|
return img.crop((x0, top, x0 + crop_w, top + crop_h))
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_portrait(student: dict) -> Optional[Image.Image]:
|
||||||
|
"""Load a cached portrait, or None when one should be (re)fetched."""
|
||||||
|
try:
|
||||||
|
wiki = PORTRAIT_CACHE_DIR / f"{student['Id']}.wiki.png"
|
||||||
|
if wiki.exists():
|
||||||
|
return Image.open(wiki).convert("RGBA")
|
||||||
|
alt = PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png"
|
||||||
|
if alt.exists() and time.time() - alt.stat().st_mtime < WIKI_RETRY_HOURS * 3600:
|
||||||
|
return Image.open(alt).convert("RGBA")
|
||||||
|
except Exception: # unreadable file would otherwise blank the card forever
|
||||||
|
log.warning("bad cached portrait for %s, refetching", student["Name"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_portrait(student: dict, img: Image.Image, from_wiki: bool) -> None:
|
||||||
|
"""Keep fetched art on disk so an upstream outage can't change the cards."""
|
||||||
|
try:
|
||||||
|
PORTRAIT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
kind = "wiki" if from_wiki else "alt"
|
||||||
|
path = PORTRAIT_CACHE_DIR / f"{student['Id']}.{kind}.png"
|
||||||
|
tmp = path.parent / f"{path.name}.{uuid.uuid4().hex[:8]}.tmp"
|
||||||
|
img.save(tmp, format="PNG") # the .tmp name hides the format from Pillow
|
||||||
|
os.replace(tmp, path) # a restart mid-save must not leave half a card
|
||||||
|
if from_wiki: # wiki art wins from here on, drop the stand-in
|
||||||
|
(PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png").unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
log.warning("could not cache portrait for %s", student["Name"], exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
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 portrait (wiki bust art first, full sprite fallback)."""
|
"""A student's portrait: disk cache, else wiki art, collection art, sprite."""
|
||||||
for url in (db.cdn_portrait(student), db.cdn_sprite(student)):
|
cached = await asyncio.to_thread(_cached_portrait, student)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
sources = (
|
||||||
|
(db.cdn_portrait(student), "wiki"),
|
||||||
|
(db.cdn_collection(student), "collection"),
|
||||||
|
(db.cdn_sprite(student), "sprite"),
|
||||||
|
)
|
||||||
|
for url, kind in sources:
|
||||||
try:
|
try:
|
||||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||||
if resp.status == 200:
|
if resp.status != 200:
|
||||||
data = await resp.read()
|
continue
|
||||||
img = Image.open(io.BytesIO(data)).convert("RGBA")
|
data = await resp.read()
|
||||||
# Full-body art is tall; crop the upper portion to fit the card.
|
img = Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
p_w, p_h = img.size
|
if kind == "sprite":
|
||||||
crop_h = int(p_w * 1.20)
|
img = _bust_crop(img)
|
||||||
if crop_h < p_h:
|
elif kind == "wiki":
|
||||||
img = img.crop((0, 0, p_w, crop_h))
|
# Wiki art is a face shot; only trim it if it is very tall.
|
||||||
return img
|
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))
|
||||||
|
await asyncio.to_thread(_cache_portrait, student, img, kind == "wiki")
|
||||||
|
return img
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("portrait fetch failed via %s", url)
|
log.debug("portrait fetch failed via %s", url)
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -17,7 +17,7 @@ from PIL import Image
|
|||||||
from . import db as gacha_db
|
from . import db as gacha_db
|
||||||
from .data import db, fetch_banners, banner_unavailable_reason
|
from .data import db, fetch_banners, banner_unavailable_reason
|
||||||
from .gacha_renderer import render_pull
|
from .gacha_renderer import render_pull
|
||||||
from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS
|
from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS, PORTRAIT_CACHE_DIR
|
||||||
|
|
||||||
OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️"
|
OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️"
|
||||||
REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh
|
REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh
|
||||||
@@ -121,7 +121,7 @@ def _check_rateups(live_banners: list[dict]) -> tuple[list[Row], list[dict]]:
|
|||||||
|
|
||||||
|
|
||||||
async def _check_portraits(students: list[dict]) -> list[Row]:
|
async def _check_portraits(students: list[dict]) -> list[Row]:
|
||||||
"""Probe wiki art and the sprite fallback separately for rate-up students."""
|
"""Probe each art source separately for rate-up students."""
|
||||||
s = "Portraits"
|
s = "Portraits"
|
||||||
if not students:
|
if not students:
|
||||||
return [(INFO, s, "No live rate-up students to probe")]
|
return [(INFO, s, "No live rate-up students to probe")]
|
||||||
@@ -136,16 +136,25 @@ async def _check_portraits(students: list[dict]) -> list[Row]:
|
|||||||
timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull
|
timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
results = await asyncio.gather(*(
|
results = await asyncio.gather(*(
|
||||||
asyncio.gather(_probe_image(session, db.cdn_portrait(st)), _probe_image(session, db.cdn_sprite(st)))
|
asyncio.gather(
|
||||||
|
_probe_image(session, db.cdn_portrait(st)),
|
||||||
|
_probe_image(session, db.cdn_collection(st)),
|
||||||
|
_probe_image(session, db.cdn_sprite(st)),
|
||||||
|
)
|
||||||
for st in sample
|
for st in sample
|
||||||
))
|
))
|
||||||
for st, (wiki, sprite) in zip(sample, results):
|
for st, (wiki, collection, sprite) in zip(sample, results):
|
||||||
if wiki is None:
|
if wiki is None:
|
||||||
rows.append((OK, s, f"{st['Name']}: wiki art OK"))
|
rows.append((OK, s, f"{st['Name']}: wiki art OK"))
|
||||||
|
elif collection is None:
|
||||||
|
rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), collection art OK"))
|
||||||
elif sprite is None:
|
elif sprite is None:
|
||||||
rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), sprite fallback OK"))
|
rows.append((WARN, s, f"{st['Name']}: wiki+collection failed, sprite fallback OK"))
|
||||||
else:
|
else:
|
||||||
rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, sprite {sprite}) — card renders blank"))
|
rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, collection {collection}, "
|
||||||
|
f"sprite {sprite}) — card renders blank"))
|
||||||
|
cached = len(list(PORTRAIT_CACHE_DIR.glob("*.png"))) if PORTRAIT_CACHE_DIR.exists() else 0
|
||||||
|
rows.append((INFO, s, f"{cached} portraits cached on disk"))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -132,10 +132,7 @@ class TimestampModal(discord.ui.Modal, title="Timestamp Converter"):
|
|||||||
|
|
||||||
# --- output single Long Date+Time format ---
|
# --- output single Long Date+Time format ---
|
||||||
lines = [
|
lines = [
|
||||||
f"**{local_dt.strftime('%d %B at %H:%M')}**",
|
f"<t:{unix_ts}:F>",
|
||||||
f"`<t:{unix_ts}:F>` ← Long Date+Time",
|
|
||||||
"",
|
|
||||||
"*Times display in each viewer's local timezone.*",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
|
|||||||
@@ -117,3 +117,18 @@ def load_routes() -> list[Route]:
|
|||||||
|
|
||||||
def avatar_channel_id() -> int | None:
|
def avatar_channel_id() -> int | None:
|
||||||
return _int_or_none(os.getenv("AVATAR_CACHE_CHANNEL_ID"))
|
return _int_or_none(os.getenv("AVATAR_CACHE_CHANNEL_ID"))
|
||||||
|
|
||||||
|
|
||||||
|
def local_api() -> dict:
|
||||||
|
"""-> bot kwargs for a self-hosted bot api server, {} for the cloud one.
|
||||||
|
|
||||||
|
A local server lifts telegram's 20 MB getFile cap and hands back a path on
|
||||||
|
disk instead of a url, which ptb reads for itself once local_mode is on.
|
||||||
|
Both directions read this or only one of them migrates.
|
||||||
|
"""
|
||||||
|
base = os.getenv("TELEGRAM_API_BASE_URL", "").strip().rstrip("/")
|
||||||
|
if not base:
|
||||||
|
return {}
|
||||||
|
return {"base_url": f"{base}/bot",
|
||||||
|
"base_file_url": f"{base}/file/bot",
|
||||||
|
"local_mode": True}
|
||||||
@@ -15,7 +15,7 @@ from telegram import (Bot, InputMediaAudio, InputMediaDocument,
|
|||||||
InputMediaPhoto, InputMediaVideo, ReplyParameters)
|
InputMediaPhoto, InputMediaVideo, ReplyParameters)
|
||||||
from telegram.error import TelegramError
|
from telegram.error import TelegramError
|
||||||
|
|
||||||
from . import entities, store
|
from . import config, entities, store
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class DiscordToTelegramForwarder(commands.Cog):
|
|||||||
|
|
||||||
def __init__(self, bot: commands.Bot, tg_token: str, routes=None):
|
def __init__(self, bot: commands.Bot, tg_token: str, routes=None):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self._tg_bot = Bot(token=tg_token)
|
self._tg_bot = Bot(token=tg_token, **config.local_api())
|
||||||
# discord_channel_id -> telegram_chat_id
|
# discord_channel_id -> telegram_chat_id
|
||||||
self.routes: dict[int, int] = {}
|
self.routes: dict[int, int] = {}
|
||||||
for route in routes or []:
|
for route in routes or []:
|
||||||
|
|||||||
+77
-10
@@ -4,8 +4,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from telegram.error import BadRequest, NetworkError
|
||||||
from telegram.ext import Application, MessageHandler, filters
|
from telegram.ext import Application, MessageHandler, filters
|
||||||
|
|
||||||
from . import config, entities, media, store
|
from . import config, entities, media, store
|
||||||
@@ -18,9 +20,24 @@ log = logging.getLogger(__name__)
|
|||||||
ALBUM_DEBOUNCE = 1.5
|
ALBUM_DEBOUNCE = 1.5
|
||||||
ALBUM_MAX_FILES = 10
|
ALBUM_MAX_FILES = 10
|
||||||
DOWNLOAD_TIMEOUT = 90.0
|
DOWNLOAD_TIMEOUT = 90.0
|
||||||
|
# ptb defaults read_timeout to 5s, which a slow transfer trips long before
|
||||||
|
# the ceilings above ever matter. kept under 24s so pool+connect+read stays
|
||||||
|
# inside the 30s get_file ceiling and ptb stays the one reporting the failure
|
||||||
|
READ_TIMEOUT = 20.0
|
||||||
|
# a local api server lifts telegram's 20 MB cap, but _download still buffers
|
||||||
|
# the whole file in memory — past this, post the note instead of eating the box
|
||||||
|
MAX_INGEST_BYTES = 200 * 1024 * 1024
|
||||||
REPLY_SNIPPET = 120
|
REPLY_SNIPPET = 120
|
||||||
|
|
||||||
|
|
||||||
|
class TooLarge(Exception):
|
||||||
|
"""An attachment we will not pull into memory."""
|
||||||
|
|
||||||
|
def __init__(self, megabytes: float | None = None):
|
||||||
|
super().__init__("attachment too large to bridge")
|
||||||
|
self.megabytes = megabytes
|
||||||
|
|
||||||
|
|
||||||
class TelegramForwarder:
|
class TelegramForwarder:
|
||||||
def __init__(self, token, discord_bot=None, avatar_channel_id=None,
|
def __init__(self, token, discord_bot=None, avatar_channel_id=None,
|
||||||
routes=None, drop_pending=True):
|
routes=None, drop_pending=True):
|
||||||
@@ -67,7 +84,10 @@ class TelegramForwarder:
|
|||||||
if removed:
|
if removed:
|
||||||
log.info("pruned %d expired message mappings", removed)
|
log.info("pruned %d expired message mappings", removed)
|
||||||
|
|
||||||
self._app = Application.builder().token(self.token).build()
|
builder = Application.builder().token(self.token)
|
||||||
|
for name, value in config.local_api().items():
|
||||||
|
builder = getattr(builder, name)(value)
|
||||||
|
self._app = builder.build()
|
||||||
self._app.add_handler(MessageHandler(filters.ALL, self._on_update))
|
self._app.add_handler(MessageHandler(filters.ALL, self._on_update))
|
||||||
self._app.add_error_handler(self._on_error)
|
self._app.add_error_handler(self._on_error)
|
||||||
|
|
||||||
@@ -125,14 +145,45 @@ class TelegramForwarder:
|
|||||||
# ── downloads ───────────────────────────────────────────────────────────
|
# ── downloads ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _download(self, file_id: str):
|
async def _download(self, file_id: str):
|
||||||
"""-> (bytes, filename). Raises on failure."""
|
"""-> (bytes, filename). Raises on failure.
|
||||||
tg_file = await asyncio.wait_for(
|
|
||||||
self._app.bot.get_file(file_id), timeout=30.0)
|
Retried once: a dropped read mid-transfer is the one failure here
|
||||||
buf = io.BytesIO()
|
nothing upstream recovers from. BadRequest subclasses NetworkError,
|
||||||
await asyncio.wait_for(
|
so it has to be ruled out first or a rejection gets re-sent.
|
||||||
tg_file.download_to_memory(buf), timeout=DOWNLOAD_TIMEOUT)
|
"""
|
||||||
path = tg_file.file_path or ""
|
for attempt in (1, 2):
|
||||||
return buf.getvalue(), (path.split("/")[-1] or "file")
|
try:
|
||||||
|
tg_file = await asyncio.wait_for(
|
||||||
|
self._app.bot.get_file(file_id, read_timeout=READ_TIMEOUT),
|
||||||
|
timeout=30.0)
|
||||||
|
size = tg_file.file_size or 0
|
||||||
|
if not size and os.path.isabs(tg_file.file_path or ""):
|
||||||
|
# local mode hands back a path, not a url — if the
|
||||||
|
# server left file_size out, ask the filesystem
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(tg_file.file_path)
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
if size > MAX_INGEST_BYTES:
|
||||||
|
raise TooLarge(size / 1048576)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
await asyncio.wait_for(
|
||||||
|
tg_file.download_to_memory(buf, read_timeout=READ_TIMEOUT),
|
||||||
|
timeout=DOWNLOAD_TIMEOUT)
|
||||||
|
except BadRequest as exc:
|
||||||
|
# only the cloud api refuses outright; a local server will
|
||||||
|
# hand the file over and MAX_INGEST_BYTES becomes the limit
|
||||||
|
if "too big" in str(exc).lower():
|
||||||
|
raise TooLarge() from exc
|
||||||
|
raise
|
||||||
|
except NetworkError as exc:
|
||||||
|
if attempt == 2:
|
||||||
|
raise
|
||||||
|
log.info("download retry after %s: %s",
|
||||||
|
type(exc).__name__, exc)
|
||||||
|
continue
|
||||||
|
path = tg_file.file_path or ""
|
||||||
|
return buf.getvalue(), (path.split("/")[-1] or "file")
|
||||||
|
|
||||||
# ── context lines ───────────────────────────────────────────────────────
|
# ── context lines ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -264,6 +315,14 @@ class TelegramForwarder:
|
|||||||
|
|
||||||
if document:
|
if document:
|
||||||
data, filename = await self._download(document.file_id)
|
data, filename = await self._download(document.file_id)
|
||||||
|
# a gif sent as a file never reaches the animation branch, so it
|
||||||
|
# went to discord at full size — same ladder, same .gif out
|
||||||
|
is_gif = (doc_mime == "image/gif"
|
||||||
|
or filename.lower().endswith(".gif"))
|
||||||
|
if is_gif and len(data) > media.MAX_CONVERTED_BYTES:
|
||||||
|
smaller = await media.to_gif(data, ".gif")
|
||||||
|
if smaller:
|
||||||
|
return [(filename, smaller)], ""
|
||||||
return [(filename, data)], ""
|
return [(filename, data)], ""
|
||||||
|
|
||||||
if message.voice:
|
if message.voice:
|
||||||
@@ -347,8 +406,16 @@ class TelegramForwarder:
|
|||||||
for message in messages:
|
for message in messages:
|
||||||
try:
|
try:
|
||||||
got, note = await self._render_media(message)
|
got, note = await self._render_media(message)
|
||||||
|
except TooLarge as exc:
|
||||||
|
what = _describe_media(message) or "attachment"
|
||||||
|
size = f" — {exc.megabytes:.0f} MB" if exc.megabytes else ""
|
||||||
|
log.info("%s in message %s too large to bridge%s",
|
||||||
|
what, message.message_id, size)
|
||||||
|
got, note = [], f"*(attachment too large to bridge{size})*"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("media render failed: %s", exc)
|
log.warning("media render failed for %s in message %s: %s: %s",
|
||||||
|
_describe_media(message) or "attachment",
|
||||||
|
message.message_id, type(exc).__name__, exc)
|
||||||
got, note = [], "*(attachment could not be fetched)*"
|
got, note = [], "*(attachment could not be fetched)*"
|
||||||
files.extend(got)
|
files.extend(got)
|
||||||
if note:
|
if note:
|
||||||
|
|||||||
Reference in new issue
Block a user