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 {