From 51d7ce61bd372e26bb103f77357f83ca73bfeb5f Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 7 May 2026 20:24:20 +0800 Subject: [PATCH] feat(web): /album/:id route reusing Playlist view Co-Authored-By: Claude Sonnet 4.6 --- src/web/api/player.ts | 72 ++++++++++++++++++++++++++++++++++++++ web/src/router/index.ts | 7 ++++ web/src/stores/player.ts | 10 ++++++ web/src/views/Playlist.vue | 28 +++++++++++---- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/web/api/player.ts b/src/web/api/player.ts index a9af600..4f0930b 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -313,6 +313,78 @@ export function createPlayerRouter( } }); + // Play an album by ID — mirrors play-playlist but calls getAlbumSongs + router.post("/:botId/play-album", async (req, res) => { + try { + const bot = (req as any).bot; + const { albumId, platform } = req.body; + const provider = bot.getProviderFor( + platform === "bilibili" || platform === "qq" || platform === "youtube" + ? platform + : "netease" + ); + + // Stop current playback + bot.getPlayer().stop(); + bot.getPlayer().resetFailures(); + + const songs = await provider.getAlbumSongs(albumId); + if (songs.length === 0) { + res.json({ message: "Album is empty" }); + return; + } + + // QQ-specific optimization: batch-resolve playable IDs to avoid + // wasting retries on region/copyright-restricted tracks. + let queueable: { id: string }[] = songs; + const totalCount = songs.length; + const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise | null> }; + if (typeof qqLike.getPlayableSongIds === "function") { + const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id)); + if (playable !== null) { + queueable = songs.filter((s: { id: string }) => playable.has(s.id)); + } + } + if (queueable.length === 0) { + res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` }); + return; + } + + const queue = bot.getQueueManager(); + queue.clear(); + for (const song of queueable) { + queue.add({ ...song, platform: provider.platform }); + } + + const mode = queue.getMode(); + let first; + if (mode === "random" || mode === "rloop") { + const idx = Math.floor(Math.random() * queue.size()); + first = queue.playAt(idx); + } else { + first = queue.play(); + } + + let started = first ? await bot.resolveAndPlay(first) : false; + if (first && !started) { + started = await bot.playNext(20); + } + + const playing = queue.current(); + const loadedMsg = queueable.length < totalCount + ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)` + : `已加载 ${queueable.length} 首`; + if (started && playing) { + res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` }); + } else { + res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` }); + } + } catch (err) { + logger.error({ err }, "play-album failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + // Play a single song by ID — resolves URL on demand router.post("/:botId/play-song", async (req, res) => { try { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index cc060f5..d62afcd 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -22,6 +22,13 @@ const router = createRouter({ path: '/playlist/:id', name: 'playlist', component: () => import('../views/Playlist.vue'), + meta: { kind: 'playlist' }, + }, + { + path: '/album/:id', + name: 'album', + component: () => import('../views/Playlist.vue'), + meta: { kind: 'album' }, }, { path: '/lyrics', diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 9bc817d..083262c 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -322,6 +322,16 @@ export const usePlayerStore = defineStore('player', { this._syncAfterAction(); }, + async playAlbum(albumId: string, platform = 'netease') { + if (!this.activeBotId) return; + const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform }); + if (res.data?.message) { + this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + } + this._setTiming(this.activeBotId, { serverElapsed: 0 }); + this._syncAfterAction(); + }, + async pause() { if (!this.activeBotId) return; // Freeze elapsed at current interpolated value diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue index d5c9f8d..d00101d 100644 --- a/web/src/views/Playlist.vue +++ b/web/src/views/Playlist.vue @@ -38,7 +38,7 @@ -
歌单不存在或加载失败
+
{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败
@@ -64,6 +64,8 @@ interface PlaylistDetail { songCount: number; } +const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album' + const playlist = ref(null); const songs = ref([]); const loading = ref(true); @@ -71,20 +73,32 @@ const loading = ref(true); async function playAll() { const id = route.params.id as string; const platform = (route.query.platform as string) || 'netease'; - await store.playPlaylist(id, platform); + if (kind === 'album') { + await store.playAlbum(id, platform); + } else { + await store.playPlaylist(id, platform); + } } onMounted(async () => { const id = route.params.id as string; const platform = (route.query.platform as string) || 'netease'; + const detailUrl = kind === 'album' + ? `/api/music/album/${id}/detail` + : `/api/music/playlist/${id}/detail`; + const songsUrl = kind === 'album' + ? `/api/music/album/${id}` + : `/api/music/playlist/${id}`; + // allSettled, not Promise.all — if detail 404s but songs is fine // (e.g., a QQ playlist whose detail endpoint flaked but the song // list resolved), we still want to show the songs rather than - // the "歌单不存在" empty state. + // the "不存在" empty state. For albums, detail always 404s — that + // is intentional; the fallback stub below handles it. const [detailRes, songsRes] = await Promise.allSettled([ - axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }), - axios.get(`/api/music/playlist/${id}`, { params: { platform } }), + axios.get(detailUrl, { params: { platform } }), + axios.get(songsUrl, { params: { platform } }), ]); const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null; @@ -96,7 +110,7 @@ onMounted(async () => { // Fall back to a stub built from the route + first song's cover. playlist.value = { id, - name: '歌单', + name: kind === 'album' ? '专辑' : '歌单', description: '', coverUrl: songList[0]?.coverUrl ?? '', songCount: songList.length, @@ -104,7 +118,7 @@ onMounted(async () => { } else { playlist.value = null; if (detailRes.status === 'rejected') { - console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message); + console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message); } } songs.value = songList;