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)