feat(web): /album/:id route reusing Playlist view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Sonnet 4.6 committed 2026-05-07 20:24:20 +08:00
1 parent 20f4cfacea
commit 51d7ce61bd
4 files changed
+109 -6

No files matched your search

+72
View File
@@ -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<Set<string> | 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 // Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => { router.post("/:botId/play-song", async (req, res) => {
try { try {
+7
View File
@@ -22,6 +22,13 @@ const router = createRouter({
path: '/playlist/:id', path: '/playlist/:id',
name: 'playlist', name: 'playlist',
component: () => import('../views/Playlist.vue'), component: () => import('../views/Playlist.vue'),
meta: { kind: 'playlist' },
},
{
path: '/album/:id',
name: 'album',
component: () => import('../views/Playlist.vue'),
meta: { kind: 'album' },
}, },
{ {
path: '/lyrics', path: '/lyrics',
+10
View File
@@ -322,6 +322,16 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction(); 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() { async pause() {
if (!this.activeBotId) return; if (!this.activeBotId) return;
// Freeze elapsed at current interpolated value // Freeze elapsed at current interpolated value
+20 -6
View File
@@ -38,7 +38,7 @@
</div> </div>
</template> </template>
<div v-else class="loading">歌单不存在或加载失败</div> <div v-else class="loading">{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败</div>
</div> </div>
</template> </template>
@@ -64,6 +64,8 @@ interface PlaylistDetail {
songCount: number; songCount: number;
} }
const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const playlist = ref<PlaylistDetail | null>(null); const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]); const songs = ref<Song[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -71,20 +73,32 @@ const loading = ref(true);
async function playAll() { async function playAll() {
const id = route.params.id as string; const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease'; const platform = (route.query.platform as string) || 'netease';
if (kind === 'album') {
await store.playAlbum(id, platform);
} else {
await store.playPlaylist(id, platform); await store.playPlaylist(id, platform);
} }
}
onMounted(async () => { onMounted(async () => {
const id = route.params.id as string; const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease'; 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 // allSettled, not Promise.all — if detail 404s but songs is fine
// (e.g., a QQ playlist whose detail endpoint flaked but the song // (e.g., a QQ playlist whose detail endpoint flaked but the song
// list resolved), we still want to show the songs rather than // 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([ const [detailRes, songsRes] = await Promise.allSettled([
axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }), axios.get(detailUrl, { params: { platform } }),
axios.get(`/api/music/playlist/${id}`, { params: { platform } }), axios.get(songsUrl, { params: { platform } }),
]); ]);
const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null; 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. // Fall back to a stub built from the route + first song's cover.
playlist.value = { playlist.value = {
id, id,
name: '歌单', name: kind === 'album' ? '专辑' : '歌单',
description: '', description: '',
coverUrl: songList[0]?.coverUrl ?? '', coverUrl: songList[0]?.coverUrl ?? '',
songCount: songList.length, songCount: songList.length,
@@ -104,7 +118,7 @@ onMounted(async () => {
} else { } else {
playlist.value = null; playlist.value = null;
if (detailRes.status === 'rejected') { 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; songs.value = songList;