feat: add QQ Music API integration, Pinia home cache, and unified search

- Start @sansenjian/qq-music-api server via dynamic import in api-server.ts
- Rewrite QQ Music provider to use real API endpoints (getSearchByKey,
  getMusicPlay, getSongListDetail, getAlbumInfo, getLyric, etc.)
- Cache home page data (recommend playlists, daily songs, user playlists)
  in Pinia store with 5-minute TTL to avoid refetching on every mount
- Add unified /api/music/search/all endpoint that queries both Netease
  and QQ providers in parallel and returns merged results
- Remove platform toggle from Search.vue; search both platforms at once
- Add platform badge (网易云 / QQ) to SongCard component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 02:31:15 +08:00
1 parent ce6fc92780
commit 94a0df8391
8 files changed
+195 -155

No files matched your search

+5 -4
View File
@@ -42,16 +42,17 @@ export function createApiServerManager(
logger.error({ err }, "Failed to start NetEase Cloud Music API");
}
// QQ Music API — placeholder (requires separate setup)
// QQ Music API — auto-starts on port 3200 when imported
try {
await import("@sansenjian/qq-music-api");
logger.info(
{ port: options.qqMusicPort },
"QQ Music API: not bundled, skipping"
"QQ Music API started"
);
} catch (err) {
logger.warn(
logger.error(
{ err },
"QQ Music API not available"
"Failed to start QQ Music API"
);
}
},
+75 -61
View File
@@ -27,57 +27,70 @@ export class QQMusicProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const res = await this.api.get("/search", {
const res = await this.api.get("/getSearchByKey", {
params: { key: query, pageSize: limit, ...this.cookieParams },
});
const songs: Song[] = (res.data?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.id),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map(
(s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
})
);
return { songs, playlists: [], albums: [] };
}
async getSongUrl(songId: string): Promise<string | null> {
const res = await this.api.get("/song/url", {
params: { id: songId, ...this.cookieParams },
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, ...this.cookieParams },
});
return res.data?.data ?? null;
const playUrl = res.data?.data?.playUrl?.[songId];
return playUrl?.url || null;
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.api.get("/song", {
params: { songmid: songId, ...this.cookieParams },
});
const s = res.data?.data;
if (!s) return null;
return {
id: String(s.mid ?? s.id),
name: s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
};
// getSongInfo requires cookie; use search as fallback
try {
const res = await this.api.get("/getSongInfo", {
params: { songmid: songId, ...this.cookieParams },
});
const s = res.data?.response?.data;
if (s && s.track_info) {
const t = s.track_info;
return {
id: String(t.mid ?? t.id),
name: t.name ?? "",
artist: (t.singer ?? []).map((a: any) => a.name).join(" / "),
album: t.album?.name ?? "",
duration: t.interval ?? 0,
coverUrl: t.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${t.album.mid}.jpg`
: "",
platform: "qq",
};
}
} catch {
// fallback: search by songmid (less reliable)
}
return null;
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/songlist", {
params: { id: playlistId, ...this.cookieParams },
const res = await this.api.get("/getSongListDetail", {
params: { disstid: playlistId, ...this.cookieParams },
});
return (res.data?.data?.songlist ?? []).map((s: any) => ({
id: String(s.songmid ?? s.id),
const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
@@ -90,25 +103,25 @@ export class QQMusicProvider implements MusicProvider {
}
async getRecommendPlaylists(): Promise<Playlist[]> {
const res = await this.api.get("/recommend/playlist", {
params: { ...this.cookieParams },
const res = await this.api.get("/getSongLists", {
params: { categoryId: 10000000, pageSize: 10, ...this.cookieParams },
});
return (res.data?.data?.list ?? []).map((p: any) => ({
id: String(p.content_id ?? p.id),
name: p.title ?? p.name ?? "",
coverUrl: p.cover ?? "",
songCount: p.cnt ?? 0,
return (res.data?.response?.data?.list ?? []).map((p: any) => ({
id: String(p.dissid),
name: p.dissname ?? "",
coverUrl: p.imgurl ?? "",
songCount: p.listennum ?? 0,
platform: "qq",
}));
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
const res = await this.api.get("/album/songs", {
const res = await this.api.get("/getAlbumInfo", {
params: { albummid: albumId, ...this.cookieParams },
});
return (res.data?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.id),
name: s.songname ?? s.name ?? "",
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
@@ -120,33 +133,34 @@ export class QQMusicProvider implements MusicProvider {
}
async getLyrics(songId: string): Promise<LyricLine[]> {
const res = await this.api.get("/lyric", {
const res = await this.api.get("/getLyric", {
params: { songmid: songId, ...this.cookieParams },
});
return parseLyrics(
res.data?.data?.lyric ?? "",
res.data?.data?.trans ?? ""
res.data?.response?.lyric ?? res.data?.lyric ?? "",
res.data?.response?.trans ?? res.data?.trans ?? ""
);
}
async getQrCode(): Promise<QrCodeResult> {
const res = await this.api.get("/login/qr/create");
const res = await this.api.get("/getQQLoginQr");
return {
qrUrl: res.data?.data?.qrurl ?? "",
key: res.data?.data?.key ?? "",
qrUrl: "",
qrImg: res.data?.img ?? "",
key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "",
};
}
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const res = await this.api.get("/login/qr/check", {
params: { key },
const res = await this.api.get("/checkQQLoginQr", {
params: { qrsig: key },
});
const code = res.data?.data?.code;
const code = res.data?.code ?? res.data?.response?.code;
if (code === 0) {
if (res.data?.data?.cookie) {
this.cookie = res.data.data.cookie;
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return "confirmed";
}
@@ -166,14 +180,14 @@ export class QQMusicProvider implements MusicProvider {
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
try {
const res = await this.api.get("/user/detail", {
const res = await this.api.get("/getUserAvatar", {
params: { ...this.cookieParams },
});
if (res.data?.data) {
if (res.data?.response?.data) {
return {
loggedIn: true,
nickname: res.data.data.nickname,
avatarUrl: res.data.data.headpic,
nickname: res.data.response.data.nickname,
avatarUrl: res.data.response.data.headpic,
};
}
} catch {