diff --git a/src/music/api-server.ts b/src/music/api-server.ts index 0b3e8c9..ad6066e 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -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" ); } }, diff --git a/src/music/qq.ts b/src/music/qq.ts index 19679f1..c0e44cb 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -27,57 +27,70 @@ export class QQMusicProvider implements MusicProvider { } async search(query: string, limit = 20): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { 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 { diff --git a/src/types/qq-music-api.d.ts b/src/types/qq-music-api.d.ts new file mode 100644 index 0000000..d163735 --- /dev/null +++ b/src/types/qq-music-api.d.ts @@ -0,0 +1 @@ +declare module "@sansenjian/qq-music-api"; diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 9ab4ae4..bde06f5 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -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) => { try { const provider = getProvider(req.query.platform as string); diff --git a/web/src/components/SongCard.vue b/web/src/components/SongCard.vue index 12ab184..f820259 100644 --- a/web/src/components/SongCard.vue +++ b/web/src/components/SongCard.vue @@ -3,7 +3,13 @@
{{ index }}
-
{{ song.name }}
+
+ {{ song.name }} + {{ song.platform === 'qq' ? 'QQ' : '网易云' }} +
{{ song.artist }}
{{ song.album }}
@@ -73,6 +79,13 @@ function formatDuration(seconds: number): string { min-width: 0; } +.song-name-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .song-name { font-size: 14px; font-weight: 500; @@ -81,6 +94,25 @@ function formatDuration(seconds: number): string { 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 { font-size: 12px; color: var(--text-secondary); diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 44944f6..541fb8a 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -23,6 +23,16 @@ export interface BotStatus { 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', { state: () => ({ bots: [] as BotStatus[], @@ -31,6 +41,11 @@ export const usePlayerStore = defineStore('player', { theme: 'dark' as 'dark' | 'light', playStartedAt: 0, // timestamp when current song started pausedElapsed: 0, // elapsed time when paused + // Home page cached data + recommendPlaylists: [] as PlaylistItem[], + dailySongs: [] as Song[], + userPlaylists: [] as PlaylistItem[], + lastFetchTime: 0, }), getters: { @@ -188,5 +203,30 @@ export const usePlayerStore = defineStore('player', { if (!this.activeBotId) return; 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(); + }, }, }); diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index 5f58b5d..8fb6ea7 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -47,11 +47,11 @@ -
+

每日推荐

-
+

推荐歌单

-
+

我的歌单

diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index 24c1b60..a6b1a01 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -16,22 +16,6 @@ autofocus />
-
- - -
搜索中...
@@ -39,7 +23,7 @@
('netease'); const results = ref>([]); const loading = ref(false); const searched = ref(false); @@ -76,8 +59,8 @@ async function doSearch() { loading.value = true; searched.value = true; try { - const res = await axios.get('/api/music/search', { - params: { q: query.value, platform: platform.value }, + const res = await axios.get('/api/music/search/all', { + params: { q: query.value }, }); results.value = res.data.songs; } 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 { text-align: center; padding: 40px;