mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
6b725f43f9
commit
67584d2622
5 files changed
+330
-16
No files matched your search
@@ -283,4 +283,39 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
platform: "netease",
|
platform: "netease",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDailyRecommendSongs(): Promise<Song[]> {
|
||||||
|
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<Playlist[]> {
|
||||||
|
// 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",
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -73,4 +73,6 @@ export interface MusicProvider {
|
|||||||
getCookie(): string;
|
getCookie(): string;
|
||||||
getAuthStatus(): Promise<AuthStatus>;
|
getAuthStatus(): Promise<AuthStatus>;
|
||||||
getPersonalFm?(): Promise<Song[]>;
|
getPersonalFm?(): Promise<Song[]>;
|
||||||
|
getDailyRecommendSongs?(): Promise<Song[]>;
|
||||||
|
getUserPlaylists?(): Promise<Playlist[]>;
|
||||||
}
|
}
|
||||||
@@ -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;
|
return router;
|
||||||
}
|
}
|
||||||
+192
-8
@@ -31,8 +31,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Recommended Playlists -->
|
<!-- 私人FM -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
<h2 class="section-title">私人FM</h2>
|
||||||
|
<div class="fm-card hover-scale" @click="playFm">
|
||||||
|
<div class="fm-icon-wrapper">
|
||||||
|
<Icon icon="mdi:radio" class="fm-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="fm-info">
|
||||||
|
<div class="fm-title">开启私人FM</div>
|
||||||
|
<div class="fm-desc">根据你的口味推荐音乐</div>
|
||||||
|
</div>
|
||||||
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 每日推荐 -->
|
||||||
|
<section class="section" v-if="dailySongs.length > 0">
|
||||||
|
<h2 class="section-title">每日推荐</h2>
|
||||||
|
<div class="daily-grid">
|
||||||
|
<div
|
||||||
|
v-for="song in dailySongs.slice(0, 12)"
|
||||||
|
:key="song.id"
|
||||||
|
class="daily-card hover-scale"
|
||||||
|
@click="store.play(song.name, song.platform)"
|
||||||
|
>
|
||||||
|
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="daily-name">{{ song.name }}</div>
|
||||||
|
<div class="daily-artist">{{ song.artist }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 推荐歌单 -->
|
||||||
|
<section class="section" v-if="playlists.length > 0">
|
||||||
<h2 class="section-title">推荐歌单</h2>
|
<h2 class="section-title">推荐歌单</h2>
|
||||||
<div class="playlist-grid">
|
<div class="playlist-grid">
|
||||||
<div
|
<div
|
||||||
@@ -46,6 +78,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 我的歌单 -->
|
||||||
|
<section class="section" v-if="userPlaylists.length > 0">
|
||||||
|
<h2 class="section-title">我的歌单</h2>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<div
|
||||||
|
v-for="pl in userPlaylists"
|
||||||
|
:key="pl.id"
|
||||||
|
class="playlist-card hover-scale"
|
||||||
|
@click="$router.push(`/playlist/${pl.id}?platform=${pl.platform}`)"
|
||||||
|
>
|
||||||
|
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="playlist-name">{{ pl.name }}</div>
|
||||||
|
<div class="playlist-count">{{ pl.songCount }} 首</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -58,21 +107,59 @@ import CoverArt from '../components/CoverArt.vue';
|
|||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
|
|
||||||
interface Playlist {
|
interface PlaylistItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
coverUrl: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlists = ref<Playlist[]>([]);
|
const playlists = ref<PlaylistItem[]>([]);
|
||||||
|
const dailySongs = ref<SongItem[]>([]);
|
||||||
|
const userPlaylists = ref<PlaylistItem[]>([]);
|
||||||
|
|
||||||
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
// Fetch all data in parallel
|
||||||
const res = await axios.get('/api/music/recommend/playlists');
|
const [playlistRes, dailyRes, userRes] = await Promise.allSettled([
|
||||||
playlists.value = res.data.playlists;
|
axios.get('/api/music/recommend/playlists'),
|
||||||
} catch {
|
axios.get('/api/music/recommend/songs'),
|
||||||
// Ignore if API not ready
|
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>
|
||||||
@@ -134,6 +221,7 @@ onMounted(async () => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now Playing
|
||||||
.now-playing {
|
.now-playing {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -154,6 +242,96 @@ onMounted(async () => {
|
|||||||
color: var(--text-secondary);
|
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 {
|
.playlist-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
@@ -176,4 +354,10 @@ onMounted(async () => {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
<CoverArt :url="playlist.coverUrl" :size="200" :radius="14" :show-shadow="true" />
|
<CoverArt :url="playlist.coverUrl" :size="200" :radius="14" :show-shadow="true" />
|
||||||
<div class="playlist-meta">
|
<div class="playlist-meta">
|
||||||
<h1 class="playlist-title">{{ playlist.name }}</h1>
|
<h1 class="playlist-title">{{ playlist.name }}</h1>
|
||||||
<p class="playlist-desc">{{ playlist.description }}</p>
|
<p class="playlist-desc" v-if="playlist.description">{{ playlist.description }}</p>
|
||||||
<div class="playlist-stats">
|
<div class="playlist-stats">
|
||||||
{{ playlist.songCount }} 首歌曲
|
{{ songs.length }} 首歌曲
|
||||||
</div>
|
</div>
|
||||||
<button class="play-all-btn" @click="playAll">
|
<button class="play-all-btn" @click="playAll">
|
||||||
<Icon icon="mdi:play" />
|
<Icon icon="mdi:play" />
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="loading">歌单不存在或加载失败</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -55,8 +57,18 @@ interface PlaylistDetail {
|
|||||||
songCount: number;
|
songCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SongItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
coverUrl: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
const playlist = ref<PlaylistDetail | null>(null);
|
const playlist = ref<PlaylistDetail | null>(null);
|
||||||
const songs = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
const songs = ref<SongItem[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
async function playAll() {
|
async function playAll() {
|
||||||
@@ -74,11 +86,12 @@ onMounted(async () => {
|
|||||||
const platform = (route.query.platform as string) || 'netease';
|
const platform = (route.query.platform as string) || 'netease';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`/api/music/playlist/${id}`, {
|
const [detailRes, songsRes] = await Promise.all([
|
||||||
params: { platform },
|
axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
|
||||||
});
|
axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
|
||||||
playlist.value = res.data.playlist;
|
]);
|
||||||
songs.value = res.data.songs;
|
playlist.value = detailRes.data.playlist;
|
||||||
|
songs.value = songsRes.data.songs;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if API not ready
|
// Ignore if API not ready
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in new issue
Block a user