Files
teamspeak-music-bot/docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md
saopig1andClaude Opus 4.7 4407cd0c67 docs(spec): custom bot avatar + album search design
Spec for issue #51: custom avatar with idle/playback precedence rules,
and surfacing albums in search results + album detail playback. Two
features grouped in one spec; will land as two separate PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:49:53 +08:00

7.5 KiB
Raw Permalink Blame History

自定义机器人头像 + 专辑搜索/播放

Date: 2026-05-07 Status: Spec — pending implementation Issue: #51

Problem

Issue #51 的两个独立但同源的反馈:

  1. 机器人头像无法固定 — 当前 ProfileConfig.avatarEnabled 控制是否同步专辑封面,但没有任何"上传一张固定头像"的入口。多 bot 房间里用户依赖头像辨识具体 bot,封面跟着歌变会让识别成本变高。
  2. 网页端搜索不能播放整张专辑 — SearchResult.albums 类型字段存在但所有 provider 都返回 [],搜索 API /search/all 只聚合 songs;Search.vue 里也只渲染 SongCard。Netease 后端 getAlbumSongs 已实现,唯独缺把搜索/UI 连起来。

两件事独立,分两个 PR;本 spec 同时覆盖两块以保持 #51 的单一 issue 关系。

Goal

自定义头像

  • "创建新实例"弹窗里有一个"自定义头像"上传/预览控件(PNG/JPG/WebP,≤200 KB,与 TS3 头像上限一致)
  • Settings 已有的"同步头像"那行下面增加同等的"自定义头像"卡片,可以在已存在的 bot 上随时改/删
  • 行为矩阵:
    avatarEnabled 有自定义 播放时 停播时
    true 是 跟当前歌曲封面 回到自定义
    true 否 跟当前歌曲封面 清空(保持现状)
    false 是 一直显示自定义 一直显示自定义
    false 否 不主动改 不主动改

专辑搜索

  • 搜索结果里能看到"专辑"分区(先支持 Netease + QQ,bilibili/youtube 仍返回 [])
  • 点专辑卡片进入详情页 → 看到曲目列表 + 顶部"播放全部 / 加入队列"

Out of Scope

  • 头像格式自动转换(用户传 GIF/BMP 不接受,前端校验拒掉)
  • 头像服务端自动 resize(本期保持"上传时校验大小",后期可加 sharp/jimp 但不在本期)
  • 专辑搜索的多平台聚合排序(按 netease → qq 简单拼接,与现有 songs 聚合一致)
  • 专辑详情页的"喜欢/收藏"按钮(playlist 详情页本身也没有)
  • 专辑作为推荐位(Home 不出现"推荐专辑"这一栏)
  • bilibili / youtube 的专辑概念(这两个平台无对应 API)

Architecture

自定义头像

存储

  • 文件落地 data/avatars/<botId>.<ext>(仿 data/cookies/<platform>.json,Docker volume 友好)
  • DB schema:bot_instances 表新增 custom_avatar_path TEXT NULL(存相对路径,如 avatars/<botId>.png),通过 migrateSchema() 迁移
  • 加载时机:BotProfileManager 构造时把文件读到内存 Buffer,避免每次 stop 都读盘

后端 API

新增 src/web/api/bot.ts 里(如不存在则在 instance.ts 同源处):

  • POST /api/bot/:id/avatar (multipart) — 校验大小 ≤200 KB、MIME ∈ {png,jpeg,webp};写盘 + 更新 DB;广播给运行中实例(重新加载 buffer + 立即 applyIdleAvatar())
  • DELETE /api/bot/:id/avatar — 删盘 + 清 DB;运行中实例切回原 clear 语义
  • GET /api/bot/:id/avatar — 直接 res.sendFile(带强 ETag)供前端预览

BotProfileManager 改动

新增字段 + 方法:

private customAvatar: Buffer | null = null;

setCustomAvatar(buf: Buffer | null): void;
private async applyIdleAvatar(gen: number): Promise<void>;  // 上传 customAvatar

修改:

  • clearAvatar(gen) → if (this.customAvatar) { applyIdleAvatar(gen) } else { 当前逻辑 }
  • onConnect() 新增:if (!avatarEnabled && customAvatar) applyIdleAvatar(gen)
  • setCustomAvatar(buf):更新内存 buffer,并触发 applyIdleAvatar 一次(仅当当前应该显示 idle avatar 时,即没在播放或 avatarEnabled=false)

前端

新组件 web/src/components/AvatarUpload.vue:

  • props: botId? (上传时空表示走临时 base64 缓存)、v-model:value
  • 拖拽 / 文件选择 / 预览圆框 / 删除按钮
  • 内部 axios.post('/api/bot/<id>/avatar', formData) 或在创建表单里把 base64 与表单一同提交

接入点:

  • 创建实例弹窗(搜索 BotEditor.vue 或类似)—— 表单提交后用返回的 botId 再 POST 头像;或者表单本身保存 base64 等创建完成后由后端解码落盘
  • Settings.vue:在 features 列表中插入一行"自定义头像",右侧渲染 <AvatarUpload :bot-id="botId" />

专辑搜索 / 详情

后端

src/music/netease.ts search():

  • 多发一个 cloudsearch?type=10 请求,把返回的 result.albums[] 映射成 Album[] 填进 SearchResult.albums
  • 字段 id / name / coverUrl (picUrl) / artist (artists[].name.join(' / '))

src/music/qq.ts search():

  • 在现有 req_0 旁增加 req_album: { module: "music.search.SearchCgiService", method: "DoSearchForQQMusicDesktop", param: { searchid, query, search_type: 8 } },映射 body.album.list[]

src/web/api/music.ts /search/all:

  • 在响应里增加 albums 和 playlists,与 songs 一同合并

/album/:id 已存在,无需改动。

前端

  • web/src/views/Search.vue:响应 schema 升级为 {songs, albums, playlists};模板加入两个新分区("专辑"、"歌单"),各自一个简单的卡片网格(参考 Home.vue 的 playlist-grid)
  • 新路由 /album/:id → 复用 Playlist.vue,把它的 loadPlaylist() 重构为根据 route.path 决定调 /playlist/:id 还是 /album/:id,或者新建 Album.vue 内部 import 同一个 <PlaylistDetail /> 子组件
    • 方案选择:拆出 <PlaylistDetail :endpoint="..."> 组件 + Album.vue / Playlist.vue 两个薄壳。当前 Playlist.vue 内部仅 ~60 行模板,单文件改造比新建 PlaylistDetail 子组件更小,先用最小改动:在 Playlist.vue 内根据 route.meta.kind === 'album' 切换 endpoint
  • 路由:router/index.ts 加 { path: '/album/:id', component: Playlist, meta: { kind: 'album' } }

拆分

两个 PR:

  1. feat(profile): custom bot avatar with idle/playback precedence
    • DB migration + ProfileManager 改动 + 上传 API + AvatarUpload.vue + 接入两个表单
  2. feat(search): album section in search results + album detail playback
    • netease/qq search 扩展 + /search/all + Search.vue 分区 + Playlist.vue 复用为 album

Testing

自定义头像

  • 单元:DB 迁移加 custom_avatar_path 列幂等;上传 API 校验大小/MIME;ProfileManager.applyIdleAvatar 在 onSongChange(null) 后被调用
  • 集成:mock TS3Client 验证 fileTransferInitUpload 收到的 buffer 是 customAvatar
  • 手动:本地起 bot 上传一张 png → 检查头像;播一首歌 → 头像切封面;停止 → 头像回到 png;关掉 avatarEnabled 重启 → 头像直接是 png

专辑搜索

  • 单元:netease/qq search() 测试:响应包含 albums 字段,长度 > 0 (mock fixture 必须含 album 段)
  • 集成:/search/all 响应 schema 包含 albums/playlists
  • 手动:搜"周杰伦" → 看到歌曲 + 专辑 + 歌单三个分区;点专辑 → 详情页 → 播放全部 → 队列加上整张专辑

Migration

DB 迁移:bot_instances.custom_avatar_path TEXT NULL,默认 NULL。已存在 bot 不受影响。

Open Questions

  • TS6 协议路径下 fileTransferInitUpload 是否一致?(既有 avatar 流程已经覆盖 TS3 + TS6,本期沿用同一路径,不单独验证)
  • 头像超过 200 KB 时前端用 Canvas 自动 resize 还是直接拒?— 决定:拒,错误提示"请压缩到 200KB 以内",简单可控