704 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package scoresaber
import (
"encoding/json"
"fmt"
"image"
"image/color"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"git.lxtend.com/qqbot/util"
"github.com/fogleman/gg"
"github.com/google/uuid"
"github.com/nfnt/resize"
"golang.org/x/image/font/opentype"
)
var (
imageCache = make(map[string]image.Image)
imageCacheLock sync.RWMutex
)
func init() {
util.AddCycleTask("bs50_image_cache_cleaner", 10*time.Minute, 10*time.Minute, func() {
imageCacheLock.Lock()
defer imageCacheLock.Unlock()
for k := range imageCache {
delete(imageCache, k)
}
})
}
func loadImageFromCache(path string) (image.Image, error) {
imageCacheLock.RLock()
if img, exists := imageCache[path]; exists {
imageCacheLock.RUnlock()
return img, nil
}
imageCacheLock.RUnlock()
img, err := gg.LoadImage(path)
if err != nil {
return nil, err
}
imageCacheLock.Lock()
imageCache[path] = img
imageCacheLock.Unlock()
return img, nil
}
// SongData 代表一首歌曲的数据
type SongData struct {
JacketPath string
Title string
Beatmapid string
Difficulty string
DifficultyColor color.Color
Stars float64
Accuracy float64
Days_ago string
UserMap_pp float64
IsBest30 bool
}
// PlayerData 代表玩家的整体数据
type PlayerData struct {
PlayerName string // 玩家昵称
PlayerID string // 玩家ID (可选)
AvatarPath string // 玩家头像路径 (可选)
PlayerCurrentPP float64 // 玩家总 UserMap_pp
B50Songs []SongData // B50 歌曲列表 (通常是按 UserMap_pp 降序排列)
PlayerRank int // 玩家排名
PlayerCountryRank int // 玩家国家排名
Country string // 玩家所在国家
}
// ScoreSaber用户信息结构体
type ScoreSaberUser struct {
ID string `json:"id"`
Name string `json:"name"`
ProfilePicture string `json:"profilePicture"`
Country string `json:"country"`
PP float64 `json:"pp"`
Rank int `json:"rank"`
CountryRank int `json:"countryRank"`
}
// ScoreSaber 相关结构体
type ScoreSaberScore struct {
ID int64 `json:"id"`
LeaderboardPlayerInfo interface{} `json:"leaderboardPlayerInfo"`
Rank int `json:"rank"`
BaseScore int `json:"baseScore"`
ModifiedScore int `json:"modifiedScore"`
PP float64 `json:"pp"`
Weight float64 `json:"weight"`
Modifiers string `json:"modifiers"`
Multiplier float64 `json:"multiplier"`
BadCuts int `json:"badCuts"`
MissedNotes int `json:"missedNotes"`
MaxCombo int `json:"maxCombo"`
FullCombo bool `json:"fullCombo"`
HMD int `json:"hmd"`
TimeSet string `json:"timeSet"`
HasReplay bool `json:"hasReplay"`
DeviceHMD string `json:"deviceHmd"`
DeviceControllerLeft string `json:"deviceControllerLeft"`
DeviceControllerRight string `json:"deviceControllerRight"`
}
type ScoreSaberLeaderboard struct {
ID int64 `json:"id"`
SongHash string `json:"songHash"`
SongName string `json:"songName"`
SongSubName string `json:"songSubName"`
SongAuthorName string `json:"songAuthorName"`
LevelAuthorName string `json:"levelAuthorName"`
Difficulty struct {
LeaderboardID int `json:"leaderboardId"`
Difficulty int `json:"difficulty"`
GameMode string `json:"gameMode"`
DifficultyRaw string `json:"difficultyRaw"`
} `json:"difficulty"`
MaxScore int `json:"maxScore"`
CreatedDate string `json:"createdDate"`
RankedDate string `json:"rankedDate"`
QualifiedDate string `json:"qualifiedDate"`
LovedDate interface{} `json:"lovedDate"`
Ranked bool `json:"ranked"`
Qualified bool `json:"qualified"`
Loved bool `json:"loved"`
MaxPP int `json:"maxPP"`
Stars float64 `json:"stars"`
Plays int `json:"plays"`
DailyPlays int `json:"dailyPlays"`
PositiveModifiers bool `json:"positiveModifiers"`
PlayerScore interface{} `json:"playerScore"`
CoverImage string `json:"coverImage"`
Difficulties interface{} `json:"difficulties"`
}
type ScoreSaberPlayerScore struct {
Score ScoreSaberScore `json:"score"`
Leaderboard ScoreSaberLeaderboard `json:"leaderboard"`
}
type ScoreSaberResponse struct {
PlayerScores []ScoreSaberPlayerScore `json:"playerScores"`
Metadata struct {
Total int `json:"total"`
Page int `json:"page"`
ItemsPerPage int `json:"itemsPerPage"`
} `json:"metadata"`
}
// loadFont 加载字体文件的辅助函数
func loadFont(path string) (*opentype.Font, error) {
fontBytes, err := os.ReadFile(path)
if err != nil {
return nil, err
}
f, err := opentype.Parse(fontBytes)
if err != nil {
return nil, err
}
return f, nil
}
// generateB50Image 生成 B50 图片
func generateB50Image(player PlayerData, outputPath string, fontPath string) error {
const (
imgWidth = 2000 // 增加画布宽度以适应四列
padding = 40.0 // 边距
headerHeight = 150.0 // 头部区域高度
songEntryHeight = 100.0 // 每首歌曲条目高度
songEntryPadding = 10.0 // 歌曲条目内边距
jacketSize = songEntryHeight - 2*songEntryPadding // 封面大小
songsPerRow = 4 // 改为4列
maxSongsPerColumn = 10 // 每列最多显示10首歌曲
columnGap = 25.0 // 列之间的间距
)
var imgHeight int
// 计算实际需要的画布高度
totalRows := (len(player.B50Songs) + songsPerRow - 1) / songsPerRow
calculatedHeight := float64(headerHeight + padding*2 + totalRows*(songEntryHeight+padding))
if calculatedHeight > float64(imgHeight) {
imgHeight = int(calculatedHeight) + 100 // 额外加一些空间
}
dc := gg.NewContext(imgWidth, imgHeight)
mainFont, err := loadFont(fontPath)
if err != nil {
return fmt.Errorf("加载主字体失败: %w", err)
}
yOffset := padding * 1.5 // 增加顶部间距
dc.SetRGBA(0, 0, 0, 0.5)
dc.DrawRoundedRectangle(padding, yOffset, float64(imgWidth)-padding*2, headerHeight, 15)
dc.Fill()
// 玩家头像 (可选)
avatarSpace := padding * 2 // 默认空间
if player.AvatarPath != "" {
avatarImg, err := loadImageFromCache(player.AvatarPath)
if err == nil {
avatarSize := headerHeight - padding
dc.DrawImageAnchored(avatarImg, int(padding*2+avatarSize/2), int(yOffset+headerHeight/2), 0.5, 0.5)
avatarSpace = padding*2 + avatarSize + padding
} else {
log.Printf("警告: 加载头像 %s 失败: %v", player.AvatarPath, err)
}
}
// 玩家昵称
faceTitle, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
Size: 54, // 增大字体
DPI: 72,
})
if err != nil {
return fmt.Errorf("创建标题字体失败: %w", err)
}
dc.SetFontFace(faceTitle)
dc.SetHexColor("#FFFFFF")
dc.DrawStringAnchored(player.PlayerName, avatarSpace, yOffset+headerHeight/4, 0.0, 0.5)
faceCountry, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
Size: 30,
DPI: 72,
})
if err != nil {
return fmt.Errorf("创建国家信息字体失败: %w", err)
}
dc.SetFontFace(faceCountry)
// 排名信息
rankInfo := fmt.Sprintf("全球排名: #%d | [%s] 排名: #%d", player.PlayerRank, player.Country, player.PlayerCountryRank)
dc.DrawStringAnchored(rankInfo, avatarSpace, yOffset+headerHeight*0.75, 0.0, 0.5)
// 总 UserMap_pp
faceUserMap_pp, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
Size: 42, // 增大字体
DPI: 72,
})
if err != nil {
return fmt.Errorf("创建评分字体失败: %w", err)
}
dc.SetFontFace(faceUserMap_pp)
dc.SetHexColor("#FFD700") // 金色
ppText := fmt.Sprintf("%.2f pp", player.PlayerCurrentPP)
dc.DrawStringAnchored(ppText, imgWidth-padding, yOffset+headerHeight/2, 1.0, 0.5)
// B30 / R10 平均
faceSubtitle, err := opentype.NewFace(mainFont, &opentype.FaceOptions{
Size: 28,
DPI: 72,
})
if err != nil {
return fmt.Errorf("创建副标题字体失败: %w", err)
}
dc.SetFontFace(faceSubtitle)
dc.SetHexColor("#DDDDDD")
yOffset += headerHeight + padding*1.5 // 增加间距
// --- 5. 绘制列标题 ---
dc.SetFontFace(faceSubtitle)
dc.SetHexColor("#FFFFFF")
// 计算每列的宽度和间距
totalContentWidth := imgWidth - padding*2
songEntryWidth := (totalContentWidth - columnGap*(float64(songsPerRow)-1)) / float64(songsPerRow)
// 绘制标题栏
sectionNames := []string{"Best 30 (1-10)", "Best 30 (11-20)", "Best 30 (21-30)", "Recent 10"}
for i := 0; i < songsPerRow; i++ {
sectionX := padding + float64(i)*(songEntryWidth+columnGap) + songEntryWidth/2
dc.DrawStringAnchored(sectionNames[i], sectionX, yOffset, 0.5, 0.5)
}
yOffset += padding * 1.5
// --- 6. 为每个区域添加半透明背景 ---
for i := 0; i < songsPerRow; i++ {
sectionX := padding + float64(i)*(songEntryWidth+columnGap)
sectionHeight := float64(maxSongsPerColumn) * (songEntryHeight + padding)
dc.SetRGBA(0, 0, 0, 0.3)
dc.DrawRoundedRectangle(sectionX, yOffset, songEntryWidth, sectionHeight, 10)
dc.Fill()
}
// --- 7. 绘制歌曲列表 ---
faceSongTitle, err := opentype.NewFace(mainFont, &opentype.FaceOptions{Size: 24, DPI: 72})
if err != nil {
return fmt.Errorf("创建歌曲标题字体失败: %w", err)
}
faceSongDetail, err := opentype.NewFace(mainFont, &opentype.FaceOptions{Size: 20, DPI: 72})
if err != nil {
return fmt.Errorf("创建歌曲详情字体失败: %w", err)
}
faceSongUserMap_pp, err := opentype.NewFace(mainFont, &opentype.FaceOptions{Size: 28, DPI: 72})
if err != nil {
return fmt.Errorf("创建歌曲评分字体失败: %w", err)
}
// 计算每列的起始位置
columnStartX := make([]float64, songsPerRow)
for i := 0; i < songsPerRow; i++ {
columnStartX[i] = padding + float64(i)*(songEntryWidth+columnGap)
}
for i, song := range player.B50Songs {
if i >= 40 {
continue
}
var col, row int
if i < 30 {
col = i / 10
row = i % 10
} else {
col = 3
row = i - 30
}
startX := columnStartX[col]
startY := yOffset + float64(row)*(songEntryHeight+padding)
dc.SetRGBA(1, 1, 1, 0.1)
dc.DrawRoundedRectangle(startX+songEntryPadding/2, startY+songEntryPadding/2,
songEntryWidth-songEntryPadding, songEntryHeight-songEntryPadding, 8)
dc.Fill()
jacketX := startX + songEntryPadding*2
jacketY := startY + songEntryPadding*2
if song.JacketPath != "" {
jacketImg, err := loadImageFromCache(song.JacketPath)
if err == nil {
jacketImg = resizeImage(jacketImg, int(jacketSize), int(jacketSize))
dc.DrawImageAnchored(jacketImg, int(jacketX+jacketSize/2), int(jacketY+jacketSize/2), 0.5, 0.5)
} else {
log.Printf("警告: 加载封面 %s 失败: %v. 绘制占位符.", song.JacketPath, err)
dc.SetHexColor("#505050")
dc.DrawRoundedRectangle(jacketX, jacketY, jacketSize, jacketSize, 5)
dc.Fill()
dc.SetHexColor("#FFFFFF")
dc.SetFontFace(faceSongDetail)
dc.DrawStringAnchored("No Art", jacketX+jacketSize/2, jacketY+jacketSize/2, 0.5, 0.5)
}
} else {
dc.SetHexColor("#505050")
dc.DrawRoundedRectangle(jacketX, jacketY, jacketSize, jacketSize, 5)
dc.Fill()
dc.SetHexColor("#FFFFFF")
dc.SetFontFace(faceSongDetail)
dc.DrawStringAnchored("No Art", jacketX+jacketSize/2, jacketY+jacketSize/2, 0.5, 0.5)
}
textStartX := jacketX + jacketSize + songEntryPadding
textWidth := songEntryWidth - jacketSize - 4*songEntryPadding
dc.SetFontFace(faceSongDetail)
dc.SetHexColor("#AAAAAA")
indexText := fmt.Sprintf("#%d", i+1)
dc.DrawStringAnchored(indexText, startX+songEntryPadding, startY+songEntryPadding, 0, 0)
dc.SetFontFace(faceSongTitle)
dc.SetHexColor("#FFFFFF")
titleToShow := song.Title
if w, _ := dc.MeasureString(titleToShow); w > textWidth*0.85 {
ellipsisWidth, _ := dc.MeasureString("...")
for len(titleToShow) > 0 && w > textWidth*0.85-ellipsisWidth {
titleToShow = titleToShow[:len(titleToShow)-1]
w, _ = dc.MeasureString(titleToShow)
}
titleToShow += "..."
}
dc.DrawString(titleToShow, textStartX, startY+songEntryPadding*2+20)
dc.SetFontFace(faceSongDetail)
dc.SetHexColor("#BBBBBB")
BeatmapidToShow := song.Beatmapid
if w, _ := dc.MeasureString(BeatmapidToShow); w > textWidth*0.85 {
ellipsisWidth, _ := dc.MeasureString("...")
for len(BeatmapidToShow) > 0 && w > textWidth*0.85-ellipsisWidth {
BeatmapidToShow = BeatmapidToShow[:len(BeatmapidToShow)-1]
w, _ = dc.MeasureString(BeatmapidToShow)
}
BeatmapidToShow += "..."
}
dc.DrawString(BeatmapidToShow, textStartX, startY+songEntryPadding*2+50)
dc.SetFontFace(faceSongDetail)
if song.DifficultyColor != nil {
dc.SetColor(song.DifficultyColor)
} else {
dc.SetHexColor("#FFFFFF")
}
diffText := fmt.Sprintf("[%s]", song.Difficulty)
dc.DrawString(diffText, textStartX, startY+songEntryPadding*2+80)
rightSideX := startX + songEntryWidth - songEntryPadding*2
dc.SetFontFace(faceSongDetail)
dc.SetHexColor("#FFCC22")
starsText := fmt.Sprintf("%.2f★", song.Stars)
dc.DrawStringAnchored(starsText, rightSideX, startY+songEntryHeight*0.25, 1.0, 0.5)
dc.SetFontFace(faceSongUserMap_pp)
if i < 30 {
dc.SetHexColor("#FFEE77")
} else {
dc.SetHexColor("#ADD8E6")
}
dc.DrawStringAnchored(fmt.Sprintf("%.2fpp", song.UserMap_pp),
rightSideX, startY+songEntryHeight*0.45, 1.0, 0.5)
dc.SetFontFace(faceSongDetail)
dc.SetHexColor("#AAAAAA")
accText := fmt.Sprintf("Acc: %.2f%%", song.Accuracy)
dc.DrawStringAnchored(accText, rightSideX, startY+songEntryHeight*0.75, 1.0, 0.5)
daysText := song.Days_ago
dc.DrawStringAnchored(daysText, rightSideX, startY+songEntryHeight*0.95, 1.0, 0.5)
}
dc.SetFontFace(faceSongDetail)
dc.SetHexColor("#999999")
generatedTime := time.Now().Format("2006-01-02 15:04:05")
dc.DrawStringAnchored(fmt.Sprintf("Generated: %s", generatedTime), padding, float64(imgHeight)-padding, 0, 1.0)
return dc.SavePNG(outputPath)
}
// 获取ScoreSaber用户信息
func fetchScoreSaberUser(userID string) (*ScoreSaberUser, error) {
url := "https://scoresaber.com/api/player/" + userID + "/full"
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP状态码错误: %d", resp.StatusCode)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var user ScoreSaberUser
err = json.Unmarshal(body, &user)
if err != nil {
return nil, err
}
return &user, nil
}
// 下载图片到本地临时文件,返回本地路径
func downloadImageToLocal(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
// 生成临时文件
// tmpFile, err := os.CreateTemp("", "avatar_*.jpg")
uuid := uuid.New().String()
tmpFile, err := os.Create(util.GenTempFilePath(uuid + ".jpg"))
if err != nil {
return "", err
}
defer tmpFile.Close()
_, err = io.Copy(tmpFile, resp.Body)
if err != nil {
return "", err
}
return tmpFile.Name(), nil
}
// 缩放图片到指定宽高
func resizeImage(img image.Image, width, height int) image.Image {
return resize.Resize(uint(width), uint(height), img, resize.Lanczos3)
}
// getScoreSaberScores 获取玩家的 ScoreSaber 分数
func getScoreSaberScores(playerID string, sort string, page int) (*ScoreSaberResponse, error) {
url := fmt.Sprintf("https://scoresaber.com/api/player/%s/scores?sort=%s&page=%d", playerID, sort, page)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("请求 ScoreSaber API 失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ScoreSaber API 返回错误状态码: %d", resp.StatusCode)
}
var result ScoreSaberResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析 ScoreSaber API 响应失败: %v", err)
}
return &result, nil
}
// 难度颜色映射
var difficultyColors = map[string]color.Color{
"Easy": color.RGBA{R: 0x00, G: 0xFF, B: 0x00, A: 0xFF}, // 绿色
"Normal": color.RGBA{R: 0x00, G: 0x7F, B: 0xFF, A: 0xFF}, // 蓝色
"Hard": color.RGBA{R: 0xFF, G: 0x7F, B: 0x00, A: 0xFF}, // 橙色
"Expert": color.RGBA{R: 0xFF, G: 0x00, B: 0x00, A: 0xFF}, // 红色
"ExpertPlus": color.RGBA{R: 0xB4, G: 0x00, B: 0xB4, A: 0xFF}, // 紫色
}
// 将 ScoreSaber 分数转换为 SongData
func convertScoreSaberToSongData(score ScoreSaberPlayerScore, cwd string) (SongData, error) {
// 下载封面图片
jacketPath := util.GenTempFilePath(score.Leaderboard.SongHash + ".png")
if _, err := os.Stat(jacketPath); os.IsNotExist(err) {
resp, err := http.Get(score.Leaderboard.CoverImage)
if err != nil {
return SongData{}, fmt.Errorf("下载封面图片失败: %v", err)
}
defer resp.Body.Close()
file, err := os.Create(jacketPath)
if err != nil {
return SongData{}, fmt.Errorf("创建封面文件失败: %v", err)
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return SongData{}, fmt.Errorf("保存封面图片失败: %v", err)
}
}
// 获取难度名称
difficultyName := "Unknown"
switch score.Leaderboard.Difficulty.Difficulty {
case 1:
difficultyName = "Easy"
case 3:
difficultyName = "Normal"
case 5:
difficultyName = "Hard"
case 7:
difficultyName = "Expert"
case 9:
difficultyName = "ExpertPlus"
}
// 计算准确率
accuracy := float64(score.Score.ModifiedScore) / float64(score.Leaderboard.MaxScore) * 100
// 计算游玩时间
timeSet, err := time.Parse(time.RFC3339, score.Score.TimeSet)
if err != nil {
return SongData{}, fmt.Errorf("解析时间失败: %v", err)
}
daysAgo := time.Since(timeSet)
daysAgoStr := formatTimeAgo(daysAgo)
return SongData{
JacketPath: jacketPath,
Title: score.Leaderboard.SongName,
Beatmapid: score.Leaderboard.SongAuthorName,
Difficulty: difficultyName,
DifficultyColor: difficultyColors[difficultyName],
Stars: score.Leaderboard.Stars,
Accuracy: accuracy,
Days_ago: daysAgoStr,
UserMap_pp: score.Score.PP,
IsBest30: true, // 这个值需要根据排序来确定
}, nil
}
// 格式化时间差
func formatTimeAgo(d time.Duration) string {
days := int(d.Hours() / 24)
if days > 0 {
return fmt.Sprintf("%dd", days)
}
hours := int(d.Hours())
if hours > 0 {
return fmt.Sprintf("%dh", hours)
}
minutes := int(d.Minutes())
if minutes > 0 {
return fmt.Sprintf("%dm", minutes)
}
return "now"
}
// 获取Best30分数最多30个
func getBest30Scores(playerID string) ([]ScoreSaberPlayerScore, error) {
var result []ScoreSaberPlayerScore
page := 1
for len(result) < 30 {
resp, err := getScoreSaberScores(playerID, "top", page)
if err != nil {
return nil, err
}
if len(resp.PlayerScores) == 0 {
break
}
result = append(result, resp.PlayerScores...)
page++
}
if len(result) > 30 {
result = result[:30]
}
return result, nil
}
// 获取Recent10分数最多10个
func getRecent10Scores(playerID string) ([]ScoreSaberPlayerScore, error) {
var result []ScoreSaberPlayerScore
page := 1
for len(result) < 10 {
resp, err := getScoreSaberScores(playerID, "recent", page)
if err != nil {
return nil, err
}
if len(resp.PlayerScores) == 0 {
break
}
result = append(result, resp.PlayerScores...)
page++
}
if len(result) > 10 {
result = result[:10]
}
return result, nil
}
// 获取玩家的所有分数并转换为 SongData
func getAllScoreSaberScores(playerID string) ([]SongData, error) {
var allSongs []SongData
cwd, _ := os.Getwd()
// 获取Best30
topScores, err := getBest30Scores(playerID)
if err != nil {
return nil, fmt.Errorf("获取Best30失败: %v", err)
}
for i, score := range topScores {
songData, err := convertScoreSaberToSongData(score, cwd)
if err != nil {
log.Printf("转换分数失败: %v", err)
continue
}
songData.IsBest30 = i < 30
allSongs = append(allSongs, songData)
}
// 获取Recent10
recentScores, err := getRecent10Scores(playerID)
if err != nil {
return nil, fmt.Errorf("获取Recent10失败: %v", err)
}
for _, score := range recentScores {
songData, err := convertScoreSaberToSongData(score, cwd)
if err != nil {
log.Printf("转换分数失败: %v", err)
continue
}
songData.IsBest30 = false
allSongs = append(allSongs, songData)
}
return allSongs, nil
}
func GenBs50(ssID string, outputPath string) error {
userID := ssID
ssUser, err := fetchScoreSaberUser(userID)
if err != nil {
log.Fatalf("获取ScoreSaber用户信息失败: %v", err)
}
songs, err := getAllScoreSaberScores(userID)
if err != nil {
log.Fatalf("获取分数失败: %v", err)
}
avatarPath := ssUser.ProfilePicture
if strings.HasPrefix(avatarPath, "http") {
localAvatar, err := downloadImageToLocal(avatarPath)
if err == nil {
avatarPath = localAvatar
} else {
log.Printf("下载头像失败: %v", err)
avatarPath = ""
}
}
player := PlayerData{
PlayerName: ssUser.Name,
PlayerID: ssUser.ID,
AvatarPath: avatarPath,
PlayerCurrentPP: ssUser.PP,
PlayerRank: ssUser.Rank,
PlayerCountryRank: ssUser.CountryRank,
B50Songs: songs,
Country: ssUser.Country,
}
if err = generateB50Image(player, outputPath, "resource/font.ttf"); err != nil {
return fmt.Errorf("生成 B50 图片失败: %v", err)
}
return nil
}