fix: corner-case audit — distinguish failures, recover from blips

Three small but real correctness fixes from auditing recent commits:

1. getPlayableSongIds returned an empty Set for both "endpoint failed"
   and "succeeded but all unplayable" — the caller couldn't tell which.
   Return Set | null now: null = error (fall through to sequential
   retry), empty Set = authoritative "all unplayable" (short-circuit
   to a clear message instead of wasting 20+ retries).

2. Web store: fetchHomeData unconditionally wrote lastFetchTime even
   when every fetch rejected (network blip, server down). That cached
   the failure for 5 minutes — user had to hard-reload to recover.
   Now only commit lastFetchTime if at least one auth-status call
   succeeded.

3. Settings profile section: if GET /profile failed, profileConfigs
   stayed undefined and the row showed "加载中..." forever. Track a
   per-bot error state and render an inline "加载失败 / 重试" link
   so the user can recover without page reload.

Out of scope but documented:
- ein=29 hardcoded in fetchCollectedPlaylists (no pagination yet —
  users with 30+ collected QQ playlists get truncated).
- /play-song single-failure UX (returns "Cannot play" message but
  frontend ignores; needs a global toast/notification primitive).
- NetEase has no analogous batch precheck (could surface same
  "click and wait silent" issue if user has region-restricted NetEase
  playlists).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-06 15:46:31 +08:00
1 parent 3d0aca52d0
commit 892d9f7959
4 files changed
+57 -15

No files matched your search

+17 -6
View File
@@ -124,24 +124,35 @@ export class QQMusicProvider implements MusicProvider {
* 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.
*
* 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.
*/
async getPlayableSongIds(songIds: string[]): Promise<Set<string>> {
async getPlayableSongIds(songIds: string[]): Promise<Set<string> | 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<string, { url?: string }> =
res.data?.data?.playUrl ?? {};
const playUrlMap: Record<string, { url?: string }> | 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<string>();
for (const [mid, info] of Object.entries(playUrlMap)) {
if (info?.url) playable.add(mid);
}
return playable;
} catch {
// On any error, fall through to per-song retry path — return empty
// and let the caller try songs sequentially via getSongUrl.
return new Set();
return null;
}
}
+7 -5
View File
@@ -255,15 +255,17 @@ export function createPlayerRouter(
// ones, otherwise the playback retry loop wastes time guessing.
let queueable: { id: string }[] = songs;
const totalCount = songs.length;
const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string>> };
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.size > 0) {
if (playable !== null) {
// Authoritative answer from upstream — even an empty set means
// "we know none are playable", short-circuit immediately rather
// than wasting 20+ retries.
queueable = songs.filter((s: { id: string }) => playable.has(s.id));
}
// If batch returned nothing, leave queueable as-is and let the
// sequential retry path try anyway (handles the case where the
// batch endpoint failed entirely).
// If null, the batch endpoint itself errored — fall through to
// 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).` });