Splices into currentIndex+1 and shifts both playedIndices and the
history back-stack to keep references valid. Falls through to plain
push when nothing is playing so the idle-bot "add → start playing"
flow is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of 390d3fa flagged that dropping playedIndices.clear() from
playAt is an unnecessary behavior change. The two operations (push
history, reset random pool) are not in tension — restoring the clear
preserves the original 'explicit pick restarts shuffle' semantic that
Random mode users rely on, while still tracking the prev-history stack.
Also add a regression test that the 50-entry HISTORY_LIMIT cap works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In random modes, prev was just doing currentIndex-1 in the array, which
has no relationship to what the user actually played before. Add a
50-entry back-stack that's pushed by next/playAt, popped by prev, reset
on play/clear/setMode, and shifted by remove. Sequential and Loop modes
keep their old fallback for the case where history is empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inert in this commit — no callers yet. Sets up the back-stack used
by the upcoming history-aware prev rewrite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight tasks: queue history field/helper, history-aware prev with
TDD tests, addNext with TDD tests, REST + command surface, store
action, SongCard third button, view wiring, final verify + smoke.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two queue features:
- prev walks back through actual play history (50-entry stack), so
random modes navigate predictably instead of falling back to the
meaningless currentIndex-1 array walk.
- addNext inserts at currentIndex+1, exposed via new /play-next-song
endpoint, !playnext command, and a third "下一首播放" button on
SongCard. Insert path keeps playedIndices and history index refs
valid by shifting entries > currentIndex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Three small reliability fixes from re-auditing:
1. Playlist.vue used Promise.all, so a flaky /detail endpoint would
blank out the whole page even though /songs returned just fine.
Switch to allSettled and synthesize a stub playlist header from
the song list when only detail fails. User can still play the
playlist; just loses the description/cover.
2. sourceTabs.readAll: typeof null === 'object' AND typeof [] ===
'object', so a corrupted localStorage value (e.g. an array) would
be treated as a record and its missing keys would silently fall
back. Reject explicitly so the failure mode is "clean defaults"
instead of "wrong shape that almost works".
3. Settings.vue loadProfileConfig: a 200 response with empty/wrong
body would set profileConfigs[botId] to a falsy/wrong-shape value,
leaving the row stuck on "加载中..." (because the v-if uses
!profileConfigs[id]). Validate the shape; surface "响应格式异常"
so the retry link is reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Many users' QQ playlists (especially collected/subscribed ones) contain
a large fraction of songs that return result=104003 (region/copyright
restricted) for the current account. Sequential retry-skip wasted time
guessing — for the user's ACG古风 collected playlist, only 3 of 115
songs are actually streamable, so a 20-retry budget had < 50% chance
of finding a hit before giving up.
Add a getPlayableSongIds(ids) method to the QQ provider that calls the
upstream /getMusicPlay with a comma-separated batch of mids — the
wrapper resolves all of them in a single upstream call (~2-3s for 100+
songs). /play-playlist now duck-types this method and, when present,
filters the playlist down to playable songs before queueing.
Response message reports "Loaded N of M songs (rest are copyright/
region restricted)" so the user sees exactly what's happening instead
of guessing why the queue is short or playback didn't start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix for "播放全部 没有反应" hooked into playNext but had
two problems:
1. Used !!queue.current() to detect success, which is always true
after queue.next() advanced — so the response message would lie
about playback starting even when all retries failed.
2. The default 3-retry budget is sized for "next song couldn't
resolve, skip it" cases. User-initiated playlist plays commonly
hit long contiguous runs of QQ 104003 songs in collected playlists
(entire ACG/古风 packs can be uniformly unstreamable in some
regions), so 3-4 attempts wasn't enough.
Make playNext return whether a song actually started, and accept a
maxRetries parameter. /play-playlist now uses 20 retries — high
enough to clear typical unplayable runs, bounded enough that fully
unstreamable playlists still surface a clear "none were playable"
message rather than hanging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QQ Music sometimes refuses to issue a play URL for individual songs
(upstream returns result=104003, "no copyright/payment"). The
/play-playlist endpoint only called resolveAndPlay once on the first
song, so when that one was 104003 the bot would sit silently with no
feedback — exactly what looked like "播放全部 没有反应".
Make BotInstance.playNext() public and call it as a fallback when the
first resolveAndPlay fails. playNext already has the 3-attempt
retry-skip used by trackEnd auto-advance, so we get the same
graceful skip behavior for explicit playlist plays.
Also tighten the response message so the user can tell when the bot
loaded songs but none were playable.
Single-song /play-song still returns "Cannot play" on failure (no
queue to advance through); UX surfacing of that goes in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each bot gets a collapsible card with 6 toggles for the TeamSpeak
profile features (avatar, description, nickname, away status, channel
description, now-playing chat message). Most relevant: lets the user
turn off "更新频道描述", which was triggering the "channel edited"
notification sound on every song change. Same goes for the now-playing
chat message.
The two toggles that broadcast a sound to other channel members are
flagged with an inline ⚠️ tag so users notice them.
Wires up to the existing GET/PUT /api/player/:botId/profile endpoints
(no backend changes). Optimistic update with revert on error. Mobile
breakpoint enlarges the switch to a 44x24 touch target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /playlist/:id/detail route was hardcoded to call the NetEase API
path /playlist/detail on whatever provider was selected. For QQ this
hit a non-existent path, so clicking into any QQ playlist showed
"歌单不存在或加载失败".
Replace the platform-specific hack in the route handler with a proper
getPlaylistDetail method on MusicProvider. Implement it for NetEase
(porting the existing /playlist/detail logic) and QQ (calling
/getSongListDetail and reading from response.cdlist[0]).
Pre-existing bug exposed by the QQ source tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes to the QQ Music provider, all surfaced by enabling the QQ
tab on Home/Library:
1. getUserPlaylists field names were wrong. The upstream
fcg_get_profile_homepage actually returns title/picurl/subtitle,
not dissname/imgurl/song_count, so all playlist cards rendered as
empty placeholders. Map title->name, picurl->coverUrl, and parse
'(\d+)首' from subtitle for songCount.
2. getUserPlaylists only returned playlists the user CREATED. Now also
fetches COLLECTED playlists from c.y.qq.com fcg_get_profile_order_asset
(reqtype=3, requires g_tk derived from p_skey cookie) and concatenates
them after the created list — same order as the QQ desktop app.
3. Add getDailyRecommendSongs implementation backed by /getNewSongs
(新歌速递, ~20 newest songs). The QQ provider previously didn't
implement this method, so the daily-recommend QQ tab was empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reset per-platform fields on fetch failure (was leaving stale data
for up to 5 minutes after the user logged out of NetEase).
- Extract availableSources getter on the store; deduplicate the daily/
user availability computeds in Home.vue and Library.vue.
- Add comment explaining why recommendAvailable always seeds netease.
- Import Source type in SourceTabs.vue from the store instead of
redeclaring locally, removing a future type-drift risk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recommend playlists, daily songs, and user playlists on Home now show
a [网易云][QQ] tab when both platforms are logged in. Library 我的歌单
gets the same tab. Selection persists per-section in localStorage and
falls back gracefully when the persisted source becomes unavailable
(e.g., user logged out). Removes dead 我的收藏 block from Library that
referenced a non-existent /api/music/user/liked endpoint.
Spec: docs/superpowers/specs/2026-05-06-music-source-tabs-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Presentational component for switching between netease and qq music
sources. Auto-hides when fewer than 2 sources are passed in. Mobile
breakpoint enlarges touch target to 36px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step-by-step plan for per-platform tabs on Home and Library: SourceTabs
component, store refactor with authStatus and per-platform data, view
migrations with localStorage persistence, build verification, and
3-scenario manual smoke test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Design for adding NetEase / QQ source switcher tabs to recommend
playlists, daily songs, and user playlists on Home, plus user playlists
on Library. Tabs auto-hide when only one source is available; selection
persists per-section in localStorage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library.vue: use Song type from store so SongCard's strict platform
union accepts the data (was platform: string, broke after merge tightened
SongCard prop type).
database.test.ts: switch toEqual -> toMatchObject so getBotInstances()
returning extra profile_* schema columns no longer fails the assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RandomLoop randomly selects from queue, causing the same song to play
multiple times when the queue is small (~3 songs from personal_fm API).
Random mode ensures each song plays once before repeating, relying on
refillFm() to fetch fresh songs before exhaustion.
Also fix the proactive refill condition to use unplayedCount() instead
of size() - getCurrentIndex(), which was meaningless for non-sequential
play modes.
Add mobile layout (mini player, bottom tab bar, responsive navbar), new
Library view, redesigned bot dropdown with action buttons, and centralize
colors/typography/shadows into CSS variables in variables.scss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Silent failure: logs showed "Client properties updated" / "Description
updated" / "Avatar updated" even though the bot's nickname never
changed and the avatar stayed as "loading image" on clients.
Root causes:
- TS6HttpQuery.clientUpdate ignored non-2xx responses, so 400 (bad
parameter) and 403 (insufficient permission) were reported as success.
- updateClientProperties built TS3-escaped strings (\\s for space) then
split them back into JSON props, so TS6 received literal backslashes
and rejected the nickname silently.
- handleFeatureError only matched textual "permission" errors; HTTP
4xx statuses weren't recognised and the feature retried every song.
- doAvatarUpload had no per-step logging, making it impossible to tell
whether a broken avatar came from init, the TCP 30033 transfer, or
the client_flag_avatar command.
Fixes:
- Add HttpQueryError (status/body/path); clientUpdate throws on non-2xx.
- Build a raw property map in updateClientProperties; escape only on
the TS3 wire path.
- Log HTTP status and updated prop names on success.
- handleFeatureError now treats HTTP 400/401/403 as unrecoverable.
- Debug-log each step of doAvatarUpload plus bytes/elapsedMs on success.
https://claude.ai/code/session_018NrpGWbQQTrahUVXyea5Jy
The "复制专属链接" button silently failed on public-IP HTTP deployments
because navigator.clipboard requires a secure context. Now the link is
always revealed in a modal with a read-only input (select-all on focus),
so users can copy manually even when clipboard APIs and execCommand both
fail. The dialog still tries to auto-copy when possible.
Also adds a publicUrl config option that overrides window.location.origin
for link generation (useful behind reverse proxies / with custom domains),
exposed via GET /api/config/public-url, and a trustProxy flag so Express
honors X-Forwarded-* when fronted by nginx/Caddy/Cloudflare.
https://claude.ai/code/session_019FSX3S3UUcKEYWanYmoqUv
navigator.clipboard requires a secure context (HTTPS or localhost). For
users hosting the bot on a remote IP and accessing via http://<ip>:3000,
clipboard.writeText is undefined and the copy silently fails. Add a
hidden-textarea + execCommand('copy') fallback, with a final prompt()
fallback so the user can always grab the URL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>