213 lines
7.2 KiB
Python
213 lines
7.2 KiB
Python
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
|