Two independent plans for spec 2026-05-07; each yields its own PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
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.tsfor 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
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:
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:
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:
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
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
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:
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):
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:
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
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:
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:
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
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
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:
{
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:
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/<firstSong.id> 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/<some-netease-album-id> (pick one from a search). Expect: hero header + song list + play-all button — same UX as a playlist page.
- Step 5: Commit
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
sed -n '1,120p' web/src/views/Search.vue
Identify: the results.value = res.data.songs line and the <div v-else-if="results.length > 0"> block.
- Step 2: Refactor to three result lists
Replace the script:
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<Song[]>([]);
const albums = ref<Album[]>([]);
const playlists = ref<Playlist[]>([]);
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 <div v-else-if="results.length > 0" class="results"> block:
<template v-else-if="songs.length || albums.length || playlists.length">
<section v-if="albums.length" class="result-section">
<h2 class="section-title">专辑</h2>
<div class="card-grid">
<router-link
v-for="al in albums"
:key="`${al.platform}-${al.id}`"
:to="`/album/${al.id}?platform=${al.platform}`"
class="card hover-scale"
>
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ al.name }}</div>
<div class="card-sub">{{ al.artist }}</div>
</router-link>
</div>
</section>
<section v-if="playlists.length" class="result-section">
<h2 class="section-title">歌单</h2>
<div class="card-grid">
<router-link
v-for="pl in playlists"
:key="`${pl.platform}-${pl.id}`"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ pl.name }}</div>
</router-link>
</div>
</section>
<section v-if="songs.length" class="result-section">
<h2 class="section-title">单曲</h2>
<SongCard
v-for="(song, i) in songs"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playSong(song)"
@playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/>
</section>
</template>
<div v-else-if="searched" class="empty">未找到相关结果</div>
(Import CoverArt: import CoverArt from '../components/CoverArt.vue';.)
- Step 4: Add minimal styles
Append to the <style lang="scss" scoped> block:
.result-section {
margin-bottom: 32px;
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
text-decoration: none;
color: inherit;
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; }
}
- Step 5: Build + visually verify
cd web && npm run build
Then npm run dev → search "周杰伦" → see three sections; click an album card → arrives at /album/:id with songs + play-all.
- Step 6: Commit
git add web/src/views/Search.vue
git commit -m "feat(web): show album + playlist sections in search"
Task 6: Open PR
- Step 1: Push branch
git checkout -b feat/album-search
git push -u origin feat/album-search
- Step 2: Create the PR
gh pr create --title "feat(search): album section + album playback" --body "Closes part of #51 (album half).
## Summary
- netease.search() / qq.search() now populate SearchResult.albums
- /api/music/search/all returns albums + playlists alongside songs
- Search.vue renders three sections: 专辑 / 歌单 / 单曲
- /album/:id route reuses Playlist.vue with meta.kind='album'
- bilibili / youtube intentionally still return albums:[] (no album API)
## Test plan
- [x] vitest covers netease + qq search returning non-empty albums
- [x] curl /search/all?q=周杰伦 returns {songs, albums, playlists}
- [x] Manual: search → click album card → /album/:id → play all
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
Self-Review Checklist
- Spec coverage: backend album search → Tasks 1+2; aggregator → Task 3; album detail route → Task 4; UI sections → Task 5
- No "TBD"/placeholder text — every step shows the actual diff or command
- Type names consistent:
Album(capital A),albums(lowercase plural),SearchResult.albums - Bilibili/YouTube explicitly out of scope per spec — confirmed in Task 3 by skipping them in albums aggregation
- Routes use
meta.kind— same key referenced in Playlist.vue (Task 4) and/album/:idregistration (Task 4 Step 2)