Compare commits

...
Author SHA1 Message Date
TIANYAO ZHANG f0a0ae5f2c Merge pull request #28 from ZHANGTIANYAO1/dev
Dev
2026-04-13 00:53:12 +08:00
saopig1andClaude Opus 4.6 a56dc5bc85 docs(readme): document bot profile auto-update feature
Add profile feature to features list, architecture tree, and changelog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:52:09 +08:00
saopig1 1a663156c3 Merge branch 'main' of https://github.com/ZHANGTIANYAO1/teamspeak-music-bot into dev 2026-04-13 00:49:40 +08:00
saopig1andClaude Opus 4.6 e92628a6b4 fix(profile): add timeout to clientedit, guard clearAvatar with generation
- Wrap clientedit (description) with 5s timeout to prevent blocking
  channel description and now-playing updates if the command hangs
- Check generation counter in clearAvatar to avoid clearing a newer
  song's avatar when stop→play happens in quick succession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:09 +08:00
saopig1andClaude Opus 4.6 b4ad78ce84 fix(profile): harden against race conditions and edge cases
- Add generation counter to prevent stale avatar uploads from
  overwriting newer song's profile when rapidly skipping tracks
- Fix nickname truncation to use UTF-8 byte length instead of JS
  string length (TS3 counts bytes, Chinese chars are 3 bytes)
- Add timeout to clearAvatar file transfer (was missing)
- Extract withTimeout helper to deduplicate timeout logic
- Add BiliBili CDN thumbnail resize support (@200w_200h)
- Bump generation on reconnect to discard in-flight updates from
  the old connection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:44:57 +08:00
saopig1andClaude Opus 4.6 30aced6d36 fix(profile): use clientedit for description, add sendCommandNoWait
Description via clientupdate is rejected (error 1538) on TS3 full-client
protocol. Switch to clientedit on the bot's own clid, which is how
TS3AudioBot handles it. Requires b_client_modify_description permission.

Also add sendCommandNoWait to TS3Client for fire-and-forget commands
(clientupdate, channeledit) that don't return timely responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:39:36 +08:00
saopig1andClaude Opus 4.6 da5b34f5f4 feat(profile): auto-update bot avatar, nickname, and away status based on playing song
Add BotProfileManager that updates the bot's TeamSpeak presence when
songs change: album cover as avatar, song info in nickname, away status
toggled on stop/play. Each feature is independently configurable via
REST API and persisted to the database. Permission-safe — features that
fail due to insufficient server permissions are silently disabled until
reconnect. Description falls back to nickname display on TS3 (only
supported via TS6 HTTP Query).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:14:29 +08:00
TIANYAO ZHANG 010aa8aa8c Update README.md 2026-04-13 00:11:41 +08:00
TIANYAO ZHANG 05edca4a9a Merge pull request #27 from ZHANGTIANYAO1/claude/fix-shuffle-loop-bug-FrIbI
Fix random mode to track played songs and prevent duplicates
2026-04-12 23:09:33 +08:00
Claude e2b1a5e055 fix: prevent skipped/duplicate songs in Random mode edge cases
Three corner cases fixed:

1. Removing the currently-playing song caused the next song in the array
   to be silently marked as "played" and skipped. Root cause: playedIndices
   was updated in next() by marking currentIndex, but after remove() shifts
   currentIndex, it pointed to the wrong song. Fix: mark songs as played
   at play-time (in play/playAt/next/prev) instead of at next-request-time.

2. Using prev() in Random mode could cause a song to play twice — the
   song navigated to via prev() was not recorded in playedIndices, so
   next() could randomly select it again. Fix: prev() now marks the
   returned song as played.

3. Switching to Random mode mid-playback could cause the current song to
   repeat because setMode() cleared playedIndices without preserving the
   currently-playing song. Fix: setMode() now re-adds currentIndex after
   clearing.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 15:02:53 +00:00
Claude 4eac2e4dde fix: Random mode now stops after all songs played instead of looping forever
In Random (shuffle) mode, the queue's next() method would return the same
song indefinitely when only one song was in the playlist, and never terminate
even with multiple songs. This happened because played songs were not tracked.

Added a playedIndices Set to track which songs have already been played in
Random mode. Once all songs have been played once, next() returns null to
stop playback — matching the expected behavior where Random plays each song
once in random order, while RandomLoop is the mode for infinite shuffling.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 14:53:15 +00:00
TIANYAO ZHANG 78380f27d4 Merge pull request #26 from ZHANGTIANYAO1/claude/fix-docker-audio-playback-9bGfw
Prioritize system FFmpeg over bundled ffmpeg-static
2026-04-12 22:42:56 +08:00
Claude 47e0d0f288 fix: resolve Docker FFmpeg SIGSEGV crash and build failures (#24)
The ffmpeg-static npm package bundles a pre-compiled binary that passes
`ffmpeg -version` but crashes with SIGSEGV during actual audio processing
inside Docker containers (incompatible glibc/missing shared libraries).

Changes:
- Install system FFmpeg via apt-get in Docker production stage, which is
  always compatible with the container runtime
- Reverse FFmpeg resolution priority in player.ts: prefer system FFmpeg,
  fall back to ffmpeg-static (for non-Docker environments like Windows)
- Optimize Dockerfile multi-stage build: compile native modules (opus,
  better-sqlite3) in builder stage and copy to production, eliminating
  the need for build tools (python3, make, g++) in the production image
- Fix qq-music-api dependency from file:../qq-music-api (path outside
  Docker build context, breaks npm ci) to npm registry ^2.2.10

https://claude.ai/code/session_01JAV8sBokoifKh4Hc8XbJws
2026-04-12 14:41:48 +00:00
TIANYAO ZHANG dab02ede9c Merge pull request #25 from ZHANGTIANYAO1/claude/fix-docker-repo-url-C4vzW
Rename project from tsmusicbot to teamspeak-music-bot
2026-04-12 22:33:31 +08:00
Claude c950c7402f fix: correct project root directory name in README architecture section
Changed `tsmusicbot/` to `teamspeak-music-bot/` in the project
architecture directory tree to match the actual repository name.

https://claude.ai/code/session_01VTscSE9PxD6qwF56WBMunQ
2026-04-12 14:32:34 +00:00
Claude c86d70ca08 fix: correct git clone URL in README.md
The repo URL was pointing to the old `tsmusicbot.git` instead of the
actual repository name `teamspeak-music-bot.git`. Fixed both occurrences
(方式二 and 方式三 sections).

https://claude.ai/code/session_01VTscSE9PxD6qwF56WBMunQ
2026-04-12 14:31:49 +00:00
TIANYAO ZHANG d7107677c0 Merge pull request #22 from ZHANGTIANYAO1/dev
Dev
2026-04-11 23:19:27 +08:00
saopig1andClaude Opus 4.6 5647cf6d36 feat(qq): consume local @sansenjian/qq-music-api fork with VIP-aware getMusicPlay
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>
2026-04-11 22:52:12 +08:00
saopig1andClaude Opus 4.6 3aa06006fe fix(qq): repair QR code login flow against @sansenjian/qq-music-api 2.x
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>
2026-04-11 21:07:26 +08:00
TIANYAO ZHANG ac9f447ad2 Merge pull request #21 from ZHANGTIANYAO1/dev
Dev
2026-04-11 02:16:18 +08:00
saopig1andClaude Opus 4.6 5692d043c9 docs(readme): prepare for merge to main — remove dev-branch framing, add changelog
- 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>
2026-04-11 02:15:08 +08:00
saopig1andClaude Opus 4.6 49879f987c docs(readme): document identity migration for @honeybbq/teamspeak-client 0.2.x upgrade
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>
2026-04-11 01:57:39 +08:00
saopig1andClaude Opus 4.6 ca4cb1790a chore(deps): bump @honeybbq/teamspeak-client to 0.2.1, drop ts6-compat shim
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>
2026-04-11 01:54:43 +08:00
saopig1andClaude Opus 4.6 4643f70f4a fix: harden bot lifecycle, validate HTTP inputs, make YouTube truly optional
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>
2026-04-11 01:35:14 +08:00
saopig1andClaude Opus 4.6 6e828b9c2d fix(ws): re-attach stateChange listeners when bot instance is replaced
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>
2026-04-10 17:16:29 +08:00
saopig1andClaude Sonnet 4.6 d5d6abeb1e feat: implement server password login, YouTube source, and improved bot selector UI
- **TS server password** (#7/#9): add serverPassword field across database,
  manager, bot API, and Settings UI — allows joining password-protected servers
- **YouTube audio source** (#1/#10): new YouTubeProvider using yt-dlp binary;
  adds -y flag in chat commands, /api/music supports platform=youtube,
  YouTube badge in SongCard, yt-dlp-wrap npm dependency
- **Bot selector UI** (#14): selector always visible (not just when >1 bot),
  bigger button with border and play-state indicator; per-bot URL routing at
  /bot/:id with BotRedirect view; copy-link button in dropdown

Closes #1, #7, #9, #10, #14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:58:07 +08:00
saopig1 b20ddf2603 feat(manager): reload bot config from database on startBot
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.
2026-04-09 11:31:00 +08:00
saopig1 419911b78b Merge origin/main into dev: sync bug fixes from main
Merges 4 PRs from main:
- PR#15: Fix ffmpeg spawn failure causing infinite retry loop
- PR#16: Fix Docker restart crash (respect autoStart flag, persist identity)
- PR#17: Add missing "play" script to package.json
- PR#18: Add detailed update/upgrade instructions to README

Conflict resolution in src/data/database.ts:
- Combined dev's serverProtocol/ts6ApiKey fields with main's identity field
- Extended migrateSchema() to also migrate serverProtocol and ts6ApiKey columns
- Updated SQL schema and upsert query to include all three new columns
2026-04-08 15:48:40 +08:00
TIANYAO ZHANG 954bbddf7e Merge pull request #12 from ZHANGTIANYAO1/claude/start-music-api-servers-XrUNR
Fix TS6 compatibility by patching handshake clientinit packet
2026-04-04 23:27:28 +08:00
Claude b00b6637d2 Fix corner cases in connect/disconnect lifecycle and resource cleanup
1. Move TS6 handler patch to after client.connect() — the library's
   connect() internally replaces handler via #S(), discarding any
   patch applied beforehand. Patching after connect() is safe because
   clientinit is sent in async message callbacks after Init1 round-trips.

2. Preserve detectedProtocol across reconnect — disconnect() resets it
   to "unknown", causing the TS6 patch to be skipped on reconnect.

3. Prevent double "disconnected" event in BotInstance — disconnect()
   emitted it directly AND the async TS3Client disconnect triggered
   another through the event chain.

4. Guard playNext() against running after disconnect — check connected
   flag to avoid ghost queue processing.

5. Fix UDP error timer leak — clear previous timer before setting a new
   one to prevent accumulation.

6. Fix isPortFree() FD leak — close the test server on error path.

https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD
2026-04-04 15:26:41 +00:00
Claude 7ad9559653 Fix TS6 connection: patch clientinit version at handler level
The @honeybbq/teamspeak-client library sends clientinit directly via
handler.sendPacket() during the handshake, bypassing the commandMiddleware
chain entirely. This meant the ts6VersionMiddleware never intercepted the
handshake clientinit, so TS6 servers always received version 3.5.3 and
silently rejected it (never responding with initserver), causing idle timeout.

Fix: monkey-patch handler.sendPacket() to intercept clientinit packets and
upgrade the version to 3.6.2 before they're sent over the wire.

https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD
2026-04-04 14:24:06 +00:00
TIANYAO ZHANG 278f892fae Update README.md 2026-04-03 22:38:36 +08:00
Claude 80e92b77b8 Fix install.sh: build from source instead of requiring pre-built dist
The script previously checked for a dist/ directory and failed with
"Please run this script from the TSMusicBot source directory after
building" — requiring users to manually build before installing.

Now the script:
- Resolves project root from script location (works from any cwd)
- Installs build tools (build-essential/gcc) for native modules
- Runs npm install + npm run build automatically
- Copies built artifacts to /opt/tsmusicbot
- Creates data directory for runtime files
- Adds journalctl command to the help output

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 14:25:08 +00:00
Claude 185f592ce9 Update README for dev branch with TS3/TS6 dual protocol changelog
- Add dev branch notice and changelog section at the top
- Add TS3/TS6 badges
- Document new protocol modules (protocol-detect, http-query, ts6-compat)
- List all bug fixes in dev branch
- Update architecture diagram with new ts-protocol files
- Add TS6 Server FAQ entry
- Credit NeteaseTSBot for TS6 protocol reference

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 14:01:01 +00:00
Claude 1379a062ba Add TS6 clientinit version middleware and fix playNext retry bug
clientinit compatibility:
- Analyzed @honeybbq/teamspeak-client's clientinit: it already sends a
  clean 14-field payload without problematic fields (no badges,
  integrations, security_hash, etc.)
- The key difference vs NeteaseTSBot is client_version: our library
  sends 3.5.3, NeteaseTSBot sends 3.6.2. TS6 servers may reject
  older versions.
- Add ts6-compat.ts with CommandMiddleware that patches clientinit
  to use version 3.6.2 + matching ECDSA signature when connecting
  to detected TS6 servers
- Middleware is automatically applied when detectedProtocol === "ts6"

Pre-existing bug fix:
- Fix playNext() in instance.ts where successful retry still fell
  through to player.stop(), killing the just-started playback

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:46:31 +00:00
Claude 6184f38330 Fix 7 corner cases found in second review pass
Critical:
- Persist serverProtocol/ts6ApiKey in database schema so TS6 config
  survives restarts (added columns + manager save/load)

Medium:
- Clear udpErrorTimer on disconnect to prevent memory leak and stale
  log messages from firing after teardown
- Guard against double connect() by disconnecting old client first
- Add settled guard in TS6HttpQuery.request() to prevent double
  reject when both res error and req error fire
- Add res.on("error") handler to TS6HttpQuery response stream

Low:
- Cap probeTS3Query banner buffer at 256 bytes to prevent memory abuse
  from non-TS3 services sending large data on port 10011
- Remove unnecessary EventEmitter inheritance from TS6HttpQuery
- Update database test fixtures with new serverProtocol/ts6ApiKey fields

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:38:49 +00:00
Claude 9e45193e2c Fix corner cases in protocol detection and connection cleanup
- Fix double-resolve race in probeTS3Query (data event vs connect timer)
  by guarding with a resolved flag
- Fix double-resolve in probeTS6HttpQuery similarly
- Support custom query ports in detectServerProtocol via DetectOptions
- Clean up httpQuery and detectedProtocol on disconnect()
- Add res.on("error") handler in HTTP Query client to avoid unhandled errors
- Improve logging: warn with actionable message when protocol is unknown

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:34:37 +00:00
Claude e5fd35da32 Update package-lock.json after dependency install
https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:04:17 +00:00
Claude 9e51ba6cd4 Add dual TS3/TS6 protocol support with auto-detection
The @honeybbq/teamspeak-client library already handles TS6 license block
type 8 (Ts5Server) in its handshake, so voice connections work with both
TS3 and TS6 servers. This commit adds the surrounding infrastructure:

- protocol-detect.ts: Auto-detect server type by probing TS3 ServerQuery
  (port 10011) and TS6 HTTP Query (port 10080) in parallel
- http-query.ts: TS6 HTTP Query client replacing the raw-TCP ServerQuery
  that TS6 servers no longer support (ports 10080/10443)
- client.ts: Protocol-aware connection with auto-detection, TS6 HTTP
  Query setup, and forced protocol override option
- manager.ts: Pass through serverProtocol and ts6ApiKey config options
- connection.ts: Mark legacy TS3 ServerQuery as deprecated for TS6

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:03:47 +00:00
48 changed files with 5075 additions and 558 deletions

No files matched your search

+2
View File
@@ -6,3 +6,5 @@ dist/
config.json
cookies/
.superpowers/
/bin/
scripts/navbar_bigger.png
+177 -17
View File
@@ -1,11 +1,11 @@
<p align="center">
<img src="https://img.shields.io/badge/TeamSpeak_3-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
<img src="https://img.shields.io/badge/TeamSpeak-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
</p>
<h1 align="center">TSMusicBot</h1>
<p align="center">
<strong>TeamSpeak 3 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 + YouTube(可选),YesPlayMusic 风格 WebUI 控制面板
</p>
<p align="center">
@@ -16,14 +16,15 @@
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
</p>
---
## 功能特性
- **三平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩,统一搜索,结果标注来源
- **真实 TS3 客户端协议** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),兼容 TS3/TS5/TS6 服务器
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
@@ -33,6 +34,7 @@
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
@@ -40,7 +42,8 @@
## 截图
> *截图即将添加*
> <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
## 快速开始
@@ -64,8 +67,8 @@ FFmpeg **已自动内置**,无需手动安装。
```bash
# 下载项目
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
cd tsmusicbot
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
cd teamspeak-music-bot
# 安装依赖
npm install
@@ -85,8 +88,8 @@ npm start
所有依赖已内置(Node.js、FFmpeg、Opus 编码器),无需安装任何额外软件。
```bash
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
cd tsmusicbot/scripts/docker
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
cd teamspeak-music-bot/scripts/docker
docker-compose up -d
```
@@ -126,6 +129,32 @@ sudo ./scripts/install.sh
## 更新升级
> **⚠️ 从使用 `@honeybbq/teamspeak-client 0.1.x` 的旧版本升级时的重要变更**
>
> 本项目已将底层 TeamSpeak 协议库升级到 `0.2.x` 并移除了内置的 TS6 兼容层,改用库自带的通用 `clientinit` 协议。这涉及一次**数据库迁移**:
>
> **旧的身份(identity)不兼容新的加密握手路径。** `0.1.0` 版本的库在生成 TS 客户端身份时存在 P-256 公钥 DER 编码错误,该 bug 在 `0.1.1` 中由本项目维护者 [ZHANGTIANYAO1](https://github.com/HoneyBBQ/teamspeak-js/pull/5) 修复并合并到上游。`0.1.0` 生成的身份与 `0.2.x` 修复后的握手路径**不兼容**:升级后用旧身份连接会卡在 `received initivexpand2` 直到 15 秒超时。
>
> **解决办法**:升级后清空受影响机器人的 `identity` 字段,下次启动时程序会自动生成新身份并持久化。
>
> ```bash
> # 对每个需要迁移的机器人执行(替换 <bot-id> 为实际 UUID):
> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \
> db.execute(\"UPDATE bot_instances SET identity=NULL WHERE id='<bot-id>'\"); \
> db.commit()"
>
> # 或者清空所有机器人的身份:
> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \
> db.execute('UPDATE bot_instances SET identity=NULL'); db.commit()"
> ```
>
> **影响范围**:
> - ✅ TS3 服务器 + 旧身份:在多数情况下仍可正常工作(TS3 对 legacy 编码更宽容),可选择不清空
> - ❌ TS6 服务器 + 旧身份:**必须**清空身份才能连接
> - ⚠️ 清空身份后,TS 服务器会把机器人识别为**全新的客户端**。之前手动赋予机器人的**服务器组需要用新 UID 重新授予一次**,之后每次重启都会自动保留
>
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
### Windows 用户
```
@@ -186,7 +215,7 @@ npm run build
sudo systemctl start tsmusicbot
```
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。但请注意本节开头关于 **身份迁移** 的警告——从 0.1.x 版本升级时需要手动清空旧身份。
## 使用说明
@@ -217,6 +246,7 @@ sudo systemctl start tsmusicbot
| `!play <歌名>` | 搜索并播放 |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!add <歌名>` | 添加到播放队列 |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
@@ -251,7 +281,7 @@ sudo systemctl start tsmusicbot
## 项目架构
```
tsmusicbot/
teamspeak-music-bot/
├── src/ # 后端源码 (TypeScript)
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
@@ -260,7 +290,8 @@ tsmusicbot/
│ ├── bot/ # 机器人核心
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
│ │ └── manager.ts # 多实例生命周期管理
│ │ ├── manager.ts # 多实例生命周期管理
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
│ ├── data/ # 数据层
│ │ ├── config.ts # JSON 配置文件
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
@@ -269,10 +300,14 @@ tsmusicbot/
│ │ ├── netease.ts # 网易云音乐适配器
│ │ ├── qq.ts # QQ 音乐适配器
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
│ │ ├── auth.ts # Cookie 持久化存储
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
│ ├── ts-protocol/ # TS3 客户端协议
│ │ └── client.ts # 完整客户端(ECDH + AES-EAX 加密协议)
│ ├── ts-protocol/ # TeamSpeak 客户端协议(TS3/TS6 双协议)
│ │ ├── client.ts # 完整客户端(ECDH + AES-EAX 加密协议)
│ │ ├── protocol-detect.ts # 服务器协议自动检测(TS3 vs TS6)
│ │ ├── http-query.ts # TS6 HTTP Query 客户端(替代 TS3 ServerQuery)
│ │ └── ts6-compat.ts # TS6 兼容中间件(版本升级 + 签名)
│ ├── web/ # Web 后端
│ │ ├── server.ts # Express + WebSocket 服务
│ │ ├── websocket.ts # 实时状态广播
@@ -310,7 +345,7 @@ tsmusicbot/
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议,兼容 TS3/TS5/TS6) |
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
| **网易云 API** | NeteaseCloudMusicApi |
| **QQ 音乐 API** | @sansenjian/qq-music-api |
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
@@ -319,6 +354,55 @@ tsmusicbot/
| **图标** | @iconify/vue |
| **日志** | pino |
## 可选:YouTube 音源
YouTube 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
### 启用方式(任选其一)
**方式一:项目本地 `bin/` 目录(推荐)**
将 `yt-dlp` 可执行文件放到项目根目录下的 `bin/` 文件夹,程序会优先使用此路径。该目录已被 `.gitignore` 忽略,不会影响代码更新。
```bash
# Windows(PowerShell 或 Git Bash)
mkdir bin
curl -L -o bin/yt-dlp.exe https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
# Linux / macOS
mkdir -p bin
curl -L -o bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp
chmod +x bin/yt-dlp
```
**方式二:系统级安装(让 `yt-dlp` 在 `PATH` 中可用)**
```bash
# Windows
winget install yt-dlp
# macOS
brew install yt-dlp
# Debian/Ubuntu
sudo apt install yt-dlp
# 通用(Python 环境下)
pip install -U yt-dlp
```
### 验证是否可用
重启机器人程序,在 WebUI 或 `!play -y lofi` 测试搜索。若 `bin/` 和 `PATH` 中都找不到 `yt-dlp`,YouTube 搜索会静默返回空结果(不会影响其他音源),其余功能正常。
### 注意事项
- YouTube 音源通过 `yt-dlp` 本地调用实现,不依赖 API Key,也无需登录
- 播放的是视频的最佳音频流(`bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio`),由 FFmpeg 解码
- 音质由源视频决定,不受音质设置影响
- 受 YouTube 风控/地域限制,部分视频可能无法播放
- `yt-dlp` 更新较频繁,如果播放失败,先尝试升级 `yt-dlp` 到最新版本
## 配置文件
`config.json` 在首次运行时自动生成,可手动编辑:
@@ -341,6 +425,9 @@ tsmusicbot/
## 常见问题
**Q:支持 TeamSpeak 6 Server 吗?**
A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
@@ -369,6 +456,9 @@ A:原生模块(opus、sqlite3)需要编译工具,Dockerfile 已包含。
**Q:B站视频搜索不到结果?**
A:B站搜索需要 buvid3 匿名 Cookie(程序启动时自动获取)。如果失败,重启程序即可。登录B站账号后搜索效果更好。
**Q:YouTube 平台搜索返回空结果?**
A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:YouTube 音源](#可选youtube-音源) 章节。快速验证:在项目根目录执行 `bin/yt-dlp --version`(或系统 `yt-dlp --version`),能打印版本号即可。若 yt-dlp 已安装但仍搜索失败,通常是网络/地域问题或 yt-dlp 版本过旧(执行 `yt-dlp -U` 升级)。
**Q:如何更新到新版本?**
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
@@ -380,12 +470,82 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
4. 推送分支 (`git push origin feature/新功能`)
5. 提交 Pull Request
## 更新日志
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本
**机器人形象自动更新(Bot Profile)**
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
**协议层 & 稳定性**
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
- ⚠️ **破坏性**:`0.1.0` 生成的旧身份与新握手路径不兼容,升级时需要迁移。详见 [更新升级](#更新升级) 章节顶部的警告。
- **修复 `startBot` 与 `stopBot` 之间的竞态**:mid-handshake 被替换的 BotInstance 不再泄漏 TS 会话,`disconnect()` 被 `connect()` 的 await 插队时不再错误地把 `connected` 翻回 `true`。
- **修复播放条自动刷新 bug**:BotManager 现在在创建新 BotInstance 时 emit `botInstance` 事件,WebSocket 监听器会立即重新挂接到新实例,播放状态变化无需手动刷新页面。
- **`connect()` 增加 15 秒超时**:握手卡住时会清理掉挂起的实例并返回 500,不再无限阻塞 HTTP 请求和 UI。
- **识别持久化修复**:`startBot` 现在会从数据库读取 `identity` 传给新 BotInstance,服务器组在机器人重启后能保留。
**HTTP API 加固**
- 新增输入校验,拒绝无效值并返回 **400**(之前会返回 200 包装 usage-text 字符串):
- `/volume`:非数字、`NaN`/`Infinity`、超出 `[0,100]`
- `/mode`:不在 `{seq, loop, random, rloop}` 中的值
- `/seek`:`NaN`/`Infinity`、负数、字符串
- `/play-at`:索引越界(**先**校验再停止当前播放,避免误杀正在播的歌)
- **修复 YouTube 平台路由**:`/play`、`/add`、`/playlist`、`/play-by-id`、`/add-by-id`、`/play-playlist` 现在都正确处理 `platform=youtube`(之前会静默回退到网易云)。
- **修复 `/auth/status?platform=youtube` 数据泄漏**:之前会回退到网易云并返回网易云用户的昵称 + 头像 URL,现在正确路由到 YouTube provider 并报告 `yt-dlp` 的实际可用状态。
- **`/auth/cookie` 拒绝 `platform=youtube`**,防止意外覆盖网易云 cookie。
**连接状态一致性**
- 断开连接时,音频命令(`play`/`add`/`next`/`prev`/`playlist`/`album`/`fm`)返回 **400 "Bot is not connected to TeamSpeak"**;配置类命令(`volume`/`mode`/`clear`/`stop`/`queue`/`now`/`lyrics`)仍可正常工作,保持 UI 可用。
- `resolveAndPlay` 在网络请求(URL 解析)前后都会检查 `this.connected`,防止在解析期间被 `stop()` 中断后仍然启动 ffmpeg。
- `tsClient` 的 `disconnected` 事件处理器现在总是清理播放器状态,不再因为 `connect()` 从未完成而遗留 `playing=true` 的僵尸状态。
**功能改进**
- **YouTube 音源(可选)**:新增基于 `yt-dlp` 的 YouTube provider,通过 `!play -y <关键词>` 或 WebUI 平台选项使用。未安装 `yt-dlp` 时静默降级、返回空结果,不影响其他音源。详见 [可选:YouTube 音源](#可选youtube-音源)。
- **Bot Selector UI**:
- 始终可见(不再只有 ≥2 个机器人时才显示)
- 尺寸放大(更大的按钮、字体、状态图标)
- 每行增加 **电源按键**(一键启动/停止对应机器人,带禁用态与播放状态高亮)
- 每行增加 **链接按钮**(复制机器人专属 URL)
- 新路由 `/bot/:id`,打开后自动切换到对应机器人
- **服务器密码登录**:`serverPassword` 字段已加入数据库与 Settings UI,支持加入需要密码的 TS 服务器。
- **`!add` 一键开播**:在连接状态下向空队列 `!add` 歌曲时自动开始播放(之前只会入队,需要再 `!play` 或 `!next`)。
- **WebSocket 新增 `botRemoved` 事件**:删除机器人后 UI 会立即从列表中移除(之前需要手动刷新页面)。
**内部修复**
- **`PlayQueue.remove()` 当前歌曲移除 bug**:移除正在播放的歌曲时,`next()` 不再跳过紧跟其后的那首歌。
- **投票跳过**:需要的票数现在至少为 1(避免 `needed=0` 时单人"全票通过"的边界情况);投票计数会在每首新歌开始时自动清零,不再跨歌曲泄漏。
- 多处输入边界修复:`seek` 防止 `NaN` 毒化 `seekOffset` 导致 `getElapsed()` 永久返回 `NaN`;`play-at` 越界时不再误杀当前播放。
### 历史重要变更
更早的变更请查阅 git log。主要里程碑:
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
## 致谢
感谢以下项目和开发者:
| 项目 | 说明 |
|------|------|
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
| [TS3AudioBot-NetEaseCloudmusic-plugin](https://github.com/ZHANGTIANYAO1/TS3AudioBot-NetEaseCloudmusic-plugin) | 提供插件开发参考和懒加载设计参考 |
@@ -0,0 +1,124 @@
# Bot Profile Manager — Design Spec
## Goal
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
## Features
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|---|---------|---------------|-----------------|---------------|---------------|
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
## Architecture
```
resolveAndPlay() success / stop / clear / playNext exhausted
↓
BotInstance → BotProfileManager.onSongChange(song | null)
├─ updateAvatar(coverUrl | null) [fire-and-forget]
├─ updateDescription(song | null)
├─ updateNickname(song | null)
├─ updateAwayStatus(song | null)
├─ updateChannelDescription(song | null)
└─ sendNowPlayingMessage(song) [only when song != null]
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
BotInstance.disconnect() → (no action needed, server cleans up)
```
## File Changes
| File | Change |
|------|--------|
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
## TS3Client Layer Extensions
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
```typescript
execCommand(cmd: string): Promise<void>
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
fileTransferInitUpload(channelID: bigint, path: string, password: string,
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
```
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
## BotProfileManager Detail
```typescript
interface ProfileConfig {
avatarEnabled: boolean; // default true
descriptionEnabled: boolean; // default true
nicknameEnabled: boolean; // default true
awayStatusEnabled: boolean; // default true
channelDescEnabled: boolean; // default true
nowPlayingMsgEnabled: boolean; // default true
}
```
### Permission Handling
- Each feature has an independent `permDenied: boolean` flag.
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
- On `onConnect()` → reset all flags (new connection may have different permissions).
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
### Nickname Truncation
- Format: `♪ {songInfo} | {defaultNickname}`
- TS3 max nickname: 30 characters
- If total > 30: truncate songInfo, keep defaultNickname
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
### Avatar Upload Flow (TS3)
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
3. `uploadFileData(host, info, Readable.from(buffer))`
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
### Fire-and-Forget
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
## Database Migration
```sql
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
```
## Web API
```
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
```
## Constraints
- All profile operations are async, never block playback
- Uses existing `axios` dependency for image download
- MD5 via Node.js built-in `crypto`
- No new npm dependencies required
+347 -349
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -15,9 +15,9 @@
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.10.0",
"@honeybbq/teamspeak-client": "^0.1.0",
"@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.9",
"@sansenjian/qq-music-api": "^2.2.10",
"axios": "^1.14.0",
"better-sqlite3": "^12.8.0",
"chalk": "^5.6.2",
@@ -30,7 +30,8 @@
"pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.20.0"
"ws": "^8.20.0",
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
+10 -13
View File
@@ -25,30 +25,27 @@ RUN cd web && npm ci
COPY . .
RUN npm run build
# Install production dependencies (with native addons compiled) in the builder
# so we don't need build tools in the production image.
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
# --- Stage 2: Production image ---
FROM node:20-slim
# Install only runtime native build tools (needed for native module rebuild)
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
# System-installed FFmpeg is always compatible with the container runtime.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ && \
ffmpeg && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy built output
# Copy built output and pre-compiled production node_modules from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/web/dist ./web/dist
COPY --from=builder /app/package*.json ./
# Install production dependencies (includes ffmpeg-static, opus, sqlite3)
# These need to compile native addons inside the container
RUN npm ci --production && npm cache clean --force
# Remove build tools to reduce image size
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# FFmpeg is bundled via ffmpeg-static — no system ffmpeg needed
COPY --from=builder /app/node_modules ./node_modules
# Data directory for database, cookies, logs
RUN mkdir -p /app/data
+2 -2
View File
@@ -1,8 +1,8 @@
# TSMusicBot — Docker Compose
# 一键部署:docker-compose up -d
#
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)
# 无需安装任何额外软件
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)无需安装任何额外软件
# FFmpeg 使用系统包管理器安装,确保容器内兼容性
services:
tsmusicbot:
+41 -17
View File
@@ -6,9 +6,19 @@ echo "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝"
echo ""
# Resolve script location → project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="/opt/tsmusicbot"
SERVICE_NAME="tsmusicbot"
# Verify we're in a valid project directory
if [ ! -f "$PROJECT_DIR/package.json" ]; then
echo "Error: Cannot find package.json in $PROJECT_DIR"
echo "Please run this script from the TSMusicBot project directory."
exit 1
fi
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
@@ -18,44 +28,57 @@ else
exit 1
fi
echo "[1/5] Installing system dependencies..."
echo "[1/6] Installing system dependencies..."
case $OS in
ubuntu|debian)
sudo apt-get update -qq
sudo apt-get install -y -qq curl ffmpeg
sudo apt-get install -y -qq curl build-essential python3
;;
centos|rhel|fedora)
sudo yum install -y curl ffmpeg
sudo yum install -y curl gcc gcc-c++ make python3
;;
arch|manjaro)
sudo pacman -S --noconfirm curl ffmpeg
sudo pacman -S --noconfirm curl base-devel python
;;
*)
echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually."
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
;;
esac
echo "[2/5] Installing Node.js 20 LTS..."
echo "[2/6] Installing Node.js 20 LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
fi
echo "Node.js $(node -v) installed"
echo "[3/5] Downloading TSMusicBot..."
sudo mkdir -p "$INSTALL_DIR"
if [ -d "$(pwd)/dist" ]; then
sudo cp -r "$(pwd)"/* "$INSTALL_DIR/"
else
echo "Please run this script from the TSMusicBot source directory after building."
exit 1
echo "[3/6] Installing dependencies..."
cd "$PROJECT_DIR"
npm install
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
(cd "$PROJECT_DIR/web" && npm install)
fi
echo "[4/5] Installing npm dependencies..."
cd "$INSTALL_DIR"
sudo npm install --production
echo "[4/6] Building project..."
npm run build
echo "[5/5] Creating systemd service..."
echo "[5/6] Copying to $INSTALL_DIR..."
sudo mkdir -p "$INSTALL_DIR"
sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
# Copy web frontend if built
if [ -d "$PROJECT_DIR/web/dist" ]; then
sudo mkdir -p "$INSTALL_DIR/web"
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
fi
# Copy scripts for future use
sudo mkdir -p "$INSTALL_DIR/scripts"
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
# Create data directory
sudo mkdir -p "$INSTALL_DIR/data"
echo "[6/6] Creating systemd service..."
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
[Unit]
Description=TSMusicBot - TeamSpeak Music Bot
@@ -88,4 +111,5 @@ echo "║ Commands: ║"
echo "║ systemctl status tsmusicbot ║"
echo "║ systemctl restart tsmusicbot ║"
echo "║ systemctl stop tsmusicbot ║"
echo "║ journalctl -u tsmusicbot -f ║"
echo "╚══════════════════════════════════════╝"
+212
View File
@@ -0,0 +1,212 @@
"""Open a real Chromium browser at y.qq.com, let the user log in via any
method (password / QR / QQ connect), then extract the resulting cookie
set and save it to data/cookies/qq.json. Also tests whether the cookie
actually unlocks a known VIP track (Jay Chou 稻香) against the local
QQ Music API before declaring success.
Usage:
"C:/Users/saopig1/miniforge3/python.exe" scripts/qq_browser_login.py
Steps:
1. A visible Chromium window opens at y.qq.com/n/ryqq/player
2. Click the login button in the top right and log in with your
real QQ Music account (the one that has VIP)
3. The script POLLS cookies in the background and auto-detects
successful login by watching for the `uin` cookie to appear
4. Once detected, cookies are captured, tested against
/getMusicPlay for 稻香, and saved on success
5. If VIP still fails, cookies are NOT saved — your existing bot
cookie stays untouched
No terminal input required — the script exits on its own when login
is detected (or after the configured timeout).
"""
from __future__ import annotations
import json
import re
import time
from pathlib import Path
import requests
from playwright.sync_api import sync_playwright
BOT_ROOT = Path(r"C:\Users\saopig1\Music\teamspeak music bot")
COOKIE_FILE = BOT_ROOT / "data" / "cookies" / "qq.json"
QQ_API = "http://localhost:3200"
VIP_TEST_SONGMID = "003aAYrm3GE0Ac" # 稻香 周杰伦
# How long to wait for the user to finish logging in
LOGIN_TIMEOUT_S = 300 # 5 minutes
POLL_INTERVAL_S = 1.0
# After detecting login, wait a bit for extra cookies (e.g. qqmusic_key)
SETTLE_DELAY_S = 4.0
def qq_cookies(ctx) -> list[dict]:
wanted_suffixes = (".qq.com", "y.qq.com", ".music.qq.com")
return [
c for c in ctx.cookies()
if any(c.get("domain", "").endswith(s) or c.get("domain", "") == s.lstrip(".")
for s in wanted_suffixes)
]
def cookies_to_header(cookies: list[dict]) -> str:
return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
def cookie_has_uin(cookies: list[dict]) -> str | None:
for c in cookies:
if c["name"] == "uin" and c["value"]:
return c["value"]
return None
def test_vip_unlock(cookie_header: str) -> tuple[bool, dict]:
try:
r = requests.get(
f"{QQ_API}/getMusicPlay",
params={"songmid": VIP_TEST_SONGMID, "cookie": cookie_header},
timeout=10,
proxies={"http": None, "https": None},
)
body = r.json()
play = body.get("data", {}).get("playUrl", {}).get(VIP_TEST_SONGMID, {})
url = play.get("url", "")
return (
bool(url),
{
"url_length": len(url),
"url_prefix": url[:120] if url else "",
"error": play.get("error", ""),
},
)
except Exception as e:
return False, {"error": f"request failed: {e}"}
def main() -> int:
print("[setup] launching visible Chromium — look for the window on your desktop")
print("[setup] goto https://y.qq.com/n/ryqq/player")
print()
print("action required:")
print(" 1. Click the 登录 button (top-right) in the browser window")
print(" 2. Log in with your VIP QQ Music account (QR / password / WeChat)")
print(" 3. Do NOTHING in this terminal — the script detects login itself")
print()
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
ctx = browser.new_context(
viewport={"width": 1280, "height": 820},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/132.0.0.0 Safari/537.36"
),
)
page = ctx.new_page()
try:
page.goto("https://y.qq.com/n/ryqq/player", wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f"[warn] initial navigation slow: {e}")
print(f"[wait] polling every {POLL_INTERVAL_S}s for login (timeout {LOGIN_TIMEOUT_S}s)")
deadline = time.time() + LOGIN_TIMEOUT_S
uin_detected: str | None = None
last_report = 0.0
while time.time() < deadline:
cks = qq_cookies(ctx)
uin = cookie_has_uin(cks)
if uin:
uin_detected = uin
print(f"[detect] uin cookie appeared: {uin}")
break
now = time.time()
if now - last_report >= 15:
remaining = int(deadline - now)
n = len(cks)
print(f"[wait] still waiting... {n} qq.com cookies so far, {remaining}s left")
last_report = now
time.sleep(POLL_INTERVAL_S)
if not uin_detected:
print("[abort] login not detected within timeout")
browser.close()
return 1
print(f"[settle] waiting {SETTLE_DELAY_S}s for session cookies to populate")
time.sleep(SETTLE_DELAY_S)
cks = qq_cookies(ctx)
cookie_header = cookies_to_header(cks)
print(f"[capture] {len(cks)} cookies, {len(cookie_header)} char header")
qm_key = next((c["value"] for c in cks if c["name"] == "qqmusic_key"), "")
qm_keyst = next((c["value"] for c in cks if c["name"] == "qm_keyst"), "")
p_skey = next((c["value"] for c in cks if c["name"] == "p_skey"), "")
print(f"[capture] qqmusic_key: {'present (' + qm_key[:20] + '...)' if qm_key else '(absent)'}")
print(f"[capture] qm_keyst : {'present (' + qm_keyst[:20] + '...)' if qm_keyst else '(absent)'}")
print(f"[capture] p_skey : {'present' if p_skey else '(absent)'}")
print("\n[test] calling /getMusicPlay for 稻香 with captured cookie...")
unlocked, details = test_vip_unlock(cookie_header)
print(f"[test] unlocked: {unlocked}")
print(f"[test] details: {details}")
if not unlocked:
print(
"\n[result] VIP did NOT unlock even with browser-extracted cookies.\n"
" Existing cookie file is UNTOUCHED.\n"
" Diagnosis: the login flow is not the bottleneck — the\n"
" account likely lacks entitlement for this specific track,\n"
" OR QQ requires additional session setup (gateway handshake)\n"
" beyond what's in the cookie itself.\n"
)
# Dump the full cookie set for inspection
dump_path = BOT_ROOT / "data" / "cookies" / "qq.browser-capture.json"
dump_path.write_text(
json.dumps(
{"cookie": cookie_header, "cookieList": cks, "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
print(f"[dump] full browser cookies written to {dump_path}")
print(" (for side-by-side comparison with OAuth-derived cookies)")
browser.close()
return 2
print("\n[save] VIP unlocked. Writing cookie to bot...")
COOKIE_FILE.write_text(
json.dumps(
{"cookie": cookie_header, "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
ensure_ascii=False,
),
encoding="utf-8",
)
print(f"[save] wrote {COOKIE_FILE}")
try:
r = requests.post(
"http://localhost:3000/api/auth/cookie",
json={"platform": "qq", "cookie": cookie_header},
timeout=5,
proxies={"http": None, "https": None},
)
print(f"[notify] /api/auth/cookie POST: {r.status_code}")
except Exception as e:
print(f"[notify] failed to push cookie to bot: {e}")
print(" Restart the bot to pick up the new cookie from disk.")
print("\n[done] VIP should now work through the bot. Try playing 稻香!")
browser.close()
return 0
if __name__ == "__main__":
import sys
sys.exit(main())
+119
View File
@@ -0,0 +1,119 @@
"""Open a visible browser at y.qq.com so the user can manually verify
whether their VIP account can play 稻香 (Jay Chou) in the real QQ Music
web player.
If the browser plays the song → entitlement exists and our 104003 is a
request-signing issue.
If the browser refuses / shows a VIP modal / silently fails → the
account doesn't have entitlement OR QQ's web player hits the same wall.
"""
import sys
import time
from playwright.sync_api import sync_playwright
# Force line-buffered stdout so logs actually reach the output file
sys.stdout.reconfigure(line_buffering=True)
START_URL = "https://y.qq.com/n/ryqq/player"
SONG_URL = "https://y.qq.com/n/ryqq/songDetail/003aAYrm3GE0Ac"
def log(msg: str) -> None:
print(msg, flush=True)
def main() -> int:
log("[setup] launching visible Chromium")
log(f"[setup] start URL: {START_URL}")
log(f"[setup] song URL: {SONG_URL}")
log("")
log("action required:")
log(" 1. The browser opens at the player page")
log(" 2. Make sure your VIP account is logged in (top-right avatar)")
log(" — if not, log in now, the script will wait")
log(" 3. Once logged in, the browser will auto-navigate to 稻香")
log(" 4. Click the PLAY button and report what happens:")
log(" (a) song plays → account has entitlement, issue is request signing")
log(" (b) VIP modal → account needs higher tier / digital album purchase")
log(" (c) silent failure → QQ web player has same 104003 wall")
log("")
log("[wait] browser stays open for 5 minutes")
log("")
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=False)
except Exception as e:
log(f"[fatal] failed to launch Chromium: {e}")
return 1
ctx = browser.new_context(
viewport={"width": 1400, "height": 900},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/132.0.0.0 Safari/537.36"
),
)
page = ctx.new_page()
# Log every navigation so we can see if pages fail
page.on("framenavigated", lambda f: log(f"[nav] {f.url[:120]}") if f == page.main_frame else None)
page.on("pageerror", lambda e: log(f"[js-error] {str(e)[:200]}"))
# Step 1: open the player page (known working)
log(f"[goto] {START_URL}")
try:
page.goto(START_URL, wait_until="domcontentloaded", timeout=30_000)
log(f"[ok] loaded: {page.url}")
except Exception as e:
log(f"[warn] initial goto failed: {e}")
log("[warn] browser stays open, try manual navigation")
# Wait briefly for auth state to settle
time.sleep(3)
# Check if the user is logged in via the uin cookie
cookies = ctx.cookies()
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
if uin:
log(f"[auth] logged in as uin={uin}")
else:
log("[auth] not logged in yet — please log in via the top-right avatar")
log("[auth] waiting up to 2 minutes for login...")
end = time.time() + 120
while time.time() < end:
time.sleep(1)
cookies = ctx.cookies()
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
if uin:
log(f"[auth] detected login: uin={uin}")
break
if not uin:
log("[abort] no login detected within 2 minutes")
time.sleep(30) # keep browser visible
browser.close()
return 2
# Step 2: navigate to the song page
log(f"[goto] {SONG_URL}")
try:
page.goto(SONG_URL, wait_until="domcontentloaded", timeout=30_000)
log(f"[ok] loaded: {page.url}")
except Exception as e:
log(f"[warn] song navigation failed: {e}")
log(f"[info] current page: {page.url}")
log("")
log("===========================================================")
log("browser is open on the song page — click PLAY and observe.")
log("keeping browser open for 5 more minutes")
log("===========================================================")
time.sleep(300)
browser.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+12
View File
@@ -0,0 +1,12 @@
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto("http://localhost:3000")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(800)
page.locator(".navbar").screenshot(path="scripts/navbar_bigger.png")
rect = page.locator(".bot-selector-btn").bounding_box()
print("bot-selector-btn bbox:", rect)
browser.close()
+99
View File
@@ -0,0 +1,99 @@
"""Regression: DELETE /api/bot/:id must broadcast botRemoved so the UI drops the row.
Creates an ephemeral bot, opens the dropdown, deletes the bot via API, and
asserts the row disappears without any page reload. Does not touch any
existing user bot.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
EPHEMERAL_NAME = "rmbot_test"
EPHEMERAL_NICK = "RmBotTest"
def api(path, method="GET", **kw):
r = getattr(requests, method.lower())(f"{BASE}{path}", timeout=10, **kw)
r.raise_for_status()
return r.json() if r.text else None
def cleanup():
for b in api("/api/bot/")["bots"]:
if b["name"] == EPHEMERAL_NAME:
try:
api(f"/api/bot/{b['id']}", method="DELETE")
except Exception:
pass
def main():
cleanup()
new = api(
"/api/bot/",
method="POST",
json={
"name": EPHEMERAL_NAME,
"serverAddress": "127.0.0.1",
"serverPort": 9987,
"nickname": EPHEMERAL_NICK,
"autoStart": False,
},
)
bot_id = new["id"]
print(f"[setup] created ephemeral bot {bot_id[:8]}")
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto(BASE)
page.wait_for_load_state("networkidle")
time.sleep(0.8)
# Open dropdown and confirm the new bot row is present
page.locator(".bot-selector-btn").click()
page.wait_for_selector(".bot-dropdown")
rows_before = page.locator(".bot-dropdown-row").count()
print(f"[ui] dropdown rows before remove: {rows_before}")
# Match the ephemeral row by its name text
present = (
page.locator(".bot-dropdown-row", has_text=EPHEMERAL_NAME).count()
)
assert present == 1, f"ephemeral row not found (got {present})"
# Delete via API
api(f"/api/bot/{bot_id}", method="DELETE")
print("[api] deleted bot")
# Wait up to 4s for UI to drop the row
removed = False
for _ in range(40):
if (
page.locator(
".bot-dropdown-row", has_text=EPHEMERAL_NAME
).count()
== 0
):
removed = True
break
time.sleep(0.1)
rows_after = page.locator(".bot-dropdown-row").count()
print(f"[ui] dropdown rows after remove: {rows_after}")
assert removed, "ephemeral row did not disappear from UI after DELETE"
assert rows_after == rows_before - 1, (
f"row count mismatch: before={rows_before} after={rows_after}"
)
print("[PASS] bot removal propagates to UI via WS")
finally:
browser.close()
finally:
cleanup()
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
"""Corner case regressions that go beyond Bugs A/B/C.
A. Race — disconnect() called during connect()'s awaited handshake must
NOT leave the bot reporting connected=true afterwards.
B. Config-only commands (vol, mode, clear) must work even when the bot is
disconnected (UI should stay usable while the bot is offline).
C. After a disconnect mid-playback, the player must not be able to
auto-advance to the next queued song (trackEnd → resolveAndPlay).
"""
import time
import threading
import requests
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
def get_bot(bot_id):
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_connected(bot_id, want, timeout=15):
end = time.time() + timeout
while time.time() < end:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.1)
return False
def test_config_commands_when_disconnected(bot_id):
"""B. vol/mode/clear should succeed while bot is disconnected."""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
# volume is the simplest config-only command
r = api(
f"/api/player/{bot_id}/volume",
method="POST",
json={"volume": 60},
)
assert r.status_code == 200, f"volume failed while disconnected: {r.status_code} {r.text[:100]}"
r = api(
f"/api/player/{bot_id}/mode",
method="POST",
json={"mode": "seq"},
)
assert r.status_code == 200, f"mode failed while disconnected: {r.status_code} {r.text[:100]}"
r = api(f"/api/player/{bot_id}/clear", method="POST")
assert r.status_code == 200, f"clear failed while disconnected: {r.status_code} {r.text[:100]}"
print("[PASS] config commands (vol/mode/clear) work when disconnected")
def test_play_rejected_when_disconnected(bot_id):
"""B (negative). play/add/next/prev should still be rejected."""
r = api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "test", "platform": "netease"},
)
assert r.status_code >= 400, f"play should fail while disconnected"
r = api(
f"/api/player/{bot_id}/add",
method="POST",
json={"query": "test", "platform": "netease"},
)
assert r.status_code >= 400, f"add should fail while disconnected"
r = api(f"/api/player/{bot_id}/next", method="POST")
assert r.status_code >= 400, f"next should fail while disconnected"
print("[PASS] audio commands (play/add/next) rejected when disconnected")
def test_disconnect_during_connect_race(bot_id):
"""A. disconnect() called while connect() is awaiting must win the race.
Fires a stop 200ms into a start call; after things settle the bot's
connected state must be stable (either cleanly disconnected, or cleanly
connected if the stop happened after connect completed). It must NOT
end up in a weird state where connected=true but a subsequent query
shows inconsistent data.
"""
# Ensure disconnected first
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1) # give TS server a moment to forget us
def delayed_stop():
time.sleep(0.2)
try:
api(f"/api/bot/{bot_id}/stop", method="POST")
except Exception:
pass
threading.Thread(target=delayed_stop, daemon=True).start()
try:
r = api(f"/api/bot/{bot_id}/start", method="POST")
except Exception as e:
r = None
print(f"[info] start threw: {e}")
# Wait for all state transitions to settle
time.sleep(2)
b = get_bot(bot_id)
# The key invariant: if connected is false, playing must also be false;
# if connected is true, the transport is actually up (we can issue
# another command without error).
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state: connected={b['connected']} playing={b['playing']}"
)
print(
f"[PASS] disconnect-during-connect race — final state consistent "
f"(connected={b['connected']} playing={b['playing']})"
)
def test_resolve_guard(bot_id):
"""C. resolveAndPlay on a disconnected bot is a no-op.
We can't directly invoke resolveAndPlay from the API, but we can
verify by checking that after a stop, the bot stays idle even if we
wait for a trackEnd-like event to fire.
"""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1.5) # more than a frame cycle
b = get_bot(bot_id)
assert not b["playing"], (
f"player should stay stopped after disconnect: {b}"
)
print("[PASS] player stays idle after disconnect (no ghost autoplay)")
def main():
bots = api("/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial = bots[0]["connected"]
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
try:
test_config_commands_when_disconnected(bot_id)
test_play_rejected_when_disconnected(bot_id)
test_resolve_guard(bot_id)
test_disconnect_during_connect_race(bot_id)
print("ALL GREEN")
finally:
if initial:
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
else:
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
print(f"[restore] connected={get_bot(bot_id)['connected']}")
if __name__ == "__main__":
main()
+852
View File
@@ -0,0 +1,852 @@
"""Comprehensive feature + corner-case test for TSMusicBot against a
local TeamSpeak 3 server.
Exercises every major HTTP endpoint, the WebSocket state-broadcast path,
all music providers, bot lifecycle transitions, and a handful of races
that have burned us in the past. Designed to be safe to run against a
real installation: captures the target bot's initial connected/volume/
mode settings and restores them in `finally`.
Usage:
"C:/Users/saopig1/miniforge3/python.exe" scripts/test_full_feature.py
Exit code: 0 if every non-skipped test passed, 1 otherwise.
"""
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass
from typing import Any
import requests
BASE = "http://localhost:3000"
POLL_INTERVAL = 0.15
CONNECT_TIMEOUT = 20 # tolerate occasional TS3 anti-flood grace
ANTIFLOOD_BREATHER = 1.5 # gap between rapid cycles so TS3 stays happy
# ----------------------------- HTTP helpers ---------------------------------
def api(path: str, method: str = "GET", json_body: Any = None):
"""Return (status_code, body). Never raises."""
try:
fn = getattr(requests, method.lower())
r = fn(f"{BASE}{path}", json=json_body, timeout=30)
try:
return r.status_code, r.json()
except Exception:
return r.status_code, r.text
except Exception as e:
return None, f"<{type(e).__name__}: {e}>"
def get_bot(bot_id: str) -> dict | None:
_, data = api("/api/bot/")
if not isinstance(data, dict):
return None
return next((b for b in data.get("bots", []) if b["id"] == bot_id), None)
def wait_connected(bot_id: str, want: bool, timeout: float = CONNECT_TIMEOUT) -> bool:
end = time.time() + timeout
while time.time() < end:
b = get_bot(bot_id)
if b is not None and b["connected"] is want:
return True
time.sleep(POLL_INTERVAL)
return False
def start_and_wait(bot_id: str, retries: int = 2) -> bool:
"""Start the bot, tolerating transient TS3 anti-flood by retrying with
exponential backoff. Returns True only when the bot reports connected."""
for attempt in range(retries + 1):
s, _ = api(f"/api/bot/{bot_id}/start", method="POST")
if s == 200 and wait_connected(bot_id, True):
return True
# If /start returned an error (e.g. connect timeout from our 15s
# deadline), back off and retry — TS3 server-side anti-flood
# usually clears in a few seconds.
if attempt < retries:
time.sleep(3.0 * (attempt + 1))
# Make sure we're fully stopped before the next attempt so
# oldBot.disconnect() doesn't double-fire
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False, timeout=5)
return False
def stop_and_wait(bot_id: str) -> bool:
api(f"/api/bot/{bot_id}/stop", method="POST")
return wait_connected(bot_id, False)
def assert_started(bot_id: str):
"""Helper that raises with a clear message when start fails so test
output points at 'could not connect' rather than an empty assertion."""
if not start_and_wait(bot_id):
raise AssertionError(
"could not bring bot online (TS3 server may be anti-flooding "
"or unreachable)"
)
# ----------------------------- test runner ----------------------------------
@dataclass
class TestResult:
name: str
status: str # PASS / FAIL / ERROR / SKIP
detail: str = ""
passed = 0
failed = 0
skipped = 0
results: list[TestResult] = []
def run(name: str, fn):
global passed, failed
try:
fn()
print(f" [PASS] {name}")
passed += 1
results.append(TestResult(name, "PASS"))
except AssertionError as e:
print(f" [FAIL] {name}: {e}")
failed += 1
results.append(TestResult(name, "FAIL", str(e)))
except Exception as e:
print(f" [ERROR] {name}: {type(e).__name__}: {e}")
failed += 1
results.append(TestResult(name, "ERROR", f"{type(e).__name__}: {e}"))
def skip(name: str, reason: str):
global skipped
print(f" [SKIP] {name} ({reason})")
skipped += 1
results.append(TestResult(name, "SKIP", reason))
# ----------------------------- test groups ----------------------------------
def group_infrastructure(bot_id: str):
print("\n== infrastructure ==")
def t_health():
s, d = api("/api/health")
assert s == 200, f"health returned {s}"
assert d.get("status") == "ok"
run("GET /api/health", t_health)
def t_list_bots():
s, d = api("/api/bot/")
assert s == 200
assert isinstance(d.get("bots"), list)
assert any(b["id"] == bot_id for b in d["bots"])
run("GET /api/bot/ lists target bot", t_list_bots)
def t_bot_config():
s, d = api(f"/api/bot/{bot_id}/config")
assert s == 200
assert d["id"] == bot_id
assert "identity" in d
assert "serverAddress" in d and "nickname" in d
assert "serverPassword" in d, "serverPassword field missing"
run("GET /api/bot/:id/config returns all fields", t_bot_config)
def t_404_on_unknown_bot():
s, _ = api("/api/bot/does-not-exist/config")
assert s == 404
run("404 on unknown bot id", t_404_on_unknown_bot)
def t_quality_shape():
s, d = api("/api/music/quality")
assert s == 200
for p in ("netease", "qq", "bilibili"):
assert p in d, f"{p} missing from quality response"
run("GET /api/music/quality shape", t_quality_shape)
def group_auth_status():
print("\n== auth status per platform ==")
def t_netease_ok():
s, d = api("/api/auth/status?platform=netease")
assert s == 200
assert d.get("platform") == "netease"
assert "loggedIn" in d
run("auth status netease", t_netease_ok)
def t_qq_ok():
s, d = api("/api/auth/status?platform=qq")
assert s == 200
assert d.get("platform") == "qq"
run("auth status qq", t_qq_ok)
def t_bilibili_ok():
s, d = api("/api/auth/status?platform=bilibili")
assert s == 200
assert d.get("platform") == "bilibili"
run("auth status bilibili", t_bilibili_ok)
def t_youtube_routed():
# Regression: /auth/status?platform=youtube used to fall through
# to NetEase and leak the NetEase user's nickname/avatar.
s, d = api("/api/auth/status?platform=youtube")
assert s == 200
assert d.get("platform") == "youtube", (
f"youtube auth status leaked to {d.get('platform')}"
)
run("auth status youtube routes correctly", t_youtube_routed)
def t_youtube_cookie_rejected():
s, d = api(
"/api/auth/cookie",
method="POST",
json_body={"platform": "youtube", "cookie": "fake"},
)
assert s == 400, f"youtube cookie should be rejected, got {s}: {d}"
run("POST /auth/cookie rejects youtube", t_youtube_cookie_rejected)
def group_search():
print("\n== multi-platform search ==")
def search(platform: str, query: str = "test"):
return api(f"/api/music/search?q={query}&platform={platform}&limit=1")
def t_netease():
s, d = search("netease")
assert s == 200
assert isinstance(d.get("songs"), list)
run("netease search", t_netease)
def t_qq():
s, d = search("qq")
assert s == 200
# QQ may return 0 results if no cookie, but shouldn't error
assert isinstance(d.get("songs"), list)
run("qq search (empty ok)", t_qq)
def t_bilibili():
s, d = search("bilibili")
assert s == 200
assert isinstance(d.get("songs"), list)
run("bilibili search", t_bilibili)
def t_missing_query():
s, _ = api("/api/music/search?platform=netease")
assert s == 400, "missing q should 400"
run("400 on missing query", t_missing_query)
_, auth = api("/api/auth/status?platform=youtube")
youtube_available = isinstance(auth, dict) and auth.get("loggedIn") is True
if youtube_available:
def t_youtube():
s, d = search("youtube", "lofi")
assert s == 200
songs = d.get("songs", [])
assert len(songs) >= 1, "expected at least 1 YouTube result"
assert songs[0]["platform"] == "youtube"
run("youtube search (yt-dlp installed)", t_youtube)
else:
skip("youtube search", "yt-dlp not installed")
def group_lifecycle(bot_id: str):
print("\n== connection lifecycle ==")
def t_stop_from_any_state():
api(f"/api/bot/{bot_id}/stop", method="POST")
assert wait_connected(bot_id, False), "bot did not stop"
b = get_bot(bot_id)
assert not b["playing"], "playing should be false after stop"
run("stop from any state \u2192 disconnected+idle", t_stop_from_any_state)
def t_start_completes_quickly():
t0 = time.time()
s, d = api(f"/api/bot/{bot_id}/start", method="POST")
elapsed = time.time() - t0
assert s == 200, f"start failed: {d}"
assert elapsed < 10, f"start took {elapsed:.1f}s (expected <10s)"
assert wait_connected(bot_id, True)
run("start completes well under 15s deadline", t_start_completes_quickly)
def t_identity_persists():
assert_started(bot_id)
_, cfg1 = api(f"/api/bot/{bot_id}/config")
id1 = cfg1["identity"]
assert id1, "identity empty after first start"
assert stop_and_wait(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
assert_started(bot_id)
_, cfg2 = api(f"/api/bot/{bot_id}/config")
assert cfg2["identity"] == id1, (
f"identity changed across restart: {id1} \u2192 {cfg2['identity']}"
)
run("identity preserved across stop/start", t_identity_persists)
def group_playback(bot_id: str):
print("\n== playback ==")
# Bring the bot online ONCE for the whole playback group, then only
# toggle player state (play/pause/stop) between tests. This keeps the
# TS3 reconnect count for this group at exactly 1.
assert_started(bot_id)
def t_play_song():
s, d = api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
assert s == 200, f"play failed: {d}"
time.sleep(1.2)
b = get_bot(bot_id)
assert b["playing"] is True, f"not playing after /play: {b}"
assert b["currentSong"] is not None
run("play netease song \u2192 playing=true", t_play_song)
def t_pause_resume():
# Previous test left a song playing
api(f"/api/player/{bot_id}/pause", method="POST")
time.sleep(0.4)
b = get_bot(bot_id)
assert b["paused"] is True, f"pause failed: {b}"
api(f"/api/player/{bot_id}/resume", method="POST")
time.sleep(0.4)
b = get_bot(bot_id)
assert b["paused"] is False and b["playing"] is True, f"resume failed: {b}"
run("pause \u2192 paused, resume \u2192 playing", t_pause_resume)
def t_volume_change():
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": 42},
)
assert s == 200
time.sleep(0.2)
b = get_bot(bot_id)
assert b["volume"] == 42, f"volume not applied: {b['volume']}"
run("volume change", t_volume_change)
def t_mode_cycle():
for m in ("seq", "loop", "random", "rloop"):
s, _ = api(
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": m}
)
assert s == 200
b = get_bot(bot_id)
assert b["playMode"] == m, f"mode {m} not applied: {b['playMode']}"
run("all four play modes apply", t_mode_cycle)
def t_queue_endpoint():
s, d = api(f"/api/player/{bot_id}/queue")
assert s == 200
assert isinstance(d.get("queue"), list)
assert "status" in d
run("GET /player/:id/queue returns queue+status", t_queue_endpoint)
def t_elapsed_endpoint():
s, d = api(f"/api/player/{bot_id}/elapsed")
assert s == 200
elapsed = d.get("elapsed")
assert isinstance(elapsed, (int, float)) and elapsed >= 0, (
f"elapsed should be non-negative number: {elapsed}"
)
run("GET /player/:id/elapsed returns finite number", t_elapsed_endpoint)
def t_add_autoplay_on_idle():
# This specific test needs an IDLE bot — stop first (but keep
# connected), then add and confirm auto-play.
api(f"/api/player/{bot_id}/stop", method="POST")
time.sleep(0.4)
b = get_bot(bot_id)
assert not b["playing"] and b["queueSize"] == 0, f"setup failed: {b}"
s, d = api(
f"/api/player/{bot_id}/add",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
assert s == 200
msg = d.get("message", "") if isinstance(d, dict) else ""
assert "Now playing" in msg, (
f"add on idle bot should auto-play, got: {msg!r}"
)
time.sleep(0.8)
b = get_bot(bot_id)
assert b["playing"] is True, f"not playing after add: {b}"
run("add on idle bot auto-plays", t_add_autoplay_on_idle)
# Leave the bot in a clean state for the next group
api(f"/api/player/{bot_id}/stop", method="POST")
def group_queue_ops(bot_id: str):
print("\n== queue operations ==")
assert_started(bot_id)
def t_clear():
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(0.8)
api(
f"/api/player/{bot_id}/add",
method="POST",
json_body={"query": "lemon tree", "platform": "netease"},
)
time.sleep(0.6)
b_before = get_bot(bot_id)
assert b_before["queueSize"] >= 2, f"expected \u22652 songs: {b_before}"
api(f"/api/player/{bot_id}/clear", method="POST")
time.sleep(0.4)
b_after = get_bot(bot_id)
assert b_after["queueSize"] == 0
run("clear queue empties it", t_clear)
def t_play_at_invalid_preserves_playback():
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(1.2)
assert get_bot(bot_id)["playing"]
s, _ = api(
f"/api/player/{bot_id}/play-at",
method="POST",
json_body={"index": 9999},
)
assert s == 400, f"invalid index should 400, got {s}"
time.sleep(0.4)
b = get_bot(bot_id)
assert b["playing"], "invalid play-at killed the current song"
run("invalid play-at preserves current playback", t_play_at_invalid_preserves_playback)
def t_play_at_negative_rejected():
s, _ = api(
f"/api/player/{bot_id}/play-at",
method="POST",
json_body={"index": -1},
)
assert s == 400
run("play-at with negative index rejected", t_play_at_negative_rejected)
api(f"/api/player/{bot_id}/stop", method="POST")
def group_input_validation(bot_id: str):
print("\n== HTTP input validation ==")
def t_volume_out_of_range():
for bad in (150, -10, 1000, -1):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": bad},
)
assert s == 400, f"volume={bad} should 400, got {s}"
run("volume out-of-range rejected (400)", t_volume_out_of_range)
def t_volume_wrong_type():
for bad in ("50", None, [50], {"v": 50}):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": bad},
)
assert s == 400, f"volume={bad!r} should 400, got {s}"
run("volume wrong-type rejected (400)", t_volume_wrong_type)
def t_volume_missing():
s, _ = api(
f"/api/player/{bot_id}/volume", method="POST", json_body={}
)
assert s == 400
run("volume missing rejected (400)", t_volume_missing)
def t_volume_valid():
for good in (0, 1, 50, 100):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": good},
)
assert s == 200, f"volume={good} should succeed, got {s}"
b = get_bot(bot_id)
assert b["volume"] == good, f"volume not applied: {b['volume']}"
run("valid volumes apply", t_volume_valid)
def t_mode_invalid():
for bad in ("bogus", "", None, 1, "SEQ"):
s, _ = api(
f"/api/player/{bot_id}/mode",
method="POST",
json_body={"mode": bad},
)
assert s == 400, f"mode={bad!r} should 400, got {s}"
run("mode invalid rejected (400)", t_mode_invalid)
def t_mode_missing():
s, _ = api(f"/api/player/{bot_id}/mode", method="POST", json_body={})
assert s == 400
run("mode missing rejected (400)", t_mode_missing)
def group_disconnect_corners(bot_id: str):
print("\n== disconnected-bot corners ==")
def t_play_rejected():
assert stop_and_wait(bot_id)
s, d = api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "x", "platform": "netease"},
)
assert s >= 400
err = (d.get("error") or "") if isinstance(d, dict) else ""
assert "not connected" in err.lower(), f"expected 'not connected' error: {d}"
run("play rejected while disconnected", t_play_rejected)
def t_add_rejected():
s, _ = api(
f"/api/player/{bot_id}/add",
method="POST",
json_body={"query": "x", "platform": "netease"},
)
assert s >= 400
run("add rejected while disconnected", t_add_rejected)
def t_next_rejected():
s, _ = api(f"/api/player/{bot_id}/next", method="POST")
assert s >= 400
run("next rejected while disconnected", t_next_rejected)
def t_volume_allowed():
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": 60},
)
assert s == 200, "volume should work while disconnected"
run("volume allowed while disconnected", t_volume_allowed)
def t_mode_allowed():
s, _ = api(
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": "random"}
)
assert s == 200, "mode should work while disconnected"
run("mode allowed while disconnected", t_mode_allowed)
def t_clear_allowed():
s, _ = api(f"/api/player/{bot_id}/clear", method="POST")
assert s == 200, "clear should work while disconnected"
run("clear allowed while disconnected", t_clear_allowed)
def t_player_state_clean():
b = get_bot(bot_id)
assert not b["playing"] and not b["paused"], f"state leak: {b}"
run("no player state leak while disconnected", t_player_state_clean)
def group_seek(bot_id: str):
print("\n== seek validation ==")
def t_negative():
s, _ = api(
f"/api/player/{bot_id}/seek", method="POST", json_body={"position": -5}
)
assert s == 400
run("negative seek rejected", t_negative)
def t_string():
s, _ = api(
f"/api/player/{bot_id}/seek",
method="POST",
json_body={"position": "abc"},
)
assert s == 400
run("string seek rejected", t_string)
def t_nan_literal():
r = requests.post(
f"{BASE}/api/player/{bot_id}/seek",
data='{"position": NaN}',
headers={"Content-Type": "application/json"},
timeout=10,
)
assert r.status_code >= 400, f"NaN literal accepted: {r.status_code}"
run("NaN literal seek rejected", t_nan_literal)
def t_valid_seek():
# Seek needs a live connection + playing song. The disconnect-
# corners group right before this one left the bot disconnected.
assert_started(bot_id)
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(1.5)
s, _ = api(
f"/api/player/{bot_id}/seek",
method="POST",
json_body={"position": 25},
)
assert s == 200
time.sleep(0.5)
_, d = api(f"/api/player/{bot_id}/elapsed")
elapsed = d.get("elapsed")
assert isinstance(elapsed, (int, float)) and 24 <= elapsed < 40, (
f"elapsed after seek(25) wrong: {elapsed}"
)
api(f"/api/player/{bot_id}/stop", method="POST")
run("valid seek produces finite elapsed", t_valid_seek)
def group_races(bot_id: str):
print("\n== race conditions ==")
def t_disconnect_during_connect():
assert stop_and_wait(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
def delayed_stop():
time.sleep(0.2)
api(f"/api/bot/{bot_id}/stop", method="POST")
threading.Thread(target=delayed_stop, daemon=True).start()
api(f"/api/bot/{bot_id}/start", method="POST")
time.sleep(2)
b = get_bot(bot_id)
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state: {b}"
)
run("disconnect during connect", t_disconnect_during_connect)
def t_stop_during_url_resolve():
assert stop_and_wait(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
assert_started(bot_id)
def delayed_stop():
time.sleep(0.15)
api(f"/api/bot/{bot_id}/stop", method="POST")
threading.Thread(target=delayed_stop, daemon=True).start()
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(3)
b = get_bot(bot_id)
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state: {b}"
)
run("stop during URL resolve", t_stop_during_url_resolve)
def t_rapid_volume_change():
# Volume is a config-only command and works while disconnected,
# so this test deliberately doesn't call assert_started — we're
# validating the API's last-write-wins behavior, not the TS
# transport. That also spares the TS3 anti-flood budget.
for v in (10, 25, 50, 75, 100, 1):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": v},
)
assert s == 200, f"volume POST failed: {s}"
time.sleep(0.3)
b = get_bot(bot_id)
assert b["volume"] == 1, f"final volume wrong: {b['volume']}"
run("rapid volume changes converge", t_rapid_volume_change)
def group_websocket(bot_id: str):
print("\n== websocket broadcasts ==")
try:
from websocket import create_connection
except Exception as e:
skip("websocket state broadcasts", f"websocket lib unavailable: {e}")
return
try:
ws = create_connection("ws://localhost:3000/ws", timeout=5)
except Exception as e:
skip("websocket state broadcasts", f"connect failed: {e}")
return
ws.settimeout(0.3)
messages: list[dict] = []
stop_reader = threading.Event()
def reader():
while not stop_reader.is_set():
try:
raw = ws.recv()
if not raw:
break
try:
messages.append(json.loads(raw))
except Exception:
pass
except Exception:
# recv() timeout or closed — keep trying until stop_reader
if stop_reader.is_set():
break
continue
reader_thread = threading.Thread(target=reader, daemon=True)
reader_thread.start()
try:
def t_init():
time.sleep(0.6)
types = [m.get("type") for m in messages]
assert "init" in types, f"no init message; got: {types}"
run("init message on connect", t_init)
def t_state_change_on_play():
assert_started(bot_id)
messages.clear()
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(1.5)
types = [m.get("type") for m in messages]
assert "stateChange" in types, (
f"no stateChange after play; got types: {types}"
)
api(f"/api/player/{bot_id}/stop", method="POST")
run("stateChange broadcast on play", t_state_change_on_play)
def t_bot_disconnected_event():
assert_started(bot_id)
messages.clear()
api(f"/api/bot/{bot_id}/stop", method="POST")
time.sleep(1.5)
types = [m.get("type") for m in messages]
assert "botDisconnected" in types or "stateChange" in types, (
f"no disconnect event; got: {types}"
)
run("botDisconnected event on stop", t_bot_disconnected_event)
finally:
stop_reader.set()
try:
ws.close()
except Exception:
pass
# ----------------------------- main ----------------------------------------
def main() -> int:
_, data = api("/api/bot/")
if not isinstance(data, dict) or not data.get("bots"):
print("[fatal] no bots registered — create one via the WebUI first")
return 2
target = data["bots"][0]
bot_id = target["id"]
initial_connected = target["connected"]
initial_volume = target["volume"]
initial_mode = target["playMode"]
print(f"[init] target bot = {bot_id[:8]} ({target['name']})")
print(
f"[init] initial state: connected={initial_connected} "
f"volume={initial_volume} mode={initial_mode}"
)
try:
# Read-only / no-lifecycle groups first — they don't consume TS3
# anti-flood budget.
group_infrastructure(bot_id)
group_auth_status()
group_search()
# Lifecycle-heavy groups — interleave with small breathers so the
# TS3 server's per-IP reconnect limit doesn't start throttling us.
group_lifecycle(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_playback(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_queue_ops(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_input_validation(bot_id)
group_disconnect_corners(bot_id)
group_seek(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_races(bot_id)
# Extra breather before websocket group — races is the heaviest
# consumer of TS3 reconnect budget (disconnect-during-connect and
# stop-during-url-resolve each burn one cycle), and the websocket
# group needs a clean reconnect to observe live state broadcasts.
time.sleep(ANTIFLOOD_BREATHER * 3)
group_websocket(bot_id)
finally:
# Restore initial state — this runs even if a test raised
try:
api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": initial_volume},
)
api(
f"/api/player/{bot_id}/mode",
method="POST",
json_body={"mode": initial_mode},
)
api(f"/api/player/{bot_id}/stop", method="POST")
if initial_connected:
start_and_wait(bot_id)
else:
stop_and_wait(bot_id)
except Exception as e:
print(f"[warn] restore failed: {e}")
print()
print("=" * 60)
print(f" PASSED: {passed}")
print(f" FAILED: {failed}")
print(f" SKIPPED: {skipped}")
print("=" * 60)
if failed > 0:
print("\nFailed tests:")
for r in results:
if r.status in ("FAIL", "ERROR"):
print(f" [{r.status}] {r.name}: {r.detail}")
return 0 if failed == 0 else 1
if __name__ == "__main__":
import sys
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
"""More corner-case regressions.
A. resolveAndPlay disconnect-during-URL-resolve race
The bot checks !this.connected at the top of resolveAndPlay, but the
URL-resolve await can take several seconds. If stop is called during
that window, playback would previously start on a disconnected bot.
B. /seek NaN/Infinity rejection
typeof NaN === "number" and NaN < 0 is false, so a plain range check
leaks NaN through and corrupts seekOffset / getElapsed.
"""
import threading
import time
import requests
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
def get_bot(bot_id):
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_connected(bot_id, want, timeout=15):
end = time.time() + timeout
while time.time() < end:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.15)
return False
def test_resolve_play_stop_race(bot_id):
"""Fire stopBot during the /play call's URL resolve window."""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1)
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
# Schedule a stop 150ms into the play call — that lands inside the
# provider.getSongUrl await, which is where the race lives.
def delayed_stop():
time.sleep(0.15)
try:
api(f"/api/bot/{bot_id}/stop", method="POST")
except Exception:
pass
threading.Thread(target=delayed_stop, daemon=True).start()
try:
api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
except Exception:
pass
# Give both calls time to settle fully
time.sleep(3)
b = get_bot(bot_id)
# Key invariant: we never want connected=false AND playing=true. That
# pair is the exact Bug C symptom and would indicate the resolveAndPlay
# post-await check didn't fire.
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state after race: {b}"
)
print(
f"[PASS] resolveAndPlay stop-race — final state consistent "
f"(connected={b['connected']} playing={b['playing']})"
)
def test_seek_nan_rejected(bot_id):
"""Verify that NaN and Infinity seek positions are rejected at the API
layer (instead of poisoning seekOffset)."""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
# Start a real song so there is an active playback to seek against
api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
time.sleep(1.2)
# JSON spec doesn't allow NaN/Infinity literals, but Python's json
# encoder emits them as bare tokens when allow_nan=True (the default).
# Express's body-parser rejects them as invalid JSON, which itself is
# a form of rejection. We additionally verify that sending a string
# "NaN" or a negative value is also rejected with a clean 400.
r = api(
f"/api/player/{bot_id}/seek",
method="POST",
json={"position": -5},
)
assert r.status_code == 400, f"negative seek should be rejected, got {r.status_code}"
r = api(
f"/api/player/{bot_id}/seek",
method="POST",
json={"position": "fifty"},
)
assert r.status_code == 400, f"string seek should be rejected, got {r.status_code}"
# Directly send NaN in raw body (body-parser will likely 400 it)
r = requests.post(
f"{BASE}/api/player/{bot_id}/seek",
data='{"position": NaN}',
headers={"Content-Type": "application/json"},
timeout=10,
)
assert r.status_code >= 400, f"NaN seek should be rejected, got {r.status_code}"
# After the junk attempts, a valid seek still works and the elapsed
# time is a finite number (not NaN).
r = api(
f"/api/player/{bot_id}/seek",
method="POST",
json={"position": 30},
)
assert r.status_code == 200, f"valid seek failed: {r.text[:120]}"
elapsed_resp = api(f"/api/player/{bot_id}/elapsed")
elapsed = elapsed_resp.json().get("elapsed")
assert elapsed is not None and isinstance(elapsed, (int, float)), (
f"elapsed should be a number, got {elapsed}"
)
# Could be exactly 30 or a tiny bit more if a frame has advanced
assert 29 <= elapsed < 40, f"elapsed after seek(30) out of range: {elapsed}"
print(f"[PASS] seek NaN/Infinity rejected; valid seek produces finite elapsed={elapsed:.2f}")
def main():
bots = api("/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial = bots[0]["connected"]
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
try:
test_resolve_play_stop_race(bot_id)
test_seek_nan_rejected(bot_id)
print("ALL GREEN")
finally:
if initial:
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
else:
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
print(f"[restore] connected={get_bot(bot_id)['connected']}")
if __name__ == "__main__":
main()
+212
View File
@@ -0,0 +1,212 @@
"""Stress-test two bots playing music concurrently on the same TS server.
Creates two temporary bots (or reuses existing named ones), starts them,
plays music on both, and polls /api/bot/ every 2 seconds to detect when
(if) either bot disconnects or stops playing. Cleans up on exit.
Usage:
python scripts/test_multibot.py --minutes 3
python scripts/test_multibot.py --minutes 10 --host 127.0.0.1 --port 9987
"""
import argparse
import sys
import time
from dataclasses import dataclass
import requests
API = "http://localhost:3000"
POLL_INTERVAL = 2.0
TEST_BOT_NAMES = ("mbtest1", "mbtest2")
TEST_BOT_NICKS = ("MBTest1", "MBTest2")
QUERIES = ("the mass", "lofi") # one different song per bot
@dataclass
class BotSnapshot:
t: float
connected: bool
playing: bool
song: str | None
def api_get(path: str):
r = requests.get(f"{API}{path}", timeout=5)
r.raise_for_status()
return r.json()
def api_post(path: str, json=None):
r = requests.post(f"{API}{path}", json=json, timeout=15)
r.raise_for_status()
return r.json()
def api_delete(path: str):
r = requests.delete(f"{API}{path}", timeout=10)
r.raise_for_status()
return r.json()
def cleanup_existing(names: tuple[str, ...]) -> None:
bots = api_get("/api/bot/")["bots"]
for b in bots:
if b["name"] in names:
try:
api_post(f"/api/player/{b['id']}/stop")
except Exception:
pass
try:
api_delete(f"/api/bot/{b['id']}")
print(f"[cleanup] removed existing bot {b['name']} ({b['id']})")
except Exception as e:
print(f"[cleanup] failed to remove {b['name']}: {e}")
def create_bot(name: str, nickname: str, host: str, port: int) -> str:
res = api_post(
"/api/bot/",
json={
"name": name,
"serverAddress": host,
"serverPort": port,
"nickname": nickname,
"autoStart": False,
},
)
bot_id = res["id"]
print(f"[create] {name} -> {bot_id}")
return bot_id
def start_bot(bot_id: str) -> None:
api_post(f"/api/bot/{bot_id}/start")
def play(bot_id: str, query: str) -> None:
api_post(f"/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
def snapshot(bot_id: str, t0: float) -> BotSnapshot:
bots = api_get("/api/bot/")["bots"]
b = next((x for x in bots if x["id"] == bot_id), None)
if not b:
return BotSnapshot(time.time() - t0, False, False, None)
song = b["currentSong"]["name"] if b.get("currentSong") else None
return BotSnapshot(time.time() - t0, b["connected"], b["playing"], song)
def run(minutes: float, host: str, port: int) -> int:
print(f"[setup] duration={minutes}min host={host}:{port}")
cleanup_existing(TEST_BOT_NAMES)
bot_ids = [
create_bot(TEST_BOT_NAMES[0], TEST_BOT_NICKS[0], host, port),
create_bot(TEST_BOT_NAMES[1], TEST_BOT_NICKS[1], host, port),
]
# Start both, allowing a small stagger to avoid handshake collision
for i, bid in enumerate(bot_ids):
start_bot(bid)
print(f"[start] bot{i+1} started")
time.sleep(1.5)
# Wait until both are connected (or bail after 15s)
deadline = time.time() + 15
while time.time() < deadline:
bots = {b["id"]: b for b in api_get("/api/bot/")["bots"]}
if all(bots[b]["connected"] for b in bot_ids):
print("[start] both bots connected")
break
time.sleep(0.5)
else:
print("[fatal] bots did not both come online in 15s")
cleanup_existing(TEST_BOT_NAMES)
return 2
# Kick off playback on both
for i, bid in enumerate(bot_ids):
play(bid, QUERIES[i])
print(f"[play] bot{i+1} -> {QUERIES[i]!r}")
t0 = time.time()
end = t0 + minutes * 60
first_drop: dict[str, float] = {}
last_state: dict[str, BotSnapshot] = {}
print(f"[monitor] polling every {POLL_INTERVAL}s for {minutes} min...")
print(f"{'time':>7} {'bot1':<40} {'bot2':<40}")
def fmt(snap: BotSnapshot) -> str:
flag = ("C" if snap.connected else "-") + ("P" if snap.playing else "-")
song = (snap.song or "").replace("\n", " ")[:30]
return f"{flag} {song}"
try:
while time.time() < end:
snaps = [snapshot(bid, t0) for bid in bot_ids]
elapsed = int(time.time() - t0)
row = f"{elapsed:>6}s {fmt(snaps[0]):<40} {fmt(snaps[1]):<40}"
# Only print when state changes or every 10s
changed = False
for bid, s in zip(bot_ids, snaps):
prev = last_state.get(bid)
if (prev is None
or prev.connected != s.connected
or prev.playing != s.playing
or prev.song != s.song):
changed = True
last_state[bid] = s
if not s.connected and bid not in first_drop:
first_drop[bid] = s.t
if changed or elapsed % 10 == 0:
print(row)
# If both stopped playing but are still connected, re-queue the same song
for i, (bid, s) in enumerate(zip(bot_ids, snaps)):
if s.connected and not s.playing:
try:
play(bid, QUERIES[i])
except Exception as e:
print(f"[warn] re-play bot{i+1} failed: {e}")
time.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
print("\n[abort] interrupted")
# Summary
total = time.time() - t0
print()
print("=" * 60)
print(f"Total observed time: {total:.1f}s")
for i, bid in enumerate(bot_ids):
drop = first_drop.get(bid)
if drop is None:
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): stayed connected the whole run")
else:
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): FIRST DISCONNECT at t+{drop:.1f}s")
print("=" * 60)
# Cleanup
cleanup_existing(TEST_BOT_NAMES)
print("[cleanup] done")
return 0 if not first_drop else 1
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--minutes", type=float, default=3.0)
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=9987)
args = p.parse_args()
try:
return run(args.minutes, args.host, args.port)
except requests.HTTPError as e:
print(f"[http-error] {e} body={e.response.text[:200] if e.response else ''}")
return 3
if __name__ == "__main__":
sys.exit(main())
+119
View File
@@ -0,0 +1,119 @@
"""Reproduce / regression-check the player-bar-not-appearing bug.
Captures the bot's initial playback state and restores it on exit so the
test never leaves the user with surprise music or a cleared queue.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
fn = getattr(requests, method.lower())
r = fn(f"{BASE}{path}", timeout=10, **kw)
r.raise_for_status()
return r.json() if r.text else None
def get_bot(bot_id):
return next(b for b in api("/api/bot/")["bots"] if b["id"] == bot_id)
def capture_state(bot_id):
b = get_bot(bot_id)
return {
"playing": b["playing"],
"paused": b["paused"],
"song": (b["currentSong"] or {}).get("name"),
}
def main():
bots = api("/api/bot/")["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial = capture_state(bot_id)
print(f"[init] initial state: {initial}")
try:
# Clear slate
api(f"/api/player/{bot_id}/stop", method="POST")
time.sleep(0.6)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
ctx = browser.new_context()
ctx.add_init_script(
"""
(() => {
const OrigWS = window.WebSocket;
window.__wsMessages = [];
window.WebSocket = function(...args) {
const ws = new OrigWS(...args);
ws.addEventListener('message', (ev) => {
try {
const d = JSON.parse(ev.data);
window.__wsMessages.push({type: d.type, botId: d.botId});
} catch(e) {}
});
return ws;
};
Object.assign(window.WebSocket, OrigWS);
})();
"""
)
page = ctx.new_page()
page.goto(BASE)
page.wait_for_load_state("networkidle")
time.sleep(0.8)
assert page.locator(".player-wrapper").count() == 0, (
"player bar should be hidden before playback"
)
# Trigger play via API (simulates any play trigger)
api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
# Poll for up to 6s to see if player bar appears automatically
appeared_at = None
for i in range(60):
if page.locator(".player-wrapper").count() > 0:
appeared_at = i * 0.1
break
page.wait_for_timeout(100)
if appeared_at is None:
msgs = page.evaluate("() => window.__wsMessages")
print(f"[FAIL] player bar never appeared; WS msgs: {msgs}")
raise AssertionError("player bar did not auto-show on stateChange")
print(f"[PASS] player bar appeared after {appeared_at:.1f}s")
finally:
browser.close()
finally:
# Restore: stop the "test" song we triggered, then re-apply initial
# state as best we can. We can't re-queue the user's previous song,
# but we can at least stop ours and leave the bot idle if it was idle.
try:
api(f"/api/player/{bot_id}/stop", method="POST")
except Exception as e:
print(f"[warn] failed to stop test song on cleanup: {e}")
post = capture_state(bot_id)
print(f"[restore] bot now idle (was playing={initial['playing']} song={initial['song']!r})")
if initial["playing"] and initial["song"]:
print(
f"[note] initial bot was playing {initial['song']!r}; "
"this test cannot resume arbitrary tracks — you may need to restart playback"
)
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
"""E2E: the new power button in the Bot Selector dropdown toggles bot connected state.
Captures the target bot's initial connected state and restores it on exit
(including on assertion failure), so running this test never pollutes the
user's current bot setup.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
def get_bot(bot_id):
return next(b for b in requests.get(f"{BASE}/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_for_connected(bot_id, want: bool, timeout_s: float = 12.0) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.2)
return False
def set_connected(bot_id, want: bool) -> None:
"""Force the bot into the given connected state via API."""
current = get_bot(bot_id)["connected"]
if current == want:
return
endpoint = "start" if want else "stop"
requests.post(f"{BASE}/api/bot/{bot_id}/{endpoint}")
wait_for_connected(bot_id, want)
def main():
bots = requests.get(f"{BASE}/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots registered, nothing to test")
return
target = bots[0]
bot_id = target["id"]
initial_connected = target["connected"]
print(f"[init] target bot {target['name']} ({bot_id[:8]}), initial connected={initial_connected}")
try:
# Force bot disconnected before the test
set_connected(bot_id, False)
assert not get_bot(bot_id)["connected"], "bot should be disconnected at start"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto(BASE)
page.wait_for_load_state("networkidle")
time.sleep(0.6)
# Open dropdown
page.locator(".bot-selector-btn").click()
page.wait_for_selector(".bot-power-btn")
# Click the power button to start
page.locator(".bot-power-btn").first.click()
print("[ui] clicked power (start)")
assert wait_for_connected(bot_id, True), "bot should be connected after clicking start"
print("[api] bot connected = True")
# Let UI catch up via WS then re-open the dropdown to re-check class
time.sleep(1.0)
page.locator(".bot-selector-btn").click() # close
time.sleep(0.2)
page.locator(".bot-selector-btn").click() # reopen
page.wait_for_selector(".bot-power-btn.online", timeout=3000)
print("[ui] power button now shows .online class")
# Click again to stop
page.locator(".bot-power-btn.online").first.click()
print("[ui] clicked power (stop)")
assert wait_for_connected(bot_id, False), "bot should be disconnected after clicking stop"
print("[api] bot connected = False")
print("[PASS] power button toggles bot connection")
finally:
browser.close()
finally:
# Always restore the initial state so the test never leaves the bot
# in an unexpected place
set_connected(bot_id, initial_connected)
final = get_bot(bot_id)["connected"]
print(f"[restore] bot connected={final} (initial was {initial_connected})")
if final != initial_connected:
print("[warn] failed to restore initial connected state")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
"""Regression for the 'connected=False but playing=True' stuck-state bug.
After rapid disconnect/reconnect, the library could drop the connection
(TS3 server anti-flood or a hung handshake). The bot then ended up in an
inconsistent state: player.state='playing' but tsClient disconnected.
This test verifies three fixes:
Bug A — startBot() has a 15s timeout instead of hanging forever on a
stalled handshake. /start returns a clean 500 instead of blocking.
Bug B — play/add/etc commands are rejected when the bot is not connected.
Bug C — the tsClient 'disconnected' handler always clears player state,
even when connect() never completed (so !this.connected).
We also sanity-check that the bot recovers (can start a fresh cycle) after
a transient failure.
"""
import time
import requests
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
def get_bot(bot_id):
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_connected(bot_id, want, timeout=15):
end = time.time() + timeout
while time.time() < end:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.15)
return False
def main():
bots = api("/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial_connected = bots[0]["connected"]
print(f"[init] bot={bot_id[:8]} initial connected={initial_connected}")
try:
# Start from a clean slate
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
# --- Bug B: play while disconnected must be rejected ---
r = api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "rejection test", "platform": "netease"},
)
assert r.status_code >= 400, (
f"play while disconnected should fail, got {r.status_code} {r.text[:120]}"
)
assert "not connected" in r.text.lower(), (
f"expected 'not connected' error, got: {r.text[:200]}"
)
b = get_bot(bot_id)
assert not b["playing"], f"player shouldn't be playing after rejected /play: {b}"
print("[PASS] Bug B — /play rejected while bot disconnected; state untouched")
# --- Bug C: normal start→play→stop leaves player state clean ---
r = api(f"/api/bot/{bot_id}/start", method="POST")
assert r.status_code == 200, f"start failed {r.text[:120]}"
assert wait_connected(bot_id, True), "bot did not connect within 15s"
r = api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
assert r.status_code == 200, f"play failed {r.text[:120]}"
time.sleep(1.2)
b = get_bot(bot_id)
assert b["connected"] and b["playing"], f"should be connected+playing: {b}"
api(f"/api/bot/{bot_id}/stop", method="POST")
assert wait_connected(bot_id, False, timeout=5), "bot did not disconnect"
b = get_bot(bot_id)
assert not b["playing"], (
f"player should have stopped after bot disconnect (Bug C): {b}"
)
print("[PASS] Bug C — stop clears both connected and playing state")
# --- Bug A: startBot has a deadline and returns a clean error if connect hangs ---
# We can't easily force a hang in-process, but we can sanity-check that
# startBot returns promptly (well under the 15s cap) on a normal run.
t0 = time.time()
r = api(f"/api/bot/{bot_id}/start", method="POST")
elapsed = time.time() - t0
assert r.status_code == 200, f"start failed {r.text[:120]}"
assert elapsed < 10, f"start should be prompt, took {elapsed:.1f}s"
assert wait_connected(bot_id, True), "bot did not connect"
print(
f"[PASS] Bug A — startBot completed in {elapsed:.2f}s "
"(deadline is 15s, would throw on hang)"
)
# --- Recovery: after any failure, another start should work ---
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
# Give TS3 server a moment to forget us (anti-flood grace)
time.sleep(2)
r = api(f"/api/bot/{bot_id}/start", method="POST")
assert r.status_code == 200, f"recovery start failed {r.text[:120]}"
assert wait_connected(bot_id, True), "bot did not recover"
print("[PASS] recovery — bot reconnects cleanly after a cycle")
print("ALL GREEN")
finally:
if initial_connected:
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
else:
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
print(f"[restore] bot connected={get_bot(bot_id)['connected']} (was {initial_connected})")
if __name__ == "__main__":
main()
+25 -7
View File
@@ -35,28 +35,39 @@ function ffmpegWorks(bin: string): boolean {
}
}
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
/**
* Resolved once at module load.
*
* Priority: system FFmpeg → bundled ffmpeg-static.
*
* System-installed FFmpeg is always compatible with the running OS/container,
* while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker
* (passes `ffmpeg -version` but crashes during actual audio processing due to
* incompatible glibc or missing shared libraries).
*/
const resolvedFfmpeg: string = (() => {
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// 2. Fall back to bundled ffmpeg-static binary
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
// it may return a Windows .exe path if node_modules was copied cross-platform.
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
const onWindows = process.platform === "win32";
// Only try bundled binary if platform matches
if (ffmpegPath && (onWindows === isWinPath)) {
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
return ffmpegPath;
}
}
// Fall back to system ffmpeg
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return "ffmpeg";
})();
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
@@ -302,6 +313,13 @@ export class AudioPlayer extends EventEmitter {
seek(seconds: number): void {
if (!this.currentUrl) return;
// Reject NaN/Infinity/negative — the HTTP layer validates too, but a
// bad value here would poison seekOffset and leave getElapsed()
// returning NaN until the track ends.
if (!Number.isFinite(seconds) || seconds < 0) {
this.logger.warn({ seek: seconds }, "Ignoring invalid seek position");
return;
}
this.logger.info({ seek: seconds }, "Seeking");
this.play(this.currentUrl, seconds);
}
+138
View File
@@ -89,6 +89,49 @@ describe("PlayQueue", () => {
expect(queue.list()[1].id).toBe("3");
});
it("removing a song before current shifts current index", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.playAt(2); // playing C at index 2
queue.remove(0); // remove A (before current)
expect(queue.current()?.id).toBe("C"); // still on C
expect(queue.getCurrentIndex()).toBe(1);
});
it("removing the currently-playing song lets next() advance to the shifted song", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.playAt(2); // playing C
queue.remove(2); // remove C — D shifts into slot 2
// Before the fix this returned null (D was silently skipped)
expect(queue.next()?.id).toBe("D");
});
it("removing the only song clears the queue", () => {
queue.add(makeSong("only"));
queue.playAt(0);
queue.remove(0);
expect(queue.size()).toBe(0);
expect(queue.current()).toBeNull();
expect(queue.next()).toBeNull();
});
it("removing the last song while playing it advances to null in sequential mode", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.playAt(1); // playing B (last)
queue.remove(1);
expect(queue.size()).toBe(1);
// currentIndex moved to 0, so next() should try to advance past the end
expect(queue.next()).toBeNull();
});
it("clears all songs", () => {
queue.add(makeSong("1"));
queue.add(makeSong("2"));
@@ -107,6 +150,101 @@ describe("PlayQueue", () => {
expect(next).not.toBeNull();
});
it("random mode with single song returns null on next", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("1"));
queue.play();
expect(queue.next()).toBeNull();
});
it("random mode plays each song exactly once then stops", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
const played = new Set<string>();
played.add(queue.current()!.id);
for (let i = 0; i < 3; i++) {
const song = queue.next();
if (!song) break;
played.add(song.id);
}
// All 3 songs should have been played
expect(played).toEqual(new Set(["A", "B", "C"]));
// next() after all played should return null
expect(queue.next()).toBeNull();
});
it("random mode: removing currently-playing song does not skip others", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.play(); // plays A (index 0)
const second = queue.next()!; // plays some song
// Remove the currently-playing song
const curIdx = queue.getCurrentIndex();
queue.remove(curIdx);
// Remaining songs (excluding A and the removed song) should all be reachable
const played = new Set<string>();
played.add("A"); // already played via play()
played.add(second.id); // played and then removed
let song = queue.next();
while (song) {
played.add(song.id);
song = queue.next();
}
// All 4 original songs should have been played or accounted for
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
});
it("random mode: prev does not cause duplicate plays", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play(); // plays A
queue.next(); // plays B or C
queue.prev(); // go back — this song is now marked as played
// Exhaust remaining songs
const ids: string[] = [];
let song = queue.next();
while (song) {
ids.push(song.id);
song = queue.next();
}
// No song ID should appear more than once across the entire session
expect(new Set(ids).size).toBe(ids.length);
});
it("random mode: adding song mid-playback includes the new song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A
queue.next(); // plays B
// Add a new song while all existing songs have been played
queue.add(makeSong("C"));
const song = queue.next();
expect(song).not.toBeNull();
expect(song!.id).toBe("C");
// After C, should stop
expect(queue.next()).toBeNull();
});
it("random mode: setMode preserves current song as played", () => {
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A in sequential mode
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
// next() should only return B, never A again
const song = queue.next();
expect(song?.id).toBe("B");
expect(queue.next()).toBeNull();
});
it("random-loop mode never returns null", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("1"));
+29 -9
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
@@ -20,6 +20,7 @@ export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
add(song: QueuedSong): void {
this.songs.push(song);
@@ -36,28 +37,39 @@ export class PlayQueue {
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
if (this.currentIndex >= this.songs.length) {
this.currentIndex = this.songs.length - 1;
}
this.currentIndex--;
}
// Rebuild playedIndices to account for shifted indices
const newPlayed = new Set<number>();
for (const idx of this.playedIndices) {
if (idx === index) continue;
newPlayed.add(idx > index ? idx - 1 : idx);
}
this.playedIndices = newPlayed;
return removed;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
this.playedIndices.clear();
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
}
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.playedIndices.clear();
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
}
@@ -76,12 +88,15 @@ export class PlayQueue {
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
if (this.songs.length === 1) return this.songs[0];
let nextIndex: number;
do {
nextIndex = Math.floor(Math.random() * this.songs.length);
} while (nextIndex === this.currentIndex && this.songs.length > 1);
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.RandomLoop: {
@@ -109,6 +124,7 @@ export class PlayQueue {
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
@@ -136,6 +152,10 @@ export class PlayQueue {
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
}
getCurrentIndex(): number {
+123 -11
View File
@@ -13,8 +13,9 @@ import {
type ParsedCommand,
} from "./commands.js";
import type { Logger } from "../logger.js";
import type { BotDatabase } from "../data/database.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
export interface BotInstanceOptions {
id: string;
@@ -23,6 +24,7 @@ export interface BotInstanceOptions {
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
youtubeProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
@@ -51,12 +53,15 @@ export class BotInstance extends EventEmitter {
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private connected = false;
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
private isAdvancing = false;
private profileManager: BotProfileManager;
constructor(options: BotInstanceOptions) {
super();
@@ -65,6 +70,7 @@ export class BotInstance extends EventEmitter {
this.neteaseProvider = options.neteaseProvider;
this.qqProvider = options.qqProvider;
this.bilibiliProvider = options.bilibiliProvider;
this.youtubeProvider = options.youtubeProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -73,6 +79,14 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue();
const profileConfig = this.database.getProfileConfig(this.id);
this.profileManager = new BotProfileManager(
this.tsClient,
this.logger,
profileConfig,
options.tsOptions.nickname,
);
this.setupPlayerEvents();
this.setupTsEvents();
}
@@ -105,23 +119,43 @@ export class BotInstance extends EventEmitter {
});
this.tsClient.on("disconnected", () => {
// Always reset local state — covers the case where connect() never
// completed (hanging handshake → 60s library idle timeout) and
// this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.player.stop();
// Only emit externally once per lifecycle so clients don't see a
// duplicate "disconnected" after an explicit disconnect() call.
if (this.disconnectEmitted) return;
this.disconnectEmitted = true;
this.emit("disconnected");
});
}
async connect(): Promise<void> {
this.disconnectEmitted = false;
await this.tsClient.connect();
// Race guard: if disconnect() was called while the handshake was
// awaiting, don't flip connected back to true — that would leave the
// bot in an inconsistent state (externally "connected" but the tsClient
// has already been torn down).
if (this.disconnectEmitted) {
throw new Error("Connect aborted by concurrent disconnect");
}
this.connected = true;
this.profileManager.onConnect();
this.emit("connected");
}
disconnect(): void {
this.player.stop();
this.tsClient.disconnect();
this.connected = false;
this.emit("disconnected");
if (!this.disconnectEmitted) {
this.disconnectEmitted = true;
this.emit("disconnected");
}
this.tsClient.disconnect();
}
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
@@ -162,6 +196,24 @@ export class BotInstance extends EventEmitter {
cmd: ParsedCommand,
msg?: TS3TextMessage
): Promise<string | null> {
// Reject commands that would push audio when the bot isn't connected:
// otherwise ffmpeg spawns and voice goes to a half-initialized or
// torn-down TS client, leaving player.state="playing" on a disconnected
// bot. Config-only commands (vol, mode, clear, stop, queue, now) are
// still allowed so the UI stays usable while the bot is offline.
const AUDIO_COMMANDS = new Set([
"play",
"add",
"next",
"skip",
"prev",
"playlist",
"album",
"fm",
]);
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
throw new Error("Bot is not connected to TeamSpeak");
}
switch (cmd.name) {
case "play":
return this.cmdPlay(cmd);
@@ -212,19 +264,29 @@ export class BotInstance extends EventEmitter {
}
}
getProviderFor(platform: "netease" | "qq" | "bilibili"): MusicProvider {
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
private getProvider(flags: Set<string>): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider;
return this.neteaseProvider;
}
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
async resolveAndPlay(song: QueuedSong): Promise<boolean> {
if (!this.connected) {
this.logger.warn({ songId: song.id, name: song.name }, "resolveAndPlay called on disconnected bot — skipping");
return false;
}
// Clear any accumulated skip votes — every fresh track starts with a
// clean slate, regardless of which code path loaded it (cmdPlay,
// cmdPlaylist, cmdAlbum, cmdFm, trackEnd auto-advance, etc.).
this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
const url = await provider.getSongUrl(song.id);
@@ -232,6 +294,18 @@ export class BotInstance extends EventEmitter {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
return false;
}
// Re-check connection state AFTER the network round-trip — the URL
// resolve can take multiple seconds and the user may have called stop
// during that window. Without this, we'd spawn ffmpeg on a
// disconnected bot and land back in the same "connected=false but
// playing=true" inconsistency that Bug C was about.
if (!this.connected) {
this.logger.warn(
{ songId: song.id, name: song.name },
"bot disconnected during URL resolve — aborting playback",
);
return false;
}
song.url = url;
this.player.play(url);
this.database.addPlayHistory({
@@ -243,6 +317,10 @@ export class BotInstance extends EventEmitter {
platform: song.platform,
coverUrl: song.coverUrl,
});
// Update bot presence (fire-and-forget — never blocks playback)
this.profileManager.onSongChange(song).catch((err) => {
this.logger.warn({ err }, "Profile update failed after song change");
});
this.emit("stateChange");
return true;
} catch (err) {
@@ -278,7 +356,20 @@ export class BotInstance extends EventEmitter {
return `No results found for: ${cmd.args}`;
const song = result.songs[0];
const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...song, platform: provider.platform });
// If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel
// the same to the user (add to idle bot → plays now).
if (wasIdle) {
this.queue.playAt(this.queue.size() - 1);
this.player.resetFailures();
await this.resolveAndPlay(this.queue.current()!);
this.emit("stateChange");
return `Now playing: ${song.name} - ${song.artist}`;
}
this.emit("stateChange");
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
}
@@ -298,6 +389,9 @@ export class BotInstance extends EventEmitter {
private cmdStop(): string {
this.player.stop();
this.queue.clear();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
this.emit("stateChange");
return "Stopped and queue cleared";
}
@@ -348,6 +442,9 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
this.emit("stateChange");
return "Queue cleared";
}
@@ -430,8 +527,11 @@ export class BotInstance extends EventEmitter {
if (!msg) return "Vote can only be used in TeamSpeak";
this.voteSkipUsers.add(msg.invokerUid);
const clients = await this.tsClient.getClientsInChannel();
const totalUsers = clients.length - 1;
const needed = Math.ceil(totalUsers / 2);
const totalUsers = clients.length - 1; // exclude the bot itself
// At least 1 vote is always required — otherwise a single voter in an
// otherwise empty channel (or a transient clients.length=1 race) could
// unanimously "win" with needed=0.
const needed = Math.max(1, Math.ceil(totalUsers / 2));
const votes = this.voteSkipUsers.size;
if (votes >= needed) {
@@ -472,6 +572,7 @@ export class BotInstance extends EventEmitter {
`${p}play <song> — Search and play`,
`${p}play -q <song> — Search from QQ Music`,
`${p}play -b <song> — Search from BiliBili`,
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
`${p}add <song> — Add to queue`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
@@ -490,24 +591,31 @@ export class BotInstance extends EventEmitter {
}
private async playNext(): Promise<void> {
if (this.isAdvancing) return;
if (this.isAdvancing || !this.connected) return;
this.isAdvancing = true;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
if (next) {
const ok = await this.resolveAndPlay(next);
if (!ok) {
let started = await this.resolveAndPlay(next);
if (!started) {
// Skip to next if URL resolve fails (up to 3 retries)
for (let i = 0; i < 3; i++) {
for (let i = 0; i < 3 && this.connected; i++) {
const retry = this.queue.next();
if (!retry) break;
if (await this.resolveAndPlay(retry)) break;
if (await this.resolveAndPlay(retry)) {
started = true;
break;
}
}
}
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
this.emit("stateChange");
} finally {
@@ -554,6 +662,10 @@ export class BotInstance extends EventEmitter {
return this.connected;
}
getProfileManager(): BotProfileManager {
return this.profileManager;
}
getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport();
}
+117 -6
View File
@@ -1,13 +1,53 @@
import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import {
BotInstance,
type BotInstanceOptions,
} from "./instance.js";
import type { MusicProvider } from "../music/provider.js";
import { YouTubeProvider } from "../music/youtube.js";
import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
* server silently drops the connection after initivexpand2), we tear the
* instance down instead of waiting for the library's 60s idle timeout, so
* the HTTP /start call returns promptly and the UI doesn't lock up.
*/
async function connectWithTimeout(
bot: BotInstance,
ms: number,
logger: Logger
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`connect timeout after ${ms}ms`)),
ms
);
});
try {
await Promise.race([bot.connect(), timeout]);
} catch (err) {
logger.warn(
{ err, botId: bot.id },
"Connect failed or timed out — tearing down instance"
);
try {
bot.disconnect();
} catch {
// ignore teardown errors
}
throw err;
} finally {
if (timer) clearTimeout(timer);
}
}
export interface CreateBotParams {
name: string;
serverAddress: string;
@@ -17,13 +57,20 @@ export interface CreateBotParams {
defaultChannel?: string;
channelPassword?: string;
autoStart?: boolean;
/** Force TS3 or TS6 protocol; omit or "unknown" for auto-detect. */
serverProtocol?: ServerProtocol;
/** API key for TS6 HTTP Query (port 10080/10443). */
ts6ApiKey?: string;
/** Password required to join the TS server. */
serverPassword?: string;
}
export class BotManager {
export class BotManager extends EventEmitter {
private bots = new Map<string, BotInstance>();
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
private youtubeProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -36,9 +83,11 @@ export class BotManager {
config: BotConfig,
logger: Logger
) {
super();
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.database = database;
this.config = config;
this.logger = logger;
@@ -57,16 +106,21 @@ export class BotManager {
nickname: params.nickname,
defaultChannel: params.defaultChannel,
channelPassword: params.channelPassword,
serverPassword: params.serverPassword,
serverProtocol: params.serverProtocol,
ts6ApiKey: params.ts6ApiKey,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
this.database.saveBotInstance({
id,
@@ -77,6 +131,9 @@ export class BotManager {
defaultChannel: params.defaultChannel ?? "",
channelPassword: params.channelPassword ?? "",
autoStart: params.autoStart ?? false,
serverProtocol: params.serverProtocol ?? "",
ts6ApiKey: params.ts6ApiKey ?? "",
serverPassword: params.serverPassword ?? "",
});
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
@@ -90,6 +147,7 @@ export class BotManager {
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed");
}
@@ -106,6 +164,9 @@ export class BotManager {
nickname: params.nickname ?? existing.nickname,
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
channelPassword: params.channelPassword ?? existing.channelPassword,
serverProtocol: params.serverProtocol ?? existing.serverProtocol,
ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
serverPassword: params.serverPassword ?? existing.serverPassword,
});
// Update in-memory name immediately (other fields need reconnect)
const bot = this.bots.get(id);
@@ -128,15 +189,59 @@ export class BotManager {
}
async startBot(id: string): Promise<void> {
const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`);
await bot.connect();
const oldBot = this.bots.get(id);
if (!oldBot) throw new Error(`Bot ${id} not found`);
// Mark as autoStart so it reconnects on Docker restart, and persist identity
// Always tear down the outgoing instance before creating a replacement.
// Covers three cases:
// 1. oldBot is fully connected (manual restart)
// 2. oldBot is mid-handshake from a prior rapid start (isConnected()
// still returns false but the library client is live and will leak
// a TS session if we abandon it)
// 3. oldBot was just created by createBot but never connected — the
// disconnect call is a cheap no-op here.
// Calling disconnect() is idempotent (disconnectEmitted guards event
// emission), so this is safe in all states.
oldBot.disconnect();
// Reload config from database so updated settings (channel, nickname, etc.) take effect
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
const bot = new BotInstance({
id: saved.id,
name: saved.name,
tsOptions: {
host: saved.serverAddress,
port: saved.serverPort,
queryPort: proto === "ts6" ? 10080 : 10011,
nickname: saved.nickname,
// Reuse the stored identity so server groups assigned to this bot
// survive restarts — without this the TS server sees a new UID
// each connect and strips all previously granted groups.
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
await connectWithTimeout(bot, 15_000, this.logger);
// Mark as autoStart so it reconnects on Docker restart, and persist identity
this.database.saveBotInstance({ ...saved, autoStart: true });
this.persistBotIdentity(saved, bot);
} else {
await connectWithTimeout(oldBot, 15_000, this.logger);
}
}
@@ -155,27 +260,33 @@ export class BotManager {
async loadSavedBots(): Promise<void> {
const savedInstances = this.database.getBotInstances();
for (const saved of savedInstances) {
const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
const bot = new BotInstance({
id: saved.id,
name: saved.name,
tsOptions: {
host: saved.serverAddress,
port: saved.serverPort,
queryPort: 10011,
queryPort: proto === "ts6" ? 10080 : 10011,
nickname: saved.nickname,
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
});
this.bots.set(saved.id, bot);
this.emit("botInstance", bot);
// Only auto-connect bots that have autoStart enabled
if (saved.autoStart) {
+407
View File
@@ -0,0 +1,407 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import axios from "axios";
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
import type { ProfileConfig } from "../data/database.js";
import type { QueuedSong } from "../audio/queue.js";
import type { Logger } from "../logger.js";
const TS3_NICKNAME_MAX = 30;
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
const AVATAR_MAX_BYTES = 200 * 1024;
/** Timeout for file-transfer operations (upload / delete). */
const FILE_TRANSFER_TIMEOUT_MS = 6000;
/**
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
* away status, channel description, now-playing messages).
*
* Every update is permission-safe: if a feature fails due to insufficient
* server permissions, it silently disables itself until the next reconnect.
*/
export class BotProfileManager {
private tsClient: TS3Client;
private logger: Logger;
private config: ProfileConfig;
private defaultNickname: string;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
/**
* Monotonically increasing generation counter. Incremented on every
* onSongChange / onConnect call. Long-running operations (avatar
* download/upload) check this before committing their result — if
* the generation changed, a newer update has superseded them.
*/
private generation = 0;
constructor(
tsClient: TS3Client,
logger: Logger,
config: ProfileConfig,
defaultNickname: string,
) {
this.tsClient = tsClient;
this.logger = logger.child({ component: "profile" });
this.config = { ...config };
this.defaultNickname = defaultNickname;
}
// --- Public API ---
/**
* Called when a new song starts playing (song != null) or playback
* stops (song == null).
*
* Commands are serialized to avoid overwhelming the TS3 command queue.
* Nickname + away status are merged into a single `clientupdate` call.
*
* A generation counter guards against stale updates: if a newer
* onSongChange fires while the avatar is still downloading, the old
* update is discarded.
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
// could confuse the command matcher.
await this.updateAvatar(song?.coverUrl ?? null, gen);
if (this.generation !== gen) return; // superseded
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
await this.updateClientProperties(song);
// 3. Description (clientedit on TS3, httpQuery on TS6)
await this.updateDescription(song);
// 4. Channel description (fire-and-forget channeledit)
await this.updateChannelDescription(song);
// 5. Now-playing chat message
if (song) await this.sendNowPlayingMessage(song);
}
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
}
getConfig(): ProfileConfig {
return { ...this.config };
}
updateConfig(partial: Partial<ProfileConfig>): void {
Object.assign(this.config, partial);
}
// --- Internal update methods ---
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
try {
if (!coverUrl) {
await this.clearAvatar(gen);
return;
}
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
const thumbUrl = this.thumbnailUrl(coverUrl);
const imageBuffer = await this.downloadImage(thumbUrl);
// Check generation after the slow download — bail if superseded.
if (this.generation !== gen) return;
if (!imageBuffer || imageBuffer.length === 0) return;
if (imageBuffer.length > AVATAR_MAX_BYTES) {
this.logger.warn(
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
"Cover image still too large after resize — skipping avatar update",
);
return;
}
// Wrap the file-transfer sequence with a timeout — the TS3
// full-client file transfer can silently hang.
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
this.logger.info("Avatar updated");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
const host = this.tsClient.getHost();
const info = await this.tsClient.fileTransferInitUpload(
0n, "/avatar", "", BigInt(imageBuffer.length), true,
);
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
const md5 = createHash("md5").update(imageBuffer).digest("hex");
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
}
private async clearAvatar(gen: number): Promise<void> {
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
try {
const text = song
? `${song.name} - ${song.artist} [${song.album}]`
: "";
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
await httpQuery.clientUpdate({ client_description: text });
} else {
// clientupdate rejects client_description (error 1538).
// Use clientedit on our own clid instead — this is what
// TS3AudioBot does via TSLib's ChangeDescription().
const clid = this.tsClient.getClientId();
if (clid <= 0) return;
// Use a 5s timeout — if clientedit hangs, don't block the
// remaining profile updates (channeledit, now-playing msg).
await this.withTimeout(
this.tsClient.execCommand(
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
),
5000,
);
}
this.logger.info("Description updated");
} catch (err) {
this.handleFeatureError("description", err);
}
}
/**
* Build and send a single `clientupdate` command that sets nickname
* and away status together, avoiding multiple round-trips that can
* cause command-queue timeouts on the TS3 protocol.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
const parts: string[] = [];
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
parts.push(`client_nickname=${escapeTS3(this.defaultNickname)}`);
} else {
const nickname = this.buildNickname(song);
if (nickname) {
parts.push(`client_nickname=${escapeTS3(nickname)}`);
}
}
}
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
parts.push("client_away=0");
} else {
parts.push(`client_away=1 client_away_message=${escapeTS3("\u7B49\u5F85\u64AD\u653E")}`);
}
}
if (parts.length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: build a properties object
const props: Record<string, string | number> = {};
for (const part of parts) {
const eq = part.indexOf("=");
if (eq > 0) props[part.slice(0, eq)] = part.slice(eq + 1);
}
await httpQuery.clientUpdate(props);
} else {
// Use sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
}
this.logger.info("Client properties updated (nickname + away)");
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
this.handleFeatureError("awayStatus", err);
}
}
/**
* Build a nickname string that fits within TS3_NICKNAME_MAX.
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
* not characters.
*/
private buildNickname(song: QueuedSong): string | null {
const songInfo = `${song.name} - ${song.artist}`;
const prefix = "\u266A "; // ♪
const sep = " - ";
const suffix = `${sep}${this.defaultNickname}`;
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
if (overheadBytes >= TS3_NICKNAME_MAX) {
// Default nickname alone is too long with decoration — skip
return null;
}
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
return `${prefix}${truncated}${suffix}`;
}
/**
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
* Appends an ellipsis if truncation occurred, taking its byte cost
* into account. Never splits a multi-byte character.
*/
private truncateUtf8(str: string, maxBytes: number): string {
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
const ellipsis = "\u2026"; // …
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
const target = maxBytes - ellipsisBytes;
if (target <= 0) return ellipsis;
// Walk characters, accumulating byte length
let byteLen = 0;
let end = 0;
for (const ch of str) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (byteLen + chBytes > target) break;
byteLen += chBytes;
end += ch.length; // ch.length handles surrogate pairs
}
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
);
return;
}
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
`\u4E13\u8F91: ${song.album}`, // 专辑:
`\u5E73\u53F0: ${song.platform}`, // 平台:
];
const desc = lines.join("\\n");
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
}
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
try {
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
await this.tsClient.sendTextMessage(text);
} catch (err) {
this.handleFeatureError("nowPlayingMsg", err);
}
}
// --- Helpers ---
/**
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
* NetEase and QQ Music CDNs support URL-based image resizing.
* BiliBili and YouTube covers fall through to the size-check guard.
*/
private thumbnailUrl(url: string): string {
if (url.includes("music.126.net") || url.includes("netease")) {
return url.includes("?") ? url : `${url}?param=200y200`;
}
if (url.includes("qqmusic") || url.includes("qq.com")) {
return url.replace(/\/\d+$/, "/200");
}
if (url.includes("bilivideo") || url.includes("hdslb")) {
// BiliBili CDN supports @<w>w_<h>h suffix
return url.includes("@") ? url : `${url}@200w_200h`;
}
return url;
}
private async downloadImage(url: string): Promise<Buffer | null> {
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 8000,
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
});
return Buffer.from(resp.data);
} catch (err) {
this.logger.warn({ err, url }, "Failed to download cover image");
return null;
}
}
/** Race a promise against a timeout. */
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
),
]);
}
private handleFeatureError(
feature: keyof typeof this.permDenied,
err: unknown,
): void {
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
// Disable the feature for this session on unrecoverable errors:
// - permission / insufficient → server denies the action
// - invalid parameter → command not supported by this protocol
if (
msg.includes("permission") ||
msg.includes("insufficient") ||
msg.includes("invalid parameter")
) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, err }, "Profile update failed");
}
}
}
+6
View File
@@ -60,6 +60,9 @@ describe("database", () => {
defaultChannel: "Music",
channelPassword: "",
autoStart: true,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(instance);
@@ -86,6 +89,9 @@ describe("database", () => {
defaultChannel: "Music",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
expect(botDb.deleteBotInstance("bot1")).toBe(true);
+106 -6
View File
@@ -6,7 +6,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
coverUrl: string;
}
@@ -24,9 +24,33 @@ export interface BotInstance {
defaultChannel: string;
channelPassword: string;
autoStart: boolean;
/** "ts3" | "ts6" | "" (empty = auto-detect) */
serverProtocol: string;
/** API key for TS6 HTTP Query */
ts6ApiKey: string;
/** Password to join the TS server (server password) */
serverPassword: string;
identity?: string;
}
export interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
avatarEnabled: true,
descriptionEnabled: true,
nicknameEnabled: true,
awayStatusEnabled: true,
channelDescEnabled: true,
nowPlayingMsgEnabled: true,
};
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -34,16 +58,40 @@ export interface BotDatabase {
saveBotInstance(instance: BotInstance): void;
getBotInstances(): BotInstance[];
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
close(): void;
}
function migrateSchema(db: Database.Database): void {
// Add identity column if it doesn't exist (migration for existing databases)
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
const hasIdentity = columns.some((c) => c.name === "identity");
if (!hasIdentity) {
const names = columns.map((c) => c.name);
if (!names.includes("identity")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
}
if (!names.includes("serverProtocol")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverProtocol TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("ts6ApiKey")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
}
// Profile feature flags
const profileCols = [
"profile_avatar_enabled",
"profile_description_enabled",
"profile_nickname_enabled",
"profile_away_enabled",
"profile_channel_desc_enabled",
"profile_now_playing_enabled",
];
for (const col of profileCols) {
if (!names.includes(col)) {
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
}
function initTables(db: Database.Database): void {
@@ -69,6 +117,9 @@ function initTables(db: Database.Database): void {
defaultChannel TEXT NOT NULL,
channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0,
serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '',
serverPassword TEXT NOT NULL DEFAULT '',
identity TEXT
);
`);
@@ -90,8 +141,8 @@ export function createDatabase(dbPath: string): BotDatabase {
`);
const upsertInstance = db.prepare(`
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @identity)
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
serverAddress = excluded.serverAddress,
@@ -100,6 +151,9 @@ export function createDatabase(dbPath: string): BotDatabase {
defaultChannel = excluded.defaultChannel,
channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart,
serverProtocol = excluded.serverProtocol,
ts6ApiKey = excluded.ts6ApiKey,
serverPassword = excluded.serverPassword,
identity = excluded.identity
`);
@@ -107,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
const selectProfileConfig = db.prepare(`
SELECT profile_avatar_enabled, profile_description_enabled,
profile_nickname_enabled, profile_away_enabled,
profile_channel_desc_enabled, profile_now_playing_enabled
FROM bot_instances WHERE id = ?
`);
const updateProfileConfig = db.prepare(`
UPDATE bot_instances SET
profile_avatar_enabled = @avatar,
profile_description_enabled = @description,
profile_nickname_enabled = @nickname,
profile_away_enabled = @away,
profile_channel_desc_enabled = @channelDesc,
profile_now_playing_enabled = @nowPlaying
WHERE id = @id
`);
return {
db,
@@ -133,6 +205,9 @@ export function createDatabase(dbPath: string): BotDatabase {
return rows.map((r) => ({
...r,
autoStart: r.autoStart === 1,
serverProtocol: r.serverProtocol ?? "",
ts6ApiKey: r.ts6ApiKey ?? "",
serverPassword: r.serverPassword ?? "",
identity: r.identity ?? undefined,
}));
},
@@ -142,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
return result.changes > 0;
},
getProfileConfig(botId) {
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
return {
avatarEnabled: row.profile_avatar_enabled === 1,
descriptionEnabled: row.profile_description_enabled === 1,
nicknameEnabled: row.profile_nickname_enabled === 1,
awayStatusEnabled: row.profile_away_enabled === 1,
channelDescEnabled: row.profile_channel_desc_enabled === 1,
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
};
},
saveProfileConfig(botId, config) {
updateProfileConfig.run({
id: botId,
avatar: config.avatarEnabled ? 1 : 0,
description: config.descriptionEnabled ? 1 : 0,
nickname: config.nicknameEnabled ? 1 : 0,
away: config.awayStatusEnabled ? 1 : 0,
channelDesc: config.channelDescEnabled ? 1 : 0,
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
});
},
close() {
db.close();
},
+28 -7
View File
@@ -17,7 +17,9 @@ export interface ApiServerManager {
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.once("error", () => {
server.close(() => resolve(false));
});
server.once("listening", () => {
server.close(() => resolve(true));
});
@@ -30,6 +32,7 @@ export function createApiServerManager(
logger: Logger
): ApiServerManager {
let neteaseServer: Server | null = null;
let qqMusicServer: Server | null = null;
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
@@ -60,7 +63,10 @@ export function createApiServerManager(
logger.error({ err }, "Failed to start NetEase Cloud Music API");
}
// Start QQ Music API (auto-starts on import)
// Start QQ Music API. Older versions auto-started on import; the
// current fork (2.2.11+) only listens when run as `require.main`,
// so we explicitly call .listen() on the imported Koa app and keep
// the server handle for clean shutdown.
try {
const portFree = await isPortFree(options.qqMusicPort);
if (!portFree) {
@@ -69,11 +75,22 @@ export function createApiServerManager(
"QQ Music API port already in use — reusing existing instance"
);
} else {
await import("@sansenjian/qq-music-api");
logger.info(
{ port: options.qqMusicPort },
"QQ Music API started"
);
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
const koaApp = qqModule.default ?? qqModule;
if (koaApp && typeof koaApp.listen === "function") {
qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
resolve(srv)
);
srv.on("error", reject);
});
logger.info(
{ port: options.qqMusicPort },
"QQ Music API started"
);
} else {
logger.warn("QQ Music API module does not expose a Koa app");
}
}
} catch (err) {
logger.warn(
@@ -89,6 +106,10 @@ export function createApiServerManager(
(neteaseServer as any).close();
}
neteaseServer = null;
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
(qqMusicServer as any).close();
}
qqMusicServer = null;
},
getNeteaseBaseUrl(): string {
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface SongWithUrl extends Song {
@@ -17,7 +17,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface Album {
@@ -26,7 +26,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface LyricLine {
@@ -54,7 +54,7 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili";
readonly platform: "netease" | "qq" | "bilibili" | "youtube";
search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<string | null>;
+77 -26
View File
@@ -66,7 +66,12 @@ export class QQMusicProvider implements MusicProvider {
}
async getSongDetail(songId: string): Promise<Song | null> {
// getSongInfo requires cookie; use search as fallback
// Try /getSongInfo for full metadata, but fall through to a minimal
// stub if the library endpoint fails (current @sansenjian/qq-music-api
// returns upstream code 500001 for this route — the param format it
// sends doesn't match QQ's current API). The bot's resolveAndPlay path
// only needs `id` and `platform` to fetch a play URL, and the fallback
// stub is sufficient to let /play-by-id and /add-by-id flows succeed.
try {
const res = await this.api.get("/getSongInfo", {
params: { songmid: songId, ...this.cookieParams },
@@ -87,9 +92,20 @@ export class QQMusicProvider implements MusicProvider {
};
}
} catch {
// fallback: search by songmid (less reliable)
// fall through to stub
}
return null;
// Minimal stub — resolveAndPlay only needs id + platform to fetch a
// play URL. Name/artist/album will be empty in play history, but the
// song will actually play, which is the important part.
return {
id: songId,
name: "",
artist: "",
album: "",
duration: 0,
coverUrl: "",
platform: "qq",
};
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
@@ -152,30 +168,53 @@ export class QQMusicProvider implements MusicProvider {
}
async getQrCode(): Promise<QrCodeResult> {
// @sansenjian/qq-music-api 2.x returns { img, qrsig, ptqrtoken } via
// customResponse (no { response: ... } wrapping). /checkQQLoginQr
// requires BOTH qrsig AND ptqrtoken — passing only one gives a 400
// "参数错误". Pack both into the opaque `key` field so the polling
// endpoint can split them back out. Separator "|" is safe: QQ tokens
// are alphanumeric.
const res = await this.api.get("/getQQLoginQr");
const qrsig: string = res.data?.qrsig ?? "";
const ptqrtoken: string = String(res.data?.ptqrtoken ?? "");
return {
qrUrl: "",
qrImg: res.data?.img ?? "",
key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "",
key: `${qrsig}|${ptqrtoken}`,
};
}
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const res = await this.api.get("/checkQQLoginQr", {
params: { qrsig: key },
});
const code = res.data?.code ?? res.data?.response?.code;
if (code === 0) {
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
const [qrsig, ptqrtoken] = key.split("|");
if (!qrsig || !ptqrtoken) return "expired";
// NOTE: /checkQQLoginQr is registered as POST only in
// @sansenjian/qq-music-api 2.x. GET returns 405 Method Not Allowed.
let res;
try {
res = await this.api.post("/checkQQLoginQr", null, {
params: { qrsig, ptqrtoken },
});
} catch {
return "expired";
}
// customResponse shape:
// success: { isOk: true, message: '登录成功', session: { cookie, ... } }
// scanning: { isOk: false, refresh: false, message: '未扫描二维码' }
// expired: { isOk: false, refresh: true, message: '二维码已失效' }
const body = res.data;
if (body?.isOk === true) {
const cookie: string = body.session?.cookie ?? "";
if (cookie) this.cookie = cookie;
return "confirmed";
}
if (code === 1) return "scanned";
if (code === 2) return "waiting";
return "expired";
if (body?.refresh === true) return "expired";
if (typeof body?.message === "string" && body.message.includes("未扫描"))
return "waiting";
return "waiting";
}
setCookie(cookie: string): void {
@@ -188,20 +227,32 @@ export class QQMusicProvider implements MusicProvider {
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
// /getUserAvatar in @sansenjian/qq-music-api 2.x is NOT registered on
// the main router; the real endpoint is /user/getUserAvatar, and even
// that just builds a static URL from a uin without validating the
// cookie against QQ. Round-trip through /user/getUserPlaylists which
// actually hits QQ Music with the cookie; if the upstream returns
// code=0, the cookie is valid.
//
// IMPORTANT: /user/getUserPlaylists requires `uin` as a query param —
// the library 400s with "缺少 uin 参数" otherwise. Parse it out of the
// cookie (uin=<qq>; comes after the various *uin prefixed names, which
// is why the regex anchors on a word boundary).
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
const uin = uinMatch ? uinMatch[1] : "";
if (!uin) return { loggedIn: false };
try {
const res = await this.api.get("/getUserAvatar", {
params: { ...this.cookieParams },
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, ...this.cookieParams },
});
if (res.data?.response?.data) {
return {
loggedIn: true,
nickname: res.data.response.data.nickname,
avatarUrl: res.data.response.data.headpic,
};
}
if (res.data?.response?.code !== 0) return { loggedIn: false };
return {
loggedIn: true,
nickname: `QQ ${uin}`,
avatarUrl: `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=100`,
};
} catch {
// ignore
return { loggedIn: false };
}
return { loggedIn: false };
}
}
+232
View File
@@ -0,0 +1,232 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import type {
MusicProvider,
Song,
SongWithUrl,
Playlist,
Album,
SearchResult,
LyricLine,
QrCodeResult,
AuthStatus,
} from "./provider.js";
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Resolve the yt-dlp binary path. Checks the project bin/ dir first, then PATH. */
function findYtDlp(): string {
const exe = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
const candidates = [
join(__dirname, "..", "..", "bin", exe),
join(__dirname, "..", "..", "bin", "yt-dlp"),
exe,
];
for (const c of candidates) {
// Absolute/relative paths: only return if the file exists.
// Bare names: return and let execFile resolve via PATH.
const isBinPath = c.includes(join("bin", "yt-dlp"));
if (!isBinPath || existsSync(c)) return c;
}
return exe;
}
/**
* Availability check for yt-dlp. Runs `yt-dlp --version` and caches only
* the positive result — if the binary is missing, subsequent calls retry
* so the user can install yt-dlp while the server is running and pick it
* up without a restart. Used by getAuthStatus() so the UI can reflect
* whether YouTube is actually usable.
*/
let cachedAvailable = false;
let pendingCheck: Promise<boolean> | null = null;
async function checkYtDlpAvailable(): Promise<boolean> {
if (cachedAvailable) return true;
if (pendingCheck) return pendingCheck;
pendingCheck = (async () => {
try {
await execFileAsync(findYtDlp(), ["--version"], {
timeout: 5_000,
maxBuffer: 1024,
});
cachedAvailable = true;
return true;
} catch {
return false;
} finally {
pendingCheck = null;
}
})();
return pendingCheck;
}
/** Force re-detection on the next call (for tests). */
export function resetYtDlpAvailabilityCache(): void {
cachedAvailable = false;
pendingCheck = null;
}
async function runYtDlp(args: string[], timeoutMs = 30_000): Promise<string> {
const binary = findYtDlp();
const env = { ...process.env };
if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) {
// yt-dlp respects these env vars natively
}
const { stdout } = await execFileAsync(binary, args, {
timeout: timeoutMs,
env,
maxBuffer: 10 * 1024 * 1024,
});
return stdout;
}
interface YtDlpEntry {
id: string;
title: string;
uploader?: string;
channel?: string;
duration?: number;
thumbnail?: string;
webpage_url?: string;
url?: string;
entries?: YtDlpEntry[];
_type?: string;
}
function entryToSong(entry: YtDlpEntry): Song {
return {
id: entry.id ?? "",
name: entry.title ?? "Unknown",
artist: entry.uploader ?? entry.channel ?? "YouTube",
album: "YouTube",
duration: Math.round(entry.duration ?? 0),
coverUrl: entry.thumbnail ?? "",
platform: "youtube",
};
}
export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const;
private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> {
try {
const raw = await runYtDlp([
`ytsearch${limit}:${query}`,
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--quiet",
]);
const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => {
const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry);
});
return { songs, playlists: [], albums: [] };
} catch {
return { songs: [], playlists: [], albums: [] };
}
}
async getSongUrl(songId: string): Promise<string | null> {
try {
const url = `https://www.youtube.com/watch?v=${songId}`;
const raw = await runYtDlp([
url,
"--get-url",
"-f",
"bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio",
"--no-warnings",
"--quiet",
], 45_000);
const audioUrl = raw.trim().split("\n")[0];
return audioUrl || null;
} catch {
return null;
}
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
async getSongDetail(songId: string): Promise<Song | null> {
try {
const url = `https://www.youtube.com/watch?v=${songId}`;
const raw = await runYtDlp([url, "--dump-json", "--no-warnings", "--quiet"]);
const entry = JSON.parse(raw.trim()) as YtDlpEntry;
return entryToSong(entry);
} catch {
return null;
}
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
try {
const url = playlistId.startsWith("http")
? playlistId
: `https://www.youtube.com/playlist?list=${playlistId}`;
const raw = await runYtDlp([
url,
"--dump-json",
"--flat-playlist",
"--no-warnings",
"--quiet",
], 60_000);
const lines = raw.trim().split("\n").filter(Boolean);
return lines.map((line) => entryToSong(JSON.parse(line) as YtDlpEntry));
} catch {
return [];
}
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getAlbumSongs(_albumId: string): Promise<Song[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(
_key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {}
getCookie(): string { return ""; }
async getAuthStatus(): Promise<AuthStatus> {
// YouTube has no login concept via yt-dlp, so "loggedIn" here means
// "yt-dlp binary is reachable and responds to --version". The UI can
// use this flag to grey out YouTube when the optional dependency is
// missing, instead of silently returning empty search results.
const available = await checkYtDlpAvailable();
if (available) {
return { loggedIn: true, nickname: "YouTube (yt-dlp)" };
}
return {
loggedIn: false,
nickname: "YouTube (yt-dlp not installed)",
};
}
}
+169 -7
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
@@ -7,23 +8,48 @@ import {
listChannels,
listClients,
clientMove,
fileTransferDeleteFile,
type Identity,
type TextMessage,
type ClientInfo,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
import {
detectServerProtocol,
type ServerProtocol,
} from "./protocol-detect.js";
import { TS6HttpQuery } from "./http-query.js";
export { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js";
export type { FileUploadInfo } from "@honeybbq/teamspeak-client";
/** Escape a string for use in TS3 ServerQuery-style commands. */
export function escapeTS3(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/ /g, "\\s")
.replace(/\//g, "\\/")
.replace(/\|/g, "\\p")
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r");
}
export interface TS3ClientOptions {
host: string;
port: number; // Voice/virtual server port (default 9987)
queryPort: number; // ServerQuery port (default 10011) — unused now, kept for compat
queryPort: number; // ServerQuery port (10011 for TS3, 10080 for TS6 HTTP)
nickname: string;
identity?: string; // Exported identity string, or undefined to generate new
defaultChannel?: string;
channelPassword?: string;
serverPassword?: string;
/** Force a specific protocol instead of auto-detecting. */
serverProtocol?: ServerProtocol;
/** API key for TS6 HTTP Query authentication. */
ts6ApiKey?: string;
}
export interface TS3TextMessage {
@@ -40,6 +66,9 @@ export class TS3Client extends EventEmitter {
private clientId = 0;
private logger: Logger;
private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown";
private httpQuery: TS6HttpQuery | null = null;
private udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private options: TS3ClientOptions, logger: Logger) {
super();
@@ -52,6 +81,16 @@ export class TS3Client extends EventEmitter {
}
}
/** The detected (or forced) server protocol after connect(). */
getServerProtocol(): ServerProtocol {
return this.detectedProtocol;
}
/** TS6 HTTP Query client (available after connecting to a TS6 server). */
getHttpQuery(): TS6HttpQuery | null {
return this.httpQuery;
}
async connect(): Promise<void> {
// Clean up any existing connection before creating a new one
if (this.client) {
@@ -66,23 +105,81 @@ export class TS3Client extends EventEmitter {
}
const addr = `${this.options.host}:${this.options.port}`;
this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)");
// Detect or use forced protocol
if (this.options.serverProtocol && this.options.serverProtocol !== "unknown") {
this.detectedProtocol = this.options.serverProtocol;
this.logger.info(
{ addr, protocol: this.detectedProtocol },
"Using forced server protocol",
);
} else {
this.logger.info({ addr }, "Detecting server protocol (TS3/TS6)...");
const detection = await detectServerProtocol(
this.options.host,
this.options.port,
3000,
{ ts3QueryPort: 10011, ts6HttpPort: 10080 },
);
this.detectedProtocol = detection.protocol;
if (this.detectedProtocol === "unknown") {
this.logger.warn(
{ addr },
"Could not detect server protocol (query ports 10011/10080 unreachable). " +
"Will attempt voice connection anyway. Use serverProtocol option to force TS3 or TS6.",
);
} else {
this.logger.info(
{ addr, protocol: this.detectedProtocol, queryPort: detection.queryPort },
`Server protocol detected: ${this.detectedProtocol.toUpperCase()}`,
);
}
}
// Set up TS6 HTTP Query if applicable
if (this.detectedProtocol === "ts6") {
const queryPort = this.options.queryPort !== 10011 ? this.options.queryPort : 10080;
this.httpQuery = new TS6HttpQuery({
host: this.options.host,
port: queryPort,
apiKey: this.options.ts6ApiKey,
});
}
// Guard against calling connect() while already connected.
// Save detectedProtocol first because disconnect() resets it.
if (this.client) {
this.logger.warn("connect() called while already connected, disconnecting first");
const savedProtocol = this.detectedProtocol;
const savedHttpQuery = this.httpQuery;
this.disconnect();
this.detectedProtocol = savedProtocol;
this.httpQuery = savedHttpQuery;
// Give the old client a moment to tear down
await new Promise((r) => setTimeout(r, 100));
}
this.logger.info(
{ addr, protocol: this.detectedProtocol },
"Connecting to TeamSpeak server (full client protocol)",
);
// Throttle repeated "udp send error" warnings (fires every 20ms during playback if UDP breaks)
let udpErrorCount = 0;
let udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
const throttledWarn = (msg: string, ...args: unknown[]) => {
if (typeof msg === "string" && msg.includes("udp send error")) {
udpErrorCount++;
if (udpErrorCount === 1) {
this.logger.warn(msg);
// After 2 seconds, log a summary and reset
udpErrorTimer = setTimeout(() => {
// After 2 seconds, log a summary and reset.
// Clear any previous timer to avoid leaking it.
if (this.udpErrorTimer) clearTimeout(this.udpErrorTimer);
this.udpErrorTimer = setTimeout(() => {
if (udpErrorCount > 1) {
this.logger.warn(`udp send error (repeated ${udpErrorCount} times, connection may be lost)`);
}
udpErrorCount = 0;
udpErrorTimer = null;
this.udpErrorTimer = null;
}, 2000);
}
return;
@@ -124,10 +221,19 @@ export class TS3Client extends EventEmitter {
});
await this.client.connect();
// Note: @honeybbq/teamspeak-client 0.2.x ships a universal clientinit
// (client_version "3.?.? [Build: 5680278000]" + matching signature)
// that works against both TS3 and TS6 servers. The old 3.6.2 monkey-
// patch on handler.sendPacket was removed when we bumped to 0.2.1 — it
// would have replaced the library's new correct version with a stale
// signature and made TS6 handshakes fail.
await this.client.waitConnected();
this.clientId = this.client.clientID();
this.voiceFramesSent = 0;
this.logger.info({ clientId: this.clientId }, "Logged in (visible client)");
this.logger.info(
{ clientId: this.clientId, protocol: this.detectedProtocol },
`Logged in (visible client, ${this.detectedProtocol.toUpperCase()} server)`,
);
// Join default channel if specified
if (this.options.defaultChannel) {
@@ -188,6 +294,56 @@ export class TS3Client extends EventEmitter {
}
}
// --- Raw command & file transfer pass-through ---
async execCommand(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.execCommand(cmd);
}
/** Fire a command without waiting for the server's response. */
async sendCommandNoWait(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.sendCommandNoWait(cmd);
}
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
if (!this.client) throw new Error("Not connected");
return this.client.execCommandWithResponse(cmd);
}
async fileTransferInitUpload(
channelID: bigint,
path: string,
password: string,
size: bigint,
overwrite = true,
): Promise<FileUploadInfo> {
if (!this.client) throw new Error("Not connected");
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
}
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.uploadFileData(host, info, data);
}
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
if (!this.client) throw new Error("Not connected");
await fileTransferDeleteFile(this.client, channelID, paths);
}
/** The server host (needed for file transfer TCP connections). */
getHost(): string {
return this.options.host;
}
/** The current channel ID of this client. */
getChannelId(): bigint {
if (!this.client) return 0n;
return this.client.channelID();
}
private voiceFramesSent = 0;
sendVoiceData(opusFrame: Buffer): void {
@@ -225,6 +381,12 @@ export class TS3Client extends EventEmitter {
});
}
this.clientId = 0;
this.httpQuery = null;
this.detectedProtocol = "unknown";
if (this.udpErrorTimer) {
clearTimeout(this.udpErrorTimer);
this.udpErrorTimer = null;
}
this.logger.info("Disconnected from TeamSpeak server");
}
}
+8 -1
View File
@@ -1,10 +1,17 @@
/**
* TS3 raw-TCP ServerQuery connection (port 10011).
*
* @deprecated This module only works with TS3 servers. TS6 servers replaced
* the raw-TCP ServerQuery with HTTP/HTTPS (port 10080/10443) and SSH (10022).
* For TS6 servers, use {@link ../http-query.js TS6HttpQuery} instead.
*/
import net from "node:net";
import { EventEmitter } from "node:events";
import { encodeCommand, decodeResponse, parseErrorLine } from "./commands.js";
export interface ConnectionOptions {
host: string;
port: number; // ServerQuery port, typically 10011
port: number; // ServerQuery port: 10011 (TS3) — not available on TS6
}
export interface CommandResult {
+179
View File
@@ -0,0 +1,179 @@
import http from "node:http";
import https from "node:https";
export interface HttpQueryOptions {
host: string;
port: number; // 10080 (HTTP) or 10443 (HTTPS)
useTls?: boolean;
apiKey?: string;
timeoutMs?: number;
}
export interface HttpQueryResult {
status: number;
body: unknown;
}
/**
* TS6 HTTP Query client.
*
* TeamSpeak 6 Server replaces the TS3 raw-TCP ServerQuery (port 10011)
* with an HTTP/HTTPS API on ports 10080/10443.
*
* Common endpoints (TS6 HTTP Query):
* GET / → server info / health check
* POST /api-key → create API key
* GET /1/serverlist → list virtual servers
* GET /1/clientlist?sid={sid} → list clients
* POST /1/sendtextmessage → send text message
* POST /1/clientmove → move a client
* GET /1/channellist?sid={sid} → list channels
* POST /1/clientupdate → update client properties
*/
export class TS6HttpQuery {
private options: Required<HttpQueryOptions>;
constructor(options: HttpQueryOptions) {
this.options = {
host: options.host,
port: options.port,
useTls: options.useTls ?? options.port === 10443,
apiKey: options.apiKey ?? "",
timeoutMs: options.timeoutMs ?? 5000,
};
}
async request(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,
body?: Record<string, unknown>,
): Promise<HttpQueryResult> {
const { host, port, useTls, apiKey, timeoutMs } = this.options;
const transport = useTls ? https : http;
const headers: Record<string, string> = {
Accept: "application/json",
};
if (apiKey) {
headers["x-api-key"] = apiKey;
}
let bodyStr: string | undefined;
if (body) {
bodyStr = JSON.stringify(body);
headers["Content-Type"] = "application/json";
headers["Content-Length"] = String(Buffer.byteLength(bodyStr));
}
return new Promise((resolve, reject) => {
let settled = false;
const fail = (err: Error) => {
if (settled) return;
settled = true;
reject(err);
};
const req = transport.request(
{
hostname: host,
port,
path,
method,
timeout: timeoutMs,
headers,
rejectUnauthorized: false, // self-signed certs common on self-hosted
},
(res) => {
let data = "";
res.setEncoding("utf-8");
res.on("data", (chunk: string) => (data += chunk));
res.on("error", fail);
res.on("end", () => {
if (settled) return;
settled = true;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
parsed = data;
}
resolve({
status: res.statusCode ?? 0,
body: parsed,
});
});
},
);
req.on("error", fail);
req.on("timeout", () => {
req.destroy();
fail(new Error("TS6 HTTP Query timeout"));
});
if (bodyStr) {
req.write(bodyStr);
}
req.end();
});
}
/** Check if the TS6 HTTP Query is reachable */
async healthCheck(): Promise<boolean> {
try {
const result = await this.request("GET", "/");
return result.status >= 200 && result.status < 500;
} catch {
return false;
}
}
/** List virtual servers */
async serverList(): Promise<HttpQueryResult> {
return this.request("GET", "/1/serverlist");
}
/** List clients on a virtual server */
async clientList(sid = 1): Promise<HttpQueryResult> {
return this.request("GET", `/1/clientlist?sid=${sid}`);
}
/** List channels on a virtual server */
async channelList(sid = 1): Promise<HttpQueryResult> {
return this.request("GET", `/1/channellist?sid=${sid}`);
}
/** Send a text message */
async sendTextMessage(
targetMode: number,
target: number,
msg: string,
sid = 1,
): Promise<HttpQueryResult> {
return this.request("POST", `/1/sendtextmessage?sid=${sid}`, {
targetmode: targetMode,
target,
msg,
});
}
/** Update client properties (e.g., description) */
async clientUpdate(
properties: Record<string, string | number>,
sid = 1,
): Promise<HttpQueryResult> {
return this.request("POST", `/1/clientupdate?sid=${sid}`, properties);
}
/** Move a client to a channel */
async clientMove(
clid: number,
cid: number,
cpw?: string,
sid = 1,
): Promise<HttpQueryResult> {
const body: Record<string, unknown> = { clid, cid };
if (cpw) body.cpw = cpw;
return this.request("POST", `/1/clientmove?sid=${sid}`, body);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import {
detectServerProtocol,
type ServerProtocol,
type ProtocolDetectResult,
} from "./protocol-detect.js";
describe("protocol-detect", () => {
it("returns unknown for unreachable hosts", async () => {
const result = await detectServerProtocol("192.0.2.1", 9987, 1000);
expect(result.protocol).toBe("unknown");
expect(result.queryPort).toBeNull();
expect(result.voicePort).toBe(9987);
});
it("result shape matches ProtocolDetectResult interface", async () => {
const result = await detectServerProtocol("127.0.0.1", 9987, 500);
expect(result).toHaveProperty("protocol");
expect(result).toHaveProperty("queryPort");
expect(result).toHaveProperty("voicePort");
expect(["ts3", "ts6", "unknown"]).toContain(result.protocol);
});
});
+128
View File
@@ -0,0 +1,128 @@
import net from "node:net";
import http from "node:http";
export type ServerProtocol = "ts3" | "ts6" | "unknown";
export interface ProtocolDetectResult {
protocol: ServerProtocol;
/** The query port that responded (10011 for TS3, 10080 for TS6 HTTP) */
queryPort: number | null;
/** Whether the voice port (UDP 9987) is the same for both */
voicePort: number;
}
export interface DetectOptions {
/** TS3 ServerQuery probe port (default: 10011) */
ts3QueryPort?: number;
/** TS6 HTTP Query probe port (default: 10080) */
ts6HttpPort?: number;
}
/**
* Probe a TeamSpeak server to determine if it's running TS3 or TS6.
*
* Detection strategy:
* 1. Try TCP connect to port 10011 (TS3 ServerQuery) — if banner starts with "TS3", it's TS3.
* 2. Try HTTP GET to port 10080 (TS6 HTTP Query) — if we get a valid HTTP response, it's TS6.
* 3. If neither responds, return "unknown" (voice-only connection may still work).
*/
export async function detectServerProtocol(
host: string,
voicePort = 9987,
timeoutMs = 3000,
options?: DetectOptions,
): Promise<ProtocolDetectResult> {
const ts3Port = options?.ts3QueryPort ?? 10011;
const ts6Port = options?.ts6HttpPort ?? 10080;
const [ts3, ts6] = await Promise.allSettled([
probeTS3Query(host, ts3Port, timeoutMs),
probeTS6HttpQuery(host, ts6Port, timeoutMs),
]);
// Prefer TS3 if both somehow respond (shouldn't happen in practice)
if (ts3.status === "fulfilled" && ts3.value) {
return { protocol: "ts3", queryPort: ts3Port, voicePort };
}
if (ts6.status === "fulfilled" && ts6.value) {
return { protocol: "ts6", queryPort: ts6Port, voicePort };
}
return { protocol: "unknown", queryPort: null, voicePort };
}
/**
* Probe TS3 ServerQuery by connecting to raw TCP and checking for "TS3" banner.
*/
function probeTS3Query(host: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false;
const done = (value: boolean) => {
if (resolved) return;
resolved = true;
socket.removeAllListeners();
socket.destroy();
resolve(value);
};
const socket = net.createConnection({ host, port, timeout: timeoutMs });
let banner = "";
const MAX_BANNER = 256; // TS3 banner is ~50 bytes; cap to avoid memory abuse
socket.setTimeout(timeoutMs);
socket.on("data", (data: Buffer) => {
banner += data.toString("utf-8");
if (banner.length > MAX_BANNER) banner = banner.slice(0, MAX_BANNER);
if (banner.includes("TS3")) {
done(true);
}
});
socket.on("connect", () => {
// Wait briefly for banner after TCP connect
setTimeout(() => done(banner.includes("TS3")), 500);
});
socket.on("error", () => done(false));
socket.on("timeout", () => done(false));
});
}
/**
* Probe TS6 HTTP Query by sending GET / and checking for a valid response.
* Any HTTP status (including 401/403) confirms the TS6 HTTP Query exists.
*/
function probeTS6HttpQuery(host: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false;
const done = (value: boolean) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const req = http.request(
{
hostname: host,
port,
path: "/",
method: "GET",
timeout: timeoutMs,
headers: { Accept: "application/json" },
},
(res) => {
res.resume();
done(res.statusCode !== undefined);
},
);
req.on("error", () => done(false));
req.on("timeout", () => {
req.destroy();
done(false);
});
req.end();
});
}
+13
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js";
@@ -11,9 +12,13 @@ export function createAuthRouter(
cookieStore?: CookieStore
): Router {
const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can
// report whether yt-dlp is actually installed (loggedIn=false otherwise).
const youtubeProvider: MusicProvider = new YouTubeProvider();
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
@@ -119,6 +124,14 @@ export function createAuthRouter(
res.status(400).json({ error: "cookie is required" });
return;
}
// YouTube has no cookie concept — reject instead of falling through and
// clobbering the NetEase cookie entry.
if (platform === "youtube") {
res
.status(400)
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
return;
}
const provider = getProvider(platform);
provider.setCookie(cookie);
const plat = platform === "bilibili" ? "bilibili" as const
+4 -2
View File
@@ -43,6 +43,7 @@ export function createBotRouter(
nickname,
defaultChannel,
channelPassword,
serverPassword,
autoStart,
} = req.body;
if (!name || !serverAddress || !nickname) {
@@ -58,6 +59,7 @@ export function createBotRouter(
nickname,
defaultChannel,
channelPassword,
serverPassword,
autoStart: autoStart ?? false,
});
res.status(201).json(bot.getStatus());
@@ -75,10 +77,10 @@ export function createBotRouter(
res.status(404).json({ error: "Bot not found" });
return;
}
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword } = req.body;
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword } = req.body;
// Update in database
botManager.updateBot(req.params.id, {
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword,
name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, serverPassword,
});
res.json({ success: true });
} catch (err) {
+3
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
export function createMusicRouter(
@@ -9,9 +10,11 @@ export function createMusicRouter(
logger: Logger
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
function getProvider(platform?: string): MusicProvider {
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
+87 -26
View File
@@ -25,6 +25,14 @@ export function createPlayerRouter(
next();
});
/** Map API platform string to the corresponding command flag. */
const platformFlag = (platform: unknown): string => {
if (platform === "bilibili") return "-b";
if (platform === "qq") return "-q";
if (platform === "youtube") return "-y";
return "";
};
router.post("/:botId/play", async (req, res) => {
try {
const bot = (req as any).bot;
@@ -33,8 +41,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "query is required" });
return;
}
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!");
const cmd = parseCommand(`!play ${platformFlag(platform)} ${query}`.trim(), "!");
if (!cmd) {
res.status(400).json({ error: "Invalid command" });
return;
@@ -50,8 +57,7 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!");
const cmd = parseCommand(`!add ${platformFlag(platform)} ${query}`.trim(), "!");
if (!cmd) {
res.status(400).json({ error: "Invalid command" });
return;
@@ -85,7 +91,21 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { volume } = req.body;
const cmd = parseCommand(`!vol ${volume}`, "!")!;
// Reject bad input with a proper 4xx instead of letting cmdVol
// return a "Usage:" string inside a 200 body — API clients can't
// detect that failure mode, and the UI would silently swallow it.
if (
typeof volume !== "number" ||
!Number.isFinite(volume) ||
volume < 0 ||
volume > 100
) {
res
.status(400)
.json({ error: "volume must be a number between 0 and 100" });
return;
}
const cmd = parseCommand(`!vol ${Math.round(volume)}`, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
@@ -93,10 +113,18 @@ export function createPlayerRouter(
}
});
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
router.post("/:botId/mode", async (req, res) => {
try {
const bot = (req as any).bot;
const { mode } = req.body;
if (typeof mode !== "string" || !VALID_MODES.has(mode)) {
res
.status(400)
.json({ error: "mode must be one of: seq, loop, random, rloop" });
return;
}
const cmd = parseCommand(`!mode ${mode}`, "!")!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
@@ -116,8 +144,12 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { position } = req.body; // seconds
if (typeof position !== "number" || position < 0) {
res.status(400).json({ error: "position (seconds) is required" });
// typeof NaN === "number" and NaN < 0 is false, so a plain range
// check lets NaN/Infinity through and later corrupts seekOffset.
if (typeof position !== "number" || !Number.isFinite(position) || position < 0) {
res
.status(400)
.json({ error: "position must be a finite non-negative number" });
return;
}
bot.getPlayer().seek(position);
@@ -153,8 +185,15 @@ export function createPlayerRouter(
return;
}
const queue = bot.getQueueManager();
bot.getPlayer().stop(); // Stop current playback first
bot.getPlayer().resetFailures(); // Reset on user-initiated play
// Validate the index BEFORE stopping current playback — otherwise an
// invalid index silently kills the user's current song and leaves the
// queue idle.
if (index >= queue.size()) {
res.status(400).json({ error: "Invalid queue index" });
return;
}
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const song = queue.playAt(index);
if (!song) {
res.status(400).json({ error: "Invalid queue index" });
@@ -175,9 +214,8 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
const flags = platform === "bilibili" ? "-b" : platform === "qq" ? "-q" : "";
const cmd = parseCommand(
`!playlist ${flags} ${playlistId}`.trim(),
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!"
)!;
const response = await bot.executeCommand(cmd);
@@ -193,11 +231,13 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
if (!provider) {
res.status(500).json({ error: "Provider not available" });
return;
}
// Use the bot's own provider lookup — it already knows about youtube,
// which the router's constructor params did not.
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
// Stop current playback
bot.getPlayer().stop();
@@ -241,11 +281,11 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
if (!provider) {
res.status(500).json({ error: "Provider not available" });
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const song = await provider.getSongDetail(songId);
if (!song) {
@@ -276,11 +316,11 @@ export function createPlayerRouter(
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
const provider = platform === "bilibili" ? bilibiliProvider : platform === "qq" ? qqProvider : neteaseProvider;
if (!provider) {
res.status(500).json({ error: "Provider not available" });
return;
}
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const song = await provider.getSongDetail(songId);
if (!song) {
@@ -303,6 +343,27 @@ export function createPlayerRouter(
}
});
// --- Profile config endpoints ---
router.get("/:botId/profile", (req, res) => {
const bot = (req as any).bot;
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
pm.updateConfig(req.body);
if (database) {
database.saveProfileConfig(bot.id, pm.getConfig());
}
res.json(pm.getConfig());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/history", (req, res) => {
if (!database) {
res.json({ history: [] });
+47 -13
View File
@@ -10,8 +10,9 @@ export function setupWebSocket(
): () => void {
const clients = new Set<WebSocket>();
/** Track which bots already have listeners attached */
/** Track which bot instances have listeners attached (keyed by id, storing ref) */
const attachedBots = new Map<string, {
bot: BotInstance;
stateChange: () => void;
connected: () => void;
disconnected: () => void;
@@ -48,8 +49,22 @@ export function setupWebSocket(
}
};
function detachBotListener(id: string): void {
const existing = attachedBots.get(id);
if (!existing) return;
existing.bot.removeListener("stateChange", existing.stateChange);
existing.bot.removeListener("connected", existing.connected);
existing.bot.removeListener("disconnected", existing.disconnected);
attachedBots.delete(id);
}
function attachBotListener(bot: BotInstance): void {
if (attachedBots.has(bot.id)) return;
const existing = attachedBots.get(bot.id);
if (existing) {
if (existing.bot === bot) return; // already attached to this instance
// Bot instance was replaced (e.g. startBot re-created it) — re-attach
detachBotListener(bot.id);
}
const onStateChange = () => {
broadcast({
@@ -81,6 +96,7 @@ export function setupWebSocket(
bot.on("disconnected", onDisconnected);
attachedBots.set(bot.id, {
bot,
stateChange: onStateChange,
connected: onConnected,
disconnected: onDisconnected,
@@ -94,21 +110,39 @@ export function setupWebSocket(
}
}
// Check for newly added bots periodically
const intervalId = setInterval(ensureAllBotsAttached, 5000);
// React immediately when a bot instance is created or replaced
const onBotInstance = (bot: BotInstance) => attachBotListener(bot);
botManager.on("botInstance", onBotInstance);
// React when a bot is removed: detach its listener and tell clients to drop it
const onBotInstanceRemoved = (id: string) => {
detachBotListener(id);
broadcast({ type: "botRemoved", botId: id });
};
botManager.on("botInstanceRemoved", onBotInstanceRemoved);
/** Drop attached listeners whose bot is no longer in the manager. */
function reconcileAttachedBots(): void {
const liveIds = new Set(botManager.getAllBots().map((b) => b.id));
for (const id of Array.from(attachedBots.keys())) {
if (!liveIds.has(id)) detachBotListener(id);
}
}
// Safety net: periodically re-check in case any bot was missed
const intervalId = setInterval(() => {
reconcileAttachedBots();
ensureAllBotsAttached();
}, 5000);
ensureAllBotsAttached();
return () => {
clearInterval(intervalId);
// Clean up named listeners
for (const bot of botManager.getAllBots()) {
const listeners = attachedBots.get(bot.id);
if (listeners) {
bot.removeListener("stateChange", listeners.stateChange);
bot.removeListener("connected", listeners.connected);
bot.removeListener("disconnected", listeners.disconnected);
}
botManager.removeListener("botInstance", onBotInstance);
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
// Clean up all attached listeners (detach from stored bot refs, not live map)
for (const id of Array.from(attachedBots.keys())) {
detachBotListener(id);
}
attachedBots.clear();
};
}
+139 -30
View File
@@ -9,36 +9,51 @@
</div>
<div class="nav-right">
<!-- Multi-bot selector -->
<div v-if="store.bots.length > 1" class="bot-selector" ref="selectorRef">
<!-- Bot selector (always shown when at least one bot exists) -->
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
</button>
<div v-if="dropdownOpen" class="bot-dropdown">
<button
<div
v-for="bot in store.bots"
:key="bot.id"
class="bot-dropdown-item"
:class="{ active: bot.id === store.activeBotId }"
@click="selectBot(bot.id)"
class="bot-dropdown-row"
>
<span class="bot-dot" :class="{ online: bot.connected }" />
<span class="bot-dropdown-name">{{ bot.name }}</span>
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span>
</button>
<button
class="bot-dropdown-item"
:class="{ active: bot.id === store.activeBotId }"
@click="selectBot(bot.id)"
>
<span class="bot-dot" :class="{ online: bot.connected }" />
<span class="bot-dropdown-name">{{ bot.name }}</span>
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span>
</button>
<button
class="bot-power-btn"
:class="{ online: bot.connected }"
:title="bot.connected ? `停止 ${bot.name}` : `启动 ${bot.name}`"
:disabled="togglingBots[bot.id]"
@click.stop="togglePower(bot)"
>
<Icon :icon="bot.connected ? 'mdi:power' : 'mdi:power-off'" />
</button>
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)">
<Icon icon="mdi:link-variant" />
</button>
</div>
<div class="bot-dropdown-divider" />
<div class="bot-dropdown-hint">点击切换 · 🔗 复制专属链接</div>
</div>
</div>
<!-- Single bot status (original behavior) -->
<div v-else-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
</div>
<RouterLink to="/settings" class="settings-btn">
<Icon icon="mdi:cog" />
</RouterLink>
@@ -55,12 +70,36 @@ const store = usePlayerStore();
const activeBot = computed(() => store.activeBot);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
const togglingBots = ref<Record<string, boolean>>({});
function selectBot(id: string) {
store.setActiveBotId(id);
dropdownOpen.value = false;
}
function copyBotLink(id: string) {
const url = `${window.location.origin}/bot/${id}`;
navigator.clipboard.writeText(url).then(() => {
dropdownOpen.value = false;
});
}
async function togglePower(bot: { id: string; connected: boolean; name: string }) {
if (togglingBots.value[bot.id]) return;
togglingBots.value[bot.id] = true;
try {
if (bot.connected) {
await store.stopBotInstance(bot.id);
} else {
await store.startBotInstance(bot.id);
}
} catch (err) {
console.error(`Failed to toggle bot ${bot.name}`, err);
} finally {
togglingBots.value[bot.id] = false;
}
}
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
dropdownOpen.value = false;
@@ -145,22 +184,31 @@ onUnmounted(() => {
.bot-selector-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
gap: 10px;
padding: 10px 20px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
transition: background var(--transition-fast);
border-radius: var(--radius-md);
font-size: 16px;
font-weight: 600;
min-height: 44px;
border: 1px solid var(--border-color);
transition: background var(--transition-fast), border-color var(--transition-fast);
cursor: pointer;
&:hover {
background: var(--bg-card);
border-color: var(--color-primary);
}
}
.bot-state-mini {
font-size: 14px;
&.playing { color: #22c55e; }
&.paused { color: #eab308; }
}
.bot-chevron {
font-size: 16px;
font-size: 20px;
opacity: 0.5;
transition: transform 0.2s ease;
@@ -170,8 +218,8 @@ onUnmounted(() => {
}
.bot-dot {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-tertiary);
flex-shrink: 0;
@@ -182,7 +230,7 @@ onUnmounted(() => {
}
.bot-selector-name {
max-width: 120px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -205,7 +253,8 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
flex: 1;
min-width: 0;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
@@ -222,6 +271,12 @@ onUnmounted(() => {
}
}
.bot-dropdown-row {
display: flex;
align-items: center;
gap: 2px;
}
.bot-dropdown-name {
flex: 1;
min-width: 0;
@@ -230,6 +285,47 @@ onUnmounted(() => {
white-space: nowrap;
}
.bot-link-btn {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 15px;
opacity: 0.4;
transition: opacity var(--transition-fast), background var(--transition-fast);
cursor: pointer;
&:hover {
opacity: 1;
background: var(--hover-bg);
}
}
.bot-power-btn {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 16px;
opacity: 0.5;
color: var(--text-tertiary);
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
&:hover:not(:disabled) {
opacity: 1;
background: var(--hover-bg);
}
&:disabled {
opacity: 0.25;
cursor: wait;
}
&.online {
color: #22c55e;
opacity: 0.9;
}
}
.bot-playing-badge,
.bot-paused-badge,
.bot-idle-badge,
@@ -261,8 +357,21 @@ onUnmounted(() => {
color: var(--text-tertiary);
}
.bot-dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
.bot-dropdown-hint {
padding: 4px 12px 6px;
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.settings-btn {
font-size: 20px;
font-size: 22px;
opacity: 0.6;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
+7 -2
View File
@@ -7,8 +7,8 @@
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : '网易云' }}</span>
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : '网易云' }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
@@ -118,6 +118,11 @@ function formatDuration(seconds: number): string {
color: #00a1d6;
}
.badge-youtube {
background: rgba(255, 0, 0, 0.12);
color: #ff0000;
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
+7
View File
@@ -56,6 +56,13 @@ export function useWebSocket() {
}
}
break;
case 'botRemoved':
// Bot was deleted from the server — drop from local state entirely
store.removeBotStatus(data.botId);
if (store.activeBotId === data.botId) {
store.activeBotId = store.bots[0]?.id ?? null;
}
break;
}
};
+6
View File
@@ -38,6 +38,12 @@ const router = createRouter({
name: 'setup',
component: () => import('../views/Setup.vue'),
},
{
// Per-bot URL: /bot/:id — sets active bot then redirects to home
path: '/bot/:id',
name: 'bot',
component: () => import('../views/BotRedirect.vue'),
},
],
});
+8
View File
@@ -168,6 +168,14 @@ export const usePlayerStore = defineStore('player', {
if (saved) this.theme = saved;
},
async startBotInstance(id: string) {
await axios.post(`/api/bot/${id}/start`);
},
async stopBotInstance(id: string) {
await axios.post(`/api/bot/${id}/stop`);
},
async fetchBots() {
const res = await axios.get('/api/bot');
this.bots = res.data.bots;
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="redirect-page">
<span v-if="notFound">机器人不存在或未加载</span>
<span v-else>正在跳转...</span>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
const route = useRoute();
const router = useRouter();
const store = usePlayerStore();
const notFound = ref(false);
onMounted(async () => {
const botId = route.params.id as string;
if (!store.bots.length) {
await store.fetchBots();
}
const bot = store.bots.find((b) => b.id === botId);
if (bot) {
store.setActiveBotId(botId);
router.replace('/');
} else {
notFound.value = true;
}
});
</script>
<style scoped>
.redirect-page {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
font-size: 16px;
color: var(--text-secondary);
}
</style>
+14
View File
@@ -76,6 +76,10 @@
<label>频道密码(可选)</label>
<input v-model="editForm.channelPassword" class="input" type="password" />
</div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="editingBot = null">取消</button>
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
@@ -108,6 +112,10 @@
<label>默认频道(可选)</label>
<input v-model="newBotChannel" class="input" placeholder="音乐频道" />
</div>
<div class="form-group">
<label>服务器密码(可选)</label>
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<button class="btn-primary" @click="createBot">创建</button>
</div>
</section>
@@ -394,6 +402,7 @@ const newBotServer = ref('');
const newBotPort = ref(9987);
const newBotNickname = ref('MusicBot');
const newBotChannel = ref('');
const newBotServerPassword = ref('');
// Edit bot
const editingBot = ref<string | null>(null);
@@ -404,6 +413,7 @@ const editForm = reactive({
nickname: '',
defaultChannel: '',
channelPassword: '',
serverPassword: '',
});
const neteaseCookie = ref('');
@@ -559,6 +569,7 @@ async function createBot() {
serverPort: newBotPort.value || 9987,
nickname: newBotNickname.value || newBotName.value,
defaultChannel: newBotChannel.value || undefined,
serverPassword: newBotServerPassword.value || undefined,
autoStart: false,
});
newBotName.value = '';
@@ -566,6 +577,7 @@ async function createBot() {
newBotPort.value = 9987;
newBotNickname.value = 'MusicBot';
newBotChannel.value = '';
newBotServerPassword.value = '';
await store.fetchBots();
} catch {
// Ignore
@@ -598,6 +610,7 @@ async function openEditBot(bot: any) {
editForm.nickname = res.data.nickname ?? '';
editForm.defaultChannel = res.data.defaultChannel ?? '';
editForm.channelPassword = res.data.channelPassword ?? '';
editForm.serverPassword = res.data.serverPassword ?? '';
} catch {
// Config not found — use defaults
editForm.serverAddress = '';
@@ -605,6 +618,7 @@ async function openEditBot(bot: any) {
editForm.nickname = bot.name;
editForm.defaultChannel = '';
editForm.channelPassword = '';
editForm.serverPassword = '';
}
}