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
+182 -142

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"); 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 { try {
await import("@sansenjian/qq-music-api");
logger.info( logger.info(
{ port: options.qqMusicPort }, { port: options.qqMusicPort },
"QQ Music API: not bundled, skipping" "QQ Music API started"
); );
} catch (err) { } catch (err) {
logger.warn( logger.error(
{ err }, { err },
"QQ Music API not available" "Failed to start QQ Music API"
); );
} }
}, },
+62 -48
View File
@@ -27,13 +27,14 @@ export class QQMusicProvider implements MusicProvider {
} }
async search(query: string, limit = 20): Promise<SearchResult> { 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 }, params: { key: query, pageSize: limit, ...this.cookieParams },
}); });
const songs: Song[] = (res.data?.data?.list ?? []).map((s: any) => ({ const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map(
id: String(s.songmid ?? s.id), (s: any) => ({
name: s.songname ?? s.name ?? "", id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "", album: s.albumname ?? "",
duration: s.interval ?? 0, duration: s.interval ?? 0,
@@ -41,43 +42,55 @@ export class QQMusicProvider implements MusicProvider {
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "", : "",
platform: "qq", platform: "qq",
})); })
);
return { songs, playlists: [], albums: [] }; return { songs, playlists: [], albums: [] };
} }
async getSongUrl(songId: string): Promise<string | null> { async getSongUrl(songId: string): Promise<string | null> {
const res = await this.api.get("/song/url", { const res = await this.api.get("/getMusicPlay", {
params: { id: songId, ...this.cookieParams }, 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> { async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.api.get("/song", { // getSongInfo requires cookie; use search as fallback
try {
const res = await this.api.get("/getSongInfo", {
params: { songmid: songId, ...this.cookieParams }, params: { songmid: songId, ...this.cookieParams },
}); });
const s = res.data?.data; const s = res.data?.response?.data;
if (!s) return null; if (s && s.track_info) {
const t = s.track_info;
return { return {
id: String(s.mid ?? s.id), id: String(t.mid ?? t.id),
name: s.name ?? "", name: t.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (t.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? "", album: t.album?.name ?? "",
duration: s.interval ?? 0, duration: t.interval ?? 0,
coverUrl: s.album?.mid coverUrl: t.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${t.album.mid}.jpg`
: "", : "",
platform: "qq", platform: "qq",
}; };
} }
} catch {
// fallback: search by songmid (less reliable)
}
return null;
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> { async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/songlist", { const res = await this.api.get("/getSongListDetail", {
params: { id: playlistId, ...this.cookieParams }, params: { disstid: playlistId, ...this.cookieParams },
}); });
return (res.data?.data?.songlist ?? []).map((s: any) => ({ const cdlist = res.data?.response?.cdlist ?? [];
id: String(s.songmid ?? s.id), if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "", name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "", album: s.albumname ?? "",
@@ -90,25 +103,25 @@ export class QQMusicProvider implements MusicProvider {
} }
async getRecommendPlaylists(): Promise<Playlist[]> { async getRecommendPlaylists(): Promise<Playlist[]> {
const res = await this.api.get("/recommend/playlist", { const res = await this.api.get("/getSongLists", {
params: { ...this.cookieParams }, params: { categoryId: 10000000, pageSize: 10, ...this.cookieParams },
}); });
return (res.data?.data?.list ?? []).map((p: any) => ({ return (res.data?.response?.data?.list ?? []).map((p: any) => ({
id: String(p.content_id ?? p.id), id: String(p.dissid),
name: p.title ?? p.name ?? "", name: p.dissname ?? "",
coverUrl: p.cover ?? "", coverUrl: p.imgurl ?? "",
songCount: p.cnt ?? 0, songCount: p.listennum ?? 0,
platform: "qq", platform: "qq",
})); }));
} }
async getAlbumSongs(albumId: string): Promise<Song[]> { 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 }, params: { albummid: albumId, ...this.cookieParams },
}); });
return (res.data?.data?.list ?? []).map((s: any) => ({ return (res.data?.response?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.id), id: String(s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "", name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "", album: s.albumname ?? "",
duration: s.interval ?? 0, duration: s.interval ?? 0,
@@ -120,33 +133,34 @@ export class QQMusicProvider implements MusicProvider {
} }
async getLyrics(songId: string): Promise<LyricLine[]> { 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 }, params: { songmid: songId, ...this.cookieParams },
}); });
return parseLyrics( return parseLyrics(
res.data?.data?.lyric ?? "", res.data?.response?.lyric ?? res.data?.lyric ?? "",
res.data?.data?.trans ?? "" res.data?.response?.trans ?? res.data?.trans ?? ""
); );
} }
async getQrCode(): Promise<QrCodeResult> { async getQrCode(): Promise<QrCodeResult> {
const res = await this.api.get("/login/qr/create"); const res = await this.api.get("/getQQLoginQr");
return { return {
qrUrl: res.data?.data?.qrurl ?? "", qrUrl: "",
key: res.data?.data?.key ?? "", qrImg: res.data?.img ?? "",
key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "",
}; };
} }
async checkQrCodeStatus( async checkQrCodeStatus(
key: string key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const res = await this.api.get("/login/qr/check", { const res = await this.api.get("/checkQQLoginQr", {
params: { key }, params: { qrsig: key },
}); });
const code = res.data?.data?.code; const code = res.data?.code ?? res.data?.response?.code;
if (code === 0) { if (code === 0) {
if (res.data?.data?.cookie) { if (res.data?.cookie) {
this.cookie = res.data.data.cookie; this.cookie = res.data.cookie;
} }
return "confirmed"; return "confirmed";
} }
@@ -166,14 +180,14 @@ export class QQMusicProvider implements MusicProvider {
async getAuthStatus(): Promise<AuthStatus> { async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false }; if (!this.cookie) return { loggedIn: false };
try { try {
const res = await this.api.get("/user/detail", { const res = await this.api.get("/getUserAvatar", {
params: { ...this.cookieParams }, params: { ...this.cookieParams },
}); });
if (res.data?.data) { if (res.data?.response?.data) {
return { return {
loggedIn: true, loggedIn: true,
nickname: res.data.data.nickname, nickname: res.data.response.data.nickname,
avatarUrl: res.data.data.headpic, avatarUrl: res.data.response.data.headpic,
}; };
} }
} catch { } catch {
+1
View File
@@ -0,0 +1 @@
declare module "@sansenjian/qq-music-api";
+27
View File
@@ -32,6 +32,33 @@ export function createMusicRouter(
} }
}); });
router.get("/search/all", async (req, res) => {
try {
const { q, limit } = req.query;
if (!q) {
res.status(400).json({ error: "q (query) is required" });
return;
}
const parsedLimit = parseInt(limit as string) || 20;
const [neteaseResult, qqResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit),
]);
const songs = [
...(neteaseResult.status === "fulfilled"
? neteaseResult.value.songs
: []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
];
res.json({ songs });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/song/:id", async (req, res) => { router.get("/song/:id", async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = getProvider(req.query.platform as string);
+33 -1
View File
@@ -3,7 +3,13 @@
<div class="song-index">{{ index }}</div> <div class="song-index">{{ index }}</div>
<CoverArt :url="song.coverUrl" :size="36" :radius="6" /> <CoverArt :url="song.coverUrl" :size="36" :radius="6" />
<div class="song-info"> <div class="song-info">
<div class="song-name">{{ song.name }}</div> <div class="song-name-row">
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
>{{ song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div> <div class="song-artist">{{ song.artist }}</div>
</div> </div>
<div class="song-album">{{ song.album }}</div> <div class="song-album">{{ song.album }}</div>
@@ -73,6 +79,13 @@ function formatDuration(seconds: number): string {
min-width: 0; min-width: 0;
} }
.song-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.song-name { .song-name {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@@ -81,6 +94,25 @@ function formatDuration(seconds: number): string {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.platform-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.4;
}
.badge-netease {
background: rgba(232, 17, 35, 0.15);
color: #e81123;
}
.badge-qq {
background: rgba(18, 183, 106, 0.15);
color: #12b76a;
}
.song-artist { .song-artist {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+40
View File
@@ -23,6 +23,16 @@ export interface BotStatus {
playMode: string; playMode: string;
} }
export interface PlaylistItem {
id: string;
name: string;
coverUrl: string;
songCount: number;
platform: string;
}
const HOME_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export const usePlayerStore = defineStore('player', { export const usePlayerStore = defineStore('player', {
state: () => ({ state: () => ({
bots: [] as BotStatus[], bots: [] as BotStatus[],
@@ -31,6 +41,11 @@ export const usePlayerStore = defineStore('player', {
theme: 'dark' as 'dark' | 'light', theme: 'dark' as 'dark' | 'light',
playStartedAt: 0, // timestamp when current song started playStartedAt: 0, // timestamp when current song started
pausedElapsed: 0, // elapsed time when paused pausedElapsed: 0, // elapsed time when paused
// Home page cached data
recommendPlaylists: [] as PlaylistItem[],
dailySongs: [] as Song[],
userPlaylists: [] as PlaylistItem[],
lastFetchTime: 0,
}), }),
getters: { getters: {
@@ -188,5 +203,30 @@ export const usePlayerStore = defineStore('player', {
if (!this.activeBotId) return; if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/mode`, { mode }); await axios.post(`/api/player/${this.activeBotId}/mode`, { mode });
}, },
async fetchHomeData() {
// Skip if data was fetched within the last 5 minutes
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
return;
}
const [playlistRes, dailyRes, userRes] = await Promise.allSettled([
axios.get('/api/music/recommend/playlists'),
axios.get('/api/music/recommend/songs'),
axios.get('/api/music/user/playlists'),
]);
if (playlistRes.status === 'fulfilled') {
this.recommendPlaylists = playlistRes.value.data.playlists;
}
if (dailyRes.status === 'fulfilled') {
this.dailySongs = dailyRes.value.data.songs;
}
if (userRes.status === 'fulfilled') {
this.userPlaylists = userRes.value.data.playlists;
}
this.lastFetchTime = Date.now();
},
}, },
}); });
+11 -48
View File
@@ -47,11 +47,11 @@
</section> </section>
<!-- 每日推荐 --> <!-- 每日推荐 -->
<section class="section" v-if="dailySongs.length > 0"> <section class="section" v-if="store.dailySongs.length > 0">
<h2 class="section-title">每日推荐</h2> <h2 class="section-title">每日推荐</h2>
<div class="daily-grid"> <div class="daily-grid">
<div <div
v-for="song in dailySongs.slice(0, 12)" v-for="song in store.dailySongs.slice(0, 12)"
:key="song.id" :key="song.id"
class="daily-card hover-scale" class="daily-card hover-scale"
@click="store.play(song.name, song.platform)" @click="store.play(song.name, song.platform)"
@@ -64,11 +64,11 @@
</section> </section>
<!-- 推荐歌单 --> <!-- 推荐歌单 -->
<section class="section" v-if="playlists.length > 0"> <section class="section" v-if="store.recommendPlaylists.length > 0">
<h2 class="section-title">推荐歌单</h2> <h2 class="section-title">推荐歌单</h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
v-for="playlist in playlists" v-for="playlist in store.recommendPlaylists"
:key="playlist.id" :key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`" :to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale" class="playlist-card hover-scale"
@@ -80,11 +80,11 @@
</section> </section>
<!-- 我的歌单 --> <!-- 我的歌单 -->
<section class="section" v-if="userPlaylists.length > 0"> <section class="section" v-if="store.userPlaylists.length > 0">
<h2 class="section-title">我的歌单</h2> <h2 class="section-title">我的歌单</h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
v-for="pl in userPlaylists" v-for="pl in store.userPlaylists"
:key="pl.id" :key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`" :to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale" class="playlist-card hover-scale"
@@ -99,40 +99,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { onMounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore, type Song } from '../stores/player.js';
import CoverArt from '../components/CoverArt.vue'; import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore(); const store = usePlayerStore();
interface PlaylistItem {
id: string;
name: string;
coverUrl: string;
songCount: number;
platform: string;
}
interface SongItem {
id: string;
name: string;
artist: string;
album: string;
duration: number;
coverUrl: string;
platform: string;
}
const playlists = ref<PlaylistItem[]>([]);
const dailySongs = ref<SongItem[]>([]);
const userPlaylists = ref<PlaylistItem[]>([]);
async function playFm() { async function playFm() {
try { try {
const res = await axios.get('/api/music/personal/fm'); const res = await axios.get('/api/music/personal/fm');
const songs: SongItem[] = res.data.songs; const songs: Song[] = res.data.songs;
if (songs.length > 0) { if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform); await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) { for (let i = 1; i < songs.length; i++) {
@@ -144,23 +122,8 @@ async function playFm() {
} }
} }
onMounted(async () => { onMounted(() => {
// Fetch all data in parallel store.fetchHomeData();
const [playlistRes, dailyRes, userRes] = await Promise.allSettled([
axios.get('/api/music/recommend/playlists'),
axios.get('/api/music/recommend/songs'),
axios.get('/api/music/user/playlists'),
]);
if (playlistRes.status === 'fulfilled') {
playlists.value = playlistRes.value.data.playlists;
}
if (dailyRes.status === 'fulfilled') {
dailySongs.value = dailyRes.value.data.songs;
}
if (userRes.status === 'fulfilled') {
userPlaylists.value = userRes.value.data.playlists;
}
}); });
</script> </script>
+3 -41
View File
@@ -16,22 +16,6 @@
autofocus autofocus
/> />
</div> </div>
<div class="platform-toggle">
<button
class="platform-btn"
:class="{ active: platform === 'netease' }"
@click="platform = 'netease'; doSearch()"
>
网易云
</button>
<button
class="platform-btn"
:class="{ active: platform === 'qq' }"
@click="platform = 'qq'; doSearch()"
>
QQ音乐
</button>
</div>
</div> </div>
<div v-if="loading" class="loading">搜索中...</div> <div v-if="loading" class="loading">搜索中...</div>
@@ -39,7 +23,7 @@
<div v-else-if="results.length > 0" class="results"> <div v-else-if="results.length > 0" class="results">
<SongCard <SongCard
v-for="(song, i) in results" v-for="(song, i) in results"
:key="song.id" :key="`${song.platform}-${song.id}`"
:song="song" :song="song"
:index="i + 1" :index="i + 1"
:active="store.currentSong?.id === song.id" :active="store.currentSong?.id === song.id"
@@ -66,7 +50,6 @@ const store = usePlayerStore();
const route = useRoute(); const route = useRoute();
const query = ref((route.query.q as string) || ''); const query = ref((route.query.q as string) || '');
const platform = ref<'netease' | 'qq'>('netease');
const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]); const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
const loading = ref(false); const loading = ref(false);
const searched = ref(false); const searched = ref(false);
@@ -76,8 +59,8 @@ async function doSearch() {
loading.value = true; loading.value = true;
searched.value = true; searched.value = true;
try { try {
const res = await axios.get('/api/music/search', { const res = await axios.get('/api/music/search/all', {
params: { q: query.value, platform: platform.value }, params: { q: query.value },
}); });
results.value = res.data.songs; results.value = res.data.songs;
} catch { } catch {
@@ -137,27 +120,6 @@ onMounted(() => {
} }
} }
.platform-toggle {
display: flex;
gap: 8px;
}
.platform-btn {
padding: 6px 18px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
background: var(--hover-bg);
opacity: 0.6;
transition: all var(--transition-fast);
&.active {
background: var(--color-primary);
color: white;
opacity: 1;
}
}
.loading { .loading {
text-align: center; text-align: center;
padding: 40px; padding: 40px;