Repoints the `@sansenjian/qq-music-api` dependency from the public npm
release to a local fork at ../qq-music-api, which ships a corrected
getMusicPlay that:
- drops the hardcoded-sign GET path (no longer honored by QQ's vkey
server for VIP entitlement lookups)
- POSTs JSON directly to u.y.qq.com/cgi-bin/musicu.fcg (mirroring
the library's own getLyric.ts pattern)
- extracts qqmusic_key from the forwarded cookie and passes it as
`comm.authst` — the inline auth field the jsososo/QQMusicApi
reference implementation sets
- uses `ct: 19` (was 24) to match the community reference
For accounts that actually have entitlement to a given track, this
now returns the real VIP URL. For accounts that don't, QQ's vkey
server still returns result=104003 with empty purl — this is correct
server-side behavior and not a bug. Verified by observing the real
QQ Music web player on y.qq.com fall back to the same 30-second
preview on a logged-in account that lacks the specific track tier.
Supporting changes:
src/music/api-server.ts
The fork (v2.2.11) stopped auto-starting a Koa server on import —
it only listens when run as `require.main`. Explicitly import the
default Koa app and call .listen() with a server handle we can
clean up on shutdown. Without this fix, port 3200 silently fails
to bind and every QQ endpoint 502s.
src/music/qq.ts (getSongDetail)
The library's /getSongInfo endpoint returns upstream code 500001
because its param format no longer matches QQ's current API.
resolveAndPlay only needs `id` + `platform` to fetch a play URL,
so fall through to a minimal stub on /getSongInfo failure. This
unblocks /play-by-id and /add-by-id for QQ — they had been
returning "Song not found" for every QQ track regardless of
entitlement.
scripts/qq_browser_login.py
Visible-browser diagnostic tool that opens Chromium at y.qq.com,
auto-detects login via uin cookie poll, captures the full
post-login cookie set, tests it against /getMusicPlay for 稻香,
and writes the cookie to data/cookies/qq.json only if VIP
actually unlocks. On failure, dumps the full cookie to
data/cookies/qq.browser-capture.json for OAuth-vs-browser diff.
scripts/qq_verify_entitlement.py
Companion diagnostic: opens the real QQ Music web player at a
specific song's detail page so the user can manually click play
and verify whether their account has entitlement — independent
of any code path in this project. If the browser plays the full
song, HTTP 104003 is a request-signing issue; if the browser
also falls back to a 30-second preview, the account lacks the
tier/album purchase and no code fix can change that.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
QR login against QQ Music has been silently broken: every call to
checkQrCodeStatus returned "expired", so the scan-and-confirm cycle
never completed even when the user successfully scanned the code. The
root cause was four independent bugs in our wrapper talking past the
library's actual HTTP shape.
1. getQrCode lost ptqrtoken.
/getQQLoginQr returns { img, qrsig, ptqrtoken }, but we stored only
one of them in the single `key` field (picking qrsig, falling back
to ptqrtoken). The polling endpoint needs BOTH — passing only one
fails with 400 "参数错误". Fix: pack both into the opaque `key` as
"qrsig|ptqrtoken" and split on the receive side.
2. checkQrCodeStatus used GET.
@sansenjian/qq-music-api 2.x registers /checkQQLoginQr as POST only
(router.js: `router.post('/checkQQLoginQr', ...)`). GET returns 405
Method Not Allowed, axios throws, the catch returns "expired".
Fix: api.post(url, null, { params }).
3. checkQrCodeStatus parsed the wrong response shape.
The endpoint uses customResponse, not successResponse, so axios sees
the body directly (no { response: ... } wrapper). The actual shape
for each state is:
waiting: { isOk: false, refresh: false, message: '未扫描二维码' }
expired: { isOk: false, refresh: true, message: '二维码已失效' }
success: { isOk: true, message: '登录成功', session: { cookie } }
We were looking for a numeric `code === 0/1/2` field that does not
exist, so every state fell through to "expired". Fix: switch on
isOk / refresh / message.
4. Cookie read from the wrong path on success.
On isOk=true the cookie lives at res.data.session.cookie, not
res.data.cookie — so even if everything else had worked, the cookie
would never have been saved. Fix: read session.cookie.
Also rewrites getAuthStatus to actually validate the cookie:
5. getAuthStatus hit a non-validating endpoint.
/getUserAvatar is not registered on the library's main router; the
real route is /user/getUserAvatar, and even that just builds a
static avatar URL from a uin without round-tripping through QQ
Music with the cookie. The result: the bot happily persisted any
user-supplied cookie to disk and sent it on every request while
every downstream login check returned "not logged in". Fix: parse
uin from the cookie, call /user/getUserPlaylists (which actually
hits QQ Music with the cookie), and derive the avatar URL from the
uin via q.qlogo.cn/headimg_dl.
This is the same getAuthStatus fix that was sitting on the
claude/bot-shutdown-disconnect-cwFvF branch, now combined with the
QR login repairs.
Verification:
- tsc --noEmit clean
- vitest: 93/93 pass
- Live: POST /api/auth/qrcode platform=qq returns both tokens packed
into `key`; polling a freshly-issued QR returns {"status":"waiting"}
instead of the old {"status":"expired"}; raw library response is
{"isOk":false,"refresh":false,"message":"未扫描二维码"} as expected.
- Regression: netease and bilibili QR flows still produce non-empty
qrUrl/key — no collateral damage to the other providers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Delete the top-of-file "dev 分支" warning block and the whole
"dev 分支最新变更" section. Content that was only a dev-branch
changelog is now folded into the new "更新日志" section.
- Reword the identity migration warning in the upgrade section so it
refers to the library version (0.1.x → 0.2.x) instead of a specific
dev-branch commit hash, and add a "how to tell if you need to
migrate" note for users on fresh installs.
- Remove "`dev` 分支已实现" phrasing from the TS6 FAQ entry.
- Add a new "更新日志" section before "致谢" that summarizes recent
changes in four buckets — protocol/stability, HTTP API hardening,
connection state consistency, and feature improvements — plus a
brief historical milestones block pointing at git log for full
history.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a prominent warning block at the top of the "更新升级" section
explaining why identities generated by 0.1.0 are incompatible with
0.2.x's corrected P-256 DER encoding path, and how to migrate: clear
the identity column so the next start regenerates a fresh key.
Covers three scenarios:
- TS3 + old identity: mostly still works (TS3 is tolerant)
- TS6 + old identity: must clear — otherwise handshake hangs at
`received initivexpand2`
- After clearing: server groups must be re-granted to the new UID once
Also adds a back-reference at the end of the upgrade section so
readers skimming per-platform instructions don't miss the migration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Version 0.2.1 ships a universal clientinit format that works natively
against both TS3 and TS6 servers:
client_version: "3.?.? [Build: 5680278000]"
client_version_sign: DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOo
BzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==
The old ts6-compat.ts workaround (monkey-patching handler.sendPacket to
rewrite clientinit's client_version to "3.6.2") is now actively wrong:
it replaces the library's new correct version/signature pair with a
stale one that TS6 servers reject, which is why the first 0.2.1 TS6
handshake attempt still hung at `received initivexpand2`.
Changes:
- package.json: "@honeybbq/teamspeak-client": "^0.1.0" -> "^0.2.1"
- src/ts-protocol/client.ts: remove patchClientInitVersion import and
the sendPacket monkey-patch block. Leave an inline comment so anyone
reading the git blame understands why the shim is gone.
- Delete src/ts-protocol/ts6-compat.ts and ts6-compat.test.ts — no
callers remain.
Other 0.2.x notes worth knowing (no code change here, just documenting):
- ClientOptions gained serverPassword / defaultChannel /
defaultChannelPassword that are sent DURING clientinit. We still call
our own joinChannel() post-connect because the existing flow works
and switching is an orthogonal refactor.
- 0.1.1 contains the P-256 DER encoding fix (PR #5 by ZHANGTIANYAO1).
Identities generated by 0.1.0 are cryptographically incompatible with
0.2.x's corrected handshake path — a bot whose identity column was
populated before this upgrade will hang at `received initivexpand2`
and fall through the 15s connect deadline. Workaround: clear the
identity column so the next start generates a fresh key. Server
groups assigned to the old UID must be re-granted once against the
new one.
Verification:
- tsc --noEmit clean
- vitest: 93/93 unit tests pass (1 test file removed with ts6-compat)
- scripts/test_full_feature.py against a local TS6 server: 51/51 pass,
including handshake, voice playback, identity persistence, WebSocket
stateChange broadcasts, and all corner-case regressions.
- Live: bot connected to TS6 in ~80ms after identity regeneration,
played NetEase audio through the voice channel, clean stop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Major bug fixes and corner-case hardening across the backend, plus a
comprehensive feature test suite. All 94 unit tests + 51 integration
tests pass against a local TS3 server.
Lifecycle & state consistency
-----------------------------
- Bug A: startBot() now wraps connect() in a 15s deadline. A hung TS
handshake no longer blocks the /start HTTP call forever; the failing
instance is torn down and the caller gets a clean 500.
- Bug B: executeCommand rejects audio-dispatching commands (play, add,
next, skip, prev, playlist, album, fm) when the bot is disconnected.
Config-only commands (vol, mode, clear, stop, queue, now, lyrics)
still work so the UI stays usable while offline.
- Bug C: the tsClient 'disconnected' handler always clears player state
now, even when connect() never completed. A separate disconnectEmitted
flag guards duplicate external event emission. Previously an orphaned
connect attempt that idle-timed-out would leave playing=true forever.
- resolveAndPlay re-checks this.connected AFTER the URL-resolve await so
a stop() during the network call can't spawn ffmpeg on a disconnected
bot.
- connect() throws if disconnect() fired during the handshake await,
preventing a concurrent stop from being overwritten by a late connected
flag flip.
- startBot always disconnects the outgoing BotInstance before creating
a replacement, covering the mid-handshake case where isConnected()
still returned false but the library client was live.
- startBot now reuses the stored identity so server groups granted to
the bot survive restarts (was regenerating a fresh UID each time).
WebSocket reliability
---------------------
- BotManager extends EventEmitter and emits 'botInstance' whenever a
new instance is created. websocket.ts listens and re-attaches its
stateChange / connected / disconnected listeners immediately, fixing
the bug where player-bar UI never updated until manual refresh.
- attachedBots map now stores the BotInstance reference and detaches
stale listeners when the instance is replaced. Safety-net interval
(5s) also reconciles to catch anything missed.
- removeBot emits 'botInstanceRemoved' -> WS broadcasts a new
{type:"botRemoved", botId} message. Client drops the bot from its
local store instead of showing it as permanently offline.
HTTP input validation
---------------------
- /volume rejects non-number, NaN, Infinity, and out-of-range values
with a proper 400 instead of a 200 OK wrapping a usage-text string.
- /mode rejects anything not in {seq, loop, random, rloop} with 400.
- /seek rejects NaN / Infinity / negative (previously NaN slipped
through typeof==="number" and poisoned seekOffset).
- /play-at validates index < queue.size() BEFORE stopping current
playback (was silently killing the current song on invalid input).
- /play, /add, /playlist, /play-by-id, /add-by-id, /play-playlist
all honour platform=youtube now (previously fell through to netease
and silently played the wrong platform).
YouTube made truly optional
---------------------------
- Lazy checkYtDlpAvailable() runs `yt-dlp --version` once, caches only
positive results so users can install yt-dlp mid-run and have it
picked up without a restart.
- getAuthStatus() returns loggedIn=false with nickname
"YouTube (yt-dlp not installed)" when the binary is missing. UI can
grey out YouTube instead of silently returning empty searches.
- findYtDlp() picks .exe on win32 and bare binary elsewhere.
- /auth/status?platform=youtube now routes to the YouTube provider
instead of falling through to NetEase and leaking the NetEase
user's nickname + avatar.
- /auth/cookie rejects platform=youtube with 400 instead of clobbering
the NetEase cookie entry.
- README documents yt-dlp install paths (bin/ local vs PATH) and adds
a dedicated "Optional: YouTube source" section.
Bot Selector UI
---------------
- New power button in each row of the dropdown with play-state-aware
styling: disabled + wait-cursor during API call, green highlight when
connected, greys out when the bot is offline.
- Dropdown always visible when >=1 bot exists, bigger font + padding.
Queue correctness
-----------------
- PlayQueue.remove(current) now decrements currentIndex so next() in
sequential mode advances to the shifted song. Previously removing
the currently-playing track silently skipped the next track because
current() falsely reported it as active and next() then incremented
past it.
Vote-skip hardening
-------------------
- cmdVote: needed threshold is Math.max(1, ceil(users/2)) so a single
voter in an empty channel can't unanimously pass a vote with
needed=0.
- resolveAndPlay clears voteSkipUsers on every new track load so votes
can't leak across songs via cmdPlay/cmdPlaylist/cmdAlbum/cmdFm paths.
cmdAdd parity
-------------
- cmdAdd auto-plays the newly-added song if the player was idle,
matching /api/player/:id/add-by-id behaviour. Previously add'ing to
an empty queue on a connected+idle bot silently enqueued without
starting playback.
Test suite
----------
- scripts/test_full_feature.py — 51 tests across 10 groups exercising
every HTTP endpoint, WebSocket broadcasts, all music providers, bot
lifecycle, disconnected-state corners, seek validation, input
validation, and the main race conditions. Captures and restores the
target bot's initial state. Resilient to TS3 anti-flood via retry
with exponential backoff. Runs against a real local TS3 server.
- scripts/test_rapid_cycle.py — Bugs A/B/C regressions
- scripts/test_corner_cases.py — disconnect-during-connect race, config
commands while disconnected, etc.
- scripts/test_more_corners.py — resolveAndPlay race, seek NaN
- scripts/test_power_button.py — E2E for the new power button
- scripts/test_bot_remove.py — E2E for WS botRemoved broadcast
- scripts/test_playbar.py — player bar auto-show regression (updated
to restore bot state on exit)
- scripts/test_multibot.py — two-bot concurrent playback monitor
- src/audio/queue.test.ts — 4 new vitest cases for remove() edge cases
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
startBot creates a fresh BotInstance but the WS layer keyed its listener
map by bot.id, so ensureAllBotsAttached skipped the new object and
stateChange events were never broadcast — the player bar only appeared
after a manual refresh. BotManager now extends EventEmitter and emits
"botInstance" whenever a new bot object is created; websocket.ts stores
the bot reference, detaches on replacement, and subscribes to the event
for immediate wiring.
Also enlarges the bot selector (padding 10×20, font 16, min-height 44,
bigger dot/chevron/state icons, wider name) and adds Playwright repro
scripts for the player bar bug and navbar sizing check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When starting a bot, re-instantiate BotInstance from latest database
config so changes to host, nickname, channel, protocol etc. take
effect immediately without requiring a full app restart.
Store queue and timing state per-bot instead of globally, move bot
selector from Home page into Navbar dropdown, show bot name badge
in the player bar, and route WebSocket stateChange queues per-bot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement BiliBiliProvider for video-as-audio playback using direct
BiliBili API calls (search, video info, DASH audio URL extraction).
Add QR code login support and cookie persistence. Update FFmpeg to
send Referer header for BiliBili CDN URLs. Extend platform union type
to "netease" | "qq" | "bilibili" across all interfaces. Add -b flag
for chat commands and B站 badge in web UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bugs fixed:
- play-at: stop current playback before jumping to queue index
- playNext retry: use break instead of return to ensure stateChange emits
- sendVoiceData: skip if disconnecting to avoid errors during teardown
UI: player-left and lyrics button now toggle lyrics page (open/close)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add isAdvancing guard to playNext() to prevent concurrent calls from
skipping songs (trackEnd + user !next race condition)
- Replace removeAllListeners in WebSocket setup with tracked named
listeners, attach only once per bot instead of every 5 seconds
- Add .catch() to unhandled playNext() in cmdVote
- Add sessionId counter to AudioPlayer to discard stale setTimeout
callbacks after stop()+play() transitions
- Defer nulling TS3 client until disconnect() promise resolves
- Add null checks for providers in play-by-id and add-by-id endpoints
- Add PCM buffer backpressure (pause FFmpeg at 960KB, resume at 480KB)
- Change volume slider from @input to @change to avoid flooding server
- Wrap error-reporting sendTextMessage in try/catch to prevent
unhandled rejection in error handler
- Clamp elapsed getter to song duration
- Log FFmpeg stderr at debug level instead of swallowing
- Return null from prev() in Sequential mode instead of wrapping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs prevented auto-advance:
1. FFmpeg close handler was empty — ChildProcess object persisted after
close, so frame loop check `!this.ffmpeg` never triggered trackEnd
2. playNext() is async (resolves URL) but trackEnd handler didn't catch
rejected promises, causing silent failures
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Multi-stage build: builder installs backend + frontend deps, compiles
- Production stage: npm ci --production rebuilds native modules (opus, sqlite3)
- FFmpeg bundled via ffmpeg-static (no apt install ffmpeg)
- .dockerignore prevents node_modules/dist from being copied
- docker-compose uses host network for TS3 UDP connectivity
- Named volume for persistent data
- Health check endpoint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Users no longer need to install FFmpeg separately — it is now bundled as
an npm dependency (ffmpeg-static). player.ts resolves the bundled binary
automatically and falls back to system PATH. start.bat and a new
setup.bat script handle dependency installation and project building.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Progress/lyrics sync:
- AudioPlayer tracks framesPlayed (ground truth: frames * 20ms + seekOffset)
- BotStatus.elapsed is the real elapsed from server
- Frontend polls GET /elapsed every 3s for ground truth
- Client interpolates between syncs (serverElapsed + timeSinceSync)
- Pause freezes at interpolated value, resume resyncs from server
- No more client-side timestamp drift
Playlist play all:
- Stops current playback before loading
- Respects play mode: random/rloop picks random first song, seq plays first
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AudioPlayer.seek() restarts FFmpeg with -ss parameter for actual seeking
- New POST /api/player/:botId/seek endpoint
- BotStatus includes seekOffset and playStartTime from server
- Frontend elapsed = seekOffset + (now - playStartedAt)
- Progress bar click now seeks to clicked position
- All timing actions use _resetTiming() helper with seekOffset
- Lyrics sync uses store.elapsed which includes seekOffset
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follows TS3AudioBot-NetEaseCloudmusic-plugin pattern:
- QueuedSong.url is now optional (metadata-only queue entries)
- resolveAndPlay() fetches URL on-demand right before playback
- Playlist/album/FM load instantly (only metadata stored)
- playNext() resolves URL lazily, skips up to 3 songs on failure
- Avoids expired URLs for songs deep in the queue
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New endpoints: play-by-id, add-by-id, play-playlist (load all songs at once)
- Playlist 'Play All' now uses server-side bulk load (no N sequential searches)
- Search/Home/Playlist views use playById instead of search-by-name
- Fixed updateBotStatus timing: capture prev state before mutation
- Early return on song change prevents stale pause/resume logic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Progress bar reads playStartedAt directly in requestAnimationFrame loop
bypassing Vue reactivity caching for smooth 60fps updates
- Queue panel moved to sibling of player-bar (was trapped inside causing z-index issues)
- Added progress thumb dot on hover
- Click on progress shows 'seek not available' tooltip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents EADDRINUSE crash that killed the entire app when port 3200
was still occupied from a previous run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Playlist: add console.error in catch block so load failures are visible
- Player: add progress bar with requestAnimationFrame and time display
- Player: extract toggleQueue function with debug logging for queue panel
- WebSocket: fetch queue on stateChange when not included in event payload
- Lyrics: use CSS transform scrolling and reduce sync to 500ms to fix flicker
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Start @sansenjian/qq-music-api server via dynamic import in api-server.ts
- Rewrite QQ Music provider to use real API endpoints (getSearchByKey,
getMusicPlay, getSongListDetail, getAlbumInfo, getLyric, etc.)
- Cache home page data (recommend playlists, daily songs, user playlists)
in Pinia store with 5-minute TTL to avoid refetching on every mount
- Add unified /api/music/search/all endpoint that queries both Netease
and QQ providers in parallel and returns merged results
- Remove platform toggle from Search.vue; search both platforms at once
- Add platform badge (网易云 / QQ) to SongCard component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add play mode cycle button (seq/loop/random/rloop) to Player bar
- Wire GET /api/player/:botId/history to query database instead of returning empty array
- Pass database instance from server.ts to createPlayerRouter
- Add back navigation button to Playlist, Lyrics, History, Search, and Settings views
- SongCard already had dblclick-to-play (no change needed)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Player store tracks playStartedAt/pausedElapsed for elapsed time
- Queue component fetches from server when opened
- Lyrics page syncs every 200ms using elapsed time
- Lyrics auto-scroll to active line with smooth animation
- Translation lines shown below main lyrics
- Fade mask on lyrics scroll area
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add backend endpoints for daily recommended songs, personal FM, user
playlists, and playlist detail. Extend NeteaseProvider and MusicProvider
interface with getDailyRecommendSongs and getUserPlaylists. Rewrite Home
page with FM card, daily recommendations grid, and user playlists section.
Fix Playlist view to fetch detail and songs from separate endpoints.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- High-precision frame loop using performance.now() tracking
- PCM-level volume control (live adjustable, no FFmpeg restart)
- Proper buffer drain on track end
- Fixes audio quality and playback speed issues
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Auth router accepts CookieStore and persists cookies on login
- NeteaseProvider passes timestamp to prevent cached QR responses
- QR image from server used directly (qrimg field)
- Cookie saved to disk on confirmed QR scan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- QR code generated client-side via qrcode library
- Auto-polls backend every 2s for scan status
- Shows waiting/scanned/confirmed/expired states
- Falls back to cookie paste method
- Auth status check on page load
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch from TCP ServerQuery (port 10011, invisible) to real TS3 client
protocol via @honeybbq/teamspeak-client (UDP 9987, ECDH handshake,
AES-EAX encryption). Bot now appears as a regular client visible to
all users in TeamSpeak.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>