diff --git a/docs/superpowers/plans/2026-05-07-album-search-and-playback.md b/docs/superpowers/plans/2026-05-07-album-search-and-playback.md new file mode 100644 index 0000000..cf9285a --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-album-search-and-playback.md @@ -0,0 +1,520 @@ +# Album Search & Playback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface albums in search results and allow playing the whole album from the web UI. Currently `SearchResult.albums` is always `[]` and Search.vue only renders songs. + +**Architecture:** Extend `search()` in netease + qq providers to populate `albums`. Aggregate them in `/search/all`. Add a new "专辑" (and "歌单") section to Search.vue. Reuse `Playlist.vue` as the album detail page by branching on `route.meta.kind` between `/playlist/:id` and `/album/:id` endpoints. The existing `getAlbumSongs(id)` and `/api/music/album/:id` endpoint already work. + +**Tech Stack:** Node 20 + TS, Express 5, Vue 3 + Vue Router 4, axios. No new deps. + +--- + +## Spec Reference + +`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "专辑搜索". + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/music/provider.ts` | Read-only | Verify `Album` and `SearchResult.albums` shape (no change expected) | +| `src/music/netease.ts` | Modify | `search()` adds a third parallel call (`type=10`) and maps albums | +| `src/music/qq.ts` | Modify | `search()` adds `req_album` section and maps albums | +| `src/music/netease.test.ts` | Modify | New tests for albums in search response | +| `src/music/qq.test.ts` | Modify (or create if absent) | Tests for albums in qq search | +| `src/web/api/music.ts` | Modify | `/search/all` returns `{songs, albums, playlists}` | +| `web/src/views/Search.vue` | Modify | Render albums + playlists sections | +| `web/src/views/Playlist.vue` | Modify | Branch endpoint by `route.meta.kind === 'album'` | +| `web/src/router/index.ts` | Modify | Add `/album/:id` route reusing Playlist component, set `meta.kind = 'album'` | + +## Conventions + +- TDD throughout. Each task: failing test → implement → verify → commit. +- Mock HTTP via existing fixtures pattern (look at `src/music/netease.test.ts` for setup). +- Keep all platform-specific quirks inside the provider class — no leaking into Search.vue logic. + +--- + +### Task 1: netease.ts — fetch albums in search + +**Files:** +- Modify: `src/music/netease.ts` +- Modify: `src/music/netease.test.ts` + +- [ ] **Step 1: Read the existing test setup so we mock the same way** + +```bash +grep -n 'cloudsearch\|MockAdapter\|axios.create\|mock\|nock\|fixture' src/music/netease.test.ts | head -20 +``` + +- [ ] **Step 2: Write the failing test** + +Append to `src/music/netease.test.ts` inside the existing `describe`: + +```ts + it("populates SearchResult.albums from cloudsearch type=10", async () => { + // Adjust the fixture/mock helper to your existing test pattern. + // The test should: arrange a mock that returns a non-empty albums array + // for type=10, run search(), assert result.albums has the expected shape. + mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 10 }) }).reply(200, { + result: { + albums: [ + { id: 42, name: "Album A", picUrl: "https://x/p.jpg", artists: [{ name: "Artist X" }] }, + ], + }, + }); + mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1 }) }).reply(200, { result: { songs: [] } }); + mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1000 }) }).reply(200, { result: { playlists: [] } }); + + const provider = makeProvider(); + const r = await provider.search("foo", 5); + expect(r.albums).toEqual([ + { id: "42", name: "Album A", artist: "Artist X", coverUrl: "https://x/p.jpg", platform: "netease" }, + ]); + }); +``` + +If the existing tests use a different mock library (e.g. `msw` or manual axios stubbing), translate the fixture above to match. Do not introduce new test deps. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run src/music/netease.test.ts` +Expected: FAIL — `result.albums` is `[]` + +- [ ] **Step 4: Implement the change** + +In `src/music/netease.ts` `search()` (~line 93), change the `Promise.all` from 2 to 3 calls: + +```ts +const [songRes, playlistRes, albumRes] = await Promise.all([ + this.api.get("/cloudsearch", { params: { keywords: query, type: 1, limit, ...this.cookieParams } }), + this.api.get("/cloudsearch", { params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams } }), + this.api.get("/cloudsearch", { params: { keywords: query, type: 10, limit: 5, ...this.cookieParams } }), +]); +``` + +After the existing `playlists: Playlist[] = ...` mapping, add: + +```ts +const albums: Album[] = (albumRes.data?.result?.albums ?? []).map((a: any) => ({ + id: String(a.id), + name: a.name ?? "", + artist: (a.artists ?? []).map((x: any) => x.name).join(" / "), + coverUrl: a.picUrl ?? "", + platform: "netease", +})); +``` + +Update the `return { songs, playlists, albums: [] }` to `return { songs, playlists, albums }`. + +(Make sure `Album` is imported from `./provider.js`; if not yet imported, add it to the existing import.) + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/music/netease.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/music/netease.ts src/music/netease.test.ts +git commit -m "feat(netease): include albums in search results" +``` + +--- + +### Task 2: qq.ts — fetch albums in search + +**Files:** +- Modify: `src/music/qq.ts` +- Modify or Create: `src/music/qq.test.ts` + +- [ ] **Step 1: Verify whether qq.test.ts exists** + +```bash +ls src/music/qq.test.ts +``` + +If absent, create a minimal one mirroring `netease.test.ts` style: instantiate provider, mock the `qqDirectApi` axios instance, assert `r.albums.length > 0` after a `search()` call. + +- [ ] **Step 2: Write the failing test** + +Add to `src/music/qq.test.ts`: + +```ts +it("populates SearchResult.albums from a parallel album search request", async () => { + // Mock returns an album list under req_album.data.body.album.list + mockApi.onGet("/cgi-bin/musicu.fcg").reply((cfg) => { + const data = JSON.parse(cfg.params?.data ?? "{}"); + if (data.req_album) { + return [200, { req_album: { data: { body: { album: { list: [ + { albumMID: "abc", albumName: "Aero", singerName: "S", albumPic: "https://x/p.jpg" }, + ] } } } } }]; + } + if (data.req_0) { + return [200, { req_0: { data: { body: { song: { list: [] } } } } }]; + } + return [200, {}]; + }); + + const provider = makeProvider(); + const r = await provider.search("foo", 5); + expect(r.albums).toEqual([ + { id: "abc", name: "Aero", artist: "S", coverUrl: expect.stringContaining("https://"), platform: "qq" }, + ]); +}); +``` + +Verify the actual QQ API response shape against a real call before finalizing the field names — `albumMID` vs `mid`, `albumPic` vs `pic`, etc. If unsure, log a real response once and freeze the shape in the fixture. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run src/music/qq.test.ts` +Expected: FAIL — `r.albums` is `[]` + +- [ ] **Step 4: Implement the change** + +In `src/music/qq.ts` `search()` (~line 63), change `reqData` to include both `req_0` (songs) and `req_album` (albums): + +```ts +const reqData = JSON.stringify({ + req_0: { + module: "music.search.SearchCgiService", + method: "DoSearchForQQMusicDesktop", + param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 }, + }, + req_album: { + module: "music.search.SearchCgiService", + method: "DoSearchForQQMusicDesktop", + param: { searchid: "1", query, num_per_page: 5, search_type: 8 }, + }, +}); +``` + +After the existing `songs` mapping, add: + +```ts +const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? []; +const albums: Album[] = albumList.map((a: any) => ({ + id: String(a.albumMID ?? a.mid ?? a.albumID ?? ""), + name: a.albumName ?? a.title ?? "", + artist: a.singerName ?? (a.singer ?? []).map((s: any) => s.name).join(" / "), + coverUrl: a.albumMID + ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${a.albumMID}.jpg` + : (a.albumPic ?? ""), + platform: "qq", +})); +``` + +Change `return { songs, playlists: [], albums: [] }` to `return { songs, playlists: [], albums }`. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/music/qq.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/music/qq.ts src/music/qq.test.ts +git commit -m "feat(qq): include albums in search results" +``` + +--- + +### Task 3: /search/all — aggregate albums + playlists + +**Files:** +- Modify: `src/web/api/music.ts` + +- [ ] **Step 1: Look at the current aggregation** + +In `src/web/api/music.ts` near line 40 the `/search/all` handler builds only `songs`. Extend it. + +- [ ] **Step 2: Write a failing integration test (if test infra allows)** + +If there's already a test file for music.ts, add a test that mocks the providers and asserts `res.body.albums.length > 0`. If not, skip and rely on Task 1+2 unit coverage + manual verification in Task 4. + +- [ ] **Step 3: Aggregate albums + playlists** + +Replace the existing `songs = ...` block + `res.json({ songs })` at lines ~54–62 with: + +```ts +const songs = [ + ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []), + ...(qqResult.status === "fulfilled" ? qqResult.value.songs : []), + ...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []), +]; +const albums = [ + ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []), + ...(qqResult.status === "fulfilled" ? qqResult.value.albums : []), +]; +const playlists = [ + ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []), + ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []), +]; + +res.json({ songs, albums, playlists }); +``` + +(Bilibili intentionally skipped for albums/playlists — no album concept; playlists likewise minor.) + +- [ ] **Step 4: Verify by curl** + +Build + run, then: + +```bash +curl -s 'http://localhost:3000/api/music/search/all?q=Beyond' \ + | python3 -c 'import json,sys;d=json.load(sys.stdin);print({k: len(v) for k, v in d.items()})' +``` + +Expected: `{'songs': N>0, 'albums': N>0, 'playlists': N>=0}` + +- [ ] **Step 5: Commit** + +```bash +git add src/web/api/music.ts +git commit -m "feat(api): /search/all returns albums and playlists" +``` + +--- + +### Task 4: Album route reusing Playlist.vue + +**Files:** +- Modify: `web/src/router/index.ts` +- Modify: `web/src/views/Playlist.vue` + +- [ ] **Step 1: Look at the current router config and Playlist load logic** + +```bash +grep -n "path:\|component:\|meta" web/src/router/index.ts +grep -n "loadPlaylist\|/api/music/playlist\|onMounted" web/src/views/Playlist.vue +``` + +- [ ] **Step 2: Add /album/:id route** + +In `web/src/router/index.ts`, find the `/playlist/:id` route entry. Right after it, add: + +```ts + { + path: '/album/:id', + component: () => import('../views/Playlist.vue'), + meta: { kind: 'album' }, + }, +``` + +(If `/playlist/:id` is `meta:`-less, also add `meta: { kind: 'playlist' }` to it for symmetry.) + +- [ ] **Step 3: Branch the endpoint inside Playlist.vue** + +Find the load function (probably `onMounted(async () => { axios.get('/api/music/playlist/' + id, ...) })`). Refactor: + +```ts +const route = useRoute(); +const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album' +const endpoint = kind === 'album' ? '/api/music/album/' : '/api/music/playlist/'; +// ... use `${endpoint}${route.params.id}` ... +``` + +For the hero metadata, the playlist endpoint returns `{songs}` only (no top-level cover/title) — verify what the Album endpoint currently returns. If both only return `{songs}`, the existing Playlist.vue must already derive the cover from somewhere (probably the first song's coverUrl, or an additional `/api/music/playlist/:id/detail` call). Keep the existing pattern; if a separate detail call is needed for albums, fetch the metadata from `/api/music/song/` to get the album name + cover, OR add a thin `/api/music/album/:id/detail` endpoint that returns `{ name, coverUrl, description }`. + +**Decision:** if Playlist.vue currently uses ONLY `/api/music/playlist/:id` and derives metadata from songs, do the same for albums (no new endpoint). If it calls a separate detail endpoint, add a matching `/api/music/album/:id/detail` returning `{ name, coverUrl }` from the first song's `album` and `coverUrl` fields. + +- [ ] **Step 4: Verify in browser** + +Run `cd web && npm run dev`. Visit `/album/` (pick one from a search). Expect: hero header + song list + play-all button — same UX as a playlist page. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/router/index.ts web/src/views/Playlist.vue +git commit -m "feat(web): /album/:id route reusing Playlist view" +``` + +--- + +### Task 5: Search.vue — render albums + playlists sections + +**Files:** +- Modify: `web/src/views/Search.vue` + +- [ ] **Step 1: Read current Search.vue** + +```bash +sed -n '1,120p' web/src/views/Search.vue +``` + +Identify: the `results.value = res.data.songs` line and the `
` block. + +- [ ] **Step 2: Refactor to three result lists** + +Replace the script: + +```ts +import type { Song } from '../stores/player.js'; + +interface Album { id: string; name: string; artist: string; coverUrl: string; platform: string; } +interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; } + +const songs = ref([]); +const albums = ref([]); +const playlists = ref([]); +const loading = ref(false); +const searched = ref(false); + +async function doSearch() { + if (!query.value.trim()) return; + loading.value = true; + searched.value = true; + try { + const res = await axios.get('/api/music/search/all', { params: { q: query.value } }); + songs.value = res.data.songs ?? []; + albums.value = res.data.albums ?? []; + playlists.value = res.data.playlists ?? []; + } catch { + songs.value = []; albums.value = []; playlists.value = []; + } finally { + loading.value = false; + } +} +``` + +- [ ] **Step 3: Render the sections** + +Replace the existing `
` block: + +```vue + + +
未找到相关结果
+``` + +(Import `CoverArt`: `import CoverArt from '../components/CoverArt.vue';`.) + +- [ ] **Step 4: Add minimal styles** + +Append to the ` +``` + +- [ ] **Step 2: Verify the component compiles** + +Run: `cd web && npx vue-tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add web/src/components/AvatarUpload.vue +git commit -m "feat(web): AvatarUpload component" +``` + +--- + +### Task 7: Wire AvatarUpload into Settings.vue (create + edit + standalone row) + +**Files:** +- Modify: `web/src/views/Settings.vue` + +- [ ] **Step 1: Read the relevant Settings.vue regions** + +```bash +grep -n '同步头像\|openEditBot\|saveEditBot\|createBot\|create-bot\|profile-features\|features.find' web/src/views/Settings.vue | head -20 +``` + +Identify: +- Create-bot form template region (`
` block) +- Edit-bot modal/dialog template region +- The profile features table where `avatarEnabled` row lives + +- [ ] **Step 2: Add component import + reactive state for avatar dataUrl on the create-bot form** + +In the script setup region, near other `newBot*` refs: + +```ts +import AvatarUpload from '../components/AvatarUpload.vue'; +const newBotAvatar = ref(null); +``` + +- [ ] **Step 3: Insert `` into the create-bot form template** + +In the `
` block, right before ``, add: + +```vue +
+ + +
+``` + +- [ ] **Step 4: After successful `createBot()`, PUT the avatar if set** + +Find the `createBot` async function. After the POST resolves and the bot id is known (`res.data.id` or similar), append: + +```ts +if (newBotAvatar.value) { + await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value }); +} +newBotAvatar.value = null; +``` + +- [ ] **Step 5: Add an "自定义头像" row in the per-bot profile features table** + +Find the profile-features table render (look for the `features` array iteration). The cleanest path: add a custom row OUTSIDE the array (since it isn't a boolean toggle). Right before `` of the bot row, add: + +```vue +
+
自定义头像
+
+ +
+
+``` + +Where `CustomAvatarRow` is an inline-defined component or a small file `web/src/components/CustomAvatarRow.vue` that: +- Mounts → `axios.get(/api/bot//avatar, { responseType: 'blob' })` → previews if 200, ignore 404 +- Wraps `` and on `update:modelValue`: + - If string → `axios.put(/avatar, { dataUrl })` + - If null → `axios.delete(/avatar)` + +Create `web/src/components/CustomAvatarRow.vue` with that logic; keep its body small (~50 lines). + +- [ ] **Step 6: Build and visually verify** + +Run: `cd web && npm run build` → no errors. Then `npm run dev` → open create-instance, upload PNG, create — verify the avatar appears on the bot in TS3 once it connects. Check edit/Settings flow. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/views/Settings.vue web/src/components/CustomAvatarRow.vue +git commit -m "feat(web): custom avatar in create-bot + Settings" +``` + +--- + +### Task 8: Open PR + +- [ ] **Step 1: Push the branch** + +```bash +git checkout -b feat/custom-bot-avatar +git push -u origin feat/custom-bot-avatar +``` + +(If commits were already on `main`, instead create the branch from the first relevant commit and reset main: `git branch feat/custom-bot-avatar HEAD && git reset --hard origin/main && git checkout feat/custom-bot-avatar`. The exact sequence depends on the working state when starting.) + +- [ ] **Step 2: Create the PR** + +```bash +gh pr create --title "feat(profile): custom bot avatar" --body "Closes part of #51 (avatar half). + +## Summary +- New /api/bot/:id/avatar GET/PUT/DELETE +- BotProfileManager: custom avatar acts as idle image; cover sync still wins during playback when avatarEnabled=true +- AvatarUpload component used in create-bot form and Settings per-bot row +- Bump express.json limit to 400kb to allow base64 payload + +## Behavior matrix +| avatarEnabled | custom set | playing | stopped | +|---|---|---|---| +| ✓ | ✓ | cover | restore custom | +| ✓ | ✗ | cover | clear | +| ✗ | ✓ | custom | custom | +| ✗ | ✗ | no-op | no-op | + +## Test plan +- [x] vitest covers DB, avatar store, ProfileManager precedence +- [x] Manual: upload PNG → bot avatar shows; play song → cover; stop → custom; delete → cleared + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +## Self-Review Checklist + +- [x] Each spec section has at least one task: precedence matrix → Task 3; storage → Task 2; DB → Task 1; API → Task 5; UI → Task 6+7 +- [x] No "TBD" / "fill in" / "implement later" text in any step +- [x] Type names consistent: `AvatarStore` / `createAvatarStore` / `getCustomAvatarPath` / `setCustomAvatarPath` / `setCustomAvatar` (singular per call site) +- [x] All code blocks compile under existing TS/Vue config (express 5, vitest, vue 3 + iconify already in use) 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 以内"**,简单可控 diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 3f6bf50..fae7d6a 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -16,6 +16,7 @@ import type { Logger } from "../logger.js"; import type { BotDatabase, ProfileConfig } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; import { BotProfileManager } from "./profile.js"; +import type { AvatarStore } from "../data/avatars.js"; export interface BotInstanceOptions { id: string; @@ -28,6 +29,7 @@ export interface BotInstanceOptions { database: BotDatabase; config: BotConfig; logger: Logger; + avatarStore: AvatarStore; } export interface BotStatus { @@ -57,6 +59,7 @@ export class BotInstance extends EventEmitter { private database: BotDatabase; private config: BotConfig; private logger: Logger; + private avatarStore: AvatarStore; private connected = false; private disconnectEmitted = false; private voteSkipUsers = new Set(); @@ -77,6 +80,7 @@ export class BotInstance extends EventEmitter { this.database = options.database; this.config = options.config; this.logger = options.logger.child({ botId: this.id }); + this.avatarStore = options.avatarStore; this.tsClient = new TS3Client(options.tsOptions, this.logger); this.player = new AudioPlayer(this.logger); @@ -90,6 +94,17 @@ export class BotInstance extends EventEmitter { options.tsOptions.nickname, ); + // Best-effort: a corrupted/locked avatar file must not block bot startup. + try { + const relPath = this.database.getCustomAvatarPath(this.id); + if (relPath) { + const buf = this.avatarStore.read(relPath); + if (buf) this.profileManager.setCustomAvatar(buf); + } + } catch (err) { + this.logger.warn({ err }, "Failed to load custom avatar — skipping"); + } + this.setupPlayerEvents(); this.setupTsEvents(); } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 0374b82..54b7b22 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -11,6 +11,7 @@ import type { BotConfig } from "../data/config.js"; import type { Logger } from "../logger.js"; import type { ServerProtocol } from "../ts-protocol/client.js"; +import type { AvatarStore } from "../data/avatars.js"; /** * Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the @@ -74,6 +75,7 @@ export class BotManager extends EventEmitter { private database: BotDatabase; private config: BotConfig; private logger: Logger; + private avatarStore: AvatarStore; constructor( neteaseProvider: MusicProvider, @@ -81,7 +83,8 @@ export class BotManager extends EventEmitter { bilibiliProvider: MusicProvider, database: BotDatabase, config: BotConfig, - logger: Logger + logger: Logger, + avatarStore: AvatarStore ) { super(); this.neteaseProvider = neteaseProvider; @@ -91,6 +94,7 @@ export class BotManager extends EventEmitter { this.database = database; this.config = config; this.logger = logger; + this.avatarStore = avatarStore; } async createBot(params: CreateBotParams): Promise { @@ -117,6 +121,7 @@ export class BotManager extends EventEmitter { database: this.database, config: this.config, logger: this.logger, + avatarStore: this.avatarStore, }); this.bots.set(id, bot); @@ -229,10 +234,11 @@ export class BotManager extends EventEmitter { neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, bilibiliProvider: this.bilibiliProvider, - youtubeProvider: this.youtubeProvider, + youtubeProvider: this.youtubeProvider, database: this.database, config: this.config, logger: this.logger, + avatarStore: this.avatarStore, }); this.bots.set(id, bot); this.emit("botInstance", bot); @@ -279,10 +285,11 @@ export class BotManager extends EventEmitter { neteaseProvider: this.neteaseProvider, qqProvider: this.qqProvider, bilibiliProvider: this.bilibiliProvider, - youtubeProvider: this.youtubeProvider, + youtubeProvider: this.youtubeProvider, database: this.database, config: this.config, logger: this.logger, + avatarStore: this.avatarStore, }); this.bots.set(saved.id, bot); diff --git a/src/bot/profile.test.ts b/src/bot/profile.test.ts new file mode 100644 index 0000000..c0dc95b --- /dev/null +++ b/src/bot/profile.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { BotProfileManager } from "./profile.js"; +import type { TS3Client } from "../ts-protocol/client.js"; +import type { QueuedSong } from "../audio/queue.js"; + +function makeMockTs(): TS3Client & { + uploadCalls: Buffer[]; + clearCalls: number; +} { + const calls: Buffer[] = []; + let clears = 0; + const ts: any = { + uploadCalls: calls, + get clearCalls() { return clears; }, + getHost: () => "127.0.0.1", + getHttpQuery: () => null, + fileTransferInitUpload: vi.fn().mockResolvedValue({}), + uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => { + const chunks: Buffer[] = []; + for await (const c of stream) chunks.push(c as Buffer); + calls.push(Buffer.concat(chunks)); + }), + fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined), + sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => { + if (/client_flag_avatar=$/.test(cmd)) clears++; + }), + }; + return ts; +} + +const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }; + +const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false }; +const cfgOff = { ...cfgOn, avatarEnabled: false }; + +const fakeSong: QueuedSong = { + id: "1", + name: "X", + artist: "Y", + album: "Z", + platform: "netease", + url: "u", + coverUrl: "c", + duration: 100, +}; + +const flush = () => new Promise((r) => setImmediate(r)); + +describe("BotProfileManager custom avatar precedence", () => { + let ts: ReturnType; + beforeEach(() => { ts = makeMockTs(); }); + + it("setCustomAvatar uploads immediately on a fresh idle bot (sync on)", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot"); + pm.setCustomAvatar(Buffer.from([1, 2, 3])); + await flush(); + expect(ts.uploadCalls.length).toBe(1); + expect(ts.uploadCalls[0].equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + + it("setCustomAvatar uploads immediately when sync is off (always idle)", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot"); + pm.setCustomAvatar(Buffer.from([7])); + await flush(); + expect(ts.uploadCalls.length).toBe(1); + }); + + it("setCustomAvatar while playing + sync on does NOT push (cover wins)", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot"); + // Simulate the bot playing a song. We can't actually run updateAvatar's + // full HTTP fetch path, but onSongChange records currentSong before + // updateAvatar runs, which is enough for this assertion. + void pm.onSongChange(fakeSong); + await flush(); + const uploadsBefore = ts.uploadCalls.length; + pm.setCustomAvatar(Buffer.from([42])); + await flush(); + expect(ts.uploadCalls.length).toBe(uploadsBefore); // no new upload + }); + + it("setCustomAvatar while playing + sync off DOES push (sync-off is idle)", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot"); + void pm.onSongChange(fakeSong); + await flush(); + const uploadsBefore = ts.uploadCalls.length; + pm.setCustomAvatar(Buffer.from([42])); + await flush(); + expect(ts.uploadCalls.length).toBe(uploadsBefore + 1); + }); + + it("setCustomAvatar(null) while idle clears the TS3 avatar", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot"); + pm.setCustomAvatar(Buffer.from([1])); + await flush(); + const clearsBefore = ts.clearCalls; + pm.setCustomAvatar(null); + await flush(); + expect(ts.clearCalls).toBe(clearsBefore + 1); + }); + + it("on stop with custom avatar set + sync on, restores custom (does not clear)", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot"); + pm.setCustomAvatar(Buffer.from([1, 2, 3, 4])); + await flush(); + const clearsBefore = ts.clearCalls; + await pm.onSongChange(null); + expect(ts.uploadCalls.at(-1)?.equals(Buffer.from([1, 2, 3, 4]))).toBe(true); + expect(ts.clearCalls).toBe(clearsBefore); // no extra clear + }); + + it("on stop with no custom avatar, falls back to clear", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot"); + await pm.onSongChange(null); + expect(ts.clearCalls).toBe(1); + expect(ts.uploadCalls.length).toBe(0); + }); + + it("on connect with custom avatar set + sync ON, applies custom (spec matrix row 1)", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot"); + pm.setCustomAvatar(Buffer.from([5, 5])); + await flush(); + ts.uploadCalls.length = 0; // reset + pm.onConnect(); + await flush(); + expect(ts.uploadCalls.length).toBe(1); + expect(ts.uploadCalls[0].equals(Buffer.from([5, 5]))).toBe(true); + }); + + it("on connect with custom avatar set + sync OFF, applies custom", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot"); + pm.setCustomAvatar(Buffer.from([9, 9])); + await flush(); + ts.uploadCalls.length = 0; + pm.onConnect(); + await flush(); + expect(ts.uploadCalls.length).toBe(1); + expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true); + }); + + it("on connect with no custom avatar, does not touch avatar", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot"); + pm.onConnect(); + await flush(); + expect(ts.uploadCalls.length).toBe(0); + expect(ts.clearCalls).toBe(0); + }); +}); diff --git a/src/bot/profile.ts b/src/bot/profile.ts index 778ba68..332638b 100644 --- a/src/bot/profile.ts +++ b/src/bot/profile.ts @@ -25,6 +25,13 @@ export class BotProfileManager { private logger: Logger; private config: ProfileConfig; private defaultNickname: string; + private customAvatar: Buffer | null = null; + /** + * Tracks the last song handed to onSongChange. null means stopped/idle. + * Used by setCustomAvatar to decide whether the new buffer should be + * pushed immediately (idle) or wait for the next stop event (playing). + */ + private currentSong: QueuedSong | null = null; /** Per-feature permission-denied flags. Reset on reconnect. */ private permDenied = { @@ -58,6 +65,26 @@ export class BotProfileManager { // --- Public API --- + /** + * Set/clear the persistent idle avatar. Pass null to remove. + * + * If the bot is currently in an idle state (no song playing OR + * avatarEnabled is off), the new buffer is pushed to TS3 right away; + * otherwise the cover-art sync is in charge until the next stop event, + * at which point clearAvatar restores from this.customAvatar. + */ + setCustomAvatar(buffer: Buffer | null): void { + this.customAvatar = buffer; + const idle = this.currentSong === null || !this.config.avatarEnabled; + if (!idle) return; + const gen = ++this.generation; + if (buffer && buffer.length > 0) { + void this.applyIdleAvatar(gen); + } else { + void this.clearAvatar(gen); + } + } + /** * Called when a new song starts playing (song != null) or playback * stops (song == null). @@ -71,6 +98,7 @@ export class BotProfileManager { */ async onSongChange(song: QueuedSong | null): Promise { const gen = ++this.generation; + this.currentSong = song; // 1. Avatar first — file transfer uses its own response tracker and // must run before sendCommandNoWait calls whose orphaned responses @@ -91,6 +119,7 @@ export class BotProfileManager { /** Reset permission-denied flags and bump generation on new connection. */ onConnect(): void { this.generation++; + this.currentSong = null; this.permDenied = { avatar: false, description: false, @@ -99,6 +128,12 @@ export class BotProfileManager { channelDesc: false, nowPlayingMsg: false, }; + // No song is playing on a fresh connect, so the matrix says the + // custom avatar should be visible regardless of avatarEnabled. + if (this.customAvatar) { + const gen = this.generation; + void this.applyIdleAvatar(gen); + } } getConfig(): ProfileConfig { @@ -171,6 +206,10 @@ export class BotProfileManager { } private async clearAvatar(gen: number): Promise { + if (this.customAvatar && this.customAvatar.length > 0) { + await this.applyIdleAvatar(gen); + return; + } try { await this.withTimeout( this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]), @@ -179,7 +218,6 @@ export class BotProfileManager { } catch { // File may not exist or transfer timed out — that's fine } - // Bail if a newer song started while we were deleting if (this.generation !== gen) return; try { await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar="); @@ -188,6 +226,18 @@ export class BotProfileManager { } } + private async applyIdleAvatar(gen: number): Promise { + if (!this.customAvatar || this.customAvatar.length === 0) return; + if (this.permDenied.avatar) return; + try { + await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS); + if (this.generation !== gen) return; + this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied"); + } catch (err) { + this.handleFeatureError("avatar", err); + } + } + private async updateDescription(song: QueuedSong | null): Promise { if (!this.config.descriptionEnabled || this.permDenied.description) return; try { diff --git a/src/data/avatars.test.ts b/src/data/avatars.test.ts new file mode 100644 index 0000000..5c0856a --- /dev/null +++ b/src/data/avatars.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAvatarStore } from "./avatars.js"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "avatar-test-")); +}); + +describe("createAvatarStore", () => { + it("write returns a relative path under the store dir", () => { + const store = createAvatarStore(dir); + const buf = Buffer.from("fake-png"); + const rel = store.write("bot-1", "image/png", buf); + expect(rel).toBe("bot-1.png"); + expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true); + }); + + it("write picks correct extension for jpeg / webp", () => { + const store = createAvatarStore(dir); + expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg"); + expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp"); + }); + + it("write rejects unsupported MIME types", () => { + const store = createAvatarStore(dir); + expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(/unsupported/i); + }); + + it("read returns the bytes for an existing file", () => { + const store = createAvatarStore(dir); + store.write("bot-1", "image/png", Buffer.from("hello")); + const buf = store.read("bot-1.png"); + expect(buf?.equals(Buffer.from("hello"))).toBe(true); + }); + + it("read returns null when path is missing", () => { + const store = createAvatarStore(dir); + expect(store.read("missing.png")).toBeNull(); + }); + + it("remove deletes the file (idempotent)", () => { + const store = createAvatarStore(dir); + store.write("bot-1", "image/png", Buffer.from("x")); + store.remove("bot-1.png"); + expect(existsSync(join(dir, "bot-1.png"))).toBe(false); + expect(() => store.remove("bot-1.png")).not.toThrow(); + }); + + it("write replaces any existing file for the same botId regardless of old extension", () => { + const store = createAvatarStore(dir); + store.write("bot-1", "image/png", Buffer.from("old")); + const rel = store.write("bot-1", "image/jpeg", Buffer.from("new")); + expect(rel).toBe("bot-1.jpg"); + expect(existsSync(join(dir, "bot-1.png"))).toBe(false); + expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true); + }); +}); diff --git a/src/data/avatars.ts b/src/data/avatars.ts new file mode 100644 index 0000000..6d7629b --- /dev/null +++ b/src/data/avatars.ts @@ -0,0 +1,43 @@ +import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", +}; + +export interface AvatarStore { + /** Returns the relative path written (e.g. "bot-1.png"). */ + write(botId: string, mime: string, buffer: Buffer): string; + read(relPath: string): Buffer | null; + remove(relPath: string): void; + getDir(): string; +} + +export function createAvatarStore(dir: string): AvatarStore { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return { + write(botId, mime, buffer) { + const ext = MIME_TO_EXT[mime]; + if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`); + for (const name of readdirSync(dir)) { + if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true }); + } + const rel = `${botId}.${ext}`; + writeFileSync(join(dir, rel), buffer); + return rel; + }, + read(relPath) { + const full = join(dir, relPath); + if (!existsSync(full)) return null; + return readFileSync(full); + }, + remove(relPath) { + rmSync(join(dir, relPath), { force: true }); + }, + getDir() { + return dir; + }, + }; +} diff --git a/src/data/database.test.ts b/src/data/database.test.ts index f9c4108..de49541 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -98,4 +98,26 @@ describe("database", () => { expect(botDb.getBotInstances()).toHaveLength(0); expect(botDb.deleteBotInstance("nonexistent")).toBe(false); }); + + it("persists and clears customAvatarPath on a bot instance", () => { + const inst = { + id: "bot-1", + name: "B", + serverAddress: "x", + serverPort: 9987, + nickname: "n", + defaultChannel: "", + channelPassword: "", + autoStart: false, + serverProtocol: "", + ts6ApiKey: "", + serverPassword: "", + }; + botDb.saveBotInstance(inst); + expect(botDb.getCustomAvatarPath("bot-1")).toBeNull(); + botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png"); + expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png"); + botDb.setCustomAvatarPath("bot-1", null); + expect(botDb.getCustomAvatarPath("bot-1")).toBeNull(); + }); }); diff --git a/src/data/database.ts b/src/data/database.ts index 3ca4631..fdcdb8f 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -60,6 +60,8 @@ export interface BotDatabase { deleteBotInstance(id: string): boolean; getProfileConfig(botId: string): ProfileConfig; saveProfileConfig(botId: string, config: ProfileConfig): void; + getCustomAvatarPath(botId: string): string | null; + setCustomAvatarPath(botId: string, path: string | null): void; close(): void; } @@ -92,6 +94,9 @@ function migrateSchema(db: Database.Database): void { db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`); } } + if (!names.includes("custom_avatar_path")) { + db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT"); + } } function initTables(db: Database.Database): void { @@ -179,6 +184,9 @@ export function createDatabase(dbPath: string): BotDatabase { WHERE id = @id `); + const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`); + const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`); + return { db, @@ -242,6 +250,14 @@ export function createDatabase(dbPath: string): BotDatabase { }); }, + getCustomAvatarPath(botId) { + const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined; + return row?.custom_avatar_path ?? null; + }, + setCustomAvatarPath(botId, path) { + updateCustomAvatar.run(path, botId); + }, + close() { db.close(); }, diff --git a/src/index.ts b/src/index.ts index 9a056a9..e107dcf 100755 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { NeteaseProvider } from "./music/netease.js"; import { QQMusicProvider } from "./music/qq.js"; import { BiliBiliProvider } from "./music/bilibili.js"; import { createCookieStore } from "./music/auth.js"; +import { createAvatarStore } from "./data/avatars.js"; import { BotManager } from "./bot/manager.js"; import { createWebServer } from "./web/server.js"; @@ -18,6 +19,7 @@ const CONFIG_PATH = path.join(ROOT_DIR, "config.json"); const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db"); const LOG_DIR = path.join(DATA_DIR, "logs"); const COOKIE_DIR = path.join(DATA_DIR, "cookies"); +const AVATAR_DIR = path.join(DATA_DIR, "avatars"); const STATIC_DIR = path.join(ROOT_DIR, "web", "dist"); async function main() { @@ -46,6 +48,7 @@ async function main() { const bilibiliProvider = new BiliBiliProvider(); const cookieStore = createCookieStore(COOKIE_DIR); + const avatarStore = createAvatarStore(AVATAR_DIR); const neteaseCookie = cookieStore.load("netease"); if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie); const qqCookie = cookieStore.load("qq"); @@ -59,7 +62,8 @@ async function main() { bilibiliProvider, db, config, - logger + logger, + avatarStore ); await botManager.loadSavedBots(); @@ -70,6 +74,7 @@ async function main() { qqProvider, bilibiliProvider, database: db, + avatarStore, config, configPath: CONFIG_PATH, logger, diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index 50a135f..51a417b 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -3,12 +3,16 @@ import type { BotManager } from "../../bot/manager.js"; import type { BotConfig } from "../../data/config.js"; import { saveConfig } from "../../data/config.js"; import type { Logger } from "../../logger.js"; +import type { BotDatabase } from "../../data/database.js"; +import type { AvatarStore } from "../../data/avatars.js"; export function createBotRouter( botManager: BotManager, config: BotConfig, configPath: string, - logger: Logger + logger: Logger, + botDb: BotDatabase, + avatarStore: AvatarStore, ): Router { const router = Router(); @@ -36,6 +40,70 @@ export function createBotRouter( res.json(saved); }); + router.get("/:id/avatar", (req, res) => { + const path = botDb.getCustomAvatarPath(req.params.id); + if (!path) { + res.status(404).end(); + return; + } + const buf = avatarStore.read(path); + if (!buf) { + res.status(404).end(); + return; + } + const ext = path.split(".").pop() ?? ""; + const mime = ext === "png" + ? "image/png" + : ext === "webp" + ? "image/webp" + : "image/jpeg"; + res.set("Content-Type", mime); + res.set("Cache-Control", "no-cache"); + res.send(buf); + }); + + router.put("/:id/avatar", (req, res) => { + const exists = + botManager.getBot(req.params.id) || + botDb.getBotInstances().some((b) => b.id === req.params.id); + if (!exists) { + res.status(404).json({ error: "Bot not found" }); + return; + } + const { dataUrl } = req.body as { dataUrl?: string }; + if (typeof dataUrl !== "string") { + res.status(400).json({ error: "dataUrl required" }); + return; + } + const m = /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/.exec(dataUrl); + if (!m) { + res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" }); + return; + } + const mime = m[1] as string; + const buf = Buffer.from(m[2] ?? "", "base64"); + if (buf.length === 0) { + res.status(400).json({ error: "empty image" }); + return; + } + if (buf.length > 200 * 1024) { + res.status(413).json({ error: "avatar exceeds 200KB limit" }); + return; + } + const rel = avatarStore.write(req.params.id, mime, buf); + botDb.setCustomAvatarPath(req.params.id, rel); + botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(buf); + res.json({ path: rel }); + }); + + router.delete("/:id/avatar", (req, res) => { + const path = botDb.getCustomAvatarPath(req.params.id); + if (path) avatarStore.remove(path); + botDb.setCustomAvatarPath(req.params.id, null); + botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(null); + res.status(204).end(); + }); + router.post("/", async (req, res) => { try { const { diff --git a/src/web/server.ts b/src/web/server.ts index 90b7dec..b496c02 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -8,6 +8,7 @@ import type { BotDatabase } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; import type { Logger } from "../logger.js"; import type { CookieStore } from "../music/auth.js"; +import type { AvatarStore } from "../data/avatars.js"; import { createBotRouter } from "./api/bot.js"; import { createMusicRouter } from "./api/music.js"; import { createPlayerRouter } from "./api/player.js"; @@ -25,6 +26,7 @@ export interface WebServerOptions { configPath: string; logger: Logger; cookieStore?: CookieStore; + avatarStore: AvatarStore; staticDir?: string; } @@ -43,7 +45,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.set("trust proxy", true); } - app.use(express.json()); + app.use(express.json({ limit: "400kb" })); app.get("/api/config/public-url", (_req, res) => { const raw = (options.config.publicUrl ?? "").trim(); @@ -52,7 +54,14 @@ export function createWebServer(options: WebServerOptions): WebServer { app.use( "/api/bot", - createBotRouter(options.botManager, options.config, options.configPath, logger) + createBotRouter( + options.botManager, + options.config, + options.configPath, + logger, + options.database, + options.avatarStore, + ) ); app.use( "/api/music", diff --git a/web/src/components/AvatarUpload.vue b/web/src/components/AvatarUpload.vue new file mode 100644 index 0000000..74cf898 --- /dev/null +++ b/web/src/components/AvatarUpload.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/web/src/components/CustomAvatarRow.vue b/web/src/components/CustomAvatarRow.vue new file mode 100644 index 0000000..22fa206 --- /dev/null +++ b/web/src/components/CustomAvatarRow.vue @@ -0,0 +1,59 @@ + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index ba2a310..5df7d1b 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -116,6 +116,10 @@
+
+ + +
@@ -439,6 +443,13 @@ @change="updateProfile(bot.id, t.key, ($event.target as HTMLInputElement).checked)" /> +
+
+
自定义头像
+
无论封面同步是否开启,停播时都会回到这张图
+
+ +
@@ -450,6 +461,8 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'; import { Icon } from '@iconify/vue'; import axios from 'axios'; +import AvatarUpload from '../components/AvatarUpload.vue'; +import CustomAvatarRow from '../components/CustomAvatarRow.vue'; import QRCode from 'qrcode'; import { usePlayerStore } from '../stores/player.js'; @@ -475,6 +488,7 @@ const newBotPort = ref(9987); const newBotNickname = ref('MusicBot'); const newBotChannel = ref(''); const newBotServerPassword = ref(''); +const newBotAvatar = ref(null); // Edit bot const editingBot = ref(null); @@ -635,7 +649,7 @@ async function pollQrStatus(platform: string) { async function createBot() { if (!newBotName.value || !newBotServer.value) return; try { - await axios.post('/api/bot', { + const res = await axios.post('/api/bot', { name: newBotName.value, serverAddress: newBotServer.value, serverPort: newBotPort.value || 9987, @@ -644,12 +658,20 @@ async function createBot() { serverPassword: newBotServerPassword.value || undefined, autoStart: false, }); + if (newBotAvatar.value && res.data?.id) { + try { + await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value }); + } catch (err) { + console.warn('failed to set avatar on new bot', err); + } + } newBotName.value = ''; newBotServer.value = ''; newBotPort.value = 9987; newBotNickname.value = 'MusicBot'; newBotChannel.value = ''; newBotServerPassword.value = ''; + newBotAvatar.value = null; await store.fetchBots(); } catch { // Ignore @@ -1408,6 +1430,11 @@ onUnmounted(() => { } } +.profile-toggle-static { + cursor: default; + align-items: flex-start; +} + @media (max-width: 768px) { .profile-bot-header { padding: 14px 12px;