mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix: address all known follow-up issues except NetEase batch precheck
Surface failures + tighten edges:
1. Toast for /play-song & /play-playlist failures. Backend now returns
{ok, message} (localized in Chinese to match the rest of the UI).
Store stashes a notification on ok=false; new Toast.vue mounted in
App.vue shows it for 3-5s then fades. Clicking the X dismisses
immediately. Sits above the player on desktop and above the mobile
tabbar on phones.
2. QQ collected playlists pagination. fcg_get_profile_order_asset.fcg
returns max 30 per call; we now loop using has_more / short-page
detection up to a 300-playlist hard cap. Single-call users (typical)
exit the loop on the first iteration so no extra requests.
3. getPlayableSongIds chunking. 100 mids per request keeps URL well
under 8KB; chunk-level errors are isolated so a transient blip on
one chunk doesn't poison the whole batch. Returns null only when
every chunk failed (caller falls back to sequential retry).
4. SourceTabs single-source mode now renders a small subdued "网易云"
or "QQ" label instead of vanishing entirely, so the user always
knows which platform's data they're looking at.
5. Hide the "我的歌单 N" count badge when N=0 — Home and Library no
longer show "我的歌单 0" with an empty grid.
6. Auth state change invalidates fetchHomeData cache. Previously, a
user who logged out as account A and into account B within 5
minutes would see A's playlists. Now we always re-check auth at
the top of fetchHomeData and bypass the TTL cache when authStatus
has changed.
Out of scope:
- NetEase analogous batch precheck (per request).
- 60s TS3 UDP idle disconnect — that's the bundled @honeybbq/teamspeak-
client UDP layer kicking when no server packet arrives in 60s. It's
baked in (constant `v=6e4`) and not exposed as an option, and root
cause is server-side or network-layer behavior we can't reach from
here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
221079b89c
commit
59fb3ee3bd
8 files changed
+245
-76
No files matched your search
+65
-45
@@ -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<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 }> | 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);
|
||||
|
||||
const CHUNK = 100; // ~14 chars/mid * 100 + commas ≈ 1.5KB
|
||||
const playable = new Set<string>();
|
||||
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<string, { url?: string }> | 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<Song | null> {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user