import asyncio import time import pytz import httpx import datetime import calendar from PIL import Image from io import BytesIO from pathlib import Path from typing import Dict, Optional from .models import Player from .steam import ( _query_game_icon_hashes, _upsert_game_icon_hashes, fetch_clienticon_hash_from_store, ) async def _fetch_avatar(avatar_url: str, proxy: str = None) -> Image.Image: default_avatar = Image.open(Path(__file__).parent / "res/unknown_avatar.jpg") try: async with httpx.AsyncClient(proxy=proxy, timeout=10) as client: response = await client.get(avatar_url) if response.status_code != 200: return default_avatar return Image.open(BytesIO(response.content)) except Exception: # 下载失败(连接超时/网络异常),降级默认头像,不中断命令 return default_avatar # 游戏图标缓存目录(插件目录下 data/game_icons/) GAME_ICON_DIR = Path(__file__).parent / "data" / "game_icons" async def _download_steam_icon( url: str, appid: int, proxy: str, cache_suffix: str ) -> Optional[Image.Image]: """从 steam CDN 下载图标并缓存到 data/game_icons/{appid}_{cache_suffix}.jpg""" try: async with httpx.AsyncClient(proxy=proxy, timeout=10) as client: resp = await client.get(url) if resp.status_code != 200: return None content = resp.content GAME_ICON_DIR.mkdir(parents=True, exist_ok=True) cache_path = GAME_ICON_DIR / f"{appid}_{cache_suffix}.jpg" await asyncio.to_thread(cache_path.write_bytes, content) return Image.open(BytesIO(content)) except Exception: return None async def get_game_icon( appid: int, proxy: str = None, db_path: Optional[Path] = None ) -> Optional[Image.Image]: """获取游戏图标(Steam clienticon .jpg),按 appid 缓存到插件 data/game_icons/ 目录 - 优先用 game_names 表里的 clienticon hash 下载 32x32 小图标 - DB 缺失时从商店页抓 hash - 取不到就返回 None,绘制时留空占位(不再回退 header.jpg) """ if not appid: return None icon_path = GAME_ICON_DIR / f"{appid}_icon.jpg" if icon_path.is_file(): try: return Image.open(icon_path) except Exception: pass icon_hash = None if db_path is not None: icon_hash = _query_game_icon_hashes(db_path, [appid]).get(appid) if not icon_hash: # DB 缺失时,从商店页 apphub_AppIcon 稳定抓取 clienticon hash icon_hash = await fetch_clienticon_hash_from_store(appid) if icon_hash and db_path is not None: _upsert_game_icon_hashes(db_path, {appid: icon_hash}) if icon_hash: icon = await _download_steam_icon( f"https://shared.akamai.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg", appid, proxy, "icon", ) if icon is None: icon = await _download_steam_icon( f"https://shared.fastly.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg", appid, proxy, "icon", ) if icon is None: # 备用:旧社区 CDN 路径(部分早期游戏仍在此处) icon = await _download_steam_icon( f"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{appid}/{icon_hash}.jpg", appid, proxy, "icon", ) if icon is not None: return icon # 没有 clienticon 就留空占位,不回退 header return None async def get_game_icons( appids: list[int], proxy: str = None, db_path: Optional[Path] = None ) -> dict[int, Image.Image]: """批量获取游戏图标;失败自动跳过,返回 {appid: Image.Image}""" if not appids: return {} results = await asyncio.gather(*(get_game_icon(a, proxy, db_path) for a in appids)) return {a: icon for a, icon in zip(appids, results) if icon is not None} async def fetch_avatar( player: Player, avatar_dir: Optional[Path], proxy: str = None ) -> Image.Image: if avatar_dir is not None: avatar_path = ( avatar_dir / f"avatar_{player['steamid']}_{player['avatarhash']}.png" ) if avatar_path.exists(): avatar = Image.open(avatar_path) else: avatar = await _fetch_avatar(player["avatarfull"], proxy) avatar.save(avatar_path) else: avatar = await _fetch_avatar(player["avatarfull"], proxy) return avatar async def simplize_steam_player_data( player: Player, proxy: str = None, avatar_dir: Path = None, game_names: Optional[Dict[int, str]] = None, ) -> Dict[str, str]: avatar = await fetch_avatar(player, avatar_dir, proxy) gameid = int(player["gameid"]) if player.get("gameid") else None game_name = game_names.get(gameid) if (gameid is not None and game_names) else None if not game_name: game_name = player.get("gameextrainfo") if player["personastate"] == 0: if not player.get("lastlogoff"): status = "离线" else: time_logged_off = player["lastlogoff"] # Unix timestamp time_to_now = calendar.timegm(time.gmtime()) - time_logged_off # 将时间转换为自然语言 if time_to_now < 60: status = "上次在线 刚刚" elif time_to_now < 3600: status = f"上次在线 {time_to_now // 60} 分钟前" elif time_to_now < 86400: status = f"上次在线 {time_to_now // 3600} 小时前" elif time_to_now < 2592000: status = f"上次在线 {time_to_now // 86400} 天前" elif time_to_now < 31536000: status = f"上次在线 {time_to_now // 2592000} 个月前" else: status = f"上次在线 {time_to_now // 31536000} 年前" elif player["personastate"] in [1, 2, 4]: status = ( "在线" if player.get("gameextrainfo") is None else player["gameextrainfo"] ) elif player["personastate"] == 3: status = ( "离开" if player.get("gameextrainfo") is None else player["gameextrainfo"] ) elif player["personastate"] in [5, 6]: status = "在线" else: status = "未知" return { "steamid": player["steamid"], "avatar": avatar, "name": player["personaname"], "status": status, "personastate": player["personastate"], "gameid": gameid, "game_name": game_name, } def image_to_bytes(image: Image.Image) -> bytes: with BytesIO() as bio: image.save(bio, format="PNG") return bio.getvalue() def hex_to_rgb(hex_color: str): return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) def convert_timestamp_to_beijing_time(timestamp: int) -> str: beijing_timezone = pytz.timezone("Asia/Shanghai") date_utc = datetime.datetime.fromtimestamp(timestamp, pytz.utc) date_beijing = date_utc.astimezone(beijing_timezone) return date_beijing.strftime("%Y-%m-%d %H:%M:%S") # example: 2021-09-06 21:00:00