From 4407cd0c674f3f361601a7712c17425dc1a205e8 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 7 May 2026 19:49:53 +0800 Subject: [PATCH] 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) --- ...7-custom-avatar-and-album-search-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md diff --git a/docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md b/docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md new file mode 100644 index 0000000..b2c3d83 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md @@ -0,0 +1,149 @@ +# 自定义机器人头像 + 专辑搜索/播放 + +**Date:** 2026-05-07 +**Status:** Spec — pending implementation +**Issue:** [#51](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/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/.`(仿 `data/cookies/.json`,Docker volume 友好) +- DB schema:`bot_instances` 表新增 `custom_avatar_path` TEXT NULL(存相对路径,如 `avatars/.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` 改动 + +新增字段 + 方法: + +```ts +private customAvatar: Buffer | null = null; + +setCustomAvatar(buf: Buffer | null): void; +private async applyIdleAvatar(gen: number): Promise; // 上传 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//avatar', formData)` 或在创建表单里把 base64 与表单一同提交 + +接入点: + +- 创建实例弹窗(搜索 `BotEditor.vue` 或类似)—— 表单提交后用返回的 botId 再 POST 头像;或者表单本身保存 base64 等创建完成后由后端解码落盘 +- Settings.vue:在 features 列表中插入一行"自定义头像",右侧渲染 `` + +### 专辑搜索 / 详情 + +#### 后端 + +`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 同一个 `` 子组件 + - **方案选择**:拆出 `` 组件 + `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 以内"**,简单可控