From 67584d26220ac691bc23a443a3b3c4ffc48ea7b7 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Mon, 30 Mar 2026 02:07:11 +0800 Subject: [PATCH] feat: add YesPlayMusic-style home page with daily recs, FM, and user playlists Add backend endpoints for daily recommended songs, personal FM, user playlists, and playlist detail. Extend NeteaseProvider and MusicProvider interface with getDailyRecommendSongs and getUserPlaylists. Rewrite Home page with FM card, daily recommendations grid, and user playlists section. Fix Playlist view to fetch detail and songs from separate endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/music/netease.ts | 35 +++++++ src/music/provider.ts | 2 + src/web/api/music.ts | 80 +++++++++++++++ web/src/views/Home.vue | 200 +++++++++++++++++++++++++++++++++++-- web/src/views/Playlist.vue | 29 ++++-- 5 files changed, 330 insertions(+), 16 deletions(-) diff --git a/src/music/netease.ts b/src/music/netease.ts index bc64c16..b4f487b 100644 --- a/src/music/netease.ts +++ b/src/music/netease.ts @@ -283,4 +283,39 @@ export class NeteaseProvider implements MusicProvider { platform: "netease", })); } + + async getDailyRecommendSongs(): Promise { + const res = await this.api.get("/recommend/songs", { + params: { ...this.cookieParams }, + }); + return (res.data?.data?.dailySongs ?? []).map((s: any) => ({ + id: String(s.id), + name: s.name, + artist: (s.ar ?? []).map((a: any) => a.name).join(" / "), + album: s.al?.name ?? "", + duration: Math.round((s.dt ?? 0) / 1000), + coverUrl: s.al?.picUrl ?? "", + platform: "netease", + })); + } + + async getUserPlaylists(): Promise { + // First get user ID from login status + const statusRes = await this.api.get("/login/status", { + params: { ...this.cookieParams }, + }); + const uid = statusRes.data?.data?.profile?.userId; + if (!uid) return []; + + const res = await this.api.get("/user/playlist", { + params: { uid, ...this.cookieParams }, + }); + return (res.data?.playlist ?? []).map((p: any) => ({ + id: String(p.id), + name: p.name, + coverUrl: p.coverImgUrl ?? "", + songCount: p.trackCount ?? 0, + platform: "netease", + })); + } } diff --git a/src/music/provider.ts b/src/music/provider.ts index 93248ca..7f8adad 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -73,4 +73,6 @@ export interface MusicProvider { getCookie(): string; getAuthStatus(): Promise; getPersonalFm?(): Promise; + getDailyRecommendSongs?(): Promise; + getUserPlaylists?(): Promise; } diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 0930fe5..9ab4ae4 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -86,5 +86,85 @@ export function createMusicRouter( } }); + router.get("/recommend/songs", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + if (!provider.getDailyRecommendSongs) { + res.status(501).json({ error: "Not supported by this provider" }); + return; + } + const songs = await provider.getDailyRecommendSongs(); + res.json({ songs }); + } catch (err) { + logger.error({ err }, "Get daily recommend songs failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/personal/fm", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + if (!provider.getPersonalFm) { + res.status(501).json({ error: "Not supported by this provider" }); + return; + } + const songs = await provider.getPersonalFm(); + res.json({ songs }); + } catch (err) { + logger.error({ err }, "Get personal FM failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/user/playlists", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + if (!provider.getUserPlaylists) { + res.status(501).json({ error: "Not supported by this provider" }); + return; + } + const playlists = await provider.getUserPlaylists(); + res.json({ playlists }); + } catch (err) { + logger.error({ err }, "Get user playlists failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/playlist/:id/detail", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + // Use the playlist songs endpoint to get basic info, + // but we also need detail info (name, cover, description). + // For netease, we access the underlying API directly. + const nProvider = provider as any; + if (nProvider.api) { + const cookieParams = nProvider.cookie + ? { cookie: nProvider.cookie } + : {}; + const detailRes = await nProvider.api.get("/playlist/detail", { + params: { id: req.params.id, ...cookieParams }, + }); + const p = detailRes.data?.playlist; + if (p) { + res.json({ + playlist: { + id: String(p.id), + name: p.name, + description: p.description ?? "", + coverUrl: p.coverImgUrl ?? "", + songCount: p.trackCount ?? 0, + }, + }); + return; + } + } + res.status(404).json({ error: "Playlist not found" }); + } catch (err) { + logger.error({ err }, "Get playlist detail failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + return router; } diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index c1720eb..44610f0 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -31,8 +31,40 @@ - +
+

私人FM

+
+
+ +
+
+
开启私人FM
+
根据你的口味推荐音乐
+
+ +
+
+ + +
+

每日推荐

+
+
+ +
{{ song.name }}
+
{{ song.artist }}
+
+
+
+ + +

推荐歌单

+ + +
+

我的歌单

+
+
+ +
{{ pl.name }}
+
{{ pl.songCount }} 首
+
+
+
@@ -58,21 +107,59 @@ import CoverArt from '../components/CoverArt.vue'; const store = usePlayerStore(); -interface Playlist { +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([]); +const playlists = ref([]); +const dailySongs = ref([]); +const userPlaylists = ref([]); + +async function playFm() { + try { + const res = await axios.get('/api/music/personal/fm'); + const songs: SongItem[] = res.data.songs; + if (songs.length > 0) { + await store.play(songs[0].name, songs[0].platform); + for (let i = 1; i < songs.length; i++) { + await store.addToQueue(songs[i].name, songs[i].platform); + } + } + } catch { + // Ignore + } +} onMounted(async () => { - try { - const res = await axios.get('/api/music/recommend/playlists'); - playlists.value = res.data.playlists; - } catch { - // Ignore if API not ready + // Fetch all data in parallel + 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; } }); @@ -134,6 +221,7 @@ onMounted(async () => { margin-bottom: 16px; } +// Now Playing .now-playing { display: flex; align-items: center; @@ -154,6 +242,96 @@ onMounted(async () => { color: var(--text-secondary); } +// 私人FM +.fm-card { + display: flex; + align-items: center; + gap: 20px; + padding: 20px 24px; + background: var(--bg-card); + border-radius: var(--radius-lg); + cursor: pointer; + transition: background var(--transition-fast); + + &:hover { + background: var(--hover-bg); + } +} + +.fm-icon-wrapper { + width: 56px; + height: 56px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--color-primary), #6366f1); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.fm-icon { + font-size: 28px; + color: white; +} + +.fm-info { + flex: 1; +} + +.fm-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +} + +.fm-desc { + font-size: 13px; + color: var(--text-secondary); +} + +.fm-play-icon { + font-size: 36px; + color: var(--color-primary); + opacity: 0.8; + transition: opacity var(--transition-fast); + + .fm-card:hover & { + opacity: 1; + } +} + +// 每日推荐 +.daily-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 20px; + + @media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); } + @media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); } +} + +.daily-card { + cursor: pointer; +} + +.daily-name { + margin-top: 8px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.daily-artist { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Playlists grid (shared for 推荐歌单 and 我的歌单) .playlist-grid { display: grid; grid-template-columns: repeat(5, 1fr); @@ -176,4 +354,10 @@ onMounted(async () => { -webkit-box-orient: vertical; overflow: hidden; } + +.playlist-count { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 2px; +} diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue index 0d22bee..1b6c95f 100644 --- a/web/src/views/Playlist.vue +++ b/web/src/views/Playlist.vue @@ -8,9 +8,9 @@

{{ playlist.name }}

-

{{ playlist.description }}

+

{{ playlist.description }}

- {{ playlist.songCount }} 首歌曲 + {{ songs.length }} 首歌曲
+ +
歌单不存在或加载失败
@@ -55,8 +57,18 @@ interface PlaylistDetail { songCount: number; } +interface SongItem { + id: string; + name: string; + artist: string; + album: string; + duration: number; + coverUrl: string; + platform: string; +} + const playlist = ref(null); -const songs = ref>([]); +const songs = ref([]); const loading = ref(true); async function playAll() { @@ -74,11 +86,12 @@ onMounted(async () => { const platform = (route.query.platform as string) || 'netease'; try { - const res = await axios.get(`/api/music/playlist/${id}`, { - params: { platform }, - }); - playlist.value = res.data.playlist; - songs.value = res.data.songs; + const [detailRes, songsRes] = await Promise.all([ + axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }), + axios.get(`/api/music/playlist/${id}`, { params: { platform } }), + ]); + playlist.value = detailRes.data.playlist; + songs.value = songsRes.data.songs; } catch { // Ignore if API not ready } finally {