diff --git a/src/music/qq.ts b/src/music/qq.ts index 47a3b95..9c0360d 100644 --- a/src/music/qq.ts +++ b/src/music/qq.ts @@ -122,38 +122,41 @@ export class QQMusicProvider implements MusicProvider { * current user — a sequential retry loop wastes time guessing. * * The wrapper's /getMusicPlay accepts a comma-separated songmid list - * and resolves all of them in a single upstream call (~2-3s for 100+ - * songs), so this is much cheaper than per-song probing. + * and resolves all of them in a single upstream call. We chunk to keep + * the URL well under typical 8KB query-string limits and to keep per- + * request latency bounded (~2-3s per 100 mids). * * Returns: * - non-null Set: authoritative result. Empty Set means all songs are * unplayable; non-empty means filter to those mids. - * - null: the batch endpoint failed (timeout/exception). Caller - * should fall back to sequential retry rather than treating as - * "all unplayable", since we don't actually know. - * - * TODO: songIds with 1000+ entries may exceed URL length; chunk if - * we ever support that scale. + * - null: every chunk failed. Caller should fall back to sequential + * retry rather than treating as "all unplayable". */ async getPlayableSongIds(songIds: string[]): Promise | null> { if (songIds.length === 0) return new Set(); - try { - const res = await this.api.get("/getMusicPlay", { - params: { songmid: songIds.join(","), quality: this.quality, ...this.cookieParams }, - }); - const playUrlMap: Record | undefined = - res.data?.data?.playUrl; - // Distinguish "endpoint returned no playUrl object at all" (treat - // as failure → null) from "returned an empty/all-unplayable map". - if (!playUrlMap) return null; - const playable = new Set(); - for (const [mid, info] of Object.entries(playUrlMap)) { - if (info?.url) playable.add(mid); + + const CHUNK = 100; // ~14 chars/mid * 100 + commas ≈ 1.5KB + const playable = new Set(); + let allChunksFailed = true; + for (let i = 0; i < songIds.length; i += CHUNK) { + const slice = songIds.slice(i, i + CHUNK); + try { + const res = await this.api.get("/getMusicPlay", { + params: { songmid: slice.join(","), quality: this.quality, ...this.cookieParams }, + }); + const playUrlMap: Record | undefined = + res.data?.data?.playUrl; + if (!playUrlMap) continue; // chunk-level failure, try next + allChunksFailed = false; + for (const [mid, info] of Object.entries(playUrlMap)) { + if (info?.url) playable.add(mid); + } + } catch { + // chunk-level failure — keep going so a transient error on one + // chunk doesn't poison the whole batch. } - return playable; - } catch { - return null; } + return allChunksFailed ? null : playable; } async getSongDetail(songId: string): Promise { @@ -433,30 +436,47 @@ export class QQMusicProvider implements MusicProvider { const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie); if (!pSkeyMatch) return []; const gtk = computeGtk(pSkeyMatch[1]); + + const PAGE_SIZE = 30; + const MAX_PAGES = 10; // 300-playlist hard cap; should cover any sane user + const all: Playlist[] = []; try { - const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", { - params: { - ct: 20, - cid: 205360956, - userid: uin, - reqtype: 3, - sin: 0, - ein: 29, - g_tk: gtk, - format: "json", - }, - headers: { Cookie: this.cookie }, - }); - if (res.data?.code !== 0) return []; - return (res.data?.data?.cdlist ?? []).map((p: any) => ({ - id: String(p.dissid ?? ""), - name: p.dissname ?? "", - coverUrl: p.logo ?? "", - songCount: p.songnum ?? 0, - platform: "qq" as const, - })); + for (let page = 0; page < MAX_PAGES; page++) { + const sin = page * PAGE_SIZE; + const ein = sin + PAGE_SIZE - 1; + const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", { + params: { + ct: 20, + cid: 205360956, + userid: uin, + reqtype: 3, + sin, + ein, + g_tk: gtk, + format: "json", + }, + headers: { Cookie: this.cookie }, + }); + if (res.data?.code !== 0) break; + const list: any[] = res.data?.data?.cdlist ?? []; + for (const p of list) { + all.push({ + id: String(p.dissid ?? ""), + name: p.dissname ?? "", + coverUrl: p.logo ?? "", + songCount: p.songnum ?? 0, + platform: "qq", + }); + } + // Stop when upstream signals no more pages, or when this page is + // short (also indicates end). has_more is the canonical signal. + const hasMore = res.data?.data?.has_more === 1 || res.data?.data?.has_more === true; + if (!hasMore || list.length < PAGE_SIZE) break; + } } catch { - return []; + // Return whatever we got so far on partial failure rather than dropping + // earlier pages. } + return all; } } diff --git a/src/web/api/player.ts b/src/web/api/player.ts index dfaf30e..cb0e0ba 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -268,7 +268,7 @@ export function createPlayerRouter( // the sequential retry path, which still has a chance. } if (queueable.length === 0) { - res.json({ message: `Loaded ${totalCount} songs but none were playable (likely copyright/region restrictions).` }); + res.json({ ok: false, message: `歌单 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` }); return; } @@ -300,12 +300,12 @@ export function createPlayerRouter( const playing = queue.current(); const loadedMsg = queueable.length < totalCount - ? `Loaded ${queueable.length} of ${totalCount} songs (rest are copyright/region restricted)` - : `Loaded ${queueable.length} songs`; + ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)` + : `已加载 ${queueable.length} 首`; if (started && playing) { - res.json({ message: `${loadedMsg}. Now playing: ${playing.name}` }); + res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` }); } else { - res.json({ message: `${loadedMsg}, but couldn't start playback.` }); + res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` }); } } catch (err) { logger.error({ err }, "Play playlist failed"); @@ -330,11 +330,11 @@ export function createPlayerRouter( bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); if (!ok) { - res.json({ message: `Cannot play: ${song.name || song.id}` }); + res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); return; } - res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); + res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } diff --git a/web/src/App.vue b/web/src/App.vue index a76eefa..5bbed9b 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -5,6 +5,7 @@ +
@@ -55,6 +56,7 @@ import { useWebSocket } from './composables/useWebSocket.js'; import Navbar from './components/Navbar.vue'; import Player from './components/Player.vue'; import CoverArt from './components/CoverArt.vue'; +import Toast from './components/Toast.vue'; const playerStore = usePlayerStore(); const theme = computed(() => playerStore.theme); diff --git a/web/src/components/SourceTabs.vue b/web/src/components/SourceTabs.vue index 365cf9b..b065199 100644 --- a/web/src/components/SourceTabs.vue +++ b/web/src/components/SourceTabs.vue @@ -1,15 +1,20 @@ @@ -63,6 +68,17 @@ defineEmits<{ } } +// Single-source mode: not interactive, but tells the user which platform +// they're looking at instead of leaving them guessing. +.source-tab-label { + padding: 4px 10px; + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + color: var(--text-tertiary); + background: var(--hover-bg); + border-radius: var(--radius-sm); +} + @media (max-width: 768px) { .source-tabs { margin-left: 8px; diff --git a/web/src/components/Toast.vue b/web/src/components/Toast.vue new file mode 100644 index 0000000..e695c98 --- /dev/null +++ b/web/src/components/Toast.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index bf24a07..9a14f11 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -63,6 +63,10 @@ export const usePlayerStore = defineStore('player', { bilibiliPopular: [] as Song[], authStatus: { netease: false, qq: false }, lastFetchTime: 0, + + // Transient notification for surfacing failures (e.g., "song not playable") + // to a global Toast. Bumped `id` triggers re-render of the same message. + notification: null as { id: number; message: string; type: 'error' | 'info' } | null, }), getters: { @@ -269,9 +273,16 @@ export const usePlayerStore = defineStore('player', { this._syncAfterAction(); }, + notify(message: string, type: 'error' | 'info' = 'info') { + this.notification = { id: Date.now(), message, type }; + }, + async playSong(song: Song) { if (!this.activeBotId) return; - await axios.post(`/api/player/${this.activeBotId}/play-song`, { song }); + const res = await axios.post(`/api/player/${this.activeBotId}/play-song`, { song }); + if (res.data?.ok === false && res.data?.message) { + this.notify(res.data.message, 'error'); + } this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, @@ -293,7 +304,10 @@ export const usePlayerStore = defineStore('player', { async playPlaylist(playlistId: string, platform = 'netease') { if (!this.activeBotId) return; - await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform }); + const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform }); + if (res.data?.message) { + this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + } this._setTiming(this.activeBotId, { serverElapsed: 0 }); this._syncAfterAction(); }, @@ -360,19 +374,30 @@ export const usePlayerStore = defineStore('player', { }, async fetchHomeData() { - if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) { - return; - } - - // 1. Fetch auth status for both platforms first. + // Always check auth status first — if it changed since the cached + // fetch (e.g., user logged in/out as a different account), the + // cached playlists belong to a different user and we MUST refetch. const [neAuthRes, qqAuthRes] = await Promise.allSettled([ axios.get('/api/auth/status', { params: { platform: 'netease' } }), axios.get('/api/auth/status', { params: { platform: 'qq' } }), ]); - this.authStatus.netease = - neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn; - this.authStatus.qq = - qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn; + const newAuth = { + netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn, + qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn, + }; + const authChanged = + newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq; + this.authStatus.netease = newAuth.netease; + this.authStatus.qq = newAuth.qq; + + // Cache hit only if auth is unchanged AND within TTL. + if ( + !authChanged && + this.lastFetchTime > 0 && + Date.now() - this.lastFetchTime < HOME_CACHE_TTL + ) { + return; + } // 2. NetEase data: recommend playlists work anonymously; daily/user // playlists need login but Promise.allSettled isolates failures. diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index 675b0d3..c7d64ed 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -76,7 +76,7 @@

我的歌单 - {{ currentUserPlaylists.length }} + {{ currentUserPlaylists.length }}

diff --git a/web/src/views/Library.vue b/web/src/views/Library.vue index e23d2e7..0f0bd6f 100644 --- a/web/src/views/Library.vue +++ b/web/src/views/Library.vue @@ -6,7 +6,7 @@

我的歌单 - {{ currentUserPlaylists.length }} + {{ currentUserPlaylists.length }}