Compare commits

...
Author SHA1 Message Date
saopig1andClaude Opus 4.8 45f5d236c1 fix(web): tick player time every frame and keep lyrics in sync (#107)
`store.elapsed` is a Pinia getter (a cached Vue computed) that interpolates
with `Date.now()`. Because `Date.now()` is not a reactive dependency, the
computed only re-ran on WebSocket pushes / the 3s server poll, so the bottom
progress bar jumped ~3s at a time and lyric highlighting lagged ~half a line —
even though the consumers read it from a 60fps requestAnimationFrame loop.

Add a pure `interpolateElapsed()` helper and a non-cached `liveElapsed()` store
action. The per-frame consumers now call `liveElapsed()` so the value advances
every frame instead of returning a frozen cache:
- web/src/components/Player.vue  (desktop progress bar, rAF)
- web/src/App.vue                (mobile progress bar, rAF)
- web/src/views/Lyrics.vue       (lyric highlight, 500ms interval)

pause() now freezes at the live value rather than a possibly-stale cached one.
The `elapsed` getter is refactored onto the same helper (behaviour unchanged).

Adds web/src/stores/elapsed.test.ts covering the time-advancing interpolation,
paused freeze, no-anchor, and duration-clamp cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:17:53 +08:00
TIANYAO ZHANG ea6820204d Merge pull request #108 from Fa1nttt/feature/local-audio-upload
feat: add local audio upload playback 增加本地音频上传播放功能
2026-06-30 16:03:32 +08:00
saopig1andClaude Opus 4.8 e849db2286 fix(local-audio): reference-aware cleanup, upload quota, stricter validation
Uploaded local files were deleted whenever a track left the current slot,
with no check on whether the file was still needed — causing data loss in
several flows. Replace with reference-aware cleanup: a file is deleted only
once it has been played AND is no longer referenced by ANY bot's queue
(BotManager.getReferencedLocalSongIds wired into the provider via
setInUseResolver), with the sweep run AFTER each queue mutation.

Fixes:
- play-song replay no longer deletes the file it is about to play
- loop / repeat-all / prev no longer destroy uploads mid-cycle
- a shared upload queued on multiple bots is not deleted while still in use
- !play / play-playlist / play-album clean the whole replaced queue, and an
  empty/failed playlist/album load keeps the previous queue + files intact
- bound disk use with an upload quota (evict oldest UNREFERENCED files)
- validate uploads by extension against the audio whitelist (never trust the
  client Content-Type); the stored extension is always a known audio type

Deletion now unlinks the file FIRST and drops the record only on success,
with a bounded non-blocking retry for briefly-locked files (Windows/ffmpeg),
so a failed unlink never orphans a file or diverges index.json. The quota
never evicts the just-uploaded file, and long filenames keep their extension.

Adds src/music/local.test.ts covering the cleanup lifecycle, quota eviction,
and upload validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:58:22 +08:00
Fa1nttt e12cbf8863 feat: add local audio upload playback 2026-06-30 14:08:42 +08:00
TIANYAO ZHANG 4e148302fc Merge pull request #106 from ZHANGTIANYAO1/fix/guest-play-collection
fix(guest): allow Play All for guests via a dedicated playCollection permission (#103)
2026-06-29 17:39:50 +08:00
saopig1 9c861f487d docs: add playCollection to the guest-permission table (#103) 2026-06-29 12:16:17 +08:00
saopig1 70c0273ae7 fix(guest): add playCollection permission so guests can Play All playlist/album (#103)
- New guest flag playCollection (default OFF), gates play-playlist/play-album
- Keeps playNow's non-destructive semantics intact (Play All clears the queue)
- Admin-toggleable in Settings → 游客模式; default-off, backward-compatible
- Frontend: gate the 播放全部 button on the flag + surface 403 as a toast
  instead of failing silently (the silent-failure half of the issue)
2026-06-29 12:12:47 +08:00
TIANYAO ZHANG e2fa288f48 Merge pull request #105 from Slldyd2077/feat/song-vip-flag
feat: expose vip flag & trial duration on Song for trial-only playback
2026-06-29 11:59:08 +08:00
TIANYAO ZHANG 59a9e742c8 Merge pull request #104 from ZHANGTIANYAO1/feat/ts-command-permissions
feat: TeamSpeak chat-command permission control (adminGroups)
2026-06-28 23:41:34 +08:00
saopig1 31d3830791 test(ts-protocol): smoke-test getClientServerGroups query string + client_servergroups parse 2026-06-28 23:40:30 +08:00
Slldyd2077 d1bd010260 Merge remote-tracking branch 'upstream/main' into feat/song-vip-flag
# Conflicts:
#	src/bot/instance.ts
2026-06-28 21:31:17 +08:00
Slldyd2077 fbb127a86d feat: resolve trial-only playback via trialDuration/effectiveDuration
VIP songs for non-VIP accounts return a ~30s trial fragment. The player used the full duration for isNearEnd, so the trial end didn't trigger auto-advance (~60s stall), and currentSong.duration stayed full, leaving the UI progress stuck.

- provider.ts: SongUrlResult {url, trialDuration?}; getSongUrl signature
- netease.ts: parseNeteaseTrial (freeTrialInfo start/end in seconds) + getSongUrl
- qq.ts: parseQqTrial (isTryout/tryEnd) + getSongUrl
- bilibili/youtube: getSongUrl returns {url}
- instance.ts: resolveAndPlay uses effectiveDuration = trialDuration ?? duration -> nearEnd at trial end -> native auto-advance; BotStatus.effectiveDuration
- VIP account: freeTrialInfo absent -> full duration -> full playback (no toggle)

Backward compatible (optional fields; getSongUrl has a single caller, updated).
Tests: parseTrial assertions (seconds/alias/ms-fallback). 14 pass.
2026-06-28 21:06:52 +08:00
Slldyd2077 7b2bd0ea6a feat: expose vip flag on Song for trial-only detection
Add optional vip?: boolean to the Song interface so downstream clients
(e.g. PowerfulTS) can mark copyright-restricted songs that non-VIP users
can only play as a trial fragment, before playback starts.

- provider.ts: add optional vip?: boolean (backward compatible)
- netease.ts: extract mapNeteaseSongs() pure fn; map fee to vip
  (1=VIP, 4=album-only). fee=8 (free low-quality) is excluded because
  it plays in full, just at lower quality.
- qq.ts: mapQqSongs() maps pay.payplay/paytrackprice to vip (one fix
  covers all callers); getDailyRecommendSongs inline mapping too.
- tests: vip mapping assertions for netease fee (1/4=vip, 0/8=free) and
  qq pay fields.
2026-06-28 17:12:57 +08:00
saopig1andClaude Opus 4.8 8e5e9c810e fix(bot): resolve sender server groups live + server-wide for the admin-command gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:51:06 +08:00
saopig1 e104093614 fix: sanitize adminGroups on config load + final-review cleanups 2026-06-26 21:12:56 +08:00
saopig1 b387d6581e docs: TS chat-command permission implementation plan 2026-06-26 20:57:39 +08:00
saopig1 17ab477af6 docs: correct stale adminGroups references now that the feature ships 2026-06-26 20:53:56 +08:00
saopig1 10e29476f4 docs: document TeamSpeak chat-command permission control 2026-06-26 20:51:08 +08:00
saopig1 215e328f17 feat(web): admin-only command-permission (adminGroups) settings section 2026-06-26 20:47:34 +08:00
saopig1 3346286ffd feat(api): read/write adminGroups in bot settings endpoints 2026-06-26 20:44:29 +08:00
saopig1 72ffd44f68 feat(bot): gate admin chat commands on adminGroups with fallback + deny reply 2026-06-26 20:40:01 +08:00
saopig1 b090a8ec21 feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper 2026-06-26 20:35:31 +08:00
saopig1 f98ce47c52 feat(commands): add canRunCommand gate helper + admin-set source of truth 2026-06-26 20:32:45 +08:00
saopig1andClaude Opus 4.8 0c7f7e128b docs: TS chat-command permission control design spec
Binary admin gate keyed on TS server groups (config.adminGroups),
opt-in/backward-compatible (empty = no enforcement), gated in the
chat handler (executeCommand stays agnostic so WebUI is unaffected),
with adminGroups editable from the WebUI settings. Completes the
unused adminGroups/ADMIN_COMMANDS scaffold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:04:58 +08:00
TIANYAO ZHANG 0cc77fdee0 Merge pull request #102 from ZHANGTIANYAO1/feat/guest-mode
docs: surface guest mode in feature list + reflect shipped behavior
2026-06-25 16:25:34 +08:00
saopig1andClaude Opus 4.8 3b2b2185a5 docs: surface guest mode in feature list + reflect shipped behavior
- Add a 游客模式 bullet to the top-level 功能特性 list.
- Note guests share one short-lived anonymous identity, and that
  disabling/narrowing takes effect live (incl. open WebSockets).
- Expand the always-denied list to include favorites, change-password,
  and the operator's personal platform-account data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:24:25 +08:00
TIANYAO ZHANG 253c0a46a1 Merge pull request #101 from ZHANGTIANYAO1/feat/guest-mode
Add guest mode (login-less WebUI access) (#83)
2026-06-25 16:20:55 +08:00
saopig1andClaude Opus 4.8 a1a70dea5d fix(guest): serialize concurrent queue-mutation playback per bot
The queue-mutating playback routes (play-now-song, play-next-song,
add-song, play-at) read queue position synchronously, mutate the queue,
then await resolveAndPlay() which suspends at an async URL fetch before
player.play(). With no serialization, two concurrent requests (normal in
login-less guest mode) interleave: the audible song (decided by URL-fetch
latency) can disagree with queue.currentIndex (decided by sync-block
ordering), corrupting "now playing" and causing skipped/duplicate songs.

Add a per-bot async serializer (BotInstance.runExclusive) and wrap the
critical region of all four routes in it. Single-request behavior and
every response shape / validation 400 are preserved; only the critical
region moved inside runExclusive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:10:38 +08:00
saopig1andClaude Opus 4.8 43c0175334 fix(guest): tear down guest WS on guest-mode config change
An open guest WebSocket stamped isGuest/botScope once at upgrade and
never rechecked them, so it kept streaming bot state after an admin
disabled guest mode or narrowed guestMode.bots. setupWebSocket now
returns { cleanup, refreshGuestPolicy }; POST /api/bot/settings invokes
refreshGuestPolicy after saving a guestMode change, force-closing guest
sockets when disabled and live re-scoping them otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:05:14 +08:00
saopig1andClaude Opus 4.8 c1d73b6ba8 fix(guest): cap guest session TTL on touch
The sliding-refresh branch in validateAndTouch hardcoded SESSION_TTL_MS
(7d) for all roles, so a guest session created with GUEST_SESSION_TTL_MS
(1d) was wrongly bumped to 7d on the first touch after the touch
interval. Derive the touch TTL from row.role instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:02:44 +08:00
saopig1andClaude Opus 4.8 66230e6b43 fix(guest): normalize guestMode config on load + strict-boolean authorize gate
loadConfig now sanitizes guestMode the same way the write path does: bots is
coerced to "all" | string[] (numbers/objects/missing fall back to the default
"all"), and permissions are rebuilt from defaults with each known flag
strict-coerced to a boolean so a hand-edited/legacy/corrupt config.json can no
longer crash the gate or leak garbage index keys. The authorize guest gate now
uses === true instead of a truthy check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:59:42 +08:00
saopig1andClaude Opus 4.8 952f1fbad3 fix(guest): deny operator personal-data reads to guests
GET /recommend/songs, /personal/fm, and /user/playlists read the
operator's own logged-in music account; gate them with requireNotGuest
so login-less guests cannot see the operator's recommendations, FM, or
playlists. Generic search/browse stays open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:29 +08:00
saopig1andClaude Opus 4.8 365352cdd3 fix(guest): guard reserved __guest__ principal in user mgmt
The by-id user-management handlers use findById, which has no role
filter, so supplying the synthetic GUEST_USER_ID let an admin delete,
re-role, reset-password, and read/write permissions on the shared guest
principal (privilege-escalation / DoS / credential-login holes).

- web/api/users.ts: 404-guard every :id handler against GUEST_USER_ID
  (DELETE, reset-password, role, GET/PUT permissions).
- data/users.ts: defense-in-depth — setRoleIfNotLastAdmin and
  deleteUserIfNotLastAdmin return "not_found" for any role=guest row.
- web/api/session.ts: wrap POST /guest createSession in try/catch so a
  missing guest row yields 503 instead of an unhandled 500.
- Tests: data-layer guest-protection + users-router 404 by-id guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:24 +08:00
saopig1andClaude Opus 4.8 45414b3baa feat(guest): prune deleted bot from guest scope on removeBot
When a bot is deleted, prune its id from config.guestMode.bots (when an
array) and persist, mirroring the existing permissions.pruneBot(id)
member-access pruning. Thread CONFIG_PATH into BotManager so removeBot
can save the updated config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:46 +08:00
saopig1andClaude Opus 4.8 f142c514cd fix(guest): deny favorites + auth-status reads to guests; UI polish
Consolidated fix wave from the final whole-branch review of guest mode.

- FIX 1 (critical): gate /api/favorites mount with requireNotGuest — the
  router keys off req.user.id (shared __guest__ principal), so guests could
  read/write a shared favorites bucket. Added focused guest-deny tests.
- FIX 2: gate GET /api/auth/status and /api/auth/qrcode/status with
  requireNotGuest so config reads no longer leak to guests.
- FIX 3: requireAuthInline in createSessionRouter now rejects guest sessions
  with 401 once guest mode is disabled (mirrors createRequireAuth), so /me
  stops returning guest data after an admin disables the feature.
- FIX 4: Login guest button now sits BELOW the card (auth-page flex-direction
  column + guest-btn width 360px) instead of beside it.
- FIX 5: mobile mini-player transport buttons in App.vue are now per-button
  gated for guests (prev/play/next/mode/volume), mirroring Player.vue.
- FIX 6: refreshed stale "gated on player.control" seek comments in Player.vue
  and relabeled the now-stale quality-GET test.

npm test: 354/354 pass. npm run build: tsc + vue-tsc + vite all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:44:07 +08:00
saopig1andClaude Opus 4.8 e47fc76529 docs: document guest mode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:25:46 +08:00
saopig1 0fe0e973e6 feat(web/settings): admin-only 游客模式 section (toggles + bot scope) 2026-06-25 12:20:53 +08:00
saopig1 28cff59a6f feat(web/queue): gate remove/clear by member capability or guest removeClear 2026-06-25 12:17:53 +08:00
saopig1 b17057cc41 feat(web/player): per-button transport gating honoring guest flags 2026-06-25 12:15:09 +08:00
saopig1 15fcb11f4f feat(web/store): route guest play to non-destructive play-now-song 2026-06-25 12:12:28 +08:00
saopig1 9d8c95b2f9 feat(web/songcard): gate play/playNext/add by member capability or guest flag 2026-06-25 12:09:46 +08:00
saopig1 e36a049216 feat(web/app): hide mobile settings tab for guests 2026-06-25 12:06:51 +08:00
saopig1 3042f87199 feat(web/navbar): hide settings cog for guests + 游客 badge 2026-06-25 12:06:26 +08:00
saopig1 a21a01f0dd feat(web/login): add Continue as guest entry when guest mode is on 2026-06-25 12:03:47 +08:00
saopig1 78cf516c4c feat(web/router): block guests from settings and setup routes 2026-06-25 12:01:10 +08:00
saopig1 0c59a9f84a feat(web/session): expose isGuest, guestCan, continueAsGuest, guestAllowed 2026-06-25 11:58:58 +08:00
saopig1 d2ab888114 feat(ws): scope guest WebSocket feed to allowed bots 2026-06-25 11:56:19 +08:00
saopig1 0073d7d612 feat(music): lock quality read from guests 2026-06-25 11:51:41 +08:00
saopig1andClaude Opus 4.8 e0acbf5457 feat(bot): lock settings reads from guests + persist guestMode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:48:26 +08:00
saopig1andClaude Opus 4.8 d763043305 feat(player): unified authorize() gating + non-destructive guest play-now
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:43:59 +08:00
saopig1andClaude Opus 4.8 821fa0669d feat(mw): add unified authorize() gate and requireNotGuest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:38:21 +08:00
saopig1 8fbc522d06 feat(session): guest login endpoint, guestAllowed, guest /me payload 2026-06-25 11:35:10 +08:00
saopig1andClaude Opus 4.8 271504eec1 feat(db): seed reserved guest principal idempotently
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:30:12 +08:00
saopig1andClaude Opus 4.8 c9a0719128 feat(auth): guest-aware requireAuth + disable invalidates guest sessions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:25:26 +08:00
saopig1andClaude Opus 4.8 0514162824 feat(sessions): guest role + per-session TTL and cap bypass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:19:21 +08:00
saopig1 60c8a5c993 feat(users): guest role + reserved guest principal, excluded from count/list 2026-06-25 11:14:47 +08:00
saopig1 fb7f187ede feat(config): add default-off guestMode block with deep-merge 2026-06-25 11:11:10 +08:00
saopig1 1fd5dbaba3 feat(permissions): guest permission types + resolve guest branch 2026-06-25 11:08:17 +08:00
saopig1andClaude Opus 4.8 3433ccb661 docs: guest-mode implementation plan (#83)
23 bite-sized TDD tasks with exact code: config + guest principal,
unified authorize() gate, route re-gating + non-destructive play-now,
settings/quality read-locks, WebSocket per-bot guest scoping, and the
full frontend (entry, gating, admin 游客模式 section).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:37:08 +08:00
saopig1andClaude Opus 4.8 694ff77712 docs: guest-mode (login-less WebUI access) design spec (#83)
Brainstorm-approved design for an optional, default-off guest mode:
- guest = anonymous, config-driven principal (no account)
- per-ability admin toggles (add-to-end default on; play-next/play-now/
  skip/transport/remove-clear/play-mode opt-in) + per-bot guest scope
- unified authorize() gate; settings always locked for guests;
  non-destructive guest "play now"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:20:49 +08:00
TIANYAO ZHANG 06aaec4ba5 Merge pull request #100 from Dr1mH4X/feat/channelid
feat: joinChannel via channelid
2026-06-24 22:58:45 +08:00
Dr1mH4X 62b5b09857 chore: add success log for numeric channel join 2026-06-23 02:45:09 +08:00
Dr1mH4X 05f090d83a chore: format 2026-06-23 02:40:54 +08:00
Dr1mH4X 4244695075 feat: Add channelId support to bot configuration and database 2026-06-23 02:37:13 +08:00
TIANYAO ZHANG 6f21b6354a Merge pull request #98 from ZHANGTIANYAO1/fix/autopause-resume-on-return
fix(auto-pause): auto-resume when a listener returns (event-driven)
2026-06-17 23:12:27 +08:00
saopig1andClaude Opus 4.8 3a34c01abb fix(auto-pause): auto-resume on a listener's return via clientEnter event
Follow-up to the auto-pause fix: resume never fired when someone came back.

Root cause (verified live against a TS3 server): the full-client library's
command/response channel is dead whenever >=2 clients are connected anywhere on
the server — clientlist, channellist and channelclientlist ALL time out
(confirmed even with the two clients in different channels). So the moment a
listener returns is exactly the moment occupancy can no longer be queried, and
the query-based refreshOccupancy() can never observe the return -> no resume.
Event channelID is also unusable (library reads notify `cid` but enter-view
carries `ctid`, so it's always 0), so per-channel membership can't be derived
from events either.

Fix (minimal, asymmetric): keep PAUSE on the authoritative clientlist path
(reliable precisely because it only succeeds when the bot is alone on the
server — the only state pause should fire), and arm RESUME directly from the
clientEnter push event. Because the bot only auto-pauses while alone, the sole
way occupancy can return while autoPaused is set is a fresh connection, which
arrives reliably as clientEnter. New pure predicate shouldResumeOnReturn() +
_resumeIfReturning() resume iff autoPaused && paused; the resume branch routes
through handleOccupancy(1) and NEVER pauses (userCount>0), so a spurious enter
can only harmlessly resume. The bot's own enter at connect is a no-op
(autoPaused is already false).

This deliberately does NOT adopt a full event-tracked peer set: events don't
reliably seed clients already present when the bot joins, so a count-from-events
==0 would reintroduce the false-pause bug we just fixed, and reconcile can't
heal it (clientlist only works when alone). Pause must trust only the
authoritative query; resume can trust the event.

Net semantics: pause when the server is empty (bot alone), resume when someone
connects. Channel granularity is impossible with this library. UI copy updated
to say "服务器" instead of "频道", and the Settings toggle default corrected to
false to match the backend default. cmdVote intentionally left as-is.

Verified live: auto-paused bot + a real client connecting -> resume fires with
no clientlist call in the path; bot's own enter and not-auto-paused enters do
not resume. 311 unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:11:43 +08:00
TIANYAO ZHANG ba11519fdb Merge pull request #97 from ZHANGTIANYAO1/fix/autopause-occupancy-unknown
fix(auto-pause): don't treat failed clientlist as empty channel; default OFF
2026-06-16 23:56:56 +08:00
saopig1andClaude Opus 4.8 d3fd547ea0 fix(auto-pause): never treat a failed clientlist as "channel empty"; default OFF
Auto-pause within the first seconds of playback (and re-pause after a manual
play) whenever a listener is actually in the channel.

Root cause (confirmed live against a TS3 server): the full-client
`clientlist -uid -away -voice -groups` command TIMES OUT when other clients are
present in the bot's channel. `getClientsInChannel()` catches the error and
returns `[]`, so the occupancy callers computed `userCount = [].length - 1 = -1`,
which `decideOccupancyAction` reads as `-1 <= 0` → "channel empty" → pause. With
the bot alone, clientlist succeeds (returns just the bot), so the bug only
surfaced when someone was listening — exactly the report.

Fix: a connected bot is always a member of its own channel, so a valid query
returns >= 1 (itself). A length of 0 therefore means the query FAILED, not that
the channel is empty. New pure helper `occupancyFromClientList()` maps a
0-length result to `null` ("occupancy unknown"); `refreshOccupancy()` and the
30s idle poller skip the auto-pause / idle-disconnect decision when the count is
unknown instead of mis-reading it as empty. This also removes a latent
false-positive idle-disconnect on the same failed query.

Also default `autoPauseOnEmpty` to OFF (occupancy detection is unreliable on
some servers); users can opt in from Settings.

Verified live with two clients in one channel: clientlist returns 0 → helper
returns null → no false pause (control: bot alone returns 1 → 0 others, normal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:55:44 +08:00
TIANYAO ZHANG 75f09694a3 Merge pull request #96 from ZHANGTIANYAO1/fix/song-ref-url-corner-cases
fix(song-ref): NetEase collection URLs + trailing-punct ids (#90 follow-up)
2026-06-16 22:06:41 +08:00
saopig1andClaude Opus 4.8 6d56f1f371 fix(song-ref): don't misparse NetEase collection URLs / trailing-punct ids (#90 follow-up)
Corner-case review of the #90 play-by-id parser found two reachable issues:

- A NetEase playlist/album/artist/toplist/djradio share URL (which reuses ?id=)
  was matched as a SONG id, so pasting one into !play called getSongDetail() on
  a collection id and returned a confusing 'No song found' instead of falling
  back to a normal search. Guard the id= branch to exclude collection pages.
- The id: prefix captured trailing punctuation from a chat paste ('id:12345.' ->
  '12345.'), which then failed to resolve. Strip trailing .,;)] from the id.

Both fall back to safe behavior (plain search / clean id). Tests added for
collection URLs and pasted ids with punctuation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:06:17 +08:00
TIANYAO ZHANG 64328d7bdf Merge pull request #95 from ZHANGTIANYAO1/feat/play-by-id-and-search
feat(play): pick same-name songs via !search / #N / id: / URL (#90)
2026-06-16 21:52:36 +08:00
saopig1andClaude Opus 4.8 287dd240b1 feat(play): pick same-name songs via !search / #N / id: / URL (#90)
!play/!add/!playnext only ever searched with limit 1, so a same-name song could
never be reached from chat (e.g. 'Die For You' always returned the most popular
match, not The Weeknd's). Add three disambiguation paths via a shared resolver:

- !search <name> — list the top matches (numbered, with id), remembered per bot
- !play #N / !add #N — play/queue the Nth result of the last !search
- !play id:<id> and pasted NetEase/QQ/BiliBili song URLs — play an exact song

Pure parsing (parseSongRef / parseSelectionIndex) is unit-tested; plain-text
search keeps the historical top-hit behavior. WebUI search (20 results) already
allowed picking same-name songs and is unchanged.

Fixes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:49:45 +08:00
TIANYAO ZHANG 22ec7328fa Merge pull request #94 from ZHANGTIANYAO1/docs/update-readme-merged-features
docs(readme): document new features (permissions/favorites/scope/auto-pause/QQ FM) + recent fixes
2026-06-16 21:26:36 +08:00
saopig1andClaude Opus 4.8 540641700d docs(readme): document permissions, favorites, scope, auto-pause, QQ FM + recent fixes
Update the README to reflect everything merged recently:
- feature list: fine-grained permissions (capabilities + per-bot allow-list),
  local favorites, dedicated-link scope, channel-empty auto-pause, QQ radar FM
- first-run + WebUI page table + !fm command (-q) + architecture tree (new modules)
- config section: config.json now lives in data/config.json (+ migration note),
  complete the example with idleTimeoutMinutes/publicUrl/trustProxy
- FAQ: fine-grained member permissions, favorites, dedicated link, auto-pause
- changelog: new entry for the feature batch + bug fixes #84/#86/#89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:26:08 +08:00
saopig1 3422d45eeb Merge PR #93: fix(audio) smooth, monotonic volume curve (#84)
# Conflicts:
#	src/audio/player.test.ts
#	src/audio/player.ts
2026-06-16 16:29:27 +08:00
saopig1 f7626f40b3 Merge PR #92: fix(player) recover B站 long-stream playback stalls (#89) 2026-06-16 16:26:15 +08:00
saopig1 de2c956c31 Merge PR #91: fix(config) generate config.json under the persisted data dir (#86) 2026-06-16 16:26:15 +08:00
saopig1andClaude Opus 4.8 3802c90d2d fix(audio): smooth, monotonic volume curve (#84)
applyVolume() mapped 0-100 with a two-piece, discontinuous curve: gain =
(vol/100)*0.2 for vol<100 (so the whole 0-99 range only spanned 0..0.198, making
80->99 feel flat) then a raw passthrough at vol===100 (a ~5x jump to full
loudness). That produced the reported dead zone + sudden ear-blast at 100.

Replace it with a single continuous, strictly-monotonic curve
volumeToFactor(v) = 0.2*x + 0.8*x^8 (x = v/100): 0 at 0, exactly 1.0 at 100, no
flat region and no discontinuity, so the slider feels proportional and full
loudness is still reserved at 100. Extracted as an exported pure function and
unit-tested (boundaries, strict monotonicity, dead-zone removal, no jump at 100).

Note: per the maintainer's note on #84 the >80% suppression was intentional
ear-protection; this change makes the upper range (above ~75%) audibly louder
than before in exchange for a proportional slider — applied per maintainer
decision.

Fixes #84

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:21:09 +08:00
saopig1andClaude Opus 4.8 839f777a75 fix(player): recover B站 long-stream playback stalls instead of going silent (#89)
Two issues caused a long BiliBili stream to stop partway (~16 min) and never resume:

1. FFmpeg lacked -reconnect_at_eof. B站 CDN sessions can close the connection
   mid-file (premature EOF); without this flag FFmpeg treats that EOF as
   end-of-input and stops. Added it (HTTP only) so FFmpeg re-issues a Range
   request and finishes the stream.

2. The frame loop only ended a live-but-silent FFmpeg when within 5s of the song
   end (isNearEnd). Far from the end, emptyFrameAttempts grew unbounded, no
   trackEnd was emitted, and audio went permanently silent ('无法继续播放').
   Added a far-from-end stall watchdog (MAX_STALL_ATTEMPTS ~= 60s) via a pure,
   tested shouldEndOnStall() helper, so a genuinely dead stream advances instead
   of hanging — while a transient underrun on a healthy stream is left alone.

Tests: assert -reconnect_at_eof 1 is present (before -i) for HTTP and absent for
local files; shouldEndOnStall covers near-end fast end, far-from-end no-false-skip,
and far-from-end eventual recovery.

Fixes #89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:46:08 +08:00
saopig1andClaude Opus 4.8 dd6affca6d fix(config): store config.json under the persisted data dir (#86)
CONFIG_PATH resolved to ROOT_DIR/config.json (/app/config.json in Docker), but only
DATA_DIR (/app/data) is the mounted volume — every other artifact (DB, cookies, logs,
avatars) already lives under DATA_DIR. So on first run the default config was written
into the ephemeral image layer (never appearing in the volume), and a manually-placed
data/config.json was ignored because the bot read/wrote the root path.

- Move CONFIG_PATH to DATA_DIR/config.json so it lands in the volume and manual edits
  take effect.
- Add migrateLegacyConfig(): one-time move of an existing root-level config.json into
  the data dir, so existing local installs keep their settings (no silent reset).
- Tests for first-run persistence + the three migration cases.
- README directory tree updated to data/config.json.

Fixes #86

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:33:59 +08:00
saopig1 bea2f92508 Merge PR #80: feat(perm) fine-grained account permissions
Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
  requirePermission('player.control') so the new control endpoint honors #80's
  permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
  /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
  POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
  displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
  user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
  two-arg signature.

#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
  identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
2026-06-16 15:05:57 +08:00
saopig1 f19f56a666 fix(favorites): error handling + state hydration + input validation [#87 review]
- addFavorite/removeFavorite now wrap axios in try/catch: a 409 (already favorited,
  common on a stale heart) or 404 resyncs instead of throwing an unhandled promise
  rejection; other errors surface a toast.
- fetchHomeData refreshes favorites BEFORE the TTL cache-return (was appended after
  the early return, so warm-cache loads never refreshed); removed the now-redundant
  trailing call. App.vue onMounted also hydrates favorites so deep-links to Search/
  Playlist show correct hearts.
- favorites API: GET /check rejects non-string (array) query params with 400 instead
  of a 500; POST defaults req.body to {} so a missing JSON body yields the intended 400.
2026-06-16 14:53:18 +08:00
saopig1 140020f63a Merge PR #87: local favorites feature
# Conflicts:
#	web/src/stores/player.ts
2026-06-16 14:50:25 +08:00
saopig1 6e10764d28 fix(qq-fm): guard FM start when offline + reset radar page on re-login [#88 review]
- startFm() now refuses with 'Bot is not connected to TeamSpeak' before mutating the
  queue, so POST /api/player/:id/fm can no longer wipe the queue and flip the bot into
  FM mode while disconnected (the !fm chat command already had this guard).
- The /fm route's success detection also treats 'not connected' as a failure so the
  toast type is correct.
- QQMusicProvider.setCookie() resets radarPage to 1 so a re-login with a different
  account no longer inherits the previous account's radar pagination cursor.
2026-06-16 14:48:01 +08:00
saopig1 9bfe831022 Merge PR #88: feat(qq) QQ Music radar / personal FM stream 2026-06-16 14:45:51 +08:00
saopig1 bbdd4cbc78 fix(autopause): decouple auto-pause toggle from idle-timeout save [#81 review]
The checkbox @change was wired to saveIdleTimeout, which POSTed BOTH idleTimeoutMinutes
and autoPauseOnEmpty: toggling silently committed an unsaved idle edit, and an empty/
non-numeric idle field made the combined POST 400 (errors swallowed), leaving the
checkbox flipped but not persisted. Give the toggle its own saveAutoPause() sending only
the boolean; 保存 now sends only idleTimeoutMinutes.
2026-06-16 14:45:01 +08:00
saopig1 c57cd35f09 Merge PR #81: feat(autopause) pause when bot channel empties 2026-06-16 14:43:54 +08:00
saopig1 1a1f365cf1 fix(scope): clear scope when the scoped bot is removed [#82 review]
removeBotStatus (botRemoved WS frame or admin deleting the scoped bot) left
scopedBotId dangling: isScoped stayed true, displayedBots went empty, and activeBot
silently fell back to bots[0], locking the UI onto a phantom bot. Clear the scope
when the scoped bot disappears.
2026-06-16 14:43:18 +08:00
saopig1 d1544bab42 Merge PR #82: feat(scope) lock UI to a bot via dedicated link 2026-06-16 14:42:32 +08:00
lTinchl e0d17cf404 feat(qq): add radar FM stream 2026-06-06 21:09:57 +08:00
Kun-ovO b2de607391 本地收藏功能 2026-05-31 23:27:02 +08:00
saopig1 355793b7e6 fix(scope): keep mounting if initial navigation errors (parity with old unconditional mount) 2026-05-30 15:25:20 +08:00
saopig1 593b42830c fix(scope): await router.isReady before mount so refreshed ?bot locks the right bot 2026-05-30 15:22:51 +08:00
saopig1andClaude Opus 4.8 463a8e2f8a feat(scope): lock Navbar selector to scoped bot + apply scope on load
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:17:01 +08:00
saopig1 88ac7d2a68 feat(scope): dedicated link seeds ?bot scope instead of bare redirect 2026-05-30 15:14:54 +08:00
saopig1 53d28de17e feat(scope): router guard syncs + preserves ?bot across navigation 2026-05-30 15:11:42 +08:00
saopig1andClaude Opus 4.8 ca07ebc3b7 feat(scope): player store scopedBotId + resolveScopedBot helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:09:03 +08:00
saopig1 bf1fb1fd88 docs(plan): dedicated-link bot scoping implementation plan (#79 items 2,4) 2026-05-30 15:07:26 +08:00
saopig1andClaude Opus 4.8 846fb2c28c docs(spec): dedicated-link bot scoping + refresh fix design (#79 items 2,4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:06:09 +08:00
saopig1 34655e5f50 feat(autopause): autoPauseOnEmpty toggle in Settings 2026-05-30 14:58:35 +08:00
saopig1andClaude Opus 4.8 491bc53dec feat(autopause): expose autoPauseOnEmpty via /api/bot/settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:55:18 +08:00
saopig1 8f5bb26b3a feat(autopause): re-emit client enter/leave/move for instant pause/resume 2026-05-30 14:50:52 +08:00
saopig1andClaude Opus 4.8 4ba4b013b0 feat(autopause): drive pause/resume from channel occupancy in BotInstance
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:46:50 +08:00
saopig1 484202e90d feat(autopause): pure occupancy-decision function 2026-05-30 14:44:28 +08:00
saopig1 9f0ac74fbc docs(plan): auto-pause on empty channel implementation plan (#79 item 3) 2026-05-30 14:43:38 +08:00
saopig1andClaude Opus 4.8 51c954993a docs(spec): auto-pause on empty channel design (#79 item 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:42:13 +08:00
saopig1andClaude Opus 4.8 907a6651f5 fix(perm): access-check before bot-existence (no 403/404 leak); label permissions audit action
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:12:04 +08:00
saopig1andClaude Opus 4.8 1ca1ca9d0c test(perm): assert /me capabilities+bots; dry backfill token list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:06:59 +08:00
saopig1andClaude Opus 4.8 d70664067c feat(perm): admin permission editor in user management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:03:54 +08:00
saopig1 5177b41951 feat(perm): gate Queue.vue remove/clear/play-at controls by capability 2026-05-30 14:00:27 +08:00
saopig1 bb86f7e9ed feat(perm): gate idle-timeout + bot-profile settings on bot.manage 2026-05-30 13:56:46 +08:00
saopig1andClaude Opus 4.8 221f7c8dcf feat(perm): hide UI a member lacks capability for
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:55:31 +08:00
saopig1 cf76e0f69a feat(perm): frontend session capabilities + can()/canControlBot() 2026-05-30 13:51:34 +08:00
saopig1andClaude Opus 4.8 abf60141d9 feat(perm): one-time backfill of existing members to full access
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:47:44 +08:00
saopig1andClaude Opus 4.8 ce15f36e5e feat(perm): admin permissions API + audit + new-member basic tier
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:44:12 +08:00
saopig1andClaude Opus 4.8 1f0f162f66 feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:38:57 +08:00
saopig1andClaude Opus 4.8 cd6f2c6078 feat(perm): enforce capabilities + bot access on action routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:32:08 +08:00
saopig1andClaude Opus 4.8 696b224f8d feat(perm): load capabilities + bot access onto req.user; expose via /me
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:24 +08:00
saopig1 7a8666efbb feat(perm): resolvePermissionContext (admin = super-user) 2026-05-30 13:17:06 +08:00
saopig1 f0c979ce71 docs(spec): use 'capabilities' consistently for req.user field 2026-05-30 13:15:44 +08:00
saopig1 d810a2ec0f test(perm): cover requireBotAccess 401 + missing-param cases 2026-05-30 13:15:01 +08:00
saopig1andClaude Opus 4.8 554501cc74 feat(perm): requirePermission + requireBotAccess middleware
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:10:39 +08:00
saopig1andClaude Opus 4.8 aaf6ba2ab4 feat(perm): permission store + capability tokens + tables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:05:38 +08:00
saopig1andClaude Opus 4.8 547aaa304e docs(plan): account permissions implementation plan (#79-E)
11 TDD tasks: permission store + tables, requirePermission/requireBotAccess, req.user wiring, route enforcement, bot-list filtering, admin API + audit, one-time member backfill, and frontend gating + permission editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:49:57 +08:00
saopig1andClaude Opus 4.8 f09a589940 docs(spec): fine-grained account permissions design (#79-E)
Capability flags (player.control/player.queue/bot.manage/platform.auth/quality) + per-member bot allow-list, layered under the existing member role; admin is super-user. Backend-enforced via requirePermission/requireBotAccess; existing members backfilled to full on upgrade, new members get a basic tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:44:24 +08:00
TIANYAO ZHANG a861b41809 Merge pull request #78 from ZHANGTIANYAO1/feat/shuffle-bag-random-modes
feat(queue): 随机循环改为洗牌袋,每首歌播完一轮再重复 (优化随机循环逻辑)
2026-05-29 22:16:35 +08:00
saopig1andClaude Opus 4.8 e9b3ba0075 feat(queue): shuffle-bag random modes so every song plays before repeating
随机循环 (rloop) used true random-with-replacement, so some songs repeated constantly while others were starved (issue #70). Both random modes now draw from a shuffle bag: every song plays exactly once per cycle in random order. They differ only at cycle end — 随机 (random) stops, 随机循环 (rloop) reshuffles and continues, excluding the just-played song from the first pick of the new cycle to avoid a back-to-back repeat across the boundary. Songs added mid-cycle stay eligible within the current cycle.

随机's visible behavior is unchanged (it already avoided in-cycle repeats); the two branches now share one selection path. Adds shuffle-bag tests (per-cycle permutation, even distribution, no cross-boundary repeat, mid-cycle add).

Closes #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:08:40 +08:00
TIANYAO ZHANG 0401534b88 Merge pull request #77 from ZHANGTIANYAO1/fix/webui-referrer-policy-csrf
fix(web): referrer-policy same-origin 修复扫码登录不弹二维码 + cookie 无法保存
2026-05-29 21:30:35 +08:00
saopig1andClaude Opus 4.8 f720da49d6 fix(web): referrer-policy same-origin so same-origin POSTs keep a real Origin
no-referrer downgraded the Origin header to the literal "null" on same-origin non-GET requests (per the WHATWG Fetch "Append a request Origin header" algorithm), which the /api/* csrfOriginCheck then rejected with 403 "bad origin" — silently breaking QR login, cookie save, and every other WebUI POST/PUT/DELETE/PATCH (playback, bot management, user admin). /api/session/* was unaffected because it mounts before the CSRF gate, which is why WebUI login still worked.

same-origin keeps the real Origin on same-origin requests (CSRF passes) while still sending no Referer cross-origin, so B站/NetEase/QQ CDN cover thumbnails keep loading. Adds referrer-policy.test.ts pinning the policy and a csrf.test.ts case for the Origin: "null" rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:25:20 +08:00
TIANYAO ZHANG 3abb468cca Merge pull request #75 from ZHANGTIANYAO1/fix/ui-overflow-and-textarea-resize
fix(web): long artist + B站 card grid + B站 image referer
2026-05-27 20:10:21 +08:00
saopig1andClaude Opus 4.7 c8daa14219 fix(web): set no-referrer at document level so B站 cover thumbnails load
Bilibili's CDN (i*.hdslb.com) returns 403 with `x-error-info:
RefererWhite` for image requests whose Referer is not on their
whitelist. `CoverArt.vue` already sets `referrerpolicy="no-referrer"`
on its `<img>` tag, BUT the `.cover-shadow` div renders the same URL
as a CSS `background-image`, which ignores the img attribute and uses
the document default policy (`strict-origin-when-cross-origin` in
modern Firefox/Chrome) — that sends `Referer: http://localhost:3000/`
and triggers the block.

Setting `<meta name="referrer" content="no-referrer">` in index.html
applies no-referrer site-wide: covers <img> tags, CSS background-image
fetches, and anywhere else CDNs check referer. Doesn't affect our
/api/* CSRF middleware because that uses Origin (still sent by the
browser), not Referer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:07:18 +08:00
saopig1andClaude Opus 4.7 81cd8a2bec fix(web): revert textarea + actual culprit was B站热门 card grid
Previous commit misidentified the second bug. Reverting the
Settings.vue `resize: vertical` → `resize: none` change — that
wasn't the issue.

Real fix: `.daily-card` (used by B站热门 and 每日推荐 sections in
Home.vue) is a CSS Grid cell with default `min-width: auto`, which
refuses to shrink below its content. A long Bilibili video title
inside `.daily-name` expanded the cell past its 1fr column, breaking
the 6-column grid and creating empty/black space on the right. The
existing `text-overflow: ellipsis` on `.daily-name` couldn't engage.

Adding `min-width: 0` to `.daily-card` lets the cell shrink to the
1fr grid track size, and the ellipsis truncation now works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:20:46 +08:00
saopig1andClaude Opus 4.7 35210cf570 fix(web): long artist name overflow + textarea resize artifact
- Player.vue: wrap artist text in a span with ellipsis. The previous
  text node sat directly inside the flex `.song-artist` container with
  no overflow handling, so a long author name expanded the container
  past its 240px parent and broke the bottom Player bar layout. Also
  add `min-width: 0 + overflow: hidden` to `.song-info` and
  `.song-artist`, and a `:title` attribute for the full text on hover.

- Settings.vue: change `resize: vertical` on the cookie textareas
  to `resize: none`. The browser's resize grip rendered as a stray
  black triangle at the bottom-right corner in dark theme, and
  dragging it caused visual artifacts on the right edge. The
  textareas keep their `rows="3"` default height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:14:26 +08:00
TIANYAO ZHANG d2bad58aa8 Merge pull request #74 from ZHANGTIANYAO1/feat/webui-auth
Add WebUI authentication: multi-user, roles, audit log
2026-05-27 16:46:03 +08:00
saopig1 f73ca1f61b docs: README updates for WebUI auth feature + pre-auth upgrade guide 2026-05-27 16:40:41 +08:00
saopig1andClaude Opus 4.7 a0f290459d feat(auth): X-Frame-Options + CSP frame-ancestors clickjacking defence
Every response now carries:
  X-Frame-Options: DENY
  Content-Security-Policy: frame-ancestors 'none'

Prevents the WebUI from being embedded in a third-party iframe.
Combined with the existing CSRF Origin-host check, this closes the
last meaningful UI-redress surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:34:52 +08:00
saopig1 b6b9aa07bc feat(auth): atomic session cap + change-password UI + trustProxy docs 2026-05-27 16:29:16 +08:00
saopig1 a39fc25104 feat(auth): rate-limit /login+/setup, per-user session cap, periodic /me poll 2026-05-27 16:16:07 +08:00
saopig1 1a11489f2e fix(auth): atomic last-admin guards on role-change and delete 2026-05-27 15:55:11 +08:00
saopig1andClaude Opus 4.7 c0504d65a5 fix(web): localize user.role_changed audit label
Adds the missing case so role-change entries display in Chinese
instead of falling through to the generic key→target format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:43:09 +08:00
saopig1 780726a4e3 feat(web): role-aware UI (badge, selector, toggle button, hide admin-only sections for members) 2026-05-27 15:38:42 +08:00
saopig1andClaude Opus 4.7 a73f797bcb feat(auth): two-role permission system (admin/member)
Adds an admin/member role to WebUI auth. /api/users and /api/audit
are now gated by a requireAdmin middleware; all other authenticated
endpoints accept both roles. Schema migration defaults all existing
users to admin to preserve access. POST /api/users defaults new users
to member; first-run setup always creates an admin. Adds PATCH
/api/users/:id/role with last-admin demotion and deletion guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:35:15 +08:00
saopig1 b0b61f8fce fix(auth): defensive audit-record + self-reset preserves current session 2026-05-27 15:16:55 +08:00
saopig1 7be4f13774 feat(web): operation audit log section in Settings 2026-05-27 15:06:54 +08:00
saopig1andClaude Sonnet 4.6 6af0e97f51 feat(auth): user-management audit log (table + record sites + /api/audit endpoint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:04:19 +08:00
saopig1andClaude Sonnet 4.6 ceb24595e6 fix(auth): race-safe first-run setup + rolling cookie max-age refresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:54:19 +08:00
saopig1andClaude Sonnet 4.6 fb7feec5cf feat(web): user management section in Settings (list/create/delete/reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:44:42 +08:00
saopig1andClaude Sonnet 4.6 175b8e6065 feat(auth): add /api/users CRUD (list, create, delete, reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:41:58 +08:00
saopig1andClaude Opus 4.7 f46b37192f fix(web): break infinite recursion in apiFetch by capturing nativeFetch
apiFetch called window.fetch which installApiClient had reassigned to
call apiFetch — every request blew the stack. Capture the native fetch
at module load (before any wrap) and use it inside apiFetch.

Symptom: first-run / login redirect never fires because the router
guard hangs on session.refresh().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:34:56 +08:00
saopig1 a8b056d2aa fix(auth): WS Origin host check + Login next-param open-redirect guard 2026-05-27 14:02:33 +08:00
saopig1andClaude Sonnet 4.6 e148c1556e feat(web): show current user + logout button in nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:57:31 +08:00
saopig1 e2e888710a fix(web): exclude /api/session/* from 401 auto-refresh to prevent re-entrancy 2026-05-27 13:56:14 +08:00
saopig1andClaude Sonnet 4.6 7509814abc feat(web): install global fetch wrapper with credentials + 401 handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:54:24 +08:00
saopig1andClaude Sonnet 4.6 0dc8746914 feat(web): add /first-run + /login routes with auth guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:52:49 +08:00
saopig1 2560fc87c2 feat(web): add Login view 2026-05-27 13:51:20 +08:00
saopig1 6db35f704d feat(web): add useSession composable 2026-05-27 13:50:14 +08:00
saopig1andClaude Sonnet 4.6 490b2d57dc test(auth): verify ws upgrade gating end-to-end
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:49:05 +08:00
saopig1andClaude Sonnet 4.6 486979841e feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:46:59 +08:00
saopig1andClaude Sonnet 4.6 ee5673a22f feat(auth): add /api/session router (setup, login, logout, me, change-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:42:00 +08:00
saopig1andClaude Sonnet 4.6 d1c9e14bf0 feat(auth): add csrfOriginCheck middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:38:35 +08:00
saopig1andClaude Sonnet 4.6 17c11f0512 feat(auth): add requireAuth middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:36:26 +08:00
saopig1 df5d125279 feat(auth): add shared validateSessionFromHeaders helper 2026-05-27 13:34:47 +08:00
saopig1andClaude Sonnet 4.6 5914f41ea1 feat(auth): add SessionStore with rolling renewal and at-rest token hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:33:22 +08:00
saopig1 68a2fb2943 refactor(users): use SQLITE_CONSTRAINT_UNIQUE error code instead of message text 2026-05-27 13:31:31 +08:00
saopig1andClaude Sonnet 4.6 34523cb00f feat(auth): add UserStore with bcryptjs password hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:29:39 +08:00
saopig1andClaude Sonnet 4.6 8ea1a64c59 feat(db): add users and sessions tables for WebUI auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:27:12 +08:00
saopig1 df79976933 deps: add bcryptjs + cookie-parser + supertest for WebUI auth 2026-05-27 13:25:32 +08:00
saopig1andClaude Opus 4.7 d3af918f53 docs(plan): WebUI authentication implementation plan
16 bite-sized tasks with TDD discipline:
- 10 backend (schema, users, sessions, middleware, /api/session router,
  server.ts wiring, WS upgrade gating + integration test)
- 5 frontend (useSession composable, Login + FirstRunSetup views,
  router guard, fetch wrapper, Navbar logout)
- 1 manual smoke test gate before PR

Notes /first-run as the admin-setup route since /setup is already taken
by the bot-creation wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:18:10 +08:00
saopig1andClaude Opus 4.7 f7c16888e7 docs(spec): WebUI authentication design
Spec for adding username+password auth to the WebUI to close the
unauthenticated-API exposure (all /api/* and /ws currently open).

Design: SQLite users + sessions tables, bcryptjs, 7-day rolling
HTTP-only cookie sessions, first-run setup wizard, Origin/Referer
CSRF check, WebSocket upgrade gated on the same session cookie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:07:51 +08:00
TIANYAO ZHANG a2982948a7 Merge pull request #71 from EvolvedGhost/main
fix(player): 歌曲结尾后持续卡死不切换下一首歌
2026-05-25 18:56:14 +08:00
EvolvedGhost b7e1f9f30b fix(player): add force trackEnd when pcmBuffer less than PCM_FRAME_BYTES 2026-05-23 22:08:52 +08:00
TIANYAO ZHANG 7fc5186c24 Merge pull request #65 from ZHANGTIANYAO1/fix/bilibili-wbi-search
fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
2026-05-16 22:31:55 +08:00
saopig1andClaude Opus 4.7 de92c8bcd4 fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
Closes #64. Bilibili moved the unsigned /x/web-interface/search/type endpoint
behind their anti-bot wall; it now returns an HTML error page (出错啦!) even
with buvid3+buvid4 cookies, causing `play -b` to report "No results found".

Switch search() to /x/web-interface/wbi/search/type with proper wbi signing:
fetch img_key/sub_key from /nav, derive the mixin key via the standard
permutation, and sign each request with wts + w_rid (md5). Keys are cached
for 6h since they rotate ~daily. Other endpoints (view, playurl, popular,
top/rcmd) still work unsigned and are left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:29:12 +08:00
TIANYAO ZHANG 6b143e1a56 Merge pull request #63 from XuVIIJay/pr/setup-scripts
安装脚本优化(对国内网络环境安装友好)
2026-05-16 02:02:10 +08:00
XuVIIJay 5728209573 fix(setup.sh): add Node.js and npm version check before install 2026-05-15 21:03:28 +08:00
XuVIIJay 02d8b39d75 fix(setup.bat): remove hardcoded personal Node.js paths 2026-05-15 20:56:59 +08:00
XuVIIJay f87aaaf8c3 feat(scripts): optimize setup/start scripts for China network 2026-05-15 20:42:51 +08:00
TIANYAO ZHANG 8861f39ecc Merge pull request #62 from NoSetViolin/main
fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
2026-05-14 22:44:41 +08:00
NoSetViolinandClaude Opus 4.7 6d5755ced7 feat(search): source filter bar, album search, and UX polish
- Add platform source filter bar (网易云/QQ/B站) above category tabs with
  localStorage persistence to remember user preference
- Remove "全部" option, single-source view only
- Increase album/playlist card column-gap to 28px for better spacing
- Sync search query to URL via router.replace so back-navigation from
  album/playlist detail pages restores search results
- Fix !album command: add name-based album search (matching !playlist
  behavior) so "!album APT." searches by name instead of treating it as ID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:56:14 +08:00
NoSetViolinandClaude Opus 4.7 997bb17ceb feat(search): add tabbed category navigation + align NetEase result counts
- Replace single-page layout with pill-slider tabs (单曲/专辑/歌单) under
  the search box, showing only one category at a time with result counts
- NetEase album/playlist search limit raised from 5 to 10 to match QQ

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:56:18 +08:00
NoSetViolinandClaude Opus 4.7 ea6501ea81 feat(web): add platform badges to album and playlist cards in search
Album and playlist results now show source tags (网易云, QQ, B站, YouTube)
matching the existing SongCard platform badge style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:34:26 +08:00
NoSetViolinandClaude Opus 4.7 1d2da33d3c fix(qq): apply musicu.fcg upstream fixes + add playlist search
Per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61:
- Remove searchid param (its presence now causes empty results)
- Enforce num_per_page >= 10 (lower values return empty)
- Fix search_type: 2 for albums, 3 for playlists (8 was "user")

Now uses the fixed musicu.fcg as primary (supports songs + albums +
playlists), with client_search_cp as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:28:12 +08:00
阿梓喵_あずにゃんandClaude Opus 4.7 0e68e287b7 fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
u.y.qq.com/cgi-bin/musicu.fcg started returning is_filter=-9 (empty results)
for unauthenticated requests. Primary search now uses the classic
c.y.qq.com/soso/fcgi-bin/client_search_cp endpoint, with the old musicu.fcg
path kept as a fallback in case the primary endpoint changes format or goes
down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:55:21 +08:00
TIANYAO ZHANG ecdb2d253e Merge pull request #60 from XuVIIJay/feat/forward-stack
随机播放模式双向可回溯
2026-05-12 22:23:35 +08:00
XuVIIJay 8860347bfe Update README.md 2026-05-12 00:04:20 +08:00
XuVIIJay beb99f1d8e Update README.md 2026-05-11 23:49:34 +08:00
XuVIIJay 63f1ced2bc feat(queue): add forwardStack for reversible prev/next navigation
In Random/RandomLoop modes, prev now records the current position
to a forwardStack, and next pops from it first. This ensures prev→next
navigation is fully reversible for any number of steps (up to 50).

Also rebuild playedIndices in prev() so songs reached via backward
navigation become available for random selection again.
2026-05-11 23:45:04 +08:00
saopig1andClaude Opus 4.7 fa6013d22e docs(commands): surface existing !remove <position> in help and README
Closes #59. The command was already implemented but not advertised in
!help output or the README command table, so users assumed it was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:49 +08:00
saopig1andClaude Opus 4.7 6b60792277 feat(web): custom avatar field in edit-bot modal
User reported that the edit-bot dialog had no avatar option (only
create-bot did). Reuses the same CustomAvatarRow component, which
auto-loads on mount and PUT/DELETEs on change. Each bot's avatar
is bound by botId — independent across bots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:23:21 +08:00
saopig1andClaude Opus 4.7 c562fe05b0 fix(player): start frame loop only after ffmpeg spawns in jdymusic path
PR #54's playViaPowerShellDownload called startFrameLoop() right after
spawning the PowerShell downloader, before ffmpeg existed. The loop's
"no ffmpeg + empty buffer → emit trackEnd" branch fired on the very
first tick (~25ms), skipping every jdymusic song. Visible as: each
PowerShell download sessionId logged "Track ended, advancing queue"
before the download completed, so the queue burned through every
track in a few seconds.

Move startFrameLoop() into spawnFfmpegFromFile() where ffmpeg is
known to be alive and producing PCM. state = "playing" still flips
in playViaPowerShellDownload so external observers see the right
status during download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:04:27 +08:00
TIANYAO ZHANG 9efa818bbb Merge pull request #57 from ZHANGTIANYAO1/feat/album-search
feat(search): album section + album playback
2026-05-07 20:50:02 +08:00
TIANYAO ZHANG d757e69bf4 Merge pull request #56 from ZHANGTIANYAO1/feat/custom-bot-avatar
feat(profile): custom bot avatar
2026-05-07 20:49:58 +08:00
saopig1andClaude Opus 4.7 88ff62c829 fix(profile): apply custom avatar immediately on idle setCustomAvatar / connect
Review feedback on PR #56:

1. setCustomAvatar(buf) now triggers applyIdleAvatar when the bot is idle
   (currentSong=null OR avatarEnabled=false). Spec said this; original impl
   only stored the buffer, so a fresh upload from Settings was invisible
   until next stop event. Track currentSong in BotProfileManager for the
   idle check.

2. onConnect drops the !avatarEnabled guard — on a fresh connect there's
   no song playing yet, so the spec matrix wants the custom avatar shown
   regardless of sync. Previously bots reconnected with a stale TS3
   server-side avatar.

3. CustomAvatarRow: defer the initializing=false flip to nextTick so the
   load-time data-url assignment's queued watcher sees initializing=true
   and bails. Removes the redundant PUT-on-mount that echoed the just-
   loaded bytes back to the server.

4. BotInstance avatar load wrapped in try/catch — a corrupt/locked file
   no longer crashes startup; we log and continue with no custom avatar.

Tests rewritten: 10 cases covering the full behavior matrix
(idle vs playing × sync on/off × custom set/null × stop/connect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:40:00 +08:00
saopig1andClaude Opus 4.7 8cccf2ed24 fix(web): album hero shows real album name from songs[0].album
The album detail endpoint intentionally 404s (no /api/music/album/:id/detail
route), so we fall through to the stub built from songs. The album name is
already on every song (Song.album), so use it instead of the literal "专辑".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:35:35 +08:00
saopig1andClaude Sonnet 4.6 f6b82b8e21 feat(web): show album + playlist sections in search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:26:03 +08:00
saopig1andClaude Sonnet 4.6 51d7ce61bd feat(web): /album/:id route reusing Playlist view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:24:20 +08:00
saopig1andClaude Sonnet 4.6 20f4cfacea feat(api): /search/all returns albums and playlists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:21:07 +08:00
saopig1andClaude Sonnet 4.6 7d9081efc1 feat(qq): include albums in search results
Add mapQqAlbums pure helper (exported, TDD-covered) and wire a parallel
req_album sub-request (search_type: 8) into search(), populating the
albums field of SearchResult.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:20:05 +08:00
saopig1andClaude Sonnet 4.6 a2453784c1 feat(netease): include albums in search results
Add mapNeteaseAlbums pure helper and wire cloudsearch?type=10 as the third Promise.all arm in search(), replacing the hardcoded albums:[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:17:46 +08:00
saopig1andClaude Sonnet 4.6 0f9c7b3c4c feat(web): custom avatar in create-bot + Settings
Adds AvatarUpload to the create-bot form (PUT on new bot id after POST)
and a CustomAvatarRow per-bot in the profile-toggles section (GET on
mount, PUT/DELETE on user action with initializing guard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:14:34 +08:00
saopig1andClaude Sonnet 4.6 914180792d feat(web): AvatarUpload component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:11:43 +08:00
saopig1andClaude Sonnet 4.6 935656dc4f feat(api): /api/bot/:id/avatar GET/PUT/DELETE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:10:06 +08:00
saopig1andClaude Sonnet 4.6 2dd6a9e20d feat(bot): load custom avatar on instance startup
Thread AvatarStore from index.ts → BotManager → BotInstance so every
BotInstance reads the persisted custom avatar from disk at construction
time and hands it to BotProfileManager via setCustomAvatar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:07:47 +08:00
saopig1andClaude Sonnet 4.6 ffa27d7224 feat(profile): custom avatar with idle/playback precedence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:05:00 +08:00
saopig1andClaude Sonnet 4.6 edd0fc58eb feat(data): avatar file store helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:00:56 +08:00
saopig1andClaude Sonnet 4.6 366edf7843 feat(db): custom_avatar_path column + accessors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:59:20 +08:00
saopig1andClaude Opus 4.7 ff5502be2f docs(plan): custom avatar + album search implementation plans
Two independent plans for spec 2026-05-07; each yields its own PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:55:52 +08:00
saopig1andClaude Opus 4.7 4407cd0c67 docs(spec): custom bot avatar + album search design
Spec for issue #51: custom avatar with idle/playback precedence rules,
and surfacing albums in search results + album detail playback. Two
features grouped in one spec; will land as two separate PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:49:53 +08:00
TIANYAO ZHANG b8c12e0291 Merge pull request #53 from L1uyQwQ/codex/mobile-responsive-controls
fix: improve mobile web responsive controls
2026-05-07 19:39:51 +08:00
TIANYAO ZHANG e7411ee7fb Merge pull request #55 from ZHANGTIANYAO1/fix/netease-ffmpeg-ua
fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
2026-05-07 19:37:58 +08:00
TIANYAO ZHANG ce88d12a60 Merge pull request #54 from ZHANGTIANYAO1/fix/jdymusic-powershell-fallback
fix(player): WinHTTP (PowerShell) fallback for /jdymusic/ CDN
2026-05-07 19:18:02 +08:00
saopig1andClaude Opus 4.7 719ceae303 fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.

On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".

stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:08:58 +08:00
saopig1andClaude Opus 4.7 52ece9321f fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.

On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".

stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:08:00 +08:00
L1uyQwQ 0bf34d8652 fix mobile web responsive controls 2026-05-07 16:58:31 +08:00
TIANYAO ZHANG 9179780afb Merge pull request #52 from ZHANGTIANYAO1/fix/netease-ffmpeg-ua 2026-05-07 16:33:25 +08:00
saopig1andClaude Opus 4.7 f76500cfb2 fix(player): set browser UA + Referer for Netease CDN to stop auto-skip
Netease's m701/m801.music.126.net CDN RST connections from FFmpeg's
default Lavf UA. The bot's frame loop treats an early ffmpeg exit as
trackEnd and advances the queue, which surfaced as songs auto-skipping
mid-playlist. Add a browser UA + music.163.com Referer for these URLs,
extract args into a tested buildFfmpegArgs(), and harden reconnect
flags (delay_max 5 -> 30, plus reconnect_on_network_error /
reconnect_on_http_error). Verified by A/B running ffmpeg against the
same fresh CDN URL: legacy args got 0 bytes + "End of file" reading
HTTP response; fixed args streamed the full track to completion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:18:23 +08:00
saopig1andClaude Opus 4.7 7eb96d9068 ci: publish multi-arch Docker image to GHCR on tag push
Add GitHub Actions workflow that builds linux/amd64 + linux/arm64
images via buildx + QEMU and pushes to ghcr.io/zhangtianyao1/teamspeak-music-bot
on v*.*.* tag pushes (or manual dispatch). Switch docker-compose.yml
to pull the prebuilt image so NAS / non-build environments can deploy
without a local toolchain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:21:37 +08:00
TIANYAO ZHANG 001fb5a451 Merge pull request #50 from ZHANGTIANYAO1/feature/design-implementation
Web UI redesign + per-platform tabs + play-next + audit fixes
2026-05-06 17:09:59 +08:00
saopig1andClaude Opus 4.7 73f3f7749a fix: cmdPrev retry-skip + show SongCard actions on touch devices
Two corner-case fixes for the prev-history + play-next feature:

1. cmdPrev only tried queue.prev() once. instance.playNext's auto-
   advance retry-skip pushes failed songs into the same history stack,
   so a single prev frequently lands on an unplayable song — returning
   "Cannot play previous song" while leaving queue.currentIndex stuck
   mid-failure (causing next() to skip past the actually-playing song).
   Retry up to 4 times so prev finds a playable history entry, matching
   the retry budget already used by playNext for auto-advance.

2. SongCard.song-actions has opacity:0 by default and is revealed via
   parent :hover. Touch devices have no hover, so all three action
   buttons (Play / Play Next / Add) were invisible to phone/tablet
   users. Add @media (pointer: coarse) → opacity:1 to always show on
   touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:06:18 +08:00
saopig1andClaude Opus 4.7 fab8c194e3 fix(player): play-next must use insertedAt, not size-1, when idle
Final review of caeef65 caught a stale-currentIndex bug in
/play-next-song and cmdPlayNext: when the player is idle but
queue.currentIndex >= 0 (natural end-of-track, or playNext gave
up after retries without queue.clear), addNext splices mid-queue
and playAt(size-1) jumps PAST the inserted song to whatever
was last. Capture the insertion slot before calling addNext and
promote that exact index instead.

Add a regression test covering the [a,b,c,d] queue with
currentIndex=1 case -- splice at 2 yields x at index 2, but
size-1 would point at d (index 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:00:05 +08:00
saopig1andClaude Opus 4.7 caeef65cdb feat(web): wire @playNext on all SongCard call sites
Library / Search / History / Playlist now route the third action to
store.playNextSong. Home is unchanged (it doesn't use SongCard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:43:10 +08:00
saopig1andClaude Opus 4.7 ad4fb5d3c7 feat(web): third 'play next' action on SongCard
Button sits between Play and Add to queue. Icon is mdi:playlist-play,
title '下一首播放'. Emits 'playNext' for callers to wire up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:41:47 +08:00
saopig1andClaude Opus 4.7 44b0b5c31c feat(web): playNextSong store action
Posts to /play-next-song, surfaces failures via the existing Toast,
and refreshes the queue panel so the inserted song appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:40:12 +08:00
saopig1andClaude Opus 4.7 3e795d9a83 feat: !playnext command and /play-next-song endpoint
Mirror of !play / /play-song but uses queue.addNext to splice in at
currentIndex+1 instead of clearing the queue. Idle bot still starts
the song immediately. Adds 'playnext' / 'pn' to AUDIO_COMMANDS, the
command switch, and the help text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:37:52 +08:00
saopig1andClaude Opus 4.7 11c6a3f51b feat(queue): addNext inserts a song to play right after current
Splices into currentIndex+1 and shifts both playedIndices and the
history back-stack to keep references valid. Falls through to plain
push when nothing is playing so the idle-bot "add → start playing"
flow is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:34:32 +08:00
saopig1andClaude Opus 4.7 30fd8a19b8 fix(queue): restore playedIndices clear in playAt; test HISTORY_LIMIT
Code review of 390d3fa flagged that dropping playedIndices.clear() from
playAt is an unnecessary behavior change. The two operations (push
history, reset random pool) are not in tension — restoring the clear
preserves the original 'explicit pick restarts shuffle' semantic that
Random mode users rely on, while still tracking the prev-history stack.

Also add a regression test that the 50-entry HISTORY_LIMIT cap works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:32:23 +08:00
saopig1andClaude Opus 4.7 390d3fa782 feat(queue): history-aware prev that walks real play history
In random modes, prev was just doing currentIndex-1 in the array, which
has no relationship to what the user actually played before. Add a
50-entry back-stack that's pushed by next/playAt, popped by prev, reset
on play/clear/setMode, and shifted by remove. Sequential and Loop modes
keep their old fallback for the case where history is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:28:52 +08:00
saopig1andClaude Opus 4.7 c4b7cfaa2f refactor(queue): add history field and pushHistory helper
Inert in this commit — no callers yet. Sets up the back-stack used
by the upcoming history-aware prev rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:24:52 +08:00
saopig1andClaude Opus 4.7 94c60ad465 docs: implementation plan for history-aware prev + play next
Eight tasks: queue history field/helper, history-aware prev with
TDD tests, addNext with TDD tests, REST + command surface, store
action, SongCard third button, view wiring, final verify + smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:10:57 +08:00
saopig1andClaude Opus 4.7 58bdfb94a9 docs: spec for history-aware prev + play-next insert
Two queue features:
- prev walks back through actual play history (50-entry stack), so
  random modes navigate predictably instead of falling back to the
  meaningless currentIndex-1 array walk.
- addNext inserts at currentIndex+1, exposed via new /play-next-song
  endpoint, !playnext command, and a third "下一首播放" button on
  SongCard. Insert path keeps playedIndices and history index refs
  valid by shifting entries > currentIndex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:06:56 +08:00
saopig1andClaude Opus 4.7 59fb3ee3bd fix: address all known follow-up issues except NetEase batch precheck
Surface failures + tighten edges:

1. Toast for /play-song & /play-playlist failures. Backend now returns
   {ok, message} (localized in Chinese to match the rest of the UI).
   Store stashes a notification on ok=false; new Toast.vue mounted in
   App.vue shows it for 3-5s then fades. Clicking the X dismisses
   immediately. Sits above the player on desktop and above the mobile
   tabbar on phones.

2. QQ collected playlists pagination. fcg_get_profile_order_asset.fcg
   returns max 30 per call; we now loop using has_more / short-page
   detection up to a 300-playlist hard cap. Single-call users (typical)
   exit the loop on the first iteration so no extra requests.

3. getPlayableSongIds chunking. 100 mids per request keeps URL well
   under 8KB; chunk-level errors are isolated so a transient blip on
   one chunk doesn't poison the whole batch. Returns null only when
   every chunk failed (caller falls back to sequential retry).

4. SourceTabs single-source mode now renders a small subdued "网易云"
   or "QQ" label instead of vanishing entirely, so the user always
   knows which platform's data they're looking at.

5. Hide the "我的歌单 N" count badge when N=0 — Home and Library no
   longer show "我的歌单 0" with an empty grid.

6. Auth state change invalidates fetchHomeData cache. Previously, a
   user who logged out as account A and into account B within 5
   minutes would see A's playlists. Now we always re-check auth at
   the top of fetchHomeData and bypass the TTL cache when authStatus
   has changed.

Out of scope:
- NetEase analogous batch precheck (per request).
- 60s TS3 UDP idle disconnect — that's the bundled @honeybbq/teamspeak-
  client UDP layer kicking when no server packet arrives in 60s. It's
  baked in (constant `v=6e4`) and not exposed as an option, and root
  cause is server-side or network-layer behavior we can't reach from
  here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:00:48 +08:00
saopig1andClaude Opus 4.7 221079b89c fix: more corner-case audit — partial-failure UX + defensive parsing
Three small reliability fixes from re-auditing:

1. Playlist.vue used Promise.all, so a flaky /detail endpoint would
   blank out the whole page even though /songs returned just fine.
   Switch to allSettled and synthesize a stub playlist header from
   the song list when only detail fails. User can still play the
   playlist; just loses the description/cover.

2. sourceTabs.readAll: typeof null === 'object' AND typeof [] ===
   'object', so a corrupted localStorage value (e.g. an array) would
   be treated as a record and its missing keys would silently fall
   back. Reject explicitly so the failure mode is "clean defaults"
   instead of "wrong shape that almost works".

3. Settings.vue loadProfileConfig: a 200 response with empty/wrong
   body would set profileConfigs[botId] to a falsy/wrong-shape value,
   leaving the row stuck on "加载中..." (because the v-if uses
   !profileConfigs[id]). Validate the shape; surface "响应格式异常"
   so the retry link is reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:52:49 +08:00
saopig1andClaude Opus 4.7 892d9f7959 fix: corner-case audit — distinguish failures, recover from blips
Three small but real correctness fixes from auditing recent commits:

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:46:31 +08:00
saopig1andClaude Opus 4.7 3d0aca52d0 fix(qq): batch-precheck playable URLs before queueing playlist
Many users' QQ playlists (especially collected/subscribed ones) contain
a large fraction of songs that return result=104003 (region/copyright
restricted) for the current account. Sequential retry-skip wasted time
guessing — for the user's ACG古风 collected playlist, only 3 of 115
songs are actually streamable, so a 20-retry budget had < 50% chance
of finding a hit before giving up.

Add a getPlayableSongIds(ids) method to the QQ provider that calls the
upstream /getMusicPlay with a comma-separated batch of mids — the
wrapper resolves all of them in a single upstream call (~2-3s for 100+
songs). /play-playlist now duck-types this method and, when present,
filters the playlist down to playable songs before queueing.

Response message reports "Loaded N of M songs (rest are copyright/
region restricted)" so the user sees exactly what's happening instead
of guessing why the queue is short or playback didn't start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:34:16 +08:00
saopig1andClaude Opus 4.7 4301da2e37 fix(player): bigger retry budget + return value for /play-playlist
The previous fix for "播放全部 没有反应" hooked into playNext but had
two problems:

1. Used !!queue.current() to detect success, which is always true
   after queue.next() advanced — so the response message would lie
   about playback starting even when all retries failed.

2. The default 3-retry budget is sized for "next song couldn't
   resolve, skip it" cases. User-initiated playlist plays commonly
   hit long contiguous runs of QQ 104003 songs in collected playlists
   (entire ACG/古风 packs can be uniformly unstreamable in some
   regions), so 3-4 attempts wasn't enough.

Make playNext return whether a song actually started, and accept a
maxRetries parameter. /play-playlist now uses 20 retries — high
enough to clear typical unplayable runs, bounded enough that fully
unstreamable playlists still surface a clear "none were playable"
message rather than hanging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:25:59 +08:00
saopig1andClaude Opus 4.7 82655afa20 fix(player): retry-skip in /play-playlist when first song can't resolve
QQ Music sometimes refuses to issue a play URL for individual songs
(upstream returns result=104003, "no copyright/payment"). The
/play-playlist endpoint only called resolveAndPlay once on the first
song, so when that one was 104003 the bot would sit silently with no
feedback — exactly what looked like "播放全部 没有反应".

Make BotInstance.playNext() public and call it as a fallback when the
first resolveAndPlay fails. playNext already has the 3-attempt
retry-skip used by trackEnd auto-advance, so we get the same
graceful skip behavior for explicit playlist plays.

Also tighten the response message so the user can tell when the bot
loaded songs but none were playable.

Single-song /play-song still returns "Cannot play" on failure (no
queue to advance through); UX surfacing of that goes in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:43:01 +08:00
saopig1andClaude Opus 4.7 5799891e4f feat(web): bot profile toggles in Settings
Each bot gets a collapsible card with 6 toggles for the TeamSpeak
profile features (avatar, description, nickname, away status, channel
description, now-playing chat message). Most relevant: lets the user
turn off "更新频道描述", which was triggering the "channel edited"
notification sound on every song change. Same goes for the now-playing
chat message.

The two toggles that broadcast a sound to other channel members are
flagged with an inline ⚠️ tag so users notice them.

Wires up to the existing GET/PUT /api/player/:botId/profile endpoints
(no backend changes). Optimistic update with revert on error. Mobile
breakpoint enlarges the switch to a 44x24 touch target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:15:56 +08:00
saopig1andClaude Opus 4.7 900191eca5 fix(api): add getPlaylistDetail provider method for QQ playlist detail
The /playlist/:id/detail route was hardcoded to call the NetEase API
path /playlist/detail on whatever provider was selected. For QQ this
hit a non-existent path, so clicking into any QQ playlist showed
"歌单不存在或加载失败".

Replace the platform-specific hack in the route handler with a proper
getPlaylistDetail method on MusicProvider. Implement it for NetEase
(porting the existing /playlist/detail logic) and QQ (calling
/getSongListDetail and reading from response.cdlist[0]).

Pre-existing bug exposed by the QQ source tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:05:48 +08:00
saopig1andClaude Opus 4.7 e8d2f1ad98 fix(qq): correct user-playlists field mapping; add collected playlists and daily recommend
Three fixes to the QQ Music provider, all surfaced by enabling the QQ
tab on Home/Library:

1. getUserPlaylists field names were wrong. The upstream
   fcg_get_profile_homepage actually returns title/picurl/subtitle,
   not dissname/imgurl/song_count, so all playlist cards rendered as
   empty placeholders. Map title->name, picurl->coverUrl, and parse
   '(\d+)首' from subtitle for songCount.

2. getUserPlaylists only returned playlists the user CREATED. Now also
   fetches COLLECTED playlists from c.y.qq.com fcg_get_profile_order_asset
   (reqtype=3, requires g_tk derived from p_skey cookie) and concatenates
   them after the created list — same order as the QQ desktop app.

3. Add getDailyRecommendSongs implementation backed by /getNewSongs
   (新歌速递, ~20 newest songs). The QQ provider previously didn't
   implement this method, so the daily-recommend QQ tab was empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:56:54 +08:00
saopig1andClaude Opus 4.7 ec02887d22 fix(web): post-review fixes for source tabs
- Reset per-platform fields on fetch failure (was leaving stale data
  for up to 5 minutes after the user logged out of NetEase).
- Extract availableSources getter on the store; deduplicate the daily/
  user availability computeds in Home.vue and Library.vue.
- Add comment explaining why recommendAvailable always seeds netease.
- Import Source type in SourceTabs.vue from the store instead of
  redeclaring locally, removing a future type-drift risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:26:46 +08:00
saopig1andClaude Opus 4.7 ac55a346be feat(web): per-platform source tabs on Home and Library
Recommend playlists, daily songs, and user playlists on Home now show
a [网易云][QQ] tab when both platforms are logged in. Library 我的歌单
gets the same tab. Selection persists per-section in localStorage and
falls back gracefully when the persisted source becomes unavailable
(e.g., user logged out). Removes dead 我的收藏 block from Library that
referenced a non-existent /api/music/user/liked endpoint.

Spec: docs/superpowers/specs/2026-05-06-music-source-tabs-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:19:49 +08:00
saopig1andClaude Opus 4.7 a216709227 feat(web): add SourceTabs component for platform switcher
Presentational component for switching between netease and qq music
sources. Auto-hides when fewer than 2 sources are passed in. Mobile
breakpoint enlarges touch target to 36px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:12:55 +08:00
saopig1andClaude Opus 4.7 4a990bfde3 docs: implementation plan for music source tabs
Step-by-step plan for per-platform tabs on Home and Library: SourceTabs
component, store refactor with authStatus and per-platform data, view
migrations with localStorage persistence, build verification, and
3-scenario manual smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:08:38 +08:00
saopig1andClaude Opus 4.7 e5ac3ad896 docs: spec for multi-source tabs on Home and Library
Design for adding NetEase / QQ source switcher tabs to recommend
playlists, daily songs, and user playlists on Home, plus user playlists
on Library. Tabs auto-hide when only one source is available; selection
persists per-section in localStorage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:04:45 +08:00
saopig1andClaude Opus 4.7 652424b74c fix: post-merge type and test fixes
Library.vue: use Song type from store so SongCard's strict platform
union accepts the data (was platform: string, broke after merge tightened
SongCard prop type).

database.test.ts: switch toEqual -> toMatchObject so getBotInstances()
returning extra profile_* schema columns no longer fails the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:47:19 +08:00
saopig1 877c431b13 Merge remote-tracking branch 'origin/main' into feature/design-implementation 2026-05-06 12:37:33 +08:00
TIANYAO ZHANG e8d14a695f Merge pull request #45 from FFatTiger/feature/fm-fix-random-repeat
fix: FM Random mode to prevent song repeats
2026-05-06 12:33:08 +08:00
fattiger fe78bf812c fix: use Random mode for FM instead of RandomLoop to prevent song repeats
RandomLoop randomly selects from queue, causing the same song to play
multiple times when the queue is small (~3 songs from personal_fm API).
Random mode ensures each song plays once before repeating, relying on
refillFm() to fetch fresh songs before exhaustion.

Also fix the proactive refill condition to use unplayedCount() instead
of size() - getCurrentIndex(), which was meaningless for non-sequential
play modes.
2026-04-29 20:51:21 +08:00
TIANYAO ZHANG 64ecd92d7c Merge pull request #43 from FFatTiger/feature/fm-artist-playlist
feat: 新增 !artist 歌手循环播放、!playlist 歌单模糊搜索,修复 !fm 播放中断,QQ 音乐个人歌单支持
2026-04-29 12:21:26 +08:00
TIANYAO ZHANG f41cd888f9 Merge pull request #42 from NoSetViolin/main
issue #39: web增加一键清空播放列表功能
2026-04-29 12:19:55 +08:00
fattiger 4de8ac1810 fix(qq): bypass broken c.y.qq.com search API, use u.y.qq.com directly 2026-04-27 19:04:31 +08:00
fattiger f9e2a210b7 docs: update README with new !artist, !playlist, FM fix, and QQ playlist features 2026-04-27 18:15:36 +08:00
fattiger 62fb67933d docs: update README with new !artist, !playlist, FM fix, and QQ playlist features 2026-04-27 18:14:27 +08:00
fattiger 43f4dd94aa feat(qq): add getUserPlaylists support for QQ Music provider 2026-04-27 18:08:09 +08:00
fattiger 1a5bd74357 feat: add !artist command for artist-based loop playback 2026-04-27 17:40:35 +08:00
fattiger 31e5e08def feat: support playlist name fuzzy search in !playlist command 2026-04-27 17:31:50 +08:00
fattiger 5d74979af3 docs: clarify exhausted-queue refill is defensive-only in FM RandomLoop mode 2026-04-27 17:29:00 +08:00
fattiger 24da8cfc2e fix: FM auto-refill to prevent audio dropout after initial batch 2026-04-27 17:20:34 +08:00
fattiger e3b4e1634d fix(player): reset consecutiveFailures after sustained healthy playback 2026-04-27 17:09:49 +08:00
fattiger 2f186ec002 chore: add .worktrees/ to .gitignore 2026-04-27 17:04:26 +08:00
fattiger 08170a2574 docs: add implementation plan for FM fix, !artist, and playlist search 2026-04-27 17:01:03 +08:00
fattiger 7eb8477dad docs: add design spec for FM bug fix, !artist command, and playlist fuzzy search 2026-04-27 16:55:35 +08:00
阿梓喵_あずにゃん 2fb0cd2489 issue #39: web增加一键清空播放列表功能 2026-04-26 23:07:48 +08:00
TIANYAO ZHANG 8dcdf01129 Update setup.bat 2026-04-25 14:34:45 +08:00
TIANYAO ZHANG da26435385 Merge pull request #38 from NoSetViolin/main
fix #37
2026-04-21 21:11:54 +08:00
阿梓喵_あずにゃん 364112d94f fix: ffmpeg残留 每次只留一个ffmpeg进程 现已加入闭包校验 防止歌曲帧异常串入 2026-04-21 03:53:50 +08:00
阿梓喵_あずにゃん 82a23d291e ffmpeg进程堆积优化
ffmpeg进程堆积优化(*)
减小缓冲区
降低100音量响度

*此处仍有问题  没招了  每次切歌会有几帧前面一首歌的残留 我不知道哪里出问题了
2026-04-21 02:03:20 +08:00
saopig1andClaude Opus 4.7 1552fa1a39 feat(web): mobile-responsive redesign with Library view and design tokens
Add mobile layout (mini player, bottom tab bar, responsive navbar), new
Library view, redesigned bot dropdown with action buttons, and centralize
colors/typography/shadows into CSS variables in variables.scss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:12:58 +08:00
阿梓喵_あずにゃん 47514f57aa fix #37 2026-04-20 23:48:46 +08:00
TIANYAO ZHANG 1bd6ab9975 Merge pull request #36 from ZHANGTIANYAO1/claude/start-music-api-servers-CQST1
Fix TS6 HTTP Query error handling and improve profile update logging
2026-04-18 00:26:33 +08:00
Claude 314d6ec955 fix(profile): validate TS6 HTTP status and stop escaping JSON body
Silent failure: logs showed "Client properties updated" / "Description
updated" / "Avatar updated" even though the bot's nickname never
changed and the avatar stayed as "loading image" on clients.

Root causes:
- TS6HttpQuery.clientUpdate ignored non-2xx responses, so 400 (bad
  parameter) and 403 (insufficient permission) were reported as success.
- updateClientProperties built TS3-escaped strings (\\s for space) then
  split them back into JSON props, so TS6 received literal backslashes
  and rejected the nickname silently.
- handleFeatureError only matched textual "permission" errors; HTTP
  4xx statuses weren't recognised and the feature retried every song.
- doAvatarUpload had no per-step logging, making it impossible to tell
  whether a broken avatar came from init, the TCP 30033 transfer, or
  the client_flag_avatar command.

Fixes:
- Add HttpQueryError (status/body/path); clientUpdate throws on non-2xx.
- Build a raw property map in updateClientProperties; escape only on
  the TS3 wire path.
- Log HTTP status and updated prop names on success.
- handleFeatureError now treats HTTP 400/401/403 as unrecoverable.
- Debug-log each step of doAvatarUpload plus bytes/elapsedMs on success.

https://claude.ai/code/session_018NrpGWbQQTrahUVXyea5Jy
2026-04-17 16:21:52 +00:00
TIANYAO ZHANG 66ae948371 Merge pull request #35 from ZHANGTIANYAO1/claude/start-music-api-servers-ZIruv
Add shareable bot links with public URL configuration
2026-04-18 00:09:31 +08:00
Claude fecda7cce3 fix(web): make bot-link usable on public IP with HTTP
The "复制专属链接" button silently failed on public-IP HTTP deployments
because navigator.clipboard requires a secure context. Now the link is
always revealed in a modal with a read-only input (select-all on focus),
so users can copy manually even when clipboard APIs and execCommand both
fail. The dialog still tries to auto-copy when possible.

Also adds a publicUrl config option that overrides window.location.origin
for link generation (useful behind reverse proxies / with custom domains),
exposed via GET /api/config/public-url, and a trustProxy flag so Express
honors X-Forwarded-* when fronted by nginx/Caddy/Cloudflare.

https://claude.ai/code/session_019FSX3S3UUcKEYWanYmoqUv
2026-04-17 16:08:00 +00:00
saopig1andClaude Opus 4.6 b9e42d543c fix(web): make bot-link copy work over plain HTTP
navigator.clipboard requires a secure context (HTTPS or localhost). For
users hosting the bot on a remote IP and accessing via http://<ip>:3000,
clipboard.writeText is undefined and the copy silently fails. Add a
hidden-textarea + execCommand('copy') fallback, with a final prompt()
fallback so the user can always grab the URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 00:26:05 +08:00
saopig1andClaude Opus 4.6 4406eeacbe fix(api-server): handle QQ Music API export differences across versions
The @sansenjian/qq-music-api module's export structure varies between
versions (2.2.10 vs 2.2.11+). Add fallback chain to find the Koa app:
try candidate.listen first, then candidate.default.listen.

Also resolve leftover merge conflict marker in instance.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:52:27 +08:00
TIANYAO ZHANG 0408f6ce3c Merge pull request #33 from NeoPecos/feature/idle-timeout
添加闲时自动退出管理功能
2026-04-13 20:46:58 +08:00
TIANYAO ZHANG 47b69a1f95 Merge pull request #29 from stanoswald/bugfix/pass-pwd-in-client
Forward server password to client opetion
2026-04-13 20:44:43 +08:00
NeoPecos 0e992f2f5f merge: resolve conflict, keep idleTimer and profileManager 2026-04-13 17:43:04 +08:00
NeoPecos 7785cc972b feat: add idle timeout setting to auto-disconnect bot when channel is empty 2026-04-13 17:27:51 +08:00
StanOswald 4b412e6277 Forward server password to client opetion 2026-04-13 02:04:05 +08:00
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
166 changed files with 34119 additions and 1538 deletions

No files matched your search

+4 -3
View File
@@ -13,11 +13,12 @@
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*",
"Bash(npx vitest:*)"
"Bash(npx vitest:*)",
"Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)",
"Bash(gunzip -f /tmp/design.gz)",
"Read(//tmp/**)"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
+64
View File
@@ -0,0 +1,64 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Extra tag to publish (optional, e.g. "edge")'
required: false
default: ''
env:
REGISTRY: ghcr.io
IMAGE_NAME: zhangtianyao1/teamspeak-music-bot
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: scripts/docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3
View File
@@ -6,3 +6,6 @@ dist/
config.json
cookies/
.superpowers/
.worktrees/
/bin/
scripts/navbar_bigger.png
+390 -40
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,23 +16,31 @@
<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 服务器
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
@@ -40,7 +48,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 +73,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 +94,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 +135,97 @@ 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` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
### 从 WebUI 无鉴权版本升级(重要)
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
**升级行为**:
- 启动时数据库自动迁移:新增 `users`、`sessions`、`user_audit` 三张表;旧的 `bot_instances`、`play_history` 数据**完全保留**。
- 第一次打开 WebUI 自动跳转到 `/first-run` 引导创建首位管理员(角色固定为 `admin`)。
- 之后访问任何页面都会校验登录态,未登录跳转 `/login`。
**会话与 Cookie**:
- 登录态保存 7 天,每次请求滚动续期(活跃用户不会被踢出)。
- 同一账号最多保持 10 个并发会话(超过自动剔除最旧的)。
- Cookie 设置为 `HttpOnly; SameSite=Lax`,HTTPS 部署需配合 `trustProxy: true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
**多用户与角色**:
- 角色 `admin`:完整权限(用户管理、审计、机器人、音乐平台、播放控制)。
- 角色 `member`:除"用户管理"和"操作审计"外的所有功能(适合给团队成员开通播放权)。
- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。
- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。
**游客模式 / Guest mode**:
让访客**无需账号密码**即可进入 WebUI 点歌,同时严格限制其可用能力。该功能**默认关闭**,只有管理员能开启。
- **开启方式**:管理员在 **设置 → 游客模式** 打开「允许游客访问」(仅管理员可见此区块)。开启后登录页会出现 **「以游客身份进入」** 按钮,访客点击即可创建游客会话,无需任何凭据。游客共享同一匿名身份、会话有效期较短(约 1 天)。关闭游客模式(或缩小机器人作用域)后立即生效,所有在线游客会话——包括正在连接的实时 WebSocket——会被立刻断开 / 重新限制。
- **逐项权限(8 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。
| 开关 | 字段 | 默认 |
|------|------|------|
| 添加到队列末尾 | `addToQueue` | **开** |
| 添加到下一首 | `playNext` | 关 |
| 立即播放(不清空队列) | `playNow` | 关 |
| 跳过当前歌曲 | `skip` | 关 |
| 暂停/继续/进度/音量 | `transport` | 关 |
| 移除/清空队列 | `removeClear` | 关 |
| 切换播放模式 / FM | `playMode` | 关 |
| 播放整个歌单/专辑 | `playCollection` | 关 |
- **按机器人授权(游客作用域)**:可选择「全部机器人」或指定一份机器人白名单。作用域之外的机器人对游客**不可见、不可控**。
- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质、收藏歌单、修改密码、访问用户管理与操作审计,以及读取机器人主人的私人歌单 / 私人 FM / 每日推荐等平台账号数据。这些限制不受上面 8 个开关影响,**永远锁死**。
- **复现 issue #83 的「下一首 only」需求**:在 **设置 → 游客模式** 中关闭「添加到队列末尾」并打开「添加到下一首」,游客便只能把歌曲加到下一首播放。
**如何重置忘记的管理员密码**:
如果你忘记了管理员密码,可以直接编辑 SQLite 数据库 `data/tsmusicbot.db`:
```bash
# 方案 1:清空所有用户,重新进入 first-run 流程
sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"
# 然后重启机器人,浏览器再次访问会自动进入 /first-run
# 方案 2:把指定用户重置为已知密码(密码 'changeme-now' 的 bcrypt 哈希示例如下)
# 先用 node 生成哈希:
node -e "console.log(require('bcryptjs').hashSync('changeme-now', 12))"
# 把输出贴到 SQL 里:
sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='<paste-hash-here>' WHERE username='你的用户名';"
```
**反向代理用户特别注意**:如果通过 nginx / Caddy / Cloudflare 暴露 WebUI,**必须**在 `config.json` 中设置 `"trustProxy": true`,否则 Cookie 不会带 `Secure` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。
**`config.adminGroups`(现已启用)**:用于限制管理类聊天命令(`stop`/`clear`/`remove`/`move`/`vol`/`mode`)只能由指定 TeamSpeak 服务器组的成员运行;为空时不做任何限制(向后兼容)。详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`config.adminPassword` 则是旧版预留字段,当前版本未使用,保留以兼容旧 `config.json`,可以放心忽略。
### Windows 用户
```
@@ -186,27 +286,33 @@ npm run build
sudo systemctl start tsmusicbot
```
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。但请注意本节开头关于 **身份迁移** 的警告——从 0.1.x 版本升级时需要手动清空旧身份。
## 使用说明
### 首次配置
1. 打开 **http://localhost:3000/setup** 进入设置向导
2. 填写 TeamSpeak 服务器地址(默认端口:9987)
3. 设置机器人昵称
4. (可选)扫码登录网易云/QQ音乐账号以播放 VIP 歌曲
1. 启动机器人后打开 **http://localhost:3000/**
- 全新部署:自动跳转 `/first-run`,填写用户名(3-32 字符)和密码(≥8 位)创建首位**管理员**账号
- 之后所有 WebUI 操作都需要登录,登录态保持 7 天(活动会滚动续期)
2. 在 **设置 → 机器人管理** 中点击"创建新实例",填写:
- TeamSpeak 服务器地址(无端口,仅主机名,例如 `ts.example.com`)
- 端口(默认 9987,自托管或非标准端口请填写实际值)
- 机器人昵称
- 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明
| 页面 | 功能 |
|------|------|
| **首页** | 推荐歌单、每日推荐、私人FM、我的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -214,19 +320,27 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 |
|------|------|
| `!play <歌名>` | 搜索并播放 |
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!add <歌名>` | 添加到播放队列 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
| `!stop` | 停止播放并清空队列 |
| `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <ID>` | 加载歌单 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 |
| `!fm` | 私人 FM(网易云) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
@@ -235,6 +349,27 @@ sudo systemctl start tsmusicbot
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
### TeamSpeak 命令权限(管理类命令限制)
默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行:
- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode`
- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放
- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容)
**配置方式**
- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。
- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。
填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限(该命令仅限管理员服务器组)」的提示。
> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。
**如何查看服务器组 ID**
在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。
### 音质等级
| 等级 | 码率 | 格式 | 说明 |
@@ -251,7 +386,7 @@ sudo systemctl start tsmusicbot
## 项目架构
```
tsmusicbot/
teamspeak-music-bot/
├── src/ # 后端源码 (TypeScript)
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
@@ -260,26 +395,36 @@ tsmusicbot/
│ ├── bot/ # 机器人核心
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
│ │ └── manager.ts # 多实例生命周期管理
│ │ ├── manager.ts # 多实例生命周期管理
│ │ ├── auto-pause.ts # 频道无人自动暂停/恢复的决策逻辑
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
│ ├── data/ # 数据层
│ │ ├── config.ts # JSON 配置文件
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
│ │ ├── config.ts # JSON 配置文件(持久化到 data/config.json)
│ │ ├── permissions.ts # 细粒度能力 + 按机器人授权白名单
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口
│ │ ├── 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 # 实时状态广播
│ │ ├── middleware/ # requireAuth / requireAdmin / requirePermission / CSRF
│ │ └── api/ # REST API 路由
│ │ ├── bot.ts # 机器人管理 CRUD
│ │ ├── music.ts # 搜索/歌单/歌词/音质
│ │ ├── player.ts # 播放控制/队列/历史/跳转
│ │ ├── player.ts # 播放控制/队列/历史/跳转/FM
│ │ ├── favorites.ts # 本地收藏歌单 CRUD
│ │ ├── users.ts # 用户管理 + 成员权限
│ │ └── auth.ts # QR登录/Cookie/SMS
│ └── index.ts # 入口(启动所有服务)
├── web/src/ # 前端源码 (Vue 3)
@@ -295,11 +440,11 @@ tsmusicbot/
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
│ └── docker-compose.yml
├── data/ # 运行时数据(自动创建,不上传)
│ ├── tsmusicbot.db # SQLite 数据库
│ ├── cookies/ # 登录 Cookie
│ └── logs/ # 日志文件
└── config.json # 配置文件(首次运行自动生成,不上传)
└── data/ # 运行时数据(自动创建,不上传)
├── config.json # 配置文件(首次运行自动生成,可手动编辑)
├── tsmusicbot.db # SQLite 数据库
├── cookies/ # 登录 Cookie
└── logs/ # 日志文件
```
## 技术栈
@@ -310,7 +455,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,9 +464,58 @@ 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` 在首次运行时自动生成,可手动编辑:
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
```json
{
@@ -335,18 +529,41 @@ tsmusicbot/
"adminPassword": "",
"adminGroups": [],
"autoReturnDelay": 300,
"autoPauseOnEmpty": true
"autoPauseOnEmpty": false,
"idleTimeoutMinutes": 0,
"publicUrl": "",
"trustProxy": false
}
```
> **配置文件位置变更**:旧版本把 `config.json` 写在项目根目录(不在 Docker 挂载卷内,导致重启丢失、手动编辑不生效)。现在统一放在 `data/config.json`。升级时若检测到根目录存在旧的 `config.json`,会在首次启动时自动迁移到 `data/` 并保留你的设置,无需手动操作。
> **关于 `adminPassword` 和 `adminGroups`**:`adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制),详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`adminPassword` 仍为旧版预留字段、当前版本未使用——WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
### 反向代理部署注意事项
当 WebUI 部署在反向代理(nginx / Caddy / Cloudflare 等)之后时,请务必在 `config.json` 中设置 `"trustProxy": true`:
- **Cookie Secure 标志**:未启用 `trustProxy` 时,Express 无法从 `X-Forwarded-Proto` 正确判断请求实际是否为 HTTPS,会话 cookie 不会被标记为 `Secure`。
- **登录限流**:登录限流以 `req.ip` 为键,未启用 `trustProxy` 时所有请求都会被识别为代理本身的 IP,单个攻击者会拖累所有合法用户共用同一个限流桶。
- **审计日志的客户端 IP**(如果未来添加该字段)也需要 `trustProxy` 才能正确记录。
直接暴露端口(无代理)时无需启用该选项。
## 常见问题
**Q:支持 TeamSpeak 6 Server 吗?**
A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
**Q:提示"无法获取播放链接"?**
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
**Q:如何更换机器人所在频道?**
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
@@ -369,9 +586,36 @@ 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`。
**Q:忘记管理员密码怎么办?**
A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
**Q:成员(member)能做什么?不能做什么?**
A:成员默认可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**始终不能**:管理其他用户、查看操作审计日志、降级或删除管理员。此外管理员可在 **设置 → 用户管理** 为每个成员单独**收紧权限**:勾选允许的能力(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)以及可操作的机器人白名单——未授权的能力会返回 403,未授权的机器人对该成员不可见也不可控。管理员不受任何限制。
**Q:收藏的歌单存在哪里?其他用户能看到吗?**
A:收藏按用户存储在本地 SQLite 数据库(`favorite_playlists` 表),仅本人可见,登录后跨设备同步。在首页、搜索结果或歌单页点击收藏图标即可增删。
**Q:什么是"专属链接"?怎么用?**
A:通过 `/bot/<机器人ID>` 打开 WebUI 会把界面锁定到该机器人(顶部显示"专属模式",刷新后保持),适合把单台机器人的控制页分享给特定用户。点击"退出"可返回多机器人视图。注意:专属链接只是 UI 层的锁定,真正的访问控制由成员权限(机器人白名单)在后端强制。
**Q:机器人播放时突然自动暂停了?**
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。该功能**默认关闭**,仅在你于 **设置 → 行为设置** 开启后生效;如需停用,在同一页面关闭即可。(占用检测依赖 TeamSpeak 的 `clientlist` 命令,部分服务器在频道有其他人时可能查询失败——此时机器人会按"占用情况未知"处理,不会误暂停。)
**Q:如何把某个用户从成员升级为管理员?**
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
**Q:登录之后多久会自动退出?**
A:登录态有效期 7 天,活跃使用会滚动续期(每次受保护请求都会刷新过期时间)。同一账号最多保持 10 个并发会话(多设备登录时超过的会自动剔除最旧的会话)。
**Q:部署到公网后如何防止暴力登录?**
A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部署建议同时在反向代理(nginx `limit_req` / Caddy 等)层加一层限流,并启用 HTTPS。反向代理部署务必设置 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
## 参与贡献
1. Fork 本仓库
@@ -380,12 +624,118 @@ 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)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
- **本地音频上传播放**:搜索页支持拖拽 / 选择本地音频上传(保存到 `data/local-audio`),上传后可像普通歌曲一样播放、下一首播放或加入队列;设置 → 行为设置 中新增「本地音频播放」开关,关闭后拒绝新的本地上传和本地歌曲播放请求。播放结束或停止 / 清空 / 替换队列时会从服务端删除已接收文件并更新索引。
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
**Bug 修复**
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
**WebUI 鉴权与权限系统**
- **首次运行强制创建管理员账号**:浏览器打开 WebUI 自动跳转 `/first-run`;之后所有 `/api/*`(除少量公共白名单:`/api/health`、`/api/config/public-url`、`/api/session/*`)和 `/ws` 都需要登录。详见 [更新升级 → 从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
- **两种角色:admin / member**。`member` 可以管理机器人、控制播放、登录音乐平台账号、修改自己密码,但不能管理其他用户或查看审计日志。`admin` 拥有全部权限。
- **用户管理 UI**:管理员在 设置 → 用户管理 可以增删用户、切换角色、重置密码。系统强制保留至少一位管理员。
- **操作审计日志**:管理员在 设置 → 操作审计 可以查看用户管理相关事件(创建、删除、密码重置、角色变更、首位管理员创建、自助修改密码)。
- **自助修改密码**:所有用户都可在 设置 → 账户 修改自己密码。
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。
### v0.x — Bot Profile 自动更新与协议层升级
**机器人形象自动更新(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` 避免命令队列超时。
**新命令 & FM 修复**
- **新增 `!artist <歌手名>` 命令**:搜索指定歌手的歌曲并循环播放,支持 `-q`(QQ 音乐)/ `-b`(B站)/ `-y`(YouTube)平台切换。一次加载最多 50 首,自动按歌手名过滤并设为 Loop 模式。
- **歌单模糊搜索**:`!playlist` 现在支持歌单名称模糊搜索(如 `!playlist 华语经典`),自动匹配公开歌单 + 个人歌单(网易云 + QQ)。纯数字 ID 和 URL 解析保持兼容。
- **修复 `!fm` 播放中断**:私人 FM 几首歌后静音的 bug 已修复。新增自动续播机制(队列低位自动拉取新歌),播放器健康帧追踪防止临时 URL 失败导致永久静音。
- **QQ 音乐个人歌单**:QQ Music provider 新增 `getUserPlaylists` 支持,登录后可通过 `!playlist -q <名称>` 模糊搜索个人歌单。
**协议层 & 稳定性**
- **升级 `@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,517 @@
# FM Bug Fix + Artist Loop + Playlist Fuzzy Search — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix FM audio dropout bug, add `!artist` command for artist-based loop playback, and support playlist name fuzzy search in `!playlist`.
**Architecture:** All changes stay within existing files. The FM fix adds auto-refill logic and a success-tracking mechanism in the player. Playlist search reuses the existing `provider.search()` API that already returns playlists. The `!artist` command is a new command method following the same pattern as `cmdPlay`/`cmdFm`.
**Tech Stack:** TypeScript, Node.js, ffmpeg-static, @honeybbq/teamspeak-client
---
## File Map
| File | Change | Purpose |
|------|--------|---------|
| `src/bot/instance.ts` | Modify | Add `isFmMode`, `refillFm()`, fix `cmdFm()`, modify `cmdPlaylist()`, add `cmdArtist()`, modify `playNext()` to trigger FM refill |
| `src/bot/commands.ts` | Modify | Register `artist` in PUBLIC_COMMANDS, update help text |
| `src/audio/player.ts` | Modify | Track healthy frame count, reset `consecutiveFailures` after sustained successful playback |
---
### Task 1: Fix FM — Track healthy playback in AudioPlayer
**Files:**
- Modify: `src/audio/player.ts:62-82` (add field)
- Modify: `src/audio/player.ts:243-261` (sendNextFrame — track healthy frames)
- [ ] **Step 1: Add healthy frame counter field**
In `src/audio/player.ts`, after the `consecutiveFailures` field (line ~80), add:
```typescript
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
```
- [ ] **Step 2: Track healthy frames and reset failures in sendNextFrame**
In `src/audio/player.ts`, in the `sendNextFrame()` method, after line 257 (`this.framesPlayed++;`), add:
```typescript
this.healthyFrames++;
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
this.consecutiveFailures = 0;
this.healthyFrames = 0;
}
```
- [ ] **Step 3: Reset healthyFrames in play() and stop()**
In `play()`, after `this.framesPlayed = 0;` (line ~95), add:
```typescript
this.healthyFrames = 0;
```
In `stop()`, after `this.framesPlayed = 0;` (line ~181), add:
```typescript
this.healthyFrames = 0;
```
- [ ] **Step 4: Commit**
```bash
git add src/audio/player.ts
git commit -m "fix(player): reset consecutiveFailures after sustained healthy playback"
```
---
### Task 2: Fix FM — Add auto-refill logic in BotInstance
**Files:**
- Modify: `src/bot/instance.ts:62-66` (add fields)
- Modify: `src/bot/instance.ts:557-573` (cmdFm)
- Modify: `src/bot/instance.ts:642-673` (playNext — add refill trigger)
- [ ] **Step 1: Add isFmMode field**
In `src/bot/instance.ts`, after `private profileManager: BotProfileManager;` (line ~66), add:
```typescript
private isFmMode = false;
```
- [ ] **Step 2: Add refillFm method**
In `src/bot/instance.ts`, before the `cmdVote` method (after `cmdFm`'s closing brace), add:
```typescript
private async refillFm(): Promise<void> {
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
try {
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.logger.debug({ count: songs.length }, "FM queue refilled");
} catch (err) {
this.logger.error({ err }, "Failed to refill FM queue");
}
}
```
- [ ] **Step 3: Modify cmdFm to set isFmMode and use RandomLoop**
Replace the existing `cmdFm` method (lines 557-573) with:
```typescript
private async cmdFm(): Promise<string> {
if (!this.neteaseProvider.getPersonalFm) {
return "Personal FM is only available for NetEase Cloud Music";
}
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0)
return "No FM songs available (need to login first)";
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.queue.setMode(PlayMode.RandomLoop);
this.isFmMode = true;
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
```
- [ ] **Step 4: Modify playNext to trigger FM refill and check isFmMode**
In `playNext()`, replace the `else` branch (lines 665-668) that handles `queue.next() === null`:
```typescript
} else {
// FM mode: try to refill instead of stopping
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
const started = await this.resolveAndPlay(refillNext);
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
this.emit("stateChange");
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
```
Also add a proactive refill after successful advance. At the end of the `if (next)` block, after `this.emit("stateChange");` is handled outside the if/else, add this right after `resolveAndPlay` succeeds (inside the `if (next)` block, after the retry loop):
After the `if (!started)` block and before the closing `}` of `if (next)`, insert:
```typescript
// Proactive FM refill when running low
if (this.isFmMode && this.queue.size() - (this.queue.getCurrentIndex()) <= 3) {
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
}
```
Wait — `this.emit("stateChange")` is outside the `if (next)` block. Let me re-read the original code structure...
The original `playNext()` structure is:
```
if (next) {
let started = await resolveAndPlay(next)
if (!started) { retry loop... }
if (!started) { stop }
} else {
stop
}
emit("stateChange")
```
So I need to add the proactive refill inside the `if (next)` block, right after `resolveAndPlay` succeeds. Let me write this more carefully:
```typescript
private async playNext(): Promise<void> {
if (this.isAdvancing || !this.connected) return;
this.isAdvancing = true;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
if (next) {
let started = await this.resolveAndPlay(next);
if (!started) {
for (let i = 0; i < 3 && this.connected; i++) {
const retry = this.queue.next();
if (!retry) break;
if (await this.resolveAndPlay(retry)) {
started = true;
break;
}
}
}
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
} else if (this.isFmMode && this.queue.size() - this.queue.getCurrentIndex() <= 3) {
// Proactive refill: when queue is running low, fetch more FM songs
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
}
} else {
// Queue exhausted — in FM mode, refill instead of stopping
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
await this.resolveAndPlay(refillNext);
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
this.emit("stateChange");
} finally {
this.isAdvancing = false;
}
}
```
OK this is getting complex. Let me simplify the plan — I'll structure it more clearly.
Also I need to clear `isFmMode` when user issues stop/clear or manually plays something else.
- [ ] **Step 5: Clear isFmMode in stop/clear/play commands**
In `cmdStop()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdClear()` (same line), add:
```typescript
this.isFmMode = false;
```
In `cmdPlay()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdPlaylist()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdAlbum()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
- [ ] **Step 6: Commit**
```bash
git add src/bot/instance.ts
git commit -m "fix: FM auto-refill to prevent audio dropout after initial batch"
```
---
### Task 3: Playlist Fuzzy Search
**Files:**
- Modify: `src/bot/instance.ts:524-539` (cmdPlaylist)
- [ ] **Step 1: Modify cmdPlaylist to support name search**
Replace the `cmdPlaylist` method (lines 524-539) with:
```typescript
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
// Determine if input is a numeric ID or a name search
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let playlistId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use existing logic
playlistId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
let playlists = result.playlists ?? [];
// Also search user's personal playlists if logged in
if (provider.getUserPlaylists) {
try {
const userPlaylists = await provider.getUserPlaylists();
const query = cmd.args.toLowerCase();
const matched = userPlaylists.filter(
p => p.name.toLowerCase().includes(query)
);
// Merge: public results first (API-ranked), then user matches
playlists = [...playlists, ...matched];
} catch {
// User playlists unavailable — continue with public results
}
}
if (playlists.length === 0)
return `No playlists found for: ${cmd.args}`;
playlistId = playlists[0].id;
}
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear();
this.isFmMode = false;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
```
- [ ] **Step 2: Update help text to reflect new usage**
In `cmdHelp()` (line ~633), change the playlist line from:
```
`${p}playlist <id> — Load playlist`
```
to:
```
`${p}playlist <name or id> — Load playlist by name or ID`
```
- [ ] **Step 3: Commit**
```bash
git add src/bot/instance.ts
git commit -m "feat: support playlist name fuzzy search in !playlist command"
```
---
### Task 4: Artist Loop Command
**Files:**
- Modify: `src/bot/commands.ts:8-11` (PUBLIC_COMMANDS)
- Modify: `src/bot/commands.ts:248-260` (AUDIO_COMMANDS in instance.ts — actually in instance.ts)
- Modify: `src/bot/instance.ts:244-314` (executeCommand switch + add cmdArtist)
Wait, AUDIO_COMMANDS is in instance.ts executeCommand. Let me check...
Actually looking back at instance.ts, the AUDIO_COMMANDS set is local to executeCommand. I don't need to add artist there since it will be handled in the switch.
- [ ] **Step 1: Register `artist` in PUBLIC_COMMANDS**
In `src/bot/commands.ts`, line 9, add `"artist"` to the PUBLIC_COMMANDS set:
```typescript
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
"artist",
]);
```
- [ ] **Step 2: Add `artist` to the AUDIO_COMMANDS set in executeCommand**
In `src/bot/instance.ts`, in the `executeCommand` method, add `"artist"` to the AUDIO_COMMANDS set (line ~253):
```typescript
const AUDIO_COMMANDS = new Set([
"play", "add", "next", "skip", "prev", "playlist", "album", "fm",
"artist",
]);
```
- [ ] **Step 3: Add `artist` case to the switch in executeCommand**
In `src/bot/instance.ts`, after the `case "fm":` block (line ~300), add:
```typescript
case "artist":
return this.cmdArtist(cmd);
```
- [ ] **Step 4: Implement cmdArtist method**
Add the `cmdArtist` method in `src/bot/instance.ts`, after `cmdFm()`:
```typescript
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50);
if (result.songs.length === 0)
return `No results found for artist: ${cmd.args}`;
const query = cmd.args.toLowerCase();
let filtered = result.songs.filter(
s => s.artist.toLowerCase().includes(query)
);
// Fallback to unfiltered results if filtering drops everything
if (filtered.length === 0) {
filtered = result.songs.slice(0, 20);
}
this.queue.clear();
this.isFmMode = false;
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
}
this.queue.setMode(PlayMode.Loop);
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
}
```
- [ ] **Step 5: Update help text**
In `cmdHelp()`, add the artist help line after the fm line:
```
`${p}artist <name> — Play songs by artist (loop)`
```
- [ ] **Step 6: Commit**
```bash
git add src/bot/commands.ts src/bot/instance.ts
git commit -m "feat: add !artist command for artist-based loop playback"
```
---
### Task 5: Type-check and verify
**Files:**
- All modified files
- [ ] **Step 1: Run type check**
```bash
cd /home/proxxy/project/teamspeak-music-bot && npm run typecheck
```
Expected: No errors.
- [ ] **Step 2: Verify command parsing**
```bash
cd /home/proxxy/project/teamspeak-music-bot && node --loader ts-node/esm -e "
const { parseCommand } = await import('./src/bot/commands.ts');
console.log(parseCommand('!artist 周杰伦', '!'));
console.log(parseCommand('!artist 周杰伦 -q', '!'));
console.log(parseCommand('!playlist 华语经典', '!'));
console.log(parseCommand('!playlist 123456', '!'));
"
```
Expected: All parse correctly; `artist` with args "周杰伦", `playlist` with args "华语经典" and "123456".
- [ ] **Step 3: Commit any fixes from type check**
```bash
git add -A && git commit -m "chore: type fixes from final verification"
```
(Only if there were issues)
---
### Self-Review Checklist
1. **Spec coverage:**
- FM bug fix → Tasks 1, 2 (healthy frame tracking + auto-refill)
- Playlist fuzzy search → Task 3
- Artist loop → Task 4
- Verification → Task 5
2. **No placeholders** — all steps have exact code.
3. **Type consistency:**
- `isFmMode: boolean` — used in cmdFm, cmdStop, cmdClear, cmdPlay, cmdPlaylist, cmdAlbum, playNext, refillFm ✓
- `refillFm(): Promise<void>` — called from cmdFm (indirectly via playNext trigger), playNext ✓
- `healthyFrames: number`, `HEALTHY_FRAME_RESET: 50` — used in play(), stop(), sendNextFrame() ✓
@@ -0,0 +1,911 @@
# Music Source Tabs Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add NetEase / QQ source-switcher tabs to Home (推荐歌单 / 每日推荐 / 我的歌单) and Library (我的歌单), with per-section persistence and graceful degradation when only one source is logged in.
**Architecture:** A single shared `<SourceTabs>` Vue component handles the tab UI and self-hides when fewer than 2 sources are available. The Pinia store splits the affected fields into `{ netease, qq }` objects, fetches from both platforms in `fetchHomeData()` based on `authStatus`, and consumers select with a reactive `activeSource` ref persisted to localStorage.
**Tech Stack:** Vue 3 (Composition API + `<script setup>`), Pinia, TypeScript, SCSS (CSS variables from `web/src/styles/variables.scss`).
**Spec:** `docs/superpowers/specs/2026-05-06-music-source-tabs-design.md`
---
## File Structure
**New:**
- `web/src/components/SourceTabs.vue` — shared tab UI (presentational, no store deps)
**Modified:**
- `web/src/stores/player.ts` — state shape change + `authStatus` + `fetchHomeData` rewrite
- `web/src/views/Home.vue` — 3 sections wired to SourceTabs
- `web/src/views/Library.vue` — 1 section wired; remove dead `liked` block
**Unchanged:**
- Backend (already supports `?platform=qq`)
- All other web pages
---
## Task 1: Build the SourceTabs component
**Files:**
- Create: `web/src/components/SourceTabs.vue`
This task is fully independent of store changes — the component is presentational, takes typed props, and emits an update event. It can land and be committed alone (build will pass; component is just unused until later tasks).
- [ ] **Step 1.1: Create the component file**
Write `web/src/components/SourceTabs.vue`:
```vue
<template>
<div v-if="sources.length >= 2" class="source-tabs">
<button
v-for="src in sources"
:key="src"
type="button"
class="source-tab"
:class="{ active: src === modelValue }"
@click="$emit('update:modelValue', src)"
>
{{ LABELS[src] }}
</button>
</div>
</template>
<script setup lang="ts">
type Source = 'netease' | 'qq';
const LABELS: Record<Source, string> = {
netease: '网易云',
qq: 'QQ',
};
defineProps<{
modelValue: Source;
sources: Source[];
}>();
defineEmits<{
'update:modelValue': [value: Source];
}>();
</script>
<style lang="scss" scoped>
.source-tabs {
display: inline-flex;
gap: 4px;
margin-left: 12px;
align-items: center;
}
.source-tab {
padding: 4px 10px;
min-height: 28px;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
&:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
&.active {
color: var(--color-primary);
background: var(--color-primary-12);
font-weight: var(--fw-semi);
}
}
@media (max-width: 768px) {
.source-tabs {
margin-left: 8px;
gap: 2px;
}
.source-tab {
padding: 6px 10px;
min-height: 36px; // larger touch target on mobile
font-size: var(--fs-xs);
}
}
</style>
```
Why these choices:
- `v-if="sources.length >= 2"` — auto-hide when only one source available; parent doesn't need wrapper logic
- Min-height 28px desktop / 36px mobile — comfortable touch on phones
- `--color-primary-12` (12% primary tint) — matches existing active-state pattern in the codebase
- No `--brand-netease/qq` in active state — keeps tab visually consistent regardless of which platform; brand colors are reserved for SongCard platform badges where they identify content origin
- [ ] **Step 1.2: Verify it imports cleanly via type check**
Run from project root:
```
npx tsc --noEmit
```
Expected: exit code 0, no output.
Then verify the web project also type-checks:
```
cd web && npx vue-tsc --noEmit && cd ..
```
Expected: exit code 0, no output.
- [ ] **Step 1.3: Commit**
```
git add web/src/components/SourceTabs.vue
git commit -m "feat(web): add SourceTabs component for platform switcher
Presentational component for switching between netease and qq music
sources. Auto-hides when fewer than 2 sources are passed in. Mobile
breakpoint enlarges touch target to 36px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Refactor the store (state + fetchHomeData)
**Files:**
- Modify: `web/src/stores/player.ts`
This task changes types, which will break Home.vue and Library.vue at compile time. **Do not run tsc/build between Task 2 and Task 4** — they are migrated in a single coherent commit. After Task 4, type-check confirms the whole change.
- [ ] **Step 2.1: Add the `Source` type alias and update state shape**
In `web/src/stores/player.ts`, locate the `state: () => ({ ... })` block (around line 47-63).
**Find:**
```ts
// Home page cache
recommendPlaylists: [] as PlaylistItem[],
dailySongs: [] as Song[],
userPlaylists: [] as PlaylistItem[],
bilibiliPopular: [] as Song[],
lastFetchTime: 0,
```
**Replace with:**
```ts
// Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false },
lastFetchTime: 0,
```
Also add this exported type at the top of the file, right after the existing `Song` interface (around line 12):
```ts
export type Source = 'netease' | 'qq';
```
- [ ] **Step 2.2: Rewrite `fetchHomeData()`**
In the same file, find the `fetchHomeData` action (around line 352-378).
**Replace the entire action body with:**
```ts
async fetchHomeData() {
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
return;
}
// 1. Fetch auth status for both platforms first.
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
]);
this.authStatus.netease =
neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn;
this.authStatus.qq =
qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn;
// 2. NetEase data: recommend playlists work anonymously; daily/user
// playlists need login but Promise.allSettled isolates failures.
const neteasePromises = [
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
];
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
// resolve to empty payloads so the same indexed handling works.
const emptyPlaylists = { data: { playlists: [] } };
const emptySongs = { data: { songs: [] } };
const qqPromises = this.authStatus.qq
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'qq' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
];
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
const results = await Promise.allSettled([
...neteasePromises,
...qqPromises,
biliPromise,
]);
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
if (neRecPL.status === 'fulfilled') {
this.recommendPlaylists.netease = neRecPL.value.data.playlists ?? [];
}
if (neDaily.status === 'fulfilled') {
this.dailySongs.netease = neDaily.value.data.songs ?? [];
}
if (neUserPL.status === 'fulfilled') {
this.userPlaylists.netease = neUserPL.value.data.playlists ?? [];
}
if (qqRecPL.status === 'fulfilled') {
this.recommendPlaylists.qq = qqRecPL.value.data.playlists ?? [];
}
if (qqDaily.status === 'fulfilled') {
this.dailySongs.qq = qqDaily.value.data.songs ?? [];
}
if (qqUserPL.status === 'fulfilled') {
this.userPlaylists.qq = qqUserPL.value.data.playlists ?? [];
}
if (bili.status === 'fulfilled') {
this.bilibiliPopular = bili.value.data.songs ?? [];
}
this.lastFetchTime = Date.now();
},
```
**Do NOT type-check yet** — Home/Library still reference the old shape. They'll be migrated in Tasks 3 and 4.
---
## Task 3: Migrate Home.vue to multi-source tabs
**Files:**
- Modify: `web/src/views/Home.vue`
- [ ] **Step 3.1: Add a localStorage helper module**
Create `web/src/stores/sourceTabs.ts`:
```ts
import type { Source } from './player.js';
const STORAGE_KEY = 'source-tabs';
export type TabKey =
| 'home.recommend'
| 'home.daily'
| 'home.user'
| 'library.user';
function readAll(): Partial<Record<TabKey, Source>> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch {
return {};
}
}
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll();
const v = all[key];
return v === 'netease' || v === 'qq' ? v : fallback;
}
export function saveTabSource(key: TabKey, value: Source): void {
try {
const all = readAll();
all[key] = value;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
// localStorage may be unavailable (private browsing); silently no-op
}
}
```
This is a separate file rather than inline so Library can reuse it without duplication.
- [ ] **Step 3.2: Update Home.vue template**
Replace the three `<section>` blocks (推荐歌单 / 每日推荐 / 我的歌单) and the `<script setup>` block.
**Find** the entire `<template>` 推荐歌单 section (currently around lines 53-67):
```vue
<!-- 推荐歌单 -->
<section class="section" v-if="store.recommendPlaylists.length > 0">
<h2 class="section-title">推荐歌单</h2>
<div class="playlist-grid">
<RouterLink
v-for="playlist in store.recommendPlaylists"
:key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ playlist.name }}</div>
</RouterLink>
</div>
</section>
```
**Replace with:**
```vue
<!-- 推荐歌单 -->
<section class="section" v-if="recommendAvailable.length > 0">
<h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="playlist in (store.recommendPlaylists[recommendSourceSafe] ?? [])"
:key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ playlist.name }}</div>
</RouterLink>
</div>
</section>
```
**Find** the 每日推荐 section (currently around lines 36-51):
```vue
<!-- 每日推荐 -->
<section class="section" v-if="store.dailySongs.length > 0">
<h2 class="section-title">每日推荐</h2>
<div class="daily-grid">
<div
v-for="song in store.dailySongs.slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
<div class="daily-artist">{{ song.artist }}</div>
</div>
</div>
</section>
```
**Replace with:**
```vue
<!-- 每日推荐 -->
<section class="section" v-if="dailyAvailable.length > 0">
<h2 class="section-title">
每日推荐
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
</h2>
<div class="daily-grid">
<div
v-for="song in (store.dailySongs[dailySourceSafe] ?? []).slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
<div class="daily-artist">{{ song.artist }}</div>
</div>
</div>
</section>
```
**Find** the 我的歌单 section (currently around lines 69-95):
```vue
<!-- 我的歌单 -->
<section class="section" v-if="store.userPlaylists.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ store.userPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in visibleUserPlaylists"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
<div class="playlist-count">{{ pl.songCount }} 首</div>
</RouterLink>
</div>
<button
v-if="store.userPlaylists.length > USER_PLAYLIST_LIMIT"
class="expand-btn"
@click="userPlaylistsExpanded = !userPlaylistsExpanded"
>
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${store.userPlaylists.length} 个歌单` }}
</button>
</section>
```
**Replace with:**
```vue
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in visibleUserPlaylists"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
<div class="playlist-count">{{ pl.songCount }} 首</div>
</RouterLink>
</div>
<button
v-if="currentUserPlaylists.length > USER_PLAYLIST_LIMIT"
class="expand-btn"
@click="userPlaylistsExpanded = !userPlaylistsExpanded"
>
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${currentUserPlaylists.length} 个歌单` }}
</button>
</section>
```
- [ ] **Step 3.3: Update Home.vue `<script setup>`**
**Find** the `<script setup lang="ts">` block (currently around lines 119-153):
```ts
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song } from '../stores/player.js';
import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false);
const visibleUserPlaylists = computed(() =>
userPlaylistsExpanded.value
? store.userPlaylists
: store.userPlaylists.slice(0, USER_PLAYLIST_LIMIT)
);
async function playFm() {
try {
const res = await axios.get('/api/music/personal/fm');
const songs: Song[] = res.data.songs;
if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) {
await store.addToQueue(songs[i].name, songs[i].platform);
}
}
} catch {
// Ignore
}
}
onMounted(() => {
store.fetchHomeData();
});
</script>
```
**Replace with:**
```ts
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue';
import SourceTabs from '../components/SourceTabs.vue';
const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false);
// Available sources per section. Recommend playlists are public for both
// platforms — netease always; qq only when logged in. Daily and user
// playlists need login on both sides.
const recommendAvailable = computed<Source[]>(() => {
const s: Source[] = ['netease'];
if (store.authStatus.qq) s.push('qq');
return s;
});
const dailyAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
const userAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
// Persisted active source per section.
const recommendSource = ref<Source>(loadTabSource('home.recommend'));
const dailySource = ref<Source>(loadTabSource('home.daily'));
const userSource = ref<Source>(loadTabSource('home.user'));
watch(recommendSource, (v) => saveTabSource('home.recommend', v));
watch(dailySource, (v) => saveTabSource('home.daily', v));
watch(userSource, (v) => saveTabSource('home.user', v));
// Fallback when persisted source is no longer available (e.g. user logged
// out of QQ since last visit). We render against `*Safe` but never write
// back, so the user's preference is preserved for when they log in again.
const recommendSourceSafe = computed<Source>(() =>
recommendAvailable.value.includes(recommendSource.value)
? recommendSource.value
: recommendAvailable.value[0] ?? 'netease'
);
const dailySourceSafe = computed<Source>(() =>
dailyAvailable.value.includes(dailySource.value)
? dailySource.value
: dailyAvailable.value[0] ?? 'netease'
);
const userSourceSafe = computed<Source>(() =>
userAvailable.value.includes(userSource.value)
? userSource.value
: userAvailable.value[0] ?? 'netease'
);
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
const visibleUserPlaylists = computed(() =>
userPlaylistsExpanded.value
? currentUserPlaylists.value
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
);
async function playFm() {
try {
const res = await axios.get('/api/music/personal/fm');
const songs: Song[] = res.data.songs;
if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) {
await store.addToQueue(songs[i].name, songs[i].platform);
}
}
} catch {
// Ignore
}
}
onMounted(() => {
store.fetchHomeData();
});
</script>
```
Note: The 我的歌单 template uses `userSource` (not `userSourceSafe`) on the `<SourceTabs>` v-model so the user's click maps directly to the persisted ref. The grid below the tabs uses `currentUserPlaylists` which derives from `userSourceSafe`, so even if `userSource` points at an unavailable platform momentarily, the grid still renders something sensible. Same pattern for 推荐歌单 / 每日推荐.
---
## Task 4: Migrate Library.vue and remove dead code
**Files:**
- Modify: `web/src/views/Library.vue`
- [ ] **Step 4.1: Replace the template**
**Find** the `<template>` block (currently lines 1-64) and **replace the entire template with:**
```vue
<template>
<div class="library-page">
<h1 class="page-title">音乐库</h1>
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in currentUserPlaylists"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
<div class="playlist-count">{{ pl.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 最近播放 -->
<section class="section">
<h2 class="section-title">最近播放</h2>
<div v-if="historyLoading" class="loading">加载中...</div>
<div v-else-if="history.length === 0" class="empty">暂无播放记录</div>
<div v-else class="song-list">
<SongCard
v-for="(song, i) in history.slice(0, 10)"
:key="`hist-${song.id}-${i}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.play(song.name, song.platform)"
@add="store.addToQueue(song.name, song.platform)"
/>
</div>
</section>
<div v-if="!historyLoading && userAvailable.length === 0 && history.length === 0" class="empty-state">
<Icon icon="mdi:music-box-outline" class="empty-icon" />
<div>登录网易云或QQ音乐后,这里将显示你的歌单和播放记录</div>
</div>
</div>
</template>
```
Changes from the previous version:
- "我的歌单" section: same data binding pattern as Home (`userAvailable`, `currentUserPlaylists`, `<SourceTabs>`)
- "我的收藏" section: removed entirely (the `/api/music/user/liked` endpoint never existed)
- Empty state condition: replaced `liked.length === 0` with `userAvailable.length === 0`
- [ ] **Step 4.2: Replace the script block**
**Find** the `<script setup lang="ts">` block (currently lines 66-105) and **replace with:**
```ts
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue';
import SongCard from '../components/SongCard.vue';
import SourceTabs from '../components/SourceTabs.vue';
const store = usePlayerStore();
const history = ref<Song[]>([]);
const historyLoading = ref(true);
const userAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
const userSource = ref<Source>(loadTabSource('library.user'));
watch(userSource, (v) => saveTabSource('library.user', v));
const userSourceSafe = computed<Source>(() =>
userAvailable.value.includes(userSource.value)
? userSource.value
: userAvailable.value[0] ?? 'netease'
);
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
onMounted(async () => {
if (!store.activeBotId) {
await store.fetchBots();
}
store.fetchHomeData();
if (store.activeBotId) {
try {
const res = await axios.get(`/api/player/${store.activeBotId}/history`);
history.value = res.data.history ?? [];
} catch {
// API may not be ready
}
}
historyLoading.value = false;
});
</script>
```
Changes:
- Removed `liked` ref and the `/api/music/user/liked` axios call
- Added auth-driven `userAvailable`, persisted `userSource`, and `currentUserPlaylists` computed
- Imports `Source` type and `SourceTabs` component
- [ ] **Step 4.3: Style — `.section-title` already supports inline children**
The existing `.section-title` style (Library.vue and Home.vue both) already uses `display: flex; align-items: center; gap: 8px;`. SourceTabs uses `display: inline-flex` with its own `margin-left`, so it sits inline with the title and count. **No style changes are required in either Home.vue or Library.vue.**
---
## Task 5: Verify the build and types
- [ ] **Step 5.1: Run TypeScript backend type check**
```
npx tsc --noEmit
```
Expected: exit code 0, no output. (No backend files were touched.)
- [ ] **Step 5.2: Run web type check + production build**
```
npm run build:web
```
Expected: build completes with `✓ built in N.NNs` and no TypeScript errors. The script runs `vue-tsc --noEmit && vite build`, so failures here mean a type or template error in our changes.
- [ ] **Step 5.3: Run the existing test suite to confirm no regression**
```
npm test
```
Expected: same baseline as before this feature (`Test Files 2 failed | 26 passed (28)`, `Tests 2 failed | 161 passed (163)`). The 2 pre-existing failures are in `dist/` and `.claude/worktrees/` and are unrelated to our changes — they should remain at exactly 2.
If any **source-tree** test fails (anything not in `dist/` or `.claude/worktrees/`), stop and investigate.
---
## Task 6: Manual smoke test on the dev server
This task verifies behavior the type system can't catch.
- [ ] **Step 6.1: Start the dev server**
```
npm run dev
```
Wait for `Web server started` and `WebUI: http://localhost:3000` log lines.
- [ ] **Step 6.2: Test scenario A — only NetEase logged in**
Open http://localhost:3000 in a browser. Confirm:
- 推荐歌单 section displays NetEase playlists, **no tab bar visible** (single source, SourceTabs auto-hidden)
- 每日推荐 section: visible only if NetEase login provides daily songs; **no tab bar**
- 我的歌单 section: visible if NetEase has user playlists; **no tab bar**
- Navigate to `/library`: 我的歌单 section: same — no tab bar, NetEase playlists shown
Open DevTools → Application → Local Storage → `localhost:3000` → `source-tabs` should be absent or `{}` (no clicks happened).
- [ ] **Step 6.3: Test scenario B — both NetEase and QQ logged in**
If QQ is not logged in, log in via Settings → QQ Music → 扫码登录.
Hard reload the browser (Cmd/Ctrl+Shift+R) to bypass the 5-min `lastFetchTime` cache.
Confirm:
- 推荐歌单: tab bar shows `[网易云] [QQ]`, NetEase active by default
- Click `QQ` — playlist grid switches to QQ data, no flicker (data already in store)
- Click `网易云` — back to NetEase
- Same for 每日推荐 and 我的歌单
- Navigate to `/library`, confirm 我的歌单 has its own tab bar with independent state
- Reload the page — Home tabs and Library tab persist their last-selected source independently
Check `localStorage['source-tabs']`: should contain JSON with up to 4 keys (`home.recommend`, `home.daily`, `home.user`, `library.user`).
- [ ] **Step 6.4: Test scenario C — fallback when persisted source becomes unavailable**
While logged into both:
1. On Home, switch 推荐歌单 to `QQ`. Confirm `localStorage['source-tabs']['home.recommend'] === 'qq'`.
2. Go to Settings → log out of QQ.
3. Hard-reload Home.
Confirm:
- 推荐歌单 tab bar disappears (only NetEase available)
- Grid shows NetEase playlists (graceful fallback via `recommendSourceSafe`)
- `localStorage['source-tabs']['home.recommend']` is **still `'qq'`** (preference preserved)
4. Log back into QQ → reload → 推荐歌单 grid is QQ again (preference restored)
- [ ] **Step 6.5: Test mobile layout**
Open DevTools → Toggle device toolbar → set width to 375px (iPhone SE).
Confirm on Home and Library:
- Section title + tab bar fit on the same line without overflow
- Tab buttons are at least 36px tall (use Inspect → check computed `min-height`)
- Tabs are tappable (clicking still switches sources)
- [ ] **Step 6.6: Stop the dev server**
Kill the `npm run dev` process.
---
## Task 7: Final commit
- [ ] **Step 7.1: Stage and commit Task 2 + 3 + 4 changes**
```
git add web/src/stores/player.ts web/src/stores/sourceTabs.ts web/src/views/Home.vue web/src/views/Library.vue
git status
```
Expected `git status` output: 4 modified/new files staged, working tree otherwise clean (apart from pre-existing `.claude/worktrees/` and `test-ts6-version.cjs` untracked).
```
git commit -m "feat(web): per-platform source tabs on Home and Library
Recommend playlists, daily songs, and user playlists on Home now show
a [网易云][QQ] tab when both platforms are logged in. Library 我的歌单
gets the same tab. Selection persists per-section in localStorage and
falls back gracefully when the persisted source becomes unavailable
(e.g., user logged out). Removes dead 我的收藏 block from Library that
referenced a non-existent /api/music/user/liked endpoint.
Spec: docs/superpowers/specs/2026-05-06-music-source-tabs-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 7.2: Verify commit landed**
```
git log --oneline -3
```
Expected:
```
<sha> feat(web): per-platform source tabs on Home and Library
<sha> feat(web): add SourceTabs component for platform switcher
<sha> docs: spec for multi-source tabs on Home and Library
```
---
## Done
The branch should now have 3 new commits on top of the merge commit, all green builds and tests, and the feature working in dev mode.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,520 @@
# Album Search & Playback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Surface albums in search results and allow playing the whole album from the web UI. Currently `SearchResult.albums` is always `[]` and Search.vue only renders songs.
**Architecture:** Extend `search()` in netease + qq providers to populate `albums`. Aggregate them in `/search/all`. Add a new "专辑" (and "歌单") section to Search.vue. Reuse `Playlist.vue` as the album detail page by branching on `route.meta.kind` between `/playlist/:id` and `/album/:id` endpoints. The existing `getAlbumSongs(id)` and `/api/music/album/:id` endpoint already work.
**Tech Stack:** Node 20 + TS, Express 5, Vue 3 + Vue Router 4, axios. No new deps.
---
## Spec Reference
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "专辑搜索".
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `src/music/provider.ts` | Read-only | Verify `Album` and `SearchResult.albums` shape (no change expected) |
| `src/music/netease.ts` | Modify | `search()` adds a third parallel call (`type=10`) and maps albums |
| `src/music/qq.ts` | Modify | `search()` adds `req_album` section and maps albums |
| `src/music/netease.test.ts` | Modify | New tests for albums in search response |
| `src/music/qq.test.ts` | Modify (or create if absent) | Tests for albums in qq search |
| `src/web/api/music.ts` | Modify | `/search/all` returns `{songs, albums, playlists}` |
| `web/src/views/Search.vue` | Modify | Render albums + playlists sections |
| `web/src/views/Playlist.vue` | Modify | Branch endpoint by `route.meta.kind === 'album'` |
| `web/src/router/index.ts` | Modify | Add `/album/:id` route reusing Playlist component, set `meta.kind = 'album'` |
## Conventions
- TDD throughout. Each task: failing test → implement → verify → commit.
- Mock HTTP via existing fixtures pattern (look at `src/music/netease.test.ts` for setup).
- Keep all platform-specific quirks inside the provider class — no leaking into Search.vue logic.
---
### Task 1: netease.ts — fetch albums in search
**Files:**
- Modify: `src/music/netease.ts`
- Modify: `src/music/netease.test.ts`
- [ ] **Step 1: Read the existing test setup so we mock the same way**
```bash
grep -n 'cloudsearch\|MockAdapter\|axios.create\|mock\|nock\|fixture' src/music/netease.test.ts | head -20
```
- [ ] **Step 2: Write the failing test**
Append to `src/music/netease.test.ts` inside the existing `describe`:
```ts
it("populates SearchResult.albums from cloudsearch type=10", async () => {
// Adjust the fixture/mock helper to your existing test pattern.
// The test should: arrange a mock that returns a non-empty albums array
// for type=10, run search(), assert result.albums has the expected shape.
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 10 }) }).reply(200, {
result: {
albums: [
{ id: 42, name: "Album A", picUrl: "https://x/p.jpg", artists: [{ name: "Artist X" }] },
],
},
});
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1 }) }).reply(200, { result: { songs: [] } });
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1000 }) }).reply(200, { result: { playlists: [] } });
const provider = makeProvider();
const r = await provider.search("foo", 5);
expect(r.albums).toEqual([
{ id: "42", name: "Album A", artist: "Artist X", coverUrl: "https://x/p.jpg", platform: "netease" },
]);
});
```
If the existing tests use a different mock library (e.g. `msw` or manual axios stubbing), translate the fixture above to match. Do not introduce new test deps.
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/music/netease.test.ts`
Expected: FAIL — `result.albums` is `[]`
- [ ] **Step 4: Implement the change**
In `src/music/netease.ts` `search()` (~line 93), change the `Promise.all` from 2 to 3 calls:
```ts
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", { params: { keywords: query, type: 1, limit, ...this.cookieParams } }),
this.api.get("/cloudsearch", { params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams } }),
this.api.get("/cloudsearch", { params: { keywords: query, type: 10, limit: 5, ...this.cookieParams } }),
]);
```
After the existing `playlists: Playlist[] = ...` mapping, add:
```ts
const albums: Album[] = (albumRes.data?.result?.albums ?? []).map((a: any) => ({
id: String(a.id),
name: a.name ?? "",
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
coverUrl: a.picUrl ?? "",
platform: "netease",
}));
```
Update the `return { songs, playlists, albums: [] }` to `return { songs, playlists, albums }`.
(Make sure `Album` is imported from `./provider.js`; if not yet imported, add it to the existing import.)
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/music/netease.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/music/netease.ts src/music/netease.test.ts
git commit -m "feat(netease): include albums in search results"
```
---
### Task 2: qq.ts — fetch albums in search
**Files:**
- Modify: `src/music/qq.ts`
- Modify or Create: `src/music/qq.test.ts`
- [ ] **Step 1: Verify whether qq.test.ts exists**
```bash
ls src/music/qq.test.ts
```
If absent, create a minimal one mirroring `netease.test.ts` style: instantiate provider, mock the `qqDirectApi` axios instance, assert `r.albums.length > 0` after a `search()` call.
- [ ] **Step 2: Write the failing test**
Add to `src/music/qq.test.ts`:
```ts
it("populates SearchResult.albums from a parallel album search request", async () => {
// Mock returns an album list under req_album.data.body.album.list
mockApi.onGet("/cgi-bin/musicu.fcg").reply((cfg) => {
const data = JSON.parse(cfg.params?.data ?? "{}");
if (data.req_album) {
return [200, { req_album: { data: { body: { album: { list: [
{ albumMID: "abc", albumName: "Aero", singerName: "S", albumPic: "https://x/p.jpg" },
] } } } } }];
}
if (data.req_0) {
return [200, { req_0: { data: { body: { song: { list: [] } } } } }];
}
return [200, {}];
});
const provider = makeProvider();
const r = await provider.search("foo", 5);
expect(r.albums).toEqual([
{ id: "abc", name: "Aero", artist: "S", coverUrl: expect.stringContaining("https://"), platform: "qq" },
]);
});
```
Verify the actual QQ API response shape against a real call before finalizing the field names — `albumMID` vs `mid`, `albumPic` vs `pic`, etc. If unsure, log a real response once and freeze the shape in the fixture.
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/music/qq.test.ts`
Expected: FAIL — `r.albums` is `[]`
- [ ] **Step 4: Implement the change**
In `src/music/qq.ts` `search()` (~line 63), change `reqData` to include both `req_0` (songs) and `req_album` (albums):
```ts
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
},
});
```
After the existing `songs` mapping, add:
```ts
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums: Album[] = albumList.map((a: any) => ({
id: String(a.albumMID ?? a.mid ?? a.albumID ?? ""),
name: a.albumName ?? a.title ?? "",
artist: a.singerName ?? (a.singer ?? []).map((s: any) => s.name).join(" / "),
coverUrl: a.albumMID
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${a.albumMID}.jpg`
: (a.albumPic ?? ""),
platform: "qq",
}));
```
Change `return { songs, playlists: [], albums: [] }` to `return { songs, playlists: [], albums }`.
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/music/qq.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/music/qq.ts src/music/qq.test.ts
git commit -m "feat(qq): include albums in search results"
```
---
### Task 3: /search/all — aggregate albums + playlists
**Files:**
- Modify: `src/web/api/music.ts`
- [ ] **Step 1: Look at the current aggregation**
In `src/web/api/music.ts` near line 40 the `/search/all` handler builds only `songs`. Extend it.
- [ ] **Step 2: Write a failing integration test (if test infra allows)**
If there's already a test file for music.ts, add a test that mocks the providers and asserts `res.body.albums.length > 0`. If not, skip and rely on Task 1+2 unit coverage + manual verification in Task 4.
- [ ] **Step 3: Aggregate albums + playlists**
Replace the existing `songs = ...` block + `res.json({ songs })` at lines ~54–62 with:
```ts
const songs = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
];
const playlists = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
res.json({ songs, albums, playlists });
```
(Bilibili intentionally skipped for albums/playlists — no album concept; playlists likewise minor.)
- [ ] **Step 4: Verify by curl**
Build + run, then:
```bash
curl -s 'http://localhost:3000/api/music/search/all?q=Beyond' \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print({k: len(v) for k, v in d.items()})'
```
Expected: `{'songs': N>0, 'albums': N>0, 'playlists': N>=0}`
- [ ] **Step 5: Commit**
```bash
git add src/web/api/music.ts
git commit -m "feat(api): /search/all returns albums and playlists"
```
---
### Task 4: Album route reusing Playlist.vue
**Files:**
- Modify: `web/src/router/index.ts`
- Modify: `web/src/views/Playlist.vue`
- [ ] **Step 1: Look at the current router config and Playlist load logic**
```bash
grep -n "path:\|component:\|meta" web/src/router/index.ts
grep -n "loadPlaylist\|/api/music/playlist\|onMounted" web/src/views/Playlist.vue
```
- [ ] **Step 2: Add /album/:id route**
In `web/src/router/index.ts`, find the `/playlist/:id` route entry. Right after it, add:
```ts
{
path: '/album/:id',
component: () => import('../views/Playlist.vue'),
meta: { kind: 'album' },
},
```
(If `/playlist/:id` is `meta:`-less, also add `meta: { kind: 'playlist' }` to it for symmetry.)
- [ ] **Step 3: Branch the endpoint inside Playlist.vue**
Find the load function (probably `onMounted(async () => { axios.get('/api/music/playlist/' + id, ...) })`). Refactor:
```ts
const route = useRoute();
const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const endpoint = kind === 'album' ? '/api/music/album/' : '/api/music/playlist/';
// ... use `${endpoint}${route.params.id}` ...
```
For the hero metadata, the playlist endpoint returns `{songs}` only (no top-level cover/title) — verify what the Album endpoint currently returns. If both only return `{songs}`, the existing Playlist.vue must already derive the cover from somewhere (probably the first song's coverUrl, or an additional `/api/music/playlist/:id/detail` call). Keep the existing pattern; if a separate detail call is needed for albums, fetch the metadata from `/api/music/song/<firstSong.id>` to get the album name + cover, OR add a thin `/api/music/album/:id/detail` endpoint that returns `{ name, coverUrl, description }`.
**Decision:** if Playlist.vue currently uses ONLY `/api/music/playlist/:id` and derives metadata from songs, do the same for albums (no new endpoint). If it calls a separate detail endpoint, add a matching `/api/music/album/:id/detail` returning `{ name, coverUrl }` from the first song's `album` and `coverUrl` fields.
- [ ] **Step 4: Verify in browser**
Run `cd web && npm run dev`. Visit `/album/<some-netease-album-id>` (pick one from a search). Expect: hero header + song list + play-all button — same UX as a playlist page.
- [ ] **Step 5: Commit**
```bash
git add web/src/router/index.ts web/src/views/Playlist.vue
git commit -m "feat(web): /album/:id route reusing Playlist view"
```
---
### Task 5: Search.vue — render albums + playlists sections
**Files:**
- Modify: `web/src/views/Search.vue`
- [ ] **Step 1: Read current Search.vue**
```bash
sed -n '1,120p' web/src/views/Search.vue
```
Identify: the `results.value = res.data.songs` line and the `<div v-else-if="results.length > 0">` block.
- [ ] **Step 2: Refactor to three result lists**
Replace the script:
```ts
import type { Song } from '../stores/player.js';
interface Album { id: string; name: string; artist: string; coverUrl: string; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
const songs = ref<Song[]>([]);
const albums = ref<Album[]>([]);
const playlists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
searched.value = true;
try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
songs.value = res.data.songs ?? [];
albums.value = res.data.albums ?? [];
playlists.value = res.data.playlists ?? [];
} catch {
songs.value = []; albums.value = []; playlists.value = [];
} finally {
loading.value = false;
}
}
```
- [ ] **Step 3: Render the sections**
Replace the existing `<div v-else-if="results.length > 0" class="results">` block:
```vue
<template v-else-if="songs.length || albums.length || playlists.length">
<section v-if="albums.length" class="result-section">
<h2 class="section-title">专辑</h2>
<div class="card-grid">
<router-link
v-for="al in albums"
:key="`${al.platform}-${al.id}`"
:to="`/album/${al.id}?platform=${al.platform}`"
class="card hover-scale"
>
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ al.name }}</div>
<div class="card-sub">{{ al.artist }}</div>
</router-link>
</div>
</section>
<section v-if="playlists.length" class="result-section">
<h2 class="section-title">歌单</h2>
<div class="card-grid">
<router-link
v-for="pl in playlists"
:key="`${pl.platform}-${pl.id}`"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">{{ pl.name }}</div>
</router-link>
</div>
</section>
<section v-if="songs.length" class="result-section">
<h2 class="section-title">单曲</h2>
<SongCard
v-for="(song, i) in songs"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playSong(song)"
@playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/>
</section>
</template>
<div v-else-if="searched" class="empty">未找到相关结果</div>
```
(Import `CoverArt`: `import CoverArt from '../components/CoverArt.vue';`.)
- [ ] **Step 4: Add minimal styles**
Append to the `<style lang="scss" scoped>` block:
```scss
.result-section {
margin-bottom: 32px;
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
text-decoration: none;
color: inherit;
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; }
}
```
- [ ] **Step 5: Build + visually verify**
```bash
cd web && npm run build
```
Then `npm run dev` → search "周杰伦" → see three sections; click an album card → arrives at `/album/:id` with songs + play-all.
- [ ] **Step 6: Commit**
```bash
git add web/src/views/Search.vue
git commit -m "feat(web): show album + playlist sections in search"
```
---
### Task 6: Open PR
- [ ] **Step 1: Push branch**
```bash
git checkout -b feat/album-search
git push -u origin feat/album-search
```
- [ ] **Step 2: Create the PR**
```bash
gh pr create --title "feat(search): album section + album playback" --body "Closes part of #51 (album half).
## Summary
- netease.search() / qq.search() now populate SearchResult.albums
- /api/music/search/all returns albums + playlists alongside songs
- Search.vue renders three sections: 专辑 / 歌单 / 单曲
- /album/:id route reuses Playlist.vue with meta.kind='album'
- bilibili / youtube intentionally still return albums:[] (no album API)
## Test plan
- [x] vitest covers netease + qq search returning non-empty albums
- [x] curl /search/all?q=周杰伦 returns {songs, albums, playlists}
- [x] Manual: search → click album card → /album/:id → play all
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
```
---
## Self-Review Checklist
- [x] Spec coverage: backend album search → Tasks 1+2; aggregator → Task 3; album detail route → Task 4; UI sections → Task 5
- [x] No "TBD"/placeholder text — every step shows the actual diff or command
- [x] Type names consistent: `Album` (capital A), `albums` (lowercase plural), `SearchResult.albums`
- [x] Bilibili/YouTube explicitly out of scope per spec — confirmed in Task 3 by skipping them in albums aggregation
- [x] Routes use `meta.kind` — same key referenced in Playlist.vue (Task 4) and `/album/:id` registration (Task 4 Step 2)
@@ -0,0 +1,903 @@
# Custom Bot Avatar Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users upload a fixed avatar per bot. When `avatarEnabled=true`, the avatar follows the song cover during playback and reverts to the custom avatar (instead of clearing) on stop. When `avatarEnabled=false` and a custom avatar exists, the bot always shows the custom avatar.
**Architecture:** New SQLite column stores a relative file path; bytes live on disk under `data/avatars/<botId>.<ext>` (mirrors `data/cookies/`). `BotProfileManager` gains a `customAvatar` Buffer; the existing `clearAvatar()` becomes "restore custom or clear"; `onConnect()` immediately applies the custom avatar when sync is off. Three new REST endpoints (GET/PUT/DELETE) under `/api/bot/:id/avatar` accept base64 JSON (avoids adding multer; bump `express.json()` limit).
**Tech Stack:** Node 20 + TS + Express 5, better-sqlite3, Vue 3 + axios. No new runtime deps.
---
## Spec Reference
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "自定义头像".
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `src/data/database.ts` | Modify | Add `custom_avatar_path` column + migration + accessor methods |
| `src/data/avatars.ts` | **Create** | Read/write/delete avatar files under `data/avatars/` |
| `src/bot/profile.ts` | Modify | `customAvatar` field, `setCustomAvatar`, `applyIdleAvatar`, modify `clearAvatar`, modify `onConnect` |
| `src/bot/instance.ts` | Modify | Load custom avatar on start, pass to ProfileManager |
| `src/web/api/bot.ts` | Modify | Add GET/PUT/DELETE `/avatar` endpoints |
| `src/web/server.ts` | Modify | Bump `express.json()` limit to `400kb` |
| `src/index.ts` | Modify | Pass `AVATAR_DIR` to bot manager / API router |
| `src/data/database.test.ts` | Modify | Test custom avatar path persistence + migration idempotency |
| `src/data/avatars.test.ts` | **Create** | Unit tests for avatar store |
| `src/bot/profile.test.ts` | **Create** | Tests for new precedence logic with a mock TS3Client |
| `web/src/components/AvatarUpload.vue` | **Create** | Reusable avatar picker + preview + delete |
| `web/src/views/Settings.vue` | Modify | Add custom avatar row in profile features list; insert into create-bot and edit-bot forms |
## Conventions
- TDD: failing test → implement → verify → commit, every step.
- Commits use conventional format: `feat(profile):`, `feat(api):`, `feat(web):`, `test(...)`. Each task ends with one commit.
- Tests live in vitest (`npm test`).
- All paths absolute or relative to repo root.
---
### Task 1: DB migration + getter/setter for custom avatar path
**Files:**
- Modify: `src/data/database.ts`
- Modify: `src/data/database.test.ts`
- [ ] **Step 1: Write the failing test**
Add to `src/data/database.test.ts` after the existing tests (find the closing `});` of the last test case in the `describe` block, insert before it):
```ts
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/database.test.ts`
Expected: FAIL — `botDb.getCustomAvatarPath is not a function`
- [ ] **Step 3: Add the column to migration + interface + statements**
In `src/data/database.ts`:
1. Find `BotDatabase` interface (~line 54), add two methods before `close()`:
```ts
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
```
2. Find `migrateSchema()` (~line 66). After the `for (const col of profileCols)` loop, append:
```ts
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
```
3. In `createDatabase()` after the existing `prepare(...)` calls (~line 180), add:
```ts
const selectCustomAvatar = db.prepare(
`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`,
);
const updateCustomAvatar = db.prepare(
`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`,
);
```
4. Inside the returned object, add (before `close()`):
```ts
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
},
setCustomAvatarPath(botId, path) {
updateCustomAvatar.run(path, botId);
},
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/database.test.ts`
Expected: PASS — all tests including the new one
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/database.test.ts
git commit -m "feat(db): custom_avatar_path column + accessors"
```
---
### Task 2: Avatar storage helper
**Files:**
- Create: `src/data/avatars.ts`
- Create: `src/data/avatars.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/data/avatars.test.ts`:
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAvatarStore } from "./avatars.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
});
describe("createAvatarStore", () => {
it("write returns a relative path under the store dir", () => {
const store = createAvatarStore(dir);
const buf = Buffer.from("fake-png");
const rel = store.write("bot-1", "image/png", buf);
expect(rel).toBe("bot-1.png");
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
});
it("write picks correct extension for jpeg / webp", () => {
const store = createAvatarStore(dir);
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
});
it("write rejects unsupported MIME types", () => {
const store = createAvatarStore(dir);
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(
/unsupported/i,
);
});
it("read returns the bytes for an existing file", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("hello"));
const buf = store.read("bot-1.png");
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
});
it("read returns null when path is missing", () => {
const store = createAvatarStore(dir);
expect(store.read("missing.png")).toBeNull();
});
it("remove deletes the file (idempotent)", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("x"));
store.remove("bot-1.png");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(() => store.remove("bot-1.png")).not.toThrow();
});
it("write replaces any existing file for the same botId regardless of old extension", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("old"));
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
expect(rel).toBe("bot-1.jpg");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/avatars.test.ts`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the store**
Create `src/data/avatars.ts`:
```ts
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export interface AvatarStore {
/** Returns the relative path written (e.g. "bot-1.png"). */
write(botId: string, mime: string, buffer: Buffer): string;
read(relPath: string): Buffer | null;
remove(relPath: string): void;
getDir(): string;
}
export function createAvatarStore(dir: string): AvatarStore {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return {
write(botId, mime, buffer) {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
// Remove any existing avatar for this bot regardless of extension.
for (const name of readdirSync(dir)) {
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
}
const rel = `${botId}.${ext}`;
writeFileSync(join(dir, rel), buffer);
return rel;
},
read(relPath) {
const full = join(dir, relPath);
if (!existsSync(full)) return null;
return readFileSync(full);
},
remove(relPath) {
rmSync(join(dir, relPath), { force: true });
},
getDir() {
return dir;
},
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/avatars.test.ts`
Expected: PASS — all 7 tests
- [ ] **Step 5: Commit**
```bash
git add src/data/avatars.ts src/data/avatars.test.ts
git commit -m "feat(data): avatar file store helper"
```
---
### Task 3: BotProfileManager — custom avatar precedence
**Files:**
- Modify: `src/bot/profile.ts`
- Create: `src/bot/profile.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/bot/profile.test.ts`:
```ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BotProfileManager } from "./profile.js";
import type { TS3Client } from "../ts-protocol/client.js";
function makeMockTs(): TS3Client & {
uploadCalls: Buffer[];
clearCalls: number;
} {
const calls: Buffer[] = [];
let clears = 0;
const ts: any = {
uploadCalls: calls,
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h, _i, stream: any) => {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
calls.push(Buffer.concat(chunks));
}),
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
if (/client_flag_avatar=$/.test(cmd)) clears++;
}),
};
return ts;
}
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
const cfgOff = { ...cfgOn, avatarEnabled: false };
describe("BotProfileManager custom avatar precedence", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("on stop with custom avatar set + sync on, uploads custom (does not clear)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
const custom = Buffer.from([1, 2, 3, 4]);
pm.setCustomAvatar(custom);
await pm.onSongChange(null);
expect(ts.uploadCalls.at(-1)?.equals(custom)).toBe(true);
expect(ts.clearCalls).toBe(0);
});
it("on stop with no custom avatar, falls back to clear", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
expect(ts.uploadCalls.length).toBe(0);
});
it("on connect with sync off + custom avatar set, applies custom immediately", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([9, 9]));
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
});
it("on connect with sync off + no custom avatar, does not touch avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
it("setCustomAvatar(null) makes subsequent onSongChange(null) clear again", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1]));
pm.setCustomAvatar(null);
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/bot/profile.test.ts`
Expected: FAIL — `pm.setCustomAvatar is not a function` and/or `onConnect` not exported
- [ ] **Step 3: Look at the existing profile.ts to understand `onConnect` shape**
Run: `grep -n 'onConnect\|public async\|public ' src/bot/profile.ts | head -10`
`onConnect` likely already exists; if not, locate where reconnect resets state. Add or extend it.
- [ ] **Step 4: Implement `customAvatar`, `setCustomAvatar`, `applyIdleAvatar`; modify `clearAvatar` and `onConnect`**
In `src/bot/profile.ts`:
1. Inside the class, add fields next to `defaultNickname` (around line 27):
```ts
private customAvatar: Buffer | null = null;
```
2. After the `constructor`, add:
```ts
/** Set/clear the persistent idle avatar. Pass null to remove. */
setCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
}
```
3. Find `clearAvatar()` (~line 173). Change the body so that if `this.customAvatar` is set, we upload it instead of clearing the flag. Replace the existing method with:
```ts
private async clearAvatar(gen: number): Promise<void> {
if (this.customAvatar && this.customAvatar.length > 0) {
await this.applyIdleAvatar(gen);
return;
}
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
}
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
```
4. Add a new private method right below `clearAvatar`:
```ts
private async applyIdleAvatar(gen: number): Promise<void> {
if (!this.customAvatar || this.customAvatar.length === 0) return;
if (this.permDenied.avatar) return;
try {
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
if (this.generation !== gen) return;
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
```
5. Find `onConnect` (the existing method that resets per-feature flags). At its end, immediately after the `permDenied` reset, add:
```ts
if (!this.config.avatarEnabled && this.customAvatar) {
const gen = ++this.generation;
void this.applyIdleAvatar(gen);
}
```
If `onConnect` does not exist as a method, search for where reconnect resets `permDenied` and add the block there.
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/bot/profile.test.ts`
Expected: PASS — 5/5
Run also: `npx vitest run src/audio src/data src/bot` — confirm no regressions.
- [ ] **Step 6: Commit**
```bash
git add src/bot/profile.ts src/bot/profile.test.ts
git commit -m "feat(profile): custom avatar with idle/playback precedence"
```
---
### Task 4: Wire avatar load on bot start
**Files:**
- Modify: `src/bot/instance.ts`
- Modify: `src/bot/manager.ts` (if it constructs the instance)
- Modify: `src/index.ts`
- [ ] **Step 1: Confirm where `BotProfileManager` is constructed and how `BotInstance` receives DB**
Run: `grep -n 'new BotProfileManager\|profileManager =\|database\|botDb' src/bot/instance.ts src/bot/manager.ts | head -20`
Identify the BotInstance constructor params and verify that the DB and the avatar dir can flow in.
- [ ] **Step 2: Add `AVATAR_DIR` constant + `avatarStore` to `src/index.ts`**
Find where `COOKIE_DIR` / `createCookieStore` are set up (~line 48 in src/index.ts) and add directly after:
```ts
const AVATAR_DIR = process.env.AVATAR_DIR ?? join(DATA_DIR, "avatars");
const avatarStore = createAvatarStore(AVATAR_DIR);
```
(import as needed: `import { createAvatarStore } from "./data/avatars.js";`)
Pass `avatarStore` through to whatever constructs `BotManager` (and from there to `BotInstance`).
- [ ] **Step 3: In `BotInstance`, after `profileManager` is created, load the avatar from disk if any**
In `src/bot/instance.ts`, after `this.profileManager = new BotProfileManager(...)`:
```ts
const relPath = this.botDb.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
}
```
(Add `private botDb: BotDatabase` and `private avatarStore: AvatarStore` constructor params; thread them down from `BotManager.createBot()` / `BotManager` constructor.)
- [ ] **Step 4: Add `getProfileManager()` accessor if not present**
If grep already shows `getProfileManager(): BotProfileManager`, skip. Otherwise add a public method that returns `this.profileManager`.
- [ ] **Step 5: Build and run the existing tests**
Run: `npx tsc --noEmit`
Expected: no TS errors
Run: `npm test`
Expected: all green
- [ ] **Step 6: Commit**
```bash
git add src/index.ts src/bot/instance.ts src/bot/manager.ts
git commit -m "feat(bot): load custom avatar on instance startup"
```
---
### Task 5: REST endpoints for avatar upload / fetch / delete
**Files:**
- Modify: `src/web/server.ts` (json size limit)
- Modify: `src/web/api/bot.ts`
- [ ] **Step 1: Bump express.json size limit**
In `src/web/server.ts`, find `app.use(express.json())` (~line 46) and change to:
```ts
app.use(express.json({ limit: "400kb" }));
```
(Avatar payload is base64-encoded ≤200 KB → ~270 KB on the wire; 400 KB gives margin.)
- [ ] **Step 2: Write a failing API test (use supertest if not present, otherwise inline fetch)**
Run: `grep -E '"supertest"|"vitest"' package.json`
If supertest is not present, write the test using `node:http` raw client or skip API integration test and rely on manual + unit tests on Task 7. Don't add new deps unless approved.
If supertest IS present, add `src/web/api/bot.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import express from "express";
import { createBotRouter } from "./bot.js";
// ... build minimal app with mocked manager + DB + avatarStore
```
If not present: skip Step 2, jump to Step 3 and verify by manual curl in Step 5.
- [ ] **Step 3: Add the three endpoints**
In `src/web/api/bot.ts`, modify the factory signature to accept `avatarStore` and `botDb`:
```ts
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
configPath: string,
logger: Logger,
botDb: BotDatabase,
avatarStore: AvatarStore,
): Router {
```
Inside the router, after the existing `/:id/config` GET, add:
```ts
router.get("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (!path) { res.status(404).end(); return; }
const buf = avatarStore.read(path);
if (!buf) { res.status(404).end(); return; }
const ext = path.split(".").pop()!;
const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
res.set("Content-Type", mime);
res.set("Cache-Control", "no-cache");
res.send(buf);
});
router.put("/:id/avatar", (req, res) => {
const bot = botManager.getBot(req.params.id);
if (!bot && !botDb.getBotInstances().some((b) => b.id === req.params.id)) {
res.status(404).json({ error: "Bot not found" });
return;
}
const { dataUrl } = req.body as { dataUrl?: string };
if (typeof dataUrl !== "string") {
res.status(400).json({ error: "dataUrl required" });
return;
}
const m = /^data:(image\/(png|jpeg|webp));base64,(.+)$/.exec(dataUrl);
if (!m) {
res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" });
return;
}
const mime = m[1];
const buf = Buffer.from(m[3], "base64");
if (buf.length > 200 * 1024) {
res.status(413).json({ error: "avatar exceeds 200KB limit" });
return;
}
const rel = avatarStore.write(req.params.id, mime, buf);
botDb.setCustomAvatarPath(req.params.id, rel);
bot?.getProfileManager().setCustomAvatar(buf);
res.json({ path: rel });
});
router.delete("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (path) avatarStore.remove(path);
botDb.setCustomAvatarPath(req.params.id, null);
const bot = botManager.getBot(req.params.id);
bot?.getProfileManager().setCustomAvatar(null);
res.status(204).end();
});
```
- [ ] **Step 4: Update the call site that constructs the router**
Search: `grep -n 'createBotRouter' src/`
In the call site (likely `src/web/server.ts` or `src/index.ts`), pass the new args. Fix the call signature.
- [ ] **Step 5: Manual smoke test**
Run: `npm run build && npm run start`
In another terminal:
```bash
# create a small valid PNG (1x1) base64
B64=$(node -e "console.log(Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,8,153,99,248,255,255,63,0,5,254,2,254,205,250,236,184,0,0,0,0,73,69,78,68,174,66,96,130]).toString('base64'))")
curl -X PUT http://localhost:3000/api/bot/<BOT_ID>/avatar \
-H 'Content-Type: application/json' \
-d "{\"dataUrl\":\"data:image/png;base64,$B64\"}"
curl http://localhost:3000/api/bot/<BOT_ID>/avatar -o /tmp/x.png
file /tmp/x.png
curl -X DELETE http://localhost:3000/api/bot/<BOT_ID>/avatar -i
```
Expected: PUT returns `{"path":"<id>.png"}`, GET returns the bytes, DELETE returns 204.
- [ ] **Step 6: Commit**
```bash
git add src/web/server.ts src/web/api/bot.ts src/index.ts
git commit -m "feat(api): /api/bot/:id/avatar GET/PUT/DELETE"
```
---
### Task 6: Frontend — `AvatarUpload.vue` component
**Files:**
- Create: `web/src/components/AvatarUpload.vue`
- [ ] **Step 1: Create the component**
```vue
<template>
<div class="avatar-upload">
<div class="preview" :class="{ empty: !previewUrl }">
<img v-if="previewUrl" :src="previewUrl" alt="avatar" />
<Icon v-else icon="mdi:account-circle-outline" />
</div>
<div class="actions">
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="onFile"
/>
<button type="button" class="btn-sm" @click="fileInput?.click()">
{{ previewUrl ? '更换' : '上传' }}
</button>
<button v-if="previewUrl" type="button" class="btn-sm btn-danger" @click="clear">
删除
</button>
</div>
<p v-if="error" class="hint error">{{ error }}</p>
<p v-else class="hint">PNG / JPG / WebP,≤200 KB</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
const props = defineProps<{ modelValue: string | null }>();
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>();
const previewUrl = ref<string | null>(props.modelValue);
const error = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
watch(() => props.modelValue, (v) => { previewUrl.value = v; });
function onFile(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
error.value = '仅支持 PNG / JPG / WebP';
return;
}
if (file.size > 200 * 1024) {
error.value = `图片 ${(file.size / 1024).toFixed(0)} KB 超过 200 KB 上限`;
return;
}
error.value = null;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
previewUrl.value = dataUrl;
emit('update:modelValue', dataUrl);
};
reader.readAsDataURL(file);
}
function clear() {
previewUrl.value = null;
emit('update:modelValue', null);
if (fileInput.value) fileInput.value.value = '';
}
</script>
<style lang="scss" scoped>
.avatar-upload { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.preview {
width: 80px; height: 80px; border-radius: 50%;
background: var(--bg-card); display: flex; align-items: center; justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
&.empty :deep(svg) { font-size: 48px; opacity: 0.4; }
}
.actions { display: flex; gap: 8px; }
.hidden { display: none; }
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
.hint.error { color: var(--color-danger, #e85060); opacity: 1; }
.btn-danger { color: var(--color-danger, #e85060); }
</style>
```
- [ ] **Step 2: Verify the component compiles**
Run: `cd web && npx vue-tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add web/src/components/AvatarUpload.vue
git commit -m "feat(web): AvatarUpload component"
```
---
### Task 7: Wire AvatarUpload into Settings.vue (create + edit + standalone row)
**Files:**
- Modify: `web/src/views/Settings.vue`
- [ ] **Step 1: Read the relevant Settings.vue regions**
```bash
grep -n '同步头像\|openEditBot\|saveEditBot\|createBot\|create-bot\|profile-features\|features.find' web/src/views/Settings.vue | head -20
```
Identify:
- Create-bot form template region (`<div class="create-bot">` block)
- Edit-bot modal/dialog template region
- The profile features table where `avatarEnabled` row lives
- [ ] **Step 2: Add component import + reactive state for avatar dataUrl on the create-bot form**
In the script setup region, near other `newBot*` refs:
```ts
import AvatarUpload from '../components/AvatarUpload.vue';
const newBotAvatar = ref<string | null>(null);
```
- [ ] **Step 3: Insert `<AvatarUpload v-model="newBotAvatar" />` into the create-bot form template**
In the `<div class="create-bot">` block, right before `<button class="btn-primary" @click="createBot">创建</button>`, add:
```vue
<div class="form-row">
<label>自定义头像(可选)</label>
<AvatarUpload v-model="newBotAvatar" />
</div>
```
- [ ] **Step 4: After successful `createBot()`, PUT the avatar if set**
Find the `createBot` async function. After the POST resolves and the bot id is known (`res.data.id` or similar), append:
```ts
if (newBotAvatar.value) {
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
}
newBotAvatar.value = null;
```
- [ ] **Step 5: Add an "自定义头像" row in the per-bot profile features table**
Find the profile-features table render (look for the `features` array iteration). The cleanest path: add a custom row OUTSIDE the array (since it isn't a boolean toggle). Right before `</template>` of the bot row, add:
```vue
<div class="feature-row">
<div class="feature-label">自定义头像</div>
<div class="feature-control">
<CustomAvatarRow :bot-id="bot.id" />
</div>
</div>
```
Where `CustomAvatarRow` is an inline-defined component or a small file `web/src/components/CustomAvatarRow.vue` that:
- Mounts → `axios.get(/api/bot/<id>/avatar, { responseType: 'blob' })` → previews if 200, ignore 404
- Wraps `<AvatarUpload>` and on `update:modelValue`:
- If string → `axios.put(/avatar, { dataUrl })`
- If null → `axios.delete(/avatar)`
Create `web/src/components/CustomAvatarRow.vue` with that logic; keep its body small (~50 lines).
- [ ] **Step 6: Build and visually verify**
Run: `cd web && npm run build` → no errors. Then `npm run dev` → open create-instance, upload PNG, create — verify the avatar appears on the bot in TS3 once it connects. Check edit/Settings flow.
- [ ] **Step 7: Commit**
```bash
git add web/src/views/Settings.vue web/src/components/CustomAvatarRow.vue
git commit -m "feat(web): custom avatar in create-bot + Settings"
```
---
### Task 8: Open PR
- [ ] **Step 1: Push the branch**
```bash
git checkout -b feat/custom-bot-avatar
git push -u origin feat/custom-bot-avatar
```
(If commits were already on `main`, instead create the branch from the first relevant commit and reset main: `git branch feat/custom-bot-avatar HEAD && git reset --hard origin/main && git checkout feat/custom-bot-avatar`. The exact sequence depends on the working state when starting.)
- [ ] **Step 2: Create the PR**
```bash
gh pr create --title "feat(profile): custom bot avatar" --body "Closes part of #51 (avatar half).
## Summary
- New /api/bot/:id/avatar GET/PUT/DELETE
- BotProfileManager: custom avatar acts as idle image; cover sync still wins during playback when avatarEnabled=true
- AvatarUpload component used in create-bot form and Settings per-bot row
- Bump express.json limit to 400kb to allow base64 payload
## Behavior matrix
| avatarEnabled | custom set | playing | stopped |
|---|---|---|---|
| ✓ | ✓ | cover | restore custom |
| ✓ | ✗ | cover | clear |
| ✗ | ✓ | custom | custom |
| ✗ | ✗ | no-op | no-op |
## Test plan
- [x] vitest covers DB, avatar store, ProfileManager precedence
- [x] Manual: upload PNG → bot avatar shows; play song → cover; stop → custom; delete → cleared
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
```
---
## Self-Review Checklist
- [x] Each spec section has at least one task: precedence matrix → Task 3; storage → Task 2; DB → Task 1; API → Task 5; UI → Task 6+7
- [x] No "TBD" / "fill in" / "implement later" text in any step
- [x] Type names consistent: `AvatarStore` / `createAvatarStore` / `getCustomAvatarPath` / `setCustomAvatarPath` / `setCustomAvatar` (singular per call site)
- [x] All code blocks compile under existing TS/Vue config (express 5, vitest, vue 3 + iconify already in use)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,811 @@
# Account Permissions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an admin grant each member account a set of capabilities and a list of bots they may control, enforced on the backend.
**Architecture:** Capability tokens + per-member bot allow-list stored in two new SQLite tables, loaded onto `req.user` per request (live, no re-login), enforced by `requirePermission` / `requireBotAccess` middleware mirroring the existing `requireAdmin`. Admin stays a super-user. Existing members are backfilled to full access on upgrade; new members get a basic tier. The Vue UI hides what a member can't do and gives admins a permission editor.
**Tech Stack:** Node ESM + TypeScript, Express, better-sqlite3, Vitest + supertest, Vue 3 + Pinia.
**Spec:** `docs/superpowers/specs/2026-05-30-account-permissions-design.md`
**Conventions:** All file paths are repo-relative. Tests run with `npx vitest run <path>`. Backend is TDD (test first, watch fail, implement, watch pass, commit). Commit after each task.
---
## File Structure
**Create:**
- `src/data/permissions.ts` — capability constants + `PermissionStore` (tables accessed here)
- `src/data/permissions.test.ts` — store + constants tests
- `src/web/middleware/requirePermission.ts` — `requirePermission(cap)` + `requireBotAccess(param)`
- `src/web/middleware/requirePermission.test.ts` — middleware tests
**Modify:**
- `src/data/database.ts` — `initTables`: add the two tables + index; migration backfill of existing members
- `src/data/audit.ts` — add `"user.permissions_changed"` to `AuditAction`
- `src/web/middleware/requireAuth.ts` — widen `req.user`; load capabilities + bot access
- `src/web/auth/validateSession.ts` — (no change; just confirm) — actually unchanged
- `src/web/api/session.ts` — `/me` returns capabilities + bots; inline auth attaches them
- `src/web/server.ts` — construct `PermissionStore`, pass into routers/middleware
- `src/web/api/player.ts` — `requireBotAccess` on `/:botId`; per-route `requirePermission`
- `src/web/api/bot.ts` — `requirePermission("bot.manage")` + `requireBotAccess("id")`
- `src/web/api/auth.ts` — `requirePermission("platform.auth")`
- `src/web/api/music.ts` — `requirePermission("quality")` on the quality POST; filter `GET /api/bot`? no — bot list is in bot.ts
- `src/web/api/bot.ts` — filter `GET /` to allowed bots for members
- `src/web/api/users.ts` — `GET/PUT /api/users/:id/permissions`
- `src/bot/manager.ts` — `removeBot` calls `permissions.pruneBot(botId)`
- Frontend: `web/src/composables/useSession.ts`, `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
---
## Task 1: Capability constants + PermissionStore + tables
**Files:**
- Create: `src/data/permissions.ts`
- Create: `src/data/permissions.test.ts`
- Modify: `src/data/database.ts` (initTables)
- [ ] **Step 1: Write the failing test**
`src/data/permissions.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
// a user row is required for FK; insert directly
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `createPermissionStore` / `CAPABILITIES` not found (module missing).
- [ ] **Step 3: Create `src/data/permissions.ts`**
```typescript
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
```
- [ ] **Step 4: Add tables in `src/data/database.ts` initTables**
Find `initTables` (creates users/sessions/user_audit). Add, after the `user_audit` CREATE:
```typescript
db.exec(`
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
`);
```
(If `initTables` uses individual `db.exec` calls, match that style. The `BotDatabase` type already exposes `.db` and `.close()` — confirm by reading the file; the test uses `db.db` and `db.close()`.)
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS (7 tests).
- [ ] **Step 6: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts src/data/database.ts
git commit -m "feat(perm): permission store + capability tokens + tables"
```
---
## Task 2: requirePermission + requireBotAccess middleware
**Files:**
- Create: `src/web/middleware/requirePermission.ts`
- Create: `src/web/middleware/requirePermission.test.ts`
- Modify: `src/web/middleware/requireAuth.ts` (widen `req.user`)
- [ ] **Step 1: Widen the `req.user` augmentation in `src/web/middleware/requireAuth.ts`**
Change the `declare module` block so `req.user` carries capabilities + bot access:
```typescript
declare module "express-serve-static-core" {
interface Request {
user?: {
id: string;
username: string;
role: "admin" | "member";
capabilities: Set<string>;
bots: "all" | Set<string>;
};
}
}
```
(The loading of these fields is done in Task 4 — for now this only widens the type. Existing assignments to `req.user` will fail to typecheck until Task 4; that is expected and Task 4 fixes them. If you need the build green between tasks, do Task 2 + Task 4 back-to-back before running `tsc`.)
- [ ] **Step 2: Write the failing middleware test**
`src/web/middleware/requirePermission.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import { requirePermission, requireBotAccess } from "./requirePermission.js";
function appWith(user: any) {
const app = express();
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1", username: "a", role: "member",
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
});
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
describe("requirePermission", () => {
it("401 when unauthenticated", async () => {
const app = express();
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/cap")).status).toBe(401);
});
it("403 when member lacks the capability", async () => {
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
});
it("200 when member has the capability", async () => {
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
});
it("200 for admin regardless of capabilities", async () => {
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
});
});
describe("requireBotAccess", () => {
it("200 when bots = all", async () => {
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
});
it("200 when botId in allow-list", async () => {
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
});
it("403 when botId not in allow-list", async () => {
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
});
it("200 for admin", async () => {
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: FAIL — module `./requirePermission.js` not found.
- [ ] **Step 4: Create `src/web/middleware/requirePermission.ts`**
```typescript
import type { Request, Response, NextFunction, RequestHandler } from "express";
export function requirePermission(capability: string): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.capabilities.has(capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
export function requireBotAccess(paramName = "botId"): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
const botId = req.params[paramName];
if (botId && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: PASS (8 tests).
- [ ] **Step 6: Commit**
```bash
git add src/web/middleware/requirePermission.ts src/web/middleware/requirePermission.test.ts src/web/middleware/requireAuth.ts
git commit -m "feat(perm): requirePermission + requireBotAccess middleware"
```
---
## Task 3: Effective-permissions resolver (admin = all)
**Files:**
- Modify: `src/data/permissions.ts` (add `resolveContext` helper)
- Modify: `src/data/permissions.test.ts` (add tests)
- [ ] **Step 1: Add failing tests** to `src/data/permissions.test.ts`:
```typescript
import { resolvePermissionContext } from "./permissions.js";
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
```
- [ ] **Step 2: Run to verify fail**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `resolvePermissionContext` not exported.
- [ ] **Step 3: Add to `src/data/permissions.ts`**
```typescript
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
}
export function resolvePermissionContext(
role: "admin" | "member",
userId: string,
store: PermissionStore
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
```
- [ ] **Step 4: Run to verify pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts
git commit -m "feat(perm): resolvePermissionContext (admin = super-user)"
```
---
## Task 4: Load permissions onto req.user (requireAuth + session inline + /me)
**Files:**
- Modify: `src/web/middleware/requireAuth.ts`
- Modify: `src/web/api/session.ts`
- Modify: `src/web/server.ts`
- [ ] **Step 1: Thread `PermissionStore` into `createRequireAuth`**
`src/web/middleware/requireAuth.ts` — change the factory signature and set the new fields:
```typescript
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
return function requireAuth(req, res, next) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
req.user = {
id: result.userId, username: result.username, role: result.role,
capabilities: ctx.capabilities, bots: ctx.bots,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true, sameSite: "lax", secure: req.secure, path: "/", maxAge: SESSION_TTL_MS,
});
}
next();
};
}
```
- [ ] **Step 2: Update `src/web/server.ts`**
Construct the store next to the others and pass it in:
```typescript
import { createPermissionStore } from "../data/permissions.js";
// ...
const permissions = createPermissionStore(options.database.db);
// ...
const requireAuth = createRequireAuth(sessions, permissions);
```
Keep `permissions` in scope — it's passed to routers in Tasks 5–7.
- [ ] **Step 3: Update session inline auth + `/me` in `src/web/api/session.ts`**
`createSessionRouter` must accept `permissions` and (a) attach capabilities in `requireAuthInline`, (b) include them in `/me`. Pass `permissions` from `server.ts` into `createSessionRouter(users, sessions, audit, logger, permissions)`. In the `/me` handler, return:
```typescript
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
res.json({
id: validation.userId, username: validation.username, role: validation.role,
capabilities: [...ctx.capabilities],
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
});
```
(Match the existing `/me` shape; just add `capabilities` + `bots`. Read the file to find the exact response object.)
- [ ] **Step 4: Verify build + existing tests**
Run: `npx tsc --noEmit`
Expected: exit 0 (the widened `req.user` is now populated everywhere it's read).
Run: `npx vitest run src/web`
Expected: PASS (existing auth/session/csrf tests still green; if a test constructs `createRequireAuth(sessions)` it must be updated to pass a `createPermissionStore(db)`).
- [ ] **Step 5: Commit**
```bash
git add src/web/middleware/requireAuth.ts src/web/server.ts src/web/api/session.ts
git commit -m "feat(perm): load capabilities + bot access onto req.user; expose via /me"
```
---
## Task 5: Enforce capabilities on the action routes
**Files:**
- Modify: `src/web/api/player.ts`, `src/web/api/bot.ts`, `src/web/api/auth.ts`, `src/web/api/music.ts`
- Modify: `src/web/api/player.test.ts` (or create `src/web/api/permissions-enforcement.test.ts`)
- [ ] **Step 1: Write a failing integration test** at `src/web/api/permissions-enforcement.test.ts` that builds the real app (or the relevant router) with a stubbed `req.user` and asserts:
- member without `player.control` → `POST /api/player/:botId/pause` → 403
- member with `player.control` + bot in allow-list → 200 (bot resolves)
- member with `player.control` but bot NOT in allow-list → 403
- member without `player.queue` → `POST /api/player/:botId/clear` → 403
- member without `bot.manage` → `POST /api/bot` → 403
- member without `platform.auth` → `POST /api/auth/cookie` → 403
- member without `quality` → `POST /api/music/quality` → 403
- admin → all 200/allowed
Use the same `appWith(user)` injection pattern as Task 2 (insert a middleware that sets `req.user` before the router) and a fake `BotManager`/providers so routes resolve. Model it on the existing `src/web/api/*.test.ts` setup (read one first for the harness).
- [ ] **Step 2: Run to verify fail** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → FAIL (routes currently allow everyone).
- [ ] **Step 3: Apply gates.**
`src/web/api/player.ts` — the shared `/:botId` middleware already resolves the bot. Add bot-access there, and add per-action capability guards. Define the queue-capability routes vs control routes:
```typescript
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
// after the existing router.use("/:botId", resolveBot):
router.use("/:botId", requireBotAccess("botId"));
const control = requirePermission("player.control");
const queue = requirePermission("player.queue");
// control: play, pause, resume, next, prev, stop, seek, volume, mode, play-song, play-at, play-by-id, play-playlist, play-album, play-next-song
// queue: add, add-song, add-by-id, clear, playlist, /queue/:index (DELETE)
// Apply per route, e.g.:
router.post("/:botId/pause", control, async (req, res) => { /* existing */ });
router.post("/:botId/add", queue, async (req, res) => { /* existing */ });
router.delete("/:botId/queue/:index", queue, async (req, res) => { /* existing */ });
```
(Insert the `control`/`queue` middleware as the 2nd arg of each existing `router.post/delete`. Do not change handler bodies. `PUT /:botId/profile` → `requirePermission("bot.manage")`.)
`src/web/api/bot.ts` — gate management + per-bot:
```typescript
const manage = requirePermission("bot.manage");
router.post("/", manage, ...); // create (no botId)
router.put("/:id", manage, requireBotAccess("id"), ...);
router.delete("/:id", manage, requireBotAccess("id"), ...);
router.post("/:id/start", manage, requireBotAccess("id"), ...);
router.post("/:id/stop", manage, requireBotAccess("id"), ...);
router.put("/:id/avatar", manage, requireBotAccess("id"), ...);
router.delete("/:id/avatar", manage, requireBotAccess("id"), ...);
router.post("/settings", manage, ...); // global idle timeout
```
`src/web/api/auth.ts` — gate every mutating route with `requirePermission("platform.auth")`:
`POST /qrcode`, `POST /sms/send`, `POST /sms/verify`, `POST /cookie`. (Leave `GET /status`, `GET /qrcode/status` open — read-only.)
`src/web/api/music.ts` — gate the one mutating route:
`router.post("/quality", requirePermission("quality"), ...)`.
- [ ] **Step 4: Run to verify pass** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → PASS. Then `npx vitest run src/web` → all green.
- [ ] **Step 5: Commit**
```bash
git add src/web/api/player.ts src/web/api/bot.ts src/web/api/auth.ts src/web/api/music.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): enforce capabilities + bot access on action routes"
```
---
## Task 6: Filter the bot list for members
**Files:**
- Modify: `src/web/api/bot.ts` (`GET /`)
- Modify: `src/bot/manager.ts` (`removeBot` → `permissions.pruneBot`)
- Modify: test from Task 5
- [ ] **Step 1: Add failing test** — member with `bots: ["b1"]` calling `GET /api/bot` sees only `b1`; admin sees all.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement.** In `GET /` of `bot.ts`:
```typescript
const all = getAllBots().map((b) => b.getStatus());
const u = req.user!;
const bots = u.role === "admin" || u.bots === "all"
? all
: all.filter((b) => (u.bots as Set<string>).has(b.id));
res.json({ bots });
```
In `src/bot/manager.ts`, give `BotManager` access to the `PermissionStore` (constructor param) and call `this.permissions.pruneBot(id)` inside `removeBot(id)` after deletion, so deleted bots drop out of allow-lists. Thread `permissions` from `index.ts`/`server.ts` into `BotManager`.
- [ ] **Step 4: Run → pass; `npx vitest run src/web src/bot` green.**
- [ ] **Step 5: Commit**
```bash
git add src/web/api/bot.ts src/bot/manager.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete"
```
---
## Task 7: Management API (GET/PUT permissions) + audit
**Files:**
- Modify: `src/data/audit.ts` (add action)
- Modify: `src/web/api/users.ts` (+ permissions endpoints; new-member default)
- Modify: `src/web/server.ts` (pass `permissions` into `createUsersRouter`)
- Create/extend: `src/web/api/users.test.ts`
- [ ] **Step 1: Add `"user.permissions_changed"`** to the `AuditAction` union in `src/data/audit.ts`.
- [ ] **Step 2: Write failing tests** for the users router (admin-only):
- `GET /api/users/:id/permissions` → `{ capabilities: [], bots: [] }` for a fresh member.
- `PUT /api/users/:id/permissions` with `{capabilities:["player.control"], bots:"all"}` → 200; subsequent GET reflects it; an audit row `user.permissions_changed` exists.
- `PUT` with an unknown capability token → it is dropped (not stored).
- New member created via `POST /api/users` → GET permissions returns basic tier (`["player.control","player.queue"]`, bots `"all"`).
- [ ] **Step 3: Run → fail.**
- [ ] **Step 4: Implement** in `src/web/api/users.ts` (router already admin-gated at mount). Accept `permissions: PermissionStore` param. Add:
```typescript
import { CAPABILITIES, isCapability, BASIC_TIER_CAPABILITIES } from "../../data/permissions.js";
router.get("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
res.json({ capabilities: permissions.getCapabilities(user.id), bots: permissions.getBotAccess(user.id) });
});
router.put("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
const body = req.body ?? {};
const caps = Array.isArray(body.capabilities) ? body.capabilities.filter(isCapability) : [];
const bots = body.bots === "all" ? "all" : (Array.isArray(body.bots) ? body.bots.map(String) : []);
permissions.setPermissions(user.id, { capabilities: caps, bots });
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: user.id, targetUsername: user.username,
action: "user.permissions_changed",
});
res.json({ success: true });
});
```
In the existing `POST /api/users` handler, after creating a member, seed the basic tier:
```typescript
if (created.role === "member") {
permissions.setPermissions(created.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
}
```
- [ ] **Step 5: Run → pass; `npx vitest run src/web` green.**
- [ ] **Step 6: Commit**
```bash
git add src/data/audit.ts src/web/api/users.ts src/web/server.ts src/web/api/users.test.ts
git commit -m "feat(perm): admin permissions API + audit + new-member basic tier"
```
---
## Task 8: One-time migration backfill (existing members → full)
**Files:**
- Modify: `src/data/database.ts` (`migrateSchema` or a dedicated backfill)
- Create: `src/data/permissions-migration.test.ts`
- [ ] **Step 1: Write failing test** — given a fresh db with an existing `member` user and NO permission rows, after `createDatabase()` runs the backfill, that member has all 5 capabilities + `bots.all`; an `admin` user gets nothing (bypasses). Backfill is idempotent (running twice does not duplicate / does not re-grant a member who was later restricted to empty).
Idempotency approach: store a one-shot marker. Use a `meta` row or check: only backfill members who currently have ZERO permission rows AND only on first introduction. Simplest robust marker: a row in a tiny `schema_meta(key TEXT PK, value TEXT)` table, key `perm_backfill_done`. If present, skip.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement** a `backfillMemberPermissions(db)` run once inside `createDatabase` after `initTables`:
```typescript
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (!done) {
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tx = db.transaction(() => {
for (const m of members) {
for (const c of ["player.control","player.queue","bot.manage","platform.auth","quality","bots.all"]) {
insCap.run(m.id, c);
}
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(Date.now()));
});
tx();
}
```
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/permissions-migration.test.ts
git commit -m "feat(perm): one-time backfill of existing members to full access"
```
---
## Task 9: Frontend — session capabilities + helpers
**Files:**
- Modify: `web/src/composables/useSession.ts`
- [ ] **Step 1:** Extend the `User` type with `capabilities: string[]` and `bots: 'all' | string[]`; populate from `/api/session/me`, `/login`, `/setup` responses (the backend now returns them).
- [ ] **Step 2:** Add computed helpers:
```typescript
function can(cap: string): boolean {
const u = currentUser.value;
return !!u && (u.role === 'admin' || (u.capabilities ?? []).includes(cap));
}
function canControlBot(botId: string): boolean {
const u = currentUser.value;
if (!u) return false;
if (u.role === 'admin' || u.bots === 'all') return true;
return Array.isArray(u.bots) && u.bots.includes(botId);
}
```
Export `can` and `canControlBot` from the composable.
- [ ] **Step 3:** Manual check: log in as admin → `can('quality')` true; (after backend done) a restricted member → false. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/composables/useSession.ts && git commit -m "feat(perm): frontend session capabilities + can()/canControlBot()"`
---
## Task 10: Frontend — gate UI by capability + filter bots
**Files:**
- Modify: `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
- [ ] **Step 1:** Navbar bot selector: render only controllable bots — `v-for="bot in store.bots"` becomes a filtered computed `controllableBots = store.bots.filter(b => session.canControlBot(b.id))`. (The backend already filters `GET /api/bot`, so this is belt-and-suspenders + correctness if both lists diverge.) Ensure `store.activeBot` fallback never lands on a bot the user can't control.
- [ ] **Step 2:** Player.vue: wrap control buttons with `v-if="session.can('player.control')"` and queue actions with `v-if="session.can('player.queue')"`.
- [ ] **Step 3:** Settings.vue: wrap the platform login cards with `v-if="session.can('platform.auth')"`, the audio-quality control with `v-if="session.can('quality')"`, and bot create/edit/delete with `v-if="session.can('bot.manage')"`.
- [ ] **Step 4:** Manual verification (see Verification section). Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 5: Commit** `git add web/src/components/Navbar.vue web/src/components/Player.vue web/src/views/Settings.vue web/src/stores/player.ts && git commit -m "feat(perm): hide UI a member lacks capability for"`
---
## Task 11: Frontend — admin permission editor
**Files:**
- Modify: `web/src/views/Settings.vue` (User Management section)
- [ ] **Step 1:** In each member row of the admin User-Management list, add a "权限" button opening an editor (inline panel or dialog) with: 5 capability checkboxes (labels: 播放控制 / 队列管理 / 机器人管理 / 平台登录凭据 / 音质设置), and a bot allow-list — an "全部机器人" toggle plus, when off, a checkbox per bot from `store.bots`.
- [ ] **Step 2:** On open, `GET /api/users/:id/permissions`; on save, `PUT /api/users/:id/permissions` with `{capabilities, bots}` then re-fetch. Admin rows show "全部权限(管理员)" and no editor.
- [ ] **Step 3:** Manual verification. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/views/Settings.vue && git commit -m "feat(perm): admin permission editor in user management"`
---
## Final verification
- [ ] `npx tsc --noEmit` → exit 0
- [ ] `npx vitest run src/` → all green (clean-checkout-equivalent; ignore stale `dist/` twins — see note)
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npm run build` → succeeds
- [ ] Manual (run the bot, log in): admin sees everything; create a member, restrict to `player.control` on one bot → member sees only that bot, can play/pause but cannot add to queue, cannot open platform login / quality / bot management; backend returns 403 on a forged request to a disallowed action (verify with curl + the member's session cookie).
> **Note (pre-existing):** `tsconfig.json` compiles `*.test.ts` into `dist/`, and vitest also runs the `dist/` twins after a build — so `npx vitest run` (no path) double-runs and can fail on stale artifacts. Scope verification to `npx vitest run src/`. (A separate cleanup PR could add `exclude: ['**/dist/**']` to a vitest config.)
## Out of scope (separate PRs, per spec)
#1 guest mode · #2 dedicated-link bot hiding UX · #3 auto-pause on empty channel · #4 dedicated-link refresh bug.
@@ -0,0 +1,196 @@
# Auto-pause on Empty Channel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
---
## Task 1: Pure occupancy-decision function
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { decideOccupancyAction } from "./auto-pause.js";
describe("decideOccupancyAction", () => {
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
```typescript
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Decide what auto-pause should do given the channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
```
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
---
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
**Files:** Modify `src/bot/instance.ts`.
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
```typescript
import { decideOccupancyAction } from "./auto-pause.js";
private handleOccupancy(userCount: number): void {
// idle-disconnect (unchanged behavior)
if (userCount <= 0) this._scheduleIdleCheck();
else this._cancelIdleTimer();
// auto-pause
const action = decideOccupancyAction(
this.player.getState() as "idle" | "playing" | "paused",
this.autoPaused,
this.config.autoPauseOnEmpty,
userCount,
);
if (action === "pause") {
this.player.pause();
this.autoPaused = true;
this.emit("stateChange");
} else if (action === "resume") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
```typescript
updateAutoPause(enabled: boolean): void {
this.config.autoPauseOnEmpty = enabled;
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
---
## Task 3: Re-emit TS member events for instant reaction
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
---
## Task 4: API wiring for the toggle
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
- [ ] **Step 2 — run, expect fail.**
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
---
## Task 5: Frontend toggle in Settings
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
---
## Final verification
- [ ] `npx tsc --noEmit` → 0
- [ ] `npx vitest run src/` → all pass
- [ ] `cd web && npx vue-tsc --noEmit` → 0
- [ ] `npm run build` → succeeds
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
@@ -0,0 +1,156 @@
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
---
## Task 1: Store scope state + pure resolve helper (with test)
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
```typescript
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
* effective scope. Returns the id if it exists, else null (graceful clear:
* a stale/forbidden id never locks the UI). */
export function resolveScopedBot(
requestedId: string | null | undefined,
knownBotIds: readonly string[],
): string | null {
if (!requestedId) return null;
return knownBotIds.includes(requestedId) ? requestedId : null;
}
```
`web/src/stores/scope.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { resolveScopedBot } from "./scope.js";
describe("resolveScopedBot", () => {
it("returns null when no id requested", () => {
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
expect(resolveScopedBot("", ["a"])).toBeNull();
});
it("returns the id when it exists in the bot list", () => {
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
});
it("clears (null) when the requested id is not a known bot", () => {
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
- [ ] **Step 3 — implement the helper** (code above).
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
- state: `scopedBotId: null as string | null`.
- getter: `isScoped: (state) => state.scopedBotId !== null`.
- actions:
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
- `clearScope()` → `this.scopedBotId = null;`.
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
---
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
**Files:** Modify `web/src/router/index.ts`.
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
```typescript
const store = usePlayerStore();
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
if (qBot) {
// entering/with a scope in the URL — store will validate against bots later
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
return next();
}
if (store.scopedBotId) {
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
if (to.query.bot !== store.scopedBotId) {
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
}
}
return next();
```
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
---
## Task 3: BotRedirect seeds the URL scope
**Files:** Modify `web/src/views/BotRedirect.vue`.
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
- keep the fetchBots + existence check,
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
- if not found: unchanged (show "机器人不存在或未加载").
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
---
## Task 4: Navbar lock UI + apply-scope-on-load
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
When not scoped: behavior unchanged.
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
---
## Final verification
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
- [ ] `npm run build` → succeeds
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
## Notes
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,840 @@
# TeamSpeak chat-command permission control — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Gate a fixed set of "admin" TeamSpeak chat commands (`stop`, `clear`, `remove`, `move`, `vol`, `mode`) behind configured TS server-group IDs, opt-in and backward-compatible, configurable from the WebUI and `config.json`.
**Architecture:** A pure helper `canRunCommand(name, invokerGroups, adminGroups)` decides allow/deny. The chat handler `handleTextMessage` (NOT the WebUI-shared `executeCommand`) consults it before executing, performs a best-effort group lookup when the sender's groups weren't delivered with the event, fails closed, and replies on deny. The privileged groups live in the already-declared `config.adminGroups`, surfaced through the existing `GET/POST /api/bot/settings` endpoints and an admin-only Settings.vue section.
**Tech Stack:** Node 20, TypeScript (ESM), Express 5, Vitest + supertest (backend), Vue 3 + `vue-tsc` (frontend), `@honeybbq/teamspeak-client`.
## Global Constraints
- **ESM import specifiers:** every relative import ends in `.js` even in `.ts` files (e.g. `import { canRunCommand } from "./commands.js"`).
- **Admin command set (exact, single source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. (Note: `follow` is intentionally NOT admin — it becomes public.)
- **Enforcement is opt-in / backward-compatible:** `config.adminGroups === []` (the default) ⇒ no enforcement; admin commands stay open to everyone exactly as today.
- **Fail closed:** an admin command, with enforcement on, whose sender groups cannot be determined (even after fallback) is **denied**.
- **Group-id normalization:** `invokerGroups` are strings, `adminGroups` are numbers — compare as the same type so `"6"` matches `6`.
- **Denial reply text (exact):** `⛔ 需要管理员权限(该命令仅限管理员服务器组)`.
- **`adminGroups` validation:** array of non-negative integers; filter out everything else; ignore a non-array value entirely.
- **Live config:** `BotInstance` shares the same `config` object the router mutates; the gate reads `this.config.adminGroups` live (no restart, no propagation call).
- **Per-task tests:** run `npx vitest run <file>` (targets `.ts` directly). Before any full `npm test`, run `rm -rf dist` first — a stale untracked `dist/` makes vitest double-run compiled `.test.js` copies (known environment quirk). The repo path contains spaces (`/c/Users/saopig1/Music/teamspeak music bot`) — quote it.
- **Frontend type-check:** `cd web && npx vue-tsc --noEmit` (must be clean).
- **TDD + frequent commits:** every task is red→green→commit. Keep project `tsc`/`vitest` green after each task.
---
### Task 1: `canRunCommand` helper + admin-set as single source of truth
**Files:**
- Modify: `src/bot/commands.ts` (lines 8-16 sets; line 59-61 `isAdminCommand`)
- Test: `src/bot/commands.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces:
- `export const ADMIN_COMMANDS: Set<string>` = `{stop, clear, remove, move, vol, mode}`
- `export function isAdminCommand(commandName: string): boolean` (unchanged signature)
- `export function canRunCommand(commandName: string, invokerGroups: readonly (string | number)[], adminGroups: readonly number[]): boolean` — consumed by Task 3.
- [ ] **Step 1: Write the failing tests**
Append to `src/bot/commands.test.ts`:
```ts
import { canRunCommand, isAdminCommand } from "./commands.js";
describe("isAdminCommand classification", () => {
it("treats stop/clear/remove/move/vol/mode as admin", () => {
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
expect(isAdminCommand(c)).toBe(true);
}
});
it("treats follow and play as NOT admin", () => {
expect(isAdminCommand("follow")).toBe(false);
expect(isAdminCommand("play")).toBe(false);
});
});
describe("canRunCommand", () => {
it("allows any public command regardless of groups", () => {
expect(canRunCommand("play", [], [6])).toBe(true);
expect(canRunCommand("follow", [], [6])).toBe(true);
});
it("allows admin command when enforcement is off (empty adminGroups)", () => {
expect(canRunCommand("stop", [], [])).toBe(true);
});
it("allows admin command when an invoker group matches (string vs number)", () => {
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
expect(canRunCommand("stop", [6], [6])).toBe(true);
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
});
it("denies admin command when no invoker group matches", () => {
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
});
it("denies admin command when invoker has no groups and enforcement is on", () => {
expect(canRunCommand("clear", [], [6])).toBe(false);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/bot/commands.test.ts"`
Expected: FAIL — `canRunCommand` is not exported / not a function.
- [ ] **Step 3: Implement the helper and tighten the admin set**
In `src/bot/commands.ts`, delete the dead `PUBLIC_COMMANDS` export (nothing imports it; the admin set is the sole source of truth), set `ADMIN_COMMANDS` to the exact spec set (drop `follow`), and add `canRunCommand`. The file becomes:
```ts
export interface ParsedCommand {
name: string;
args: string;
rawArgs: string[];
flags: Set<string>;
}
/**
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
* for which commands the permission gate restricts; reclassifying a command is
* a one-line edit here. Everything not in this set is public.
*/
export const ADMIN_COMMANDS = new Set([
"stop", "clear", "remove", "move", "vol", "mode",
]);
export function parseCommand(
message: string,
prefix: string,
aliases: Record<string, string> = {},
): ParsedCommand | null {
const trimmed = message.trim();
if (!trimmed.startsWith(prefix)) return null;
const withoutPrefix = trimmed.slice(prefix.length);
if (!withoutPrefix) return null;
const parts = withoutPrefix.split(/\s+/);
let name = parts[0].toLowerCase();
if (aliases[name]) {
name = aliases[name];
}
const flags = new Set<string>();
const argParts: string[] = [];
for (let i = 1; i < parts.length; i++) {
if (
parts[i].startsWith("-") &&
parts[i].length === 2 &&
/[a-zA-Z]/.test(parts[i][1])
) {
flags.add(parts[i][1].toLowerCase());
} else {
argParts.push(parts[i]);
}
}
return {
name,
args: argParts.join(" "),
rawArgs: argParts,
flags,
};
}
export function isAdminCommand(commandName: string): boolean {
return ADMIN_COMMANDS.has(commandName);
}
/**
* Decide whether a chat command may run, given the invoker's TS server groups
* and the configured admin groups. Pure + synchronous so it is trivially unit
* tested and reused by the async gate in BotInstance.
*
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
* strings before comparison so "6" matches 6.
*/
export function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[],
): boolean {
if (!isAdminCommand(commandName)) return true;
if (adminGroups.length === 0) return true;
const admin = new Set(adminGroups.map((g) => String(g)));
return invokerGroups.some((g) => admin.has(String(g)));
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npx vitest run "src/bot/commands.test.ts"`
Expected: PASS (parser tests + the new classification/canRunCommand tests).
- [ ] **Step 5: Verify nothing else imported the deleted symbol**
Run: `grep -rn "PUBLIC_COMMANDS" src/`
Expected: no matches (confirms the deletion is safe).
- [ ] **Step 6: Commit**
```bash
git add "src/bot/commands.ts" "src/bot/commands.test.ts"
git commit -m "feat(commands): add canRunCommand gate helper + admin-set source of truth"
```
---
### Task 2: Surface `invokerGroups` on `TS3TextMessage`
**Files:**
- Modify: `src/ts-protocol/client.ts` (interface lines 58-64; mapping lines 205-214)
- Test: `src/ts-protocol/text-message.test.ts` (new)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces:
- `TS3TextMessage` gains `invokerGroups: string[]`.
- `export function toTS3TextMessage(msg: TextMessage): TS3TextMessage` — a pure mapper, used by the `textMessage` event handler and unit-testable. Consumed (the field) by Task 3.
- [ ] **Step 1: Write the failing test**
Create `src/ts-protocol/text-message.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { toTS3TextMessage } from "./client.js";
import type { TextMessage } from "@honeybbq/teamspeak-client";
function makeMsg(over: Partial<TextMessage> = {}): TextMessage {
return {
invokerName: "Alice",
invokerUID: "uid-abc",
message: "!stop",
invokerGroups: ["6", "8"],
targetMode: 2,
targetID: 0n,
invokerID: 5,
...over,
};
}
describe("toTS3TextMessage", () => {
it("maps core fields and stringifies invokerID", () => {
const r = toTS3TextMessage(makeMsg());
expect(r.invokerName).toBe("Alice");
expect(r.invokerId).toBe("5");
expect(r.invokerUid).toBe("uid-abc");
expect(r.message).toBe("!stop");
expect(r.targetMode).toBe(2);
});
it("preserves the sender's server groups", () => {
expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]);
});
it("defaults missing invokerGroups to an empty array", () => {
const partial = {
invokerName: "Bob",
invokerUID: "u",
message: "!stop",
targetMode: 1,
targetID: 0n,
invokerID: 7,
} as unknown as TextMessage;
expect(toTS3TextMessage(partial).invokerGroups).toEqual([]);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
Expected: FAIL — `toTS3TextMessage` is not exported.
- [ ] **Step 3: Add the field and the pure mapper, and use it in the handler**
In `src/ts-protocol/client.ts`, extend the interface (add `invokerGroups`):
```ts
export interface TS3TextMessage {
invokerName: string;
invokerId: string;
invokerUid: string;
message: string;
targetMode: number; // 1=private, 2=channel, 3=server
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
}
```
Add the pure mapper just below the interface (still above the `TS3Client` class):
```ts
/**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender
* is in the bot's client-view cache; otherwise it is []. Used by the chat
* command permission gate.
*/
export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
return {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
invokerGroups: msg.invokerGroups ?? [],
};
}
```
Replace the inline mapping inside `this.client.on("textMessage", ...)` (currently lines 205-214) with a call to the mapper:
```ts
this.client.on("textMessage", (msg: TextMessage) => {
this.emit("textMessage", toTS3TextMessage(msg));
});
```
(`TextMessage` is already imported at the top of the file.)
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add "src/ts-protocol/client.ts" "src/ts-protocol/text-message.test.ts"
git commit -m "feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper"
```
---
### Task 3: Permission gate in `handleTextMessage` (fallback lookup + fail-closed + denial reply)
**Files:**
- Modify: `src/bot/instance.ts` (imports lines 10-14; add a module constant; `handleTextMessage` lines 317-349; add two private methods)
- Test: `src/bot/instance.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes:
- `canRunCommand(commandName, invokerGroups, adminGroups)` from `./commands.js` (Task 1).
- `TS3TextMessage.invokerGroups: string[]` (Task 2).
- Existing `this.tsClient.getClientsInChannel(): Promise<ClientInfo[]>` where each `ClientInfo` has `id: number` and `serverGroups: string[]` (library already parses these).
- Existing `this.tsClient.sendTextMessage(message: string, targetMode?: number): Promise<void>`.
- Produces:
- `export const COMMAND_DENIED_MESSAGE: string` (exported so the test can assert it).
- Private `isCommandAllowed(commandName, msg)` and `lookupInvokerGroups(invokerId)` (exercised via prototype in the test).
- [ ] **Step 1: Write the failing tests**
Append to `src/bot/instance.test.ts`:
```ts
import { vi } from "vitest";
import { COMMAND_DENIED_MESSAGE } from "./instance.js";
import type { TS3TextMessage } from "../ts-protocol/client.js";
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
* The gate methods live on the prototype and are attached here so calls like
* `this.isCommandAllowed(...)` resolve against this same object. */
function makeGateCtx(opts: {
adminGroups?: number[];
clients?: Array<{ id: number; serverGroups: string[] }>;
}) {
const ctx: any = {
config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] },
logger: { info: vi.fn(), error: vi.fn() },
tsClient: {
sendTextMessage: vi.fn(async () => {}),
getClientsInChannel: vi.fn(async () => opts.clients ?? []),
},
executeCommand: vi.fn(async () => null),
isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed,
lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups,
};
return ctx;
}
function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage {
return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups };
}
const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as (
this: unknown,
msg: TS3TextMessage,
) => Promise<void>;
describe("BotInstance.handleTextMessage — command permission gate", () => {
it("runs a public command even with enforcement on", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!play 晴天"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("runs an admin command when enforcement is off (empty adminGroups)", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("runs an admin command when the event carried a matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["6"]));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); // no fallback needed
});
it("denies an admin command when known groups do not match (no fallback, with reply)", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["8"]));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("falls back to a group lookup when the event carried no groups, and allows on match", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["6"] }] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.tsClient.getClientsInChannel).toHaveBeenCalledTimes(1);
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("fails closed when the fallback finds the client but no matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["8"] }] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("fails closed when the fallback cannot find the client at all", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/bot/instance.test.ts"`
Expected: FAIL — `COMMAND_DENIED_MESSAGE` is not exported; `isCommandAllowed`/`lookupInvokerGroups` are undefined.
- [ ] **Step 3: Implement the gate**
In `src/bot/instance.ts`, change the commands import (lines 10-14) from `isAdminCommand` to `canRunCommand`:
```ts
import {
parseCommand,
canRunCommand,
type ParsedCommand,
} from "./commands.js";
```
Add a module-level constant just after the imports (above `export interface BotInstanceOptions`):
```ts
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
```
Replace `handleTextMessage` (lines 317-349) so the dead stub becomes the real gate:
```ts
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand(
msg.message,
this.config.commandPrefix,
this.config.commandAliases
);
if (!parsed) return;
if (!(await this.isCommandAllowed(parsed.name, msg))) {
this.logger.info(
{ command: parsed.name, invoker: msg.invokerName },
"Command denied: invoker not in adminGroups"
);
try {
await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat");
}
return;
}
this.logger.info(
{ command: parsed.name, args: parsed.args, invoker: msg.invokerName },
"Command received"
);
try {
const response = await this.executeCommand(parsed, msg);
if (response) {
await this.tsClient.sendTextMessage(response);
}
} catch (err) {
this.logger.error({ err, command: parsed.name }, "Command execution error");
try {
await this.tsClient.sendTextMessage(
`Error: ${(err as Error).message}`
);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
}
}
}
/**
* Decide whether a chat command may run for this sender. Reads adminGroups
* live from this.config (the router mutates the same object). Only performs
* the async group lookup when the synchronous decision is "deny because the
* event carried no groups" — i.e. an admin command, enforcement on, and
* empty invokerGroups. Fails closed if groups remain undeterminable.
*/
private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise<boolean> {
const adminGroups = this.config.adminGroups;
if (canRunCommand(commandName, msg.invokerGroups, adminGroups)) return true;
// Here: admin command, enforcement on, and the provided groups did not match.
// If the event actually carried groups, this is a genuine deny — no lookup.
if (msg.invokerGroups.length > 0) return false;
// Groups unknown (sender not in the view cache): one targeted lookup, then
// re-decide. canRunCommand([], …) is false ⇒ fail-closed when still unknown.
const groups = await this.lookupInvokerGroups(msg.invokerId);
return canRunCommand(commandName, groups, adminGroups);
}
/**
* Best-effort lookup of a sender's server groups by client id, via the
* channel client list (whose entries already carry parsed serverGroups).
* Returns [] when the client can't be found or the query fails (→ deny).
*/
private async lookupInvokerGroups(invokerId: string): Promise<string[]> {
const clid = Number(invokerId);
if (!Number.isFinite(clid) || clid <= 0) return [];
try {
const clients = await this.tsClient.getClientsInChannel();
const match = clients.find((c) => c.id === clid);
return match?.serverGroups ?? [];
} catch {
return [];
}
}
```
- [ ] **Step 4: Run the gate tests to verify they pass**
Run: `npx vitest run "src/bot/instance.test.ts"`
Expected: PASS (existing `runExclusive` tests + the 7 new gate tests).
- [ ] **Step 5: Confirm the live-config invariant**
Confirm `BotInstance` reads `adminGroups` from the shared, mutable config — not a copy. The constructor stores `this.config = options.config` (line 91 region) and the router (`src/web/api/bot.ts`) mutates that same object; no propagation call is needed. Quick check:
Run: `grep -n "this.config = options.config\|this.config.adminGroups" "src/bot/instance.ts"`
Expected: shows the assignment and the gate read (proves the gate uses the live reference).
- [ ] **Step 6: Commit**
```bash
git add "src/bot/instance.ts" "src/bot/instance.test.ts"
git commit -m "feat(bot): gate admin chat commands on adminGroups with fallback + deny reply"
```
---
### Task 4: Read/write `adminGroups` in the settings endpoints
**Files:**
- Modify: `src/web/api/bot.ts` (GET `/settings` lines 35-41; POST `/settings` lines 45-97)
- Test: `src/web/api/bot.test.ts` (append `it` cases to the first `describe("bot router /settings", …)` block)
**Interfaces:**
- Consumes: existing `config.adminGroups: number[]` (already declared in `src/data/config.ts`, default `[]`).
- Produces: `GET /api/bot/settings` returns `adminGroups: number[]`; `POST /api/bot/settings` accepts, validates, persists, and echoes `adminGroups`.
- [ ] **Step 1: Write the failing tests**
Append these `it` cases inside the existing first `describe("bot router /settings", …)` block in `src/web/api/bot.test.ts` (it already wires `app`, `config`, and an admin `cookie`):
```ts
it("GET /settings includes adminGroups reflecting config", async () => {
config.adminGroups = [6, 8];
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings persists a validated adminGroups and GET returns it", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, 8] });
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
expect(config.adminGroups).toEqual([6, 8]);
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings filters invalid adminGroups entries (negative, non-integer, non-number)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, -1, 2.5, "x", 8] });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6, 8]);
});
it("POST /settings ignores a non-array adminGroups (leaves config unchanged)", async () => {
config.adminGroups = [6];
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: "6" });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6]);
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/web/api/bot.test.ts"`
Expected: FAIL — `res.body.adminGroups` is `undefined`; the POST does not persist `adminGroups`.
- [ ] **Step 3: Extend the GET handler**
In `src/web/api/bot.ts`, add `adminGroups` to the GET `/settings` response (the handler at lines 35-41):
```ts
router.get("/settings", requireNotGuest, (_req, res) => {
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
});
```
- [ ] **Step 4: Extend the POST handler**
In the POST `/settings` handler: (a) pull `adminGroups` out of `req.body`; (b) validate + assign before `saveConfig`; (c) echo it in the response. Change the destructuring line (46):
```ts
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body;
```
Add this block just before `saveConfig(configPath, config);` (line 77):
```ts
if (Array.isArray(adminGroups)) {
config.adminGroups = adminGroups.filter(
(g: unknown): g is number =>
typeof g === "number" && Number.isInteger(g) && g >= 0,
);
}
```
Add `adminGroups` to BOTH `res.json({ … })` bodies in this handler (the success response near line 92, and — if present — keep them consistent):
```ts
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `npx vitest run "src/web/api/bot.test.ts"`
Expected: PASS (existing settings/guest-mode tests + the 4 new adminGroups tests).
- [ ] **Step 6: Commit**
```bash
git add "src/web/api/bot.ts" "src/web/api/bot.test.ts"
git commit -m "feat(api): read/write adminGroups in bot settings endpoints"
```
---
### Task 5: Admin-only "命令权限" section in Settings.vue
**Files:**
- Modify: `web/src/views/Settings.vue` (template: add a section after the Guest Mode section, before the Bot Profile section ~line 506; script: add state + handlers near the guest-mode block ~line 1093; hydrate in `loadIdleTimeout` ~line 1024)
**Interfaces:**
- Consumes: `GET /api/bot/settings` → `adminGroups: number[]`; `POST /api/bot/settings` with `{ adminGroups: number[] }` (Task 4). Existing `session.isAdmin.value`.
- Produces: UI only.
- [ ] **Step 1: Add the template section**
In `web/src/views/Settings.vue`, insert this `<section>` immediately AFTER the closing `</section>` of the Guest Mode block (the one whose title is `游客模式`, ends ~line 505) and BEFORE the `<!-- Bot Profile … -->` section:
```html
<!-- Command Permissions (admin only) -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">命令权限</h2>
<p class="profile-section-hint">
限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。
填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。
</p>
<div class="setting-row">
<div class="prefix-input-wrap">
<input v-model="adminGroupsText" class="input input-sm" placeholder="如 6, 8" />
<button class="btn-primary" :disabled="adminGroupsSaving" @click="saveAdminGroups">
{{ adminGroupsSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
</section>
```
- [ ] **Step 2: Add the script state + handlers**
In the `<script setup>` block, add this just after the guest-mode block (after `saveGuestMode` closes, ~line 1093):
```ts
// --- Command permissions (admin only) ---
const adminGroupsText = ref('');
const adminGroupsSaving = ref(false);
function applyAdminGroupsFromServer(groups: unknown) {
if (Array.isArray(groups)) {
adminGroupsText.value = groups.filter((g) => typeof g === 'number').join(', ');
}
}
function parseAdminGroups(text: string): number[] {
return text
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => Number(s))
.filter((n) => Number.isInteger(n) && n >= 0);
}
async function saveAdminGroups() {
adminGroupsSaving.value = true;
try {
const res = await axios.post('/api/bot/settings', { adminGroups: parseAdminGroups(adminGroupsText.value) });
applyAdminGroupsFromServer(res.data?.adminGroups);
} catch { /* ignore */ } finally {
adminGroupsSaving.value = false;
}
}
```
- [ ] **Step 3: Hydrate on load**
In `loadIdleTimeout` (the existing function ~lines 1024-1031), add the hydrate call alongside `applyGuestModeFromServer`:
```ts
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups);
} catch { /* ignore */ }
}
```
- [ ] **Step 4: Type-check the frontend**
Run: `cd "web" && npx vue-tsc --noEmit`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add "web/src/views/Settings.vue"
git commit -m "feat(web): admin-only command-permission (adminGroups) settings section"
```
---
### Task 6: Document the feature in the README
**Files:**
- Modify: `README.md`
**Interfaces:**
- Consumes: nothing (docs).
- Produces: user-facing documentation of the feature + how to find TS server-group IDs.
- [ ] **Step 1: Locate the insertion point**
Run: `grep -n "游客模式\|Guest\|权限\|adminGroups" "README.md"`
Expected: shows the guest-mode / permissions area. Insert the new subsection immediately after the guest-mode documentation block (or, if there is a dedicated permissions/features section, at its end).
- [ ] **Step 2: Add the documentation block**
Insert this markdown at the chosen point:
```markdown
### TeamSpeak 命令权限(管理类命令限制)
默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行:
- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode`
- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放
- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容)
**配置方式**
- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。
- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。
填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限」的提示。
> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。
**如何查看服务器组 ID**
在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。
```
- [ ] **Step 3: Sanity-check the docs render**
Run: `grep -n "命令权限\|adminGroups" "README.md"`
Expected: shows the newly added section.
- [ ] **Step 4: Commit**
```bash
git add "README.md"
git commit -m "docs: document TeamSpeak chat-command permission control"
```
---
## Final verification (after all tasks)
- [ ] Remove stale compiled output, then run the full suite:
```bash
rm -rf dist
npm test
```
Expected: all tests pass (the new `canRunCommand`, `toTS3TextMessage`, gate, and `adminGroups` settings tests included).
- [ ] Full build (backend `tsc` + frontend `vue-tsc` + vite):
```bash
npm run build
```
Expected: SUCCESS (no type errors).
@@ -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
@@ -0,0 +1,125 @@
# Design: FM Bug Fix + Artist Loop + Playlist Fuzzy Search
Date: 2026-04-27
## Overview
Three features for the TeamSpeak Music Bot:
1. New `!artist <name>` command — loop playback filtered by artist
2. Fuzzy playlist name search in existing `!playlist` command
3. Fix `!fm` audio dropout bug (no sound after a few songs but status shows playing)
---
## Feature 1: `!artist` Command
### Behavior
`!artist <歌手名> [-q|-b|-y]` searches for songs by the artist, loads them into the queue, sets the queue mode to `Loop`, and starts playing.
### Flow
1. Parse command with optional platform flags (`-q`, `-b`, `-y`)
2. Call `provider.search(歌手名, 50)` to get up to 50 results
3. Filter results: only keep songs where `song.artist` contains the search query (case-insensitive)
4. If filtered list is empty, fall back to unfiltered search results (up to 20)
5. Clear current queue, add filtered songs, set mode to `Loop`
6. Play first song via `resolveAndPlay`
### Key Decisions
- **Why Loop mode?** The user said "循环播放" (loop playback). After the artist's songs are exhausted, they should restart.
- **Why filter client-side?** The search API doesn't support artist-only filtering. We search broadly then narrow down.
- **Why 50 results?** The default limit is 20, but for prolific artists we want more coverage. 50 balances API response size with coverage.
### Files Changed
- `src/bot/commands.ts`: Register `artist` in PUBLIC_COMMANDS, update help text
- `src/bot/instance.ts`: New `cmdArtist()` method
---
## Feature 2: Playlist Fuzzy Search
### Behavior
`!playlist <name or ID>` now accepts both playlist IDs and playlist names. When the input is not a pure numeric ID, it searches for matching playlists and uses the top result.
### Flow
1. Parse input — if it's a pure numeric ID or contains a URL with an ID, use existing logic
2. Otherwise, call `provider.search(input)` which already returns `playlists[]` in the result
3. Also call `provider.getUserPlaylists()` if the provider supports it (logged-in state)
4. Client-side fuzzy match user playlists: `playlist.name` contains input (case-insensitive)
5. Merge results: public search results first (sorted by API relevance), then user matches
6. Take the first playlist, load its songs, play
### Key Decisions
- **Why public search first?** It's already sorted by relevance from the API. User playlists are a secondary source.
- **Why client-side matching for user playlists?** The `getUserPlaylists()` API returns all user playlists without a search parameter, so we must filter locally.
- **Backward compatibility:** Numeric IDs and URL parsing are unchanged.
### Files Changed
- `src/bot/instance.ts`: Modify `cmdPlaylist()` to add search fallback
- `src/bot/commands.ts`: Update help text
---
## Feature 3: FM Bug Fix
### Root Cause Analysis
The `!fm` bug manifests as: audio stops after a few songs, but `!now` shows a playing song and the song name keeps changing.
`getPersonalFm()` returns only ~3 songs per API call. After those are consumed:
- In `Sequential` mode: `queue.next()` returns null → `player.stop()` is called → playback stops entirely. This does NOT match "歌还在轮播" (songs still rotating).
- In `Loop` mode (if user changed mode): the same 3 songs loop, but URLs may expire, causing silent playback failures.
The most likely scenario for "no audio but status shows playing + song names changing":
1. FM songs have URLs that resolve but don't produce playable audio (copyright/region restrictions)
2. ffmpeg spawns, connects to the URL, gets an HTTP error or silent stream
3. ffmpeg exits quickly (clean exit or error)
4. The frame loop detects ffmpeg gone + buffer empty → emits `trackEnd`
5. `playNext()` advances to the next song
6. This rapid cycle (spawn → fail → advance) makes it appear that songs are "playing and rotating" but with no audio
7. After 3 consecutive ffmpeg spawn failures, `consecutiveFailures >= MAX_CONSECUTIVE_FAILURES` → player refuses to spawn new ffmpeg processes
8. After that, `resolveAndPlay` still sets state via `player.play()` which immediately emits "error" → `playNext()` skips to next → cycle continues with no ffmpeg at all
### Fix Strategy
**Fix 1 — FM auto-refill (primary fix):**
- In `cmdFm()`, set queue mode to `RandomLoop` so the queue never "runs out"
- Add a `refillFm()` method that fetches more FM songs and appends to queue
- Hook into the `trackEnd` flow: when queue has ≤ 2 songs remaining and we're in FM mode, trigger a refill
- Track FM state with a boolean flag `isFmMode` on the instance
**Fix 2 — Reset consecutive failures on successful playback (safety net):**
- Reset `consecutiveFailures` when a track plays successfully for at least N frames (e.g., 50 frames = 1 second)
- This prevents transient URL failures from accumulating toward the hard limit
**Fix 3 — FM refill before queue exhaustion:**
- After `playNext()` successfully starts a song, check if `isFmMode` and `queue.size() - currentIndex <= 2`
- If so, fire an async refill (don't block playback)
### Files Changed
- `src/bot/instance.ts`: Modify `cmdFm()`, add `refillFm()`, add FM state tracking, modify `playNext()` to check for FM refill
- `src/audio/player.ts`: Add `framesPlayed` threshold check to reset `consecutiveFailures`
---
## Implementation Order
1. **FM bug fix** first — it's a bug fix affecting current users
2. **Playlist fuzzy search** — small change, quick win
3. **Artist loop** — new feature, depends on queue/player being stable
---
## Testing
- FM: Verify songs keep playing beyond the initial 3-song batch, verify auto-refill works
- Playlist: Test with numeric ID (backward compat), test with playlist name (fuzzy search)
- Artist: Test with known artist names, test edge case (no results), test with platform flags
@@ -0,0 +1,189 @@
# Multi-Source Tabs for Recommend / User Playlists / Daily Songs
**Date:** 2026-05-06
**Status:** Spec — pending implementation
## Problem
Home 和 Library 页面的"推荐歌单 / 每日推荐 / 我的歌单"这三类内容当前硬编码只走网易云。当用户同时登录了网易云和 QQ 音乐时,无法在 Web UI 上看到 QQ 侧的对应内容、也无法切换查看。
## Goal
在以下 4 个 section 上提供"网易云 / QQ"来源切换 tab,桌面端和移动端均可用:
- `Home.vue` — 推荐歌单
- `Home.vue` — 每日推荐
- `Home.vue` — 我的歌单
- `Library.vue` — 我的歌单
切换为纯前端动作(数据已预先 fetch),无加载闪烁。各 section 的选择独立持久化。
## Out of Scope
- 私人 FM(QQ 无对应概念)
- B 站热门(独立第三来源,不属于网易/QQ 切换语义)
- 最近播放(bot 维度的播放历史,与音乐源无关)
- Library 现有的"我的收藏"段落 —— 当前调用的 `/api/music/user/liked` 端点不存在,是死代码,本次顺手移除
- 登录状态实时同步(用户在 Settings 登录后需手动刷新 Home/Library 才能看到 QQ tab)
- Tab 排序、隐藏、拖动等高级配置
## Non-functional Constraints
- 桌面端(>768px)和移动端(≤768px)布局均可用,tab 与 section title 同行排布;空间不足时允许 flex-wrap
- Tab 触控区域有效高度 ≥36px
- 现有 5 分钟 home data cache 行为保留
- 不引入新的后端端点(后端已通过 `?platform=` 参数支持多源)
## Architecture
### 数据层(`web/src/stores/player.ts`)
字段从单平台改为按 platform 切分:
```ts
// 前
recommendPlaylists: PlaylistItem[]
userPlaylists: PlaylistItem[]
dailySongs: Song[]
// 后
recommendPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
userPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
dailySongs: { netease: Song[]; qq: Song[] }
// 新增
authStatus: { netease: boolean; qq: boolean }
```
`fetchHomeData()` 改写:
1. 并发调用 `/api/auth/status?platform=netease` 与 `?platform=qq`,写入 `authStatus`
2. 网易云的三类数据照常 fetch(推荐歌单匿名可访问;每日推荐和我的歌单需登录,未登录时 API 自然返回空或失败,`Promise.allSettled` 已隔离)
3. QQ 的三类数据**仅在 QQ 登录时** fetch,未登录则为空数组
4. B 站热门保持原样
5. 5 分钟缓存 TTL 不变
### UI 组件
新增 `web/src/components/SourceTabs.vue`:
```vue
<SourceTabs v-model="activeSource" :sources="availableSources" />
```
Props:
- `sources: ('netease' | 'qq')[]` — 由父组件根据 auth 状态过滤后传入
- `modelValue: 'netease' | 'qq'` — v-model 绑定
行为:
- `sources.length < 2` 时组件**自身不渲染**(返回空),父组件无需 v-if 包装
- 文字标签:`{ netease: '网易云', qq: 'QQ' }`
- 视觉:水平排列,激活态用主色(`var(--color-primary)`)下划线 + 加粗,未激活态使用次要文字色
- 紧贴 section-title 右侧,使用 `display: inline-flex`,移动端 padding/font-size 缩小
### 各 section 接入模板
```vue
<section v-if="recommendAvailable.length > 0" class="section">
<h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in store.recommendPlaylists[recommendSource]"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
</RouterLink>
</div>
</section>
```
每个 section 在 `<script setup>` 维护两个值:
- `recommendSource: Ref<'netease' | 'qq'>` — 当前选中
- `recommendAvailable: ComputedRef<('netease' | 'qq')[]>` — 该 section 在当前登录状态下有哪些 source 可选
`recommendAvailable` 计算规则:
| Section | netease 加入条件 | qq 加入条件 |
|---|---|---|
| Home 推荐歌单 | 总是(公开数据) | `authStatus.qq` |
| Home 每日推荐 | `authStatus.netease` | `authStatus.qq` |
| Home 我的歌单 | `authStatus.netease` | `authStatus.qq` |
| Library 我的歌单 | `authStatus.netease` | `authStatus.qq` |
#### "我的歌单"展开按钮兼容
Home 的"我的歌单"现有 `USER_PLAYLIST_LIMIT = 20` 折叠/展开。改造后:
```ts
const visibleUserPlaylists = computed(() => {
const all = store.userPlaylists[userSource.value] ?? [];
return userPlaylistsExpanded.value ? all : all.slice(0, USER_PLAYLIST_LIMIT);
});
```
切换 source 时折叠态保留(不需 reset)。
### 持久化
localStorage 键统一为一个 JSON:
```
key: "source-tabs"
value: {
"home.recommend": "qq",
"home.daily": "netease",
"home.user": "qq",
"library.user": "netease"
}
```
读:组件 mount 时一次性读 + 解析。
写:在 v-model 的 setter 里 `watch` 一次写回。
不存在的键 / 解析失败 / 老用户没这个 key —— 默认值 `"netease"`。
### 边界与回退
| 情况 | 行为 |
|---|---|
| 选的 source 不在 `available` 里(例:选了 QQ,登出后回到页面) | 渲染时 fallback 到 `available[0]`,不修改 localStorage(保留用户偏好,下次登回来仍生效) |
| `recommendPlaylists.qq` 为空数组(API 失败 or 无数据) | tab 仍可切,切过去显示空 grid(无错误提示,符合现有"空数据隐藏 section"语义;section 顶层 v-if 检查的是 `available.length > 0`,单 platform 数据为空不影响 section 显隐) |
| QQ provider 不支持 `getDailyRecommendSongs`(501) | `Promise.allSettled` 已捕获,`dailySongs.qq` 保持 `[]`,等同上一行 |
| 用户在 Settings 登录 QQ 后切回 Home | 不自动 refetch / 不自动出 tab —— 用户需手动刷新页面(保留 5 分钟缓存语义) |
| 网易云和 QQ 都没登录 | `available` 为空时 section 隐藏(沿用现有逻辑) |
| Library "我的收藏" 段落 | 整段移除(含 template、script 中的 `liked` ref、对应 axios 调用) |
## 修改文件清单
新增:
- `web/src/components/SourceTabs.vue` — 共享 tab 组件
修改:
- `web/src/stores/player.ts` — 多平台 state、`authStatus`、`fetchHomeData` 重写
- `web/src/views/Home.vue` — 三个 section 接入 SourceTabs,对应 ref + computed
- `web/src/views/Library.vue` — 我的歌单接入 SourceTabs;移除我的收藏死代码
不改:
- 后端(API 已支持 `?platform=`)
- `Search.vue` / `Playlist.vue` / `Settings.vue` 等其他页面
## 测试方案
- 手动:在两种登录组合下访问 Home 和 Library,验证 tab 显隐、切换、刷新后持久化
- 仅网易登录 → 不显示 tab
- 两边登录 → 显示 tab,切换后刷新页面来源不变
- QQ 登录 → 网易登出 → 网易 tab 消失,若上次选的是网易则 fallback 到 QQ
- 移动端(DevTools 768px 以下):tab 与 section-title 同行不溢出,触控区域可点
- TypeScript 类型检查通过:`npx tsc --noEmit` + `npm run build:web`
- 单元测试:现有 vitest 套件不应回归(store 改动不破坏其他用法)
## 风险
- store 字段类型变更(数组 → 对象)会影响所有读取这三个字段的地方。需 grep 确认没有遗漏的消费者。
- localStorage 解析失败的容错必须周全,避免一次脏数据导致整个页面白屏。
@@ -0,0 +1,226 @@
# History-aware `prev` + "Play Next" Insert
**Date:** 2026-05-06
**Status:** Spec — pending implementation
## Problem
Two queue/playback gaps surfaced in real use:
1. In `PlayMode.Random` and `PlayMode.RandomLoop`, `!prev` does not play the
actually-previously-played song. It just walks `currentIndex - 1` in the
underlying array — but in random modes `currentIndex` jumps non-sequentially,
so the "previous" array slot has no relationship to play history.
2. `!add` / web "添加到队列" appends to the queue tail. There is no way to
say "play this song right after the current one." Users want a "下一首
播放" affordance comparable to Spotify "Add to Queue (next up)" or Apple
Music "Play Next".
## Goals
- `prev` walks back through the actual play history regardless of mode.
- A new "Play Next" path inserts a song at `currentIndex + 1`, available
via web UI button and TS3 chat command.
- Both features are usable on desktop and mobile web.
## Out of Scope
- Forward/redo through prev'd songs (user would need to push next manually,
which picks a fresh random in random modes — acceptable simplification).
- Reordering songs already in the queue ("move to next" inside Queue.vue).
- Persisting play history across bot restarts (in-memory only).
## Non-functional Constraints
- History capped at 50 entries to bound memory.
- `addNext` must keep `playedIndices` and `history` index references valid
after insertion (shift all indices > current by +1).
- New Toast UX from the previous round still applies (failures surface).
- TypeScript and existing test suite must not regress.
## Architecture
### A. History-aware `prev`
**`src/audio/queue.ts`** — `PlayQueue` gains a back-stack:
```ts
private history: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
if (idx < 0) return;
this.history.push(idx);
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
this.history.shift();
}
}
```
Mutators call `pushHistory(this.currentIndex)` **before** changing `currentIndex`:
| Method | History action |
|---|---|
| `play()` | `this.history = []` (fresh playback) |
| `playAt(idx)` | `pushHistory(currentIndex)`, then set `currentIndex = idx` |
| `next()` | `pushHistory(currentIndex)`, then advance per mode |
| `prev()` | **Pop** from history → `currentIndex = popped`. If empty, fall back to existing `currentIndex - 1` (which keeps Sequential's wrap behavior; Random returns null). `prev` itself does NOT push to history. |
| `clear()` | `this.history = []` |
| `setMode(m)` | `this.history = []` (mode change resets context) |
| `remove(idx)` | Drop matching entries from history; shift any entry `> idx` by `-1`. Same logic as the existing `playedIndices` rebuild. |
**`prev()` rewrite:**
```ts
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// History-driven path (preferred when we have one)
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices.add(idx);
return this.songs[idx];
}
// popped index is stale (song removed) — keep popping
}
// Fallback: old index-based prev
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
```
### B. Play Next (insert after current)
**`PlayQueue.addNext(song)`:**
```ts
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
this.songs.push(song);
return;
}
const insertAt = this.currentIndex + 1;
this.songs.splice(insertAt, 0, song);
// Shift any tracked index > currentIndex by +1
const shifted = new Set<number>();
for (const i of this.playedIndices) {
shifted.add(i > this.currentIndex ? i + 1 : i);
}
this.playedIndices = shifted;
this.history = this.history.map((i) => (i > this.currentIndex ? i + 1 : i));
}
```
**Backend endpoint** — `src/web/api/player.ts`:
```
POST /api/player/:botId/play-next-song
body: { song: Song }
```
Behavior:
- If queue is empty or `currentIndex < 0`: `queue.addNext(song)` (which falls
through to plain push), then `queue.play()`, then `resolveAndPlay`. Same
semantics as a successful `/play-song` — message: "正在播放:…"
- Otherwise: `queue.addNext(song)`, no resolveAndPlay. Message: "已加入下一首:…"
- Returns `{ ok: boolean, message: string }` matching the convention
established in the previous round.
**Bot command** — `src/bot/instance.ts`:
Register `!playnext <query>` (alias `!pn`):
- Mirror of `cmdPlay`'s search step
- On match: `queue.addNext(song)`. If no current playback, fall through to
`resolveAndPlay`.
- Reply with `已加入下一首:<name>` or `正在播放:<name>` accordingly
**Frontend store action** — `web/src/stores/player.ts`:
```ts
async playNextSong(song: Song) {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
}
```
**Frontend SongCard** — `web/src/components/SongCard.vue`:
Add a third action button between the existing "play" and "add to queue":
```vue
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<Icon icon="mdi:playlist-play" />
</button>
```
Add `playNext: []` to `defineEmits`.
**Caller updates** — `Home.vue`, `Library.vue`, `Search.vue`, `History.vue`,
`Playlist.vue`: each `<SongCard>` usage adds
`@playNext="store.playNextSong(song)"`.
**Queue.vue** is intentionally **not** updated — clicking "play next" on a
song already in the queue would create a confusing duplicate.
## Edge Cases
| Case | Behavior |
|---|---|
| `prev` with empty history in Sequential mode | Walks `currentIndex - 1`; returns null at index 0 (existing) |
| `prev` with empty history in Random/RandomLoop | Returns null (no past to recover) |
| Repeated `prev` past start of history | Pops what's there, then falls back to index walk; eventually null |
| `addNext` while `currentIndex == -1` (nothing played yet) | Falls through to push; queue.play() will pick it as first |
| `addNext` while playing and queue size = 1 | Inserts at index 1; current index unchanged; next() will advance to it |
| `remove` removes a song whose index is in history | Entry dropped; shifted accordingly |
| Mode switched mid-playback | History cleared (intentional — mode change is a context boundary) |
| `addNext` then `prev` | Inserted song was never played → not in history; prev pops the previously-played song, NOT the just-inserted one |
## Files Touched
- `src/audio/queue.ts` — history field, `pushHistory`, `addNext`, rewritten `prev`, mutator updates
- `src/audio/queue.test.ts` (or add if missing) — unit tests for history behavior + addNext shift logic
- `src/bot/instance.ts` — register `!playnext` / `!pn` command handler
- `src/web/api/player.ts` — new `/play-next-song` route
- `web/src/stores/player.ts` — `playNextSong` action
- `web/src/components/SongCard.vue` — third action button + emit
- `web/src/views/Home.vue` — wire `@playNext`
- `web/src/views/Library.vue` — wire `@playNext`
- `web/src/views/Search.vue` — wire `@playNext`
- `web/src/views/History.vue` — wire `@playNext`
- `web/src/views/Playlist.vue` — wire `@playNext`
## Test Plan
**Unit (vitest, `queue.test.ts`):**
- prev with empty history in Sequential: walks back, null at index 0
- prev with empty history in Random: returns null
- next → next → next → prev pops correctly; prev again pops earlier
- prev after `clear()` returns null (history reset)
- prev after `setMode()` returns null (history reset)
- `remove(idx)` drops from history and shifts entries > idx
- `addNext` while empty: appends
- `addNext` while playing index 2 in a 5-song queue: ends up at index 3, currentIndex still 2, queue size 6
- `addNext` then `next()`: plays the inserted song
- `addNext` shifts existing playedIndices and history correctly
**Integration (manual smoke):**
- Random mode: play 4 songs, hit `prev` 3 times → walks back through history
- Click "下一首播放" on a search result → next song after current is the chosen one
- `!playnext 七里香` → bot replies "已加入下一首:..."; current keeps playing; next song is 七里香
- `!playnext` while idle → starts playing immediately
**Regression:**
- Existing 161 source-tree tests still pass.
- TypeScript `tsc --noEmit` and `npm run build:web` clean.
@@ -0,0 +1,149 @@
# 自定义机器人头像 + 专辑搜索/播放
**Date:** 2026-05-07
**Status:** Spec — pending implementation
**Issue:** [#51](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/51)
## Problem
Issue #51 的两个独立但同源的反馈:
1. **机器人头像无法固定** — 当前 `ProfileConfig.avatarEnabled` 控制是否同步专辑封面,但没有任何"上传一张固定头像"的入口。多 bot 房间里用户依赖头像辨识具体 bot,封面跟着歌变会让识别成本变高。
2. **网页端搜索不能播放整张专辑** — `SearchResult.albums` 类型字段存在但所有 provider 都返回 `[]`,搜索 API `/search/all` 只聚合 `songs`;Search.vue 里也只渲染 SongCard。Netease 后端 `getAlbumSongs` 已实现,唯独缺把搜索/UI 连起来。
两件事独立,分两个 PR;本 spec 同时覆盖两块以保持 #51 的单一 issue 关系。
## Goal
### 自定义头像
- "创建新实例"弹窗里有一个"自定义头像"上传/预览控件(PNG/JPG/WebP,≤200 KB,与 TS3 头像上限一致)
- Settings 已有的"同步头像"那行下面增加同等的"自定义头像"卡片,可以在已存在的 bot 上随时改/删
- 行为矩阵:
| `avatarEnabled` | 有自定义 | 播放时 | 停播时 |
|---|---|---|---|
| true | 是 | 跟当前歌曲封面 | **回到自定义** |
| true | 否 | 跟当前歌曲封面 | 清空(保持现状) |
| false | 是 | 一直显示自定义 | 一直显示自定义 |
| false | 否 | 不主动改 | 不主动改 |
### 专辑搜索
- 搜索结果里能看到"专辑"分区(先支持 Netease + QQ,bilibili/youtube 仍返回 `[]`)
- 点专辑卡片进入详情页 → 看到曲目列表 + 顶部"播放全部 / 加入队列"
## Out of Scope
- 头像格式自动转换(用户传 GIF/BMP 不接受,前端校验拒掉)
- 头像服务端自动 resize(本期保持"上传时校验大小",后期可加 sharp/jimp 但不在本期)
- 专辑搜索的多平台聚合排序(按 `netease → qq` 简单拼接,与现有 `songs` 聚合一致)
- 专辑详情页的"喜欢/收藏"按钮(playlist 详情页本身也没有)
- 专辑作为推荐位(Home 不出现"推荐专辑"这一栏)
- bilibili / youtube 的专辑概念(这两个平台无对应 API)
## Architecture
### 自定义头像
#### 存储
- 文件落地 `data/avatars/<botId>.<ext>`(仿 `data/cookies/<platform>.json`,Docker volume 友好)
- DB schema:`bot_instances` 表新增 `custom_avatar_path` TEXT NULL(存相对路径,如 `avatars/<botId>.png`),通过 `migrateSchema()` 迁移
- 加载时机:`BotProfileManager` 构造时把文件读到内存 `Buffer`,避免每次 stop 都读盘
#### 后端 API
新增 `src/web/api/bot.ts` 里(如不存在则在 `instance.ts` 同源处):
- `POST /api/bot/:id/avatar` (multipart) — 校验大小 ≤200 KB、MIME ∈ {png,jpeg,webp};写盘 + 更新 DB;广播给运行中实例(重新加载 buffer + 立即 `applyIdleAvatar()`)
- `DELETE /api/bot/:id/avatar` — 删盘 + 清 DB;运行中实例切回原 clear 语义
- `GET /api/bot/:id/avatar` — 直接 `res.sendFile`(带强 ETag)供前端预览
#### `BotProfileManager` 改动
新增字段 + 方法:
```ts
private customAvatar: Buffer | null = null;
setCustomAvatar(buf: Buffer | null): void;
private async applyIdleAvatar(gen: number): Promise<void>; // 上传 customAvatar
```
修改:
- `clearAvatar(gen)` → `if (this.customAvatar) { applyIdleAvatar(gen) } else { 当前逻辑 }`
- `onConnect()` 新增:`if (!avatarEnabled && customAvatar) applyIdleAvatar(gen)`
- `setCustomAvatar(buf)`:更新内存 buffer,并触发 `applyIdleAvatar` 一次(仅当当前应该显示 idle avatar 时,即没在播放或 avatarEnabled=false)
#### 前端
新组件 `web/src/components/AvatarUpload.vue`:
- props: `botId?` (上传时空表示走临时 base64 缓存)、`v-model:value`
- 拖拽 / 文件选择 / 预览圆框 / 删除按钮
- 内部 `axios.post('/api/bot/<id>/avatar', formData)` 或在创建表单里把 base64 与表单一同提交
接入点:
- 创建实例弹窗(搜索 `BotEditor.vue` 或类似)—— 表单提交后用返回的 botId 再 POST 头像;或者表单本身保存 base64 等创建完成后由后端解码落盘
- Settings.vue:在 features 列表中插入一行"自定义头像",右侧渲染 `<AvatarUpload :bot-id="botId" />`
### 专辑搜索 / 详情
#### 后端
`src/music/netease.ts` `search()`:
- 多发一个 `cloudsearch?type=10` 请求,把返回的 `result.albums[]` 映射成 `Album[]` 填进 `SearchResult.albums`
- 字段 `id` / `name` / `coverUrl` (`picUrl`) / `artist` (`artists[].name.join(' / ')`)
`src/music/qq.ts` `search()`:
- 在现有 `req_0` 旁增加 `req_album: { module: "music.search.SearchCgiService", method: "DoSearchForQQMusicDesktop", param: { searchid, query, search_type: 8 } }`,映射 `body.album.list[]`
`src/web/api/music.ts` `/search/all`:
- 在响应里增加 `albums` 和 `playlists`,与 `songs` 一同合并
`/album/:id` 已存在,无需改动。
#### 前端
- `web/src/views/Search.vue`:响应 schema 升级为 `{songs, albums, playlists}`;模板加入两个新分区("专辑"、"歌单"),各自一个简单的卡片网格(参考 Home.vue 的 `playlist-grid`)
- 新路由 `/album/:id` → 复用 `Playlist.vue`,把它的 `loadPlaylist()` 重构为根据 `route.path` 决定调 `/playlist/:id` 还是 `/album/:id`,或者新建 `Album.vue` 内部 import 同一个 `<PlaylistDetail />` 子组件
- **方案选择**:拆出 `<PlaylistDetail :endpoint="...">` 组件 + `Album.vue` / `Playlist.vue` 两个薄壳。当前 `Playlist.vue` 内部仅 ~60 行模板,单文件改造比新建 PlaylistDetail 子组件更小,先用最小改动:在 `Playlist.vue` 内根据 `route.meta.kind === 'album'` 切换 endpoint
- 路由:`router/index.ts` 加 `{ path: '/album/:id', component: Playlist, meta: { kind: 'album' } }`
### 拆分
**两个 PR:**
1. `feat(profile): custom bot avatar with idle/playback precedence`
- DB migration + ProfileManager 改动 + 上传 API + AvatarUpload.vue + 接入两个表单
2. `feat(search): album section in search results + album detail playback`
- netease/qq search 扩展 + /search/all + Search.vue 分区 + Playlist.vue 复用为 album
## Testing
### 自定义头像
- 单元:DB 迁移加 `custom_avatar_path` 列幂等;上传 API 校验大小/MIME;ProfileManager.applyIdleAvatar 在 onSongChange(null) 后被调用
- 集成:mock TS3Client 验证 fileTransferInitUpload 收到的 buffer 是 customAvatar
- 手动:本地起 bot 上传一张 png → 检查头像;播一首歌 → 头像切封面;停止 → 头像回到 png;关掉 avatarEnabled 重启 → 头像直接是 png
### 专辑搜索
- 单元:netease/qq `search()` 测试:响应包含 albums 字段,长度 > 0 (mock fixture 必须含 album 段)
- 集成:`/search/all` 响应 schema 包含 `albums`/`playlists`
- 手动:搜"周杰伦" → 看到歌曲 + 专辑 + 歌单三个分区;点专辑 → 详情页 → 播放全部 → 队列加上整张专辑
## Migration
DB 迁移:`bot_instances.custom_avatar_path` TEXT NULL,默认 NULL。已存在 bot 不受影响。
## Open Questions
- TS6 协议路径下 `fileTransferInitUpload` 是否一致?(既有 avatar 流程已经覆盖 TS3 + TS6,本期沿用同一路径,不单独验证)
- 头像超过 200 KB 时前端用 Canvas 自动 resize 还是直接拒?— **决定:拒,错误提示"请压缩到 200KB 以内"**,简单可控
@@ -0,0 +1,360 @@
# WebUI Authentication
**Date:** 2026-05-27
**Status:** Spec — pending implementation
**Branch:** `feat/webui-auth`
## Problem
WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权:
- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。
- 静态前端通过 `express.static()` 直接对外提供。
后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。
## Goal
为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。
## Out of Scope(明确不做)
- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做)
- 角色与权限(admin / viewer)—— 全员同权
- 密码重置流程(不挂邮件;仅提供登录后 `change-password`)
- 双因素认证(2FA)
- "记住我" / 绝对过期 vs 滑动过期的可配置
- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权
- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json`
## Non-functional Constraints
- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。
- Cookie 行为必须兼容现有 `trustProxy` 反代部署。
- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。
- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。
## Architecture
### 数据层(`src/data/`)
扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表:
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- uuid v4
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- sha256(rawToken) hex
userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL, -- ms epoch
lastSeenAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
```
**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。
新模块:
`src/data/users.ts`
- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError`
- `findByUsername(username): User | null`
- `verifyPassword(plain, hash): Promise<boolean>` — bcryptjs compare
- `countUsers(): number` — 用于 `/needs-setup`
- `changePassword(userId, newPassword): void`
`src/data/sessions.ts`
- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256
- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d`
- `deleteSession(rawToken): void` — 退出
- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话
- `cleanupExpired(): void` — 定时任务
### HTTP 层(`src/web/`)
#### 新增中间件
`src/web/middleware/requireAuth.ts`
```
读取 req.cookies.tsmb_session
→ 缺失 → 401 { error: "unauthenticated" }
→ 调 sessions.validateAndTouch
→ null → 清 cookie + 401
→ 有效 → req.user = { id, username }; next()
```
`src/web/middleware/csrf.ts`
```
若 method ∈ {GET, HEAD, OPTIONS} → next()
否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致
→ 不一致或两者都缺失 → 403 { error: "bad origin" }
```
#### 新路由:`src/web/api/session.ts`
挂在 `/api/session`,全部公共(不挂 requireAuth):
| Method | Path | 行为 |
|---|---|---|
| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` |
| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` |
| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) |
| POST | `/logout` | 删 session,清 cookie,204 |
| GET | `/me` | 走 requireAuth;返回 `{ id, username }` |
| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 |
> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。
#### Cookie 规范
- 名称:`tsmb_session`
- 值:32 字节 random → base64url
- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天)
- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃
#### 装配顺序(`src/web/server.ts`)
```ts
app.use(express.json({ limit: "400kb" }));
app.use(cookieParser()); // 新增
// 公共
app.get("/api/health", …);
app.get("/api/config/public-url", …);
app.use("/api/session", createSessionRouter(...));
// 闸门(仅作用于下方注册的 /api/* 路由)
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// 受保护
app.use("/api/bot", createBotRouter(...));
app.use("/api/music", createMusicRouter(...));
app.use("/api/player", createPlayerRouter(...));
app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR
// 静态 SPA(公共,前端自行判定登录态后跳转)
app.use(express.static(staticDir));
app.get(/^(?!\/api|\/ws)/, sendIndex);
```
> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。
#### 定时清理
`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。
### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`)
改造为手动 upgrade:
```ts
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") { socket.destroy(); return; }
const session = validateCookieFromHeaders(req.headers.cookie);
if (!session) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
(ws as any).userId = session.userId;
wss.emit("connection", ws, req);
});
});
```
`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。
不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。
### 前端层(`web/`)
#### 新视图
- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/`
- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/`
- 名称避免与既有 `Setup.vue`(bot 创建向导)冲突
#### Session 状态
新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref<User|null>`、`refresh()`、`logout()`、`needsSetup: Ref<boolean>`。在 `App.vue` mount 时调用 `refresh()`。
#### 路由守卫(`web/src/router/index.ts`)
- 公共路由:`/login`、`/setup`
- 全局 `beforeEach`:
1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存)
2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')`
3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=<path>')`
#### API 客户端
- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出)
- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')`
#### UI
- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`)
- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section)
### 依赖
新增到 `package.json`:
```
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7"
```
不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。
## Data Flow
### 首次启动
```
Browser → GET / → static SPA
SPA mounted → GET /api/session/needs-setup → { needsSetup: true }
SPA → router.replace('/setup')
User submits form → POST /api/session/setup
Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200
SPA → currentUser refresh → router.replace('/')
```
### 已部署用户升级
旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。
### 后续登录
```
SPA → GET /api/session/me → 401
SPA → router.replace('/login?next=/queue')
User submits → POST /api/session/login → Set-Cookie + 200
SPA → currentUser refresh → router.replace('/queue')
```
### 受保护请求
```
SPA → fetch('/api/bot', { credentials: 'same-origin' })
Server requireAuth: validateAndTouch(cookie)
→ ok → req.user 注入 → 业务路由处理
→ 不 ok → 401 → SPA 拦截器跳 /login
```
### WebSocket
```
SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie
Server upgrade handler: validateCookieFromHeaders
→ ok → handleUpgrade → connection event
→ 不 ok → HTTP 401 写回原始 socket → destroy
```
## Error Handling
| 场景 | HTTP 响应 | 备注 |
|---|---|---|
| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth |
| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 |
| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 |
| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 |
| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 |
| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | |
| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | |
| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | |
| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 |
所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。
## Testing Strategy
### 单元(vitest)
`src/data/users.test.ts`
- createUser 成功后 findByUsername 命中(大小写不敏感)
- 重复 username 抛 UsernameTakenError
- verifyPassword 正反例
- changePassword 之后旧哈希不再验证通过
`src/data/sessions.test.ts`
- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token))
- validateAndTouch 过期记录返回 null 且记录被删
- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证)
- deleteAllForUser(exceptToken) 保留指定会话
### 集成(vitest + supertest,真 SQLite in-memory)
`src/web/api/session.test.ts`
- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409
- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401
- /logout 之后同一 cookie 调受保护路由 401
- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用
`src/web/middleware/csrf.test.ts`
- 带匹配 Origin 的 POST 通过
- Origin 与 host 不匹配 → 403
- 同样规则适用 Referer
- GET 永远通过
`src/web/websocket.test.ts`(新增或扩展)
- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭
- 带有效 cookie → 握手成功,收到 init 消息
- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs)
### 前端
不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列):
- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面
- 退出 → 自动跳 /login
- 关闭浏览器 7 天内再开 → 仍登录
- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login
## Files Changed
```
src/data/database.ts (schema migration)
src/data/users.ts (new)
src/data/users.test.ts (new)
src/data/sessions.ts (new)
src/data/sessions.test.ts (new)
src/web/auth/validateSession.ts (new, shared by HTTP + WS)
src/web/middleware/requireAuth.ts (new)
src/web/middleware/csrf.ts (new)
src/web/middleware/csrf.test.ts (new)
src/web/api/session.ts (new)
src/web/api/session.test.ts (new)
src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用)
src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式)
src/web/websocket.test.ts (新增 / 扩展)
package.json (deps)
web/src/views/Login.vue (new)
web/src/views/FirstRunSetup.vue (new)
web/src/composables/useSession.ts (new)
web/src/router/index.ts (公共路由 + beforeEach 守卫)
web/src/api/*.ts (credentials: 'same-origin' + 401 拦截)
web/src/App.vue (顶栏 logout + 当前用户名)
```
## Trade-offs / 已知妥协
1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。
2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。
3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。
4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。
5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。
@@ -0,0 +1,167 @@
# Fine-grained account permissions — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) (item E — the maintainer's permission-management idea)
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Issue #79 bundles five things. This spec covers **only item E**: allow an admin to
grant each non-admin (member) account a set of capabilities and a list of bots they
may control. The other items are handled in separate PRs and are **out of scope**
here:
- #1 Guest mode (login-less playback)
- #2 Dedicated-link hides other bots (subsumed conceptually by E's bot allow-list, but the link-specific UX is separate)
- #3 Auto-pause when channel empty
- #4 Dedicated link loses bot binding on refresh (a bug)
## Problem
Today the bot has a coarse two-role system: `admin | member` (single `role` column,
read live per request). `requireAdmin` gates only `/api/users` and `/api/audit`.
**Every other action — create/edit/delete bots, start/stop, all playback & queue
control, set platform login cookies, set audio quality — is open to any logged-in
member, on every bot.** Admins want to delegate limited control to members without
handing them full power.
## Decisions (from brainstorm)
1. **Model = capability flags + per-member bot allow-list** (not a per-bot×per-action
matrix, not role templates).
2. **Defaults:** on upgrade, existing members are backfilled with full capabilities +
all bots (no behavior change); newly-created members get a **basic tier**.
3. **Bot allow-list semantics:** an explicit "all bots" toggle OR a specific list;
empty list = no bots controllable. Members **cannot see** bots outside their
allow-list (hidden, not merely disabled).
4. **Capability set (5 toggles)** — see below; basic tier = playback + queue + all bots.
5. **Admin is a super-user** (bypasses all checks). The last admin cannot be demoted
(existing invariant preserved). Permission grants/revokes are written to the
existing audit log.
## Capability taxonomy
| Capability token | Covers | Scope |
|---|---|---|
| `player.control` | play/pause/resume/next/prev/stop/seek/volume/mode | per-bot (allow-list) |
| `player.queue` | search-add / clear / remove / play-at / playlist / album / play-song | per-bot (allow-list) |
| `bot.manage` | create / edit / delete / start / stop / avatar / profile / idle settings | global (create) + per-bot (operate a specific bot) |
| `platform.auth` | set NetEase/QQ/Bilibili cookie, QR, SMS | **global** (shared credentials) |
| `quality` | set audio quality per platform | **global** |
Bot scope is independent of capabilities: a member with `player.control` can only
exercise it on bots in their allow-list (or all, if the "all bots" flag is set).
`platform.auth` and `quality` are global capabilities with no bot scope.
**Basic tier** (new members): `{ player.control, player.queue }` + `bots.all = true`.
A new member can play/queue on every bot but cannot manage bots, change credentials,
or change quality.
## Data model (SQLite, additive — follows existing `CREATE TABLE IF NOT EXISTS` pattern)
```sql
-- capability tokens + the "all bots" flag (stored as token 'bots.all')
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
-- specific bot allow-list (only consulted when 'bots.all' is NOT present)
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
```
- Admins have no rows (they bypass). Only members are constrained.
- `bots.all` present ⇒ all bots (incl. future ones). Absent ⇒ only `user_bot_access`
rows; empty ⇒ none.
- `foreign_keys = ON` and WAL are already enabled; cascade-on-user-delete works.
- `user_bot_access.botId` references bot instance ids; when a bot is deleted, its
access rows should be cleaned up (either an FK to the bot table if one exists, or an
explicit cleanup in `BotManager.removeBot` / `PermissionStore.pruneBot(botId)`).
New `PermissionStore` in `src/data/permissions.ts` (mirrors `createUserStore` /
`createSessionStore`: prepared statements + an interface). Methods:
`getCapabilities(userId)`, `getBotAccess(userId)` → `'all' | string[]`,
`setPermissions(userId, { capabilities, bots })`, `pruneBot(botId)`.
## Backend enforcement (real 403 — not just hidden UI)
- **`req.user` widened** to carry `capabilities: Set<string>` and bot access. Loaded in
`requireAuth` (one extra lookup, or a JOIN in the session query). The same
`{id,username,role,capabilities,bots}` shape must be kept in sync in the three places
it is built today: `requireAuth.ts`, `session.ts` `requireAuthInline`, and the WS
upgrade handler in `server.ts` (WS only needs it if a push action becomes gated).
Because it's read live, permission changes take effect immediately (no re-login).
- **`requirePermission(cap)`** middleware (new, mirrors `requireAdmin.ts`): 401 if no
user; allow if `role === 'admin'` or `capabilities.has(cap)`; else 403.
- **`requireBotAccess`** helper: allow if admin or `bots.all` or botId ∈ access list;
else 403. Mounted on the player router's existing `/:botId` choke-point
(`src/web/api/player.ts`) and on each `:id` route in `src/web/api/bot.ts`
(start/stop/edit/delete/avatar/profile).
- **Route → capability mapping:**
- `/api/player/:botId/*` playback actions → `player.control` (+ `requireBotAccess`)
- `/api/player/:botId/*` queue actions → `player.queue` (+ `requireBotAccess`)
- `/api/bot` create, `/api/bot/:id` edit/delete, `/api/bot/:id/start|stop|avatar|profile`, `/api/bot/settings` → `bot.manage` (+ `requireBotAccess` for the `:id` ones)
- `/api/auth/*` (cookie/QR/SMS) → `platform.auth`
- `/api/music/quality` POST → `quality`
- **`GET /api/bot`** filters its result to the caller's allowed bots for members
(admins see all). This is what "hides" disallowed bots in the UI.
## Management API (admin-only, added to the existing users router)
- `GET /api/users/:id/permissions` → `{ capabilities: string[], bots: 'all' | string[] }`
- `PUT /api/users/:id/permissions` → body `{ capabilities, bots }`; validates tokens
against the known set and botIds against existing bots; writes audit
`user.permissions_changed`.
- `GET /api/session/me` is extended to include the **current** user's
`{ capabilities, bots }` so the frontend can gate UI. (admins report effectively-all.)
## Frontend
- `useSession` extends `User` with `capabilities` + bot scope and exposes
`can(cap)` and `canControlBot(botId)` helpers.
- **Navbar bot selector** filters `store.bots` to controllable bots (others hidden);
`activeBot` fallback and `fetchBots` default only ever land on an allowed bot.
- **Player / Settings** hide controls and whole sections a member lacks: platform
login, audio quality, and bot create/edit/delete are hidden without the matching
capability; playback/queue buttons hidden without `player.control` / `player.queue`.
- **Admin permission editor:** in the Settings → User Management list, each member row
gets a "权限" editor — capability checkboxes + a bot allow-list with an "全部机器人"
toggle. Saving calls `PUT /api/users/:id/permissions`.
## Defaults & migration
- New tables created idempotently in `initTables`.
- **One-time backfill** (guarded so it runs once): every existing `member` gets all
five capabilities + `bots.all`. Admins are skipped (they bypass). This preserves
current behavior for existing members on upgrade.
- **New member default** (`POST /api/users` with role member): capabilities
`{ player.control, player.queue }` + `bots.all` (basic tier).
- Pre-existing accounts default to `role = 'admin'` per the current schema — those are
super-users and unaffected.
## Testing (TDD)
- `PermissionStore` unit tests (set/get capabilities + bot access; `'all'` vs list vs
empty; `pruneBot`).
- `requirePermission` / `requireBotAccess` middleware tests (admin bypass; has/lacks
cap → 200/403; bot in/out of allow-list; `bots.all`).
- API tests: member without cap → 403; with cap → 200; bot not allowed → 403/hidden;
`GET /api/bot` filtered for members, full for admin; `PUT .../permissions` validates
+ audits.
- Migration test: existing members backfilled to full + `bots.all`; new member gets
basic tier.
## Non-goals
- No per-bot×per-capability matrix, no custom role templates (YAGNI).
- Guest mode, dedicated-link UX, auto-pause, and the refresh bug (#1–#4) are separate.
- No change to the admin/member role concept itself; this layers capabilities under
the existing `member` role.
@@ -0,0 +1,138 @@
# Auto-pause on empty channel — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
and resume when someone returns.
## Decisions (from brainstorm)
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
enter/leave/move events; keep the existing 30s idle poll as a fallback.
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
emptiness signal but act independently (pause immediately; disconnect after N minutes).
## Current state (verified)
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
bot*; callers compute "others" as `length - 1`. No persistent roster.
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
*logs* `clientEnter` and does not re-emit leave/moved.
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
idle-disconnect after `idleTimeoutMinutes`.
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
auto-vs-user-pause distinction today.
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
## Design
### Occupancy signal (shared)
Extract the idle poller's count into one method on `BotInstance`:
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
auto-pause logic below. It is called by:
1. the existing 30s poll (fallback), and
2. new TS event handlers.
### Event subscription
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
the poll remains as a safety net.
### Auto-pause logic (inside `checkChannelOccupancy`)
Add a private `autoPaused = false` flag to `BotInstance`.
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
scheduled as today.)
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
### `autoPaused` bookkeeping (so user pauses are respected)
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
### Config wiring
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
can be as simple as updating the stored config reference / a field the check reads.
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
it on save.
## Components / files
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
connect/disconnect; `updateAutoPause(enabled)`.
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
- `src/data/config.ts` — field already exists (no change beyond confirming default).
## Testing
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
persists, propagates).
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
## Non-goals
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
- Reaction relies on events the bot can already see (same-channel members are always in view);
no extra channel subscription needed.
---
## Update (2026-06): occupancy is event-driven & server-wide, not channel-filtered
Live testing against a real TS3 server (with `@honeybbq/teamspeak-client` 0.2.2)
invalidated two assumptions above. Recording the corrected model here so nobody
reintroduces the old design:
- **Default is OFF**, not on. See `getDefaultConfig()` in `src/data/config.ts`
and the rationale comment there.
- **Query commands are unusable when others are present.** `clientlist`,
`channellist`, and `channelclientlist` ALL time out (~5–10s) whenever ≥2
clients are connected to the **server** (verified even when the two clients
are in *different* channels). They succeed only when the bot is the sole
client on the whole server. So `getClientsInChannel()` returns `[]` exactly
when occupancy matters, and `occupancyFromClientList(0)` returns `null`
("unknown") so callers skip the decision rather than mis-reading it as empty.
- **PAUSE** therefore only ever fires when the bot becomes alone on the server
(the one state where the query works). This is reliable and stays on the
query path (`refreshOccupancy()` + the 30s idle poller).
- **RESUME** is armed directly from the `clientEnter` push event
(`shouldResumeOnReturn()` + `_resumeIfReturning()` in `instance.ts`), NOT from
a query. Because the bot only auto-pauses while alone, the sole way occupancy
can return while `autoPaused` is set is a fresh connection — delivered as
`clientEnter`. The resume branch never pauses (userCount is always > 0).
- **Net semantics:** "pause when the server is empty (bot alone), resume when
someone connects." Channel granularity is **impossible** with this library:
`clientEnter`'s channel field is always `0` (library reads notify param `cid`
but enter-view carries `ctid`), and `clientMoved` delivery is flaky. Do NOT
attempt to layer `clientMoved.targetChannelID` channel-accuracy on top — it is
systematically wrong for direct-connect clients and reintroduces unreliability.
The correct path to true channel scoping is an upstream library fix.
- **Knock-on:** idle-disconnect shares the same signal and is likewise
server-wide. UI copy in `web/src/views/Settings.vue` was updated to say
"服务器" rather than "频道" to match. `cmdVote` was intentionally left on the
query path (out of scope; switching it would inherit the same timeout).
@@ -0,0 +1,65 @@
# Dedicated-link bot scoping (+ refresh fix) — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
## Decision (from brainstorm: Q2 = URL-carried scope)
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
## Design
### Scope state (store)
Add to the player store:
- `scopedBotId: string | null` — the bot the UI is locked to.
- getter `isScoped` = `scopedBotId !== null`.
- action `setScope(id)` / `clearScope()`.
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
### URL as the durable source of truth
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
- **Router `beforeEach` guard** (the heart of it):
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
- Else → no scope; continue.
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
### Exit
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
### Navbar (the lock UI)
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
- When not scoped: unchanged (full selector over `controllableBots`).
### Active-bot coherence
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
## Components / files
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
## Testing
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
- `clearScope` resets state.
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
## Non-goals
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
@@ -0,0 +1,317 @@
# Guest mode (login-less WebUI access) — design
**Issue:** [#83](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/83) — "请求增加 WebUI 鉴权 guest 登录功能"
**Date:** 2026-06-24
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Add an optional, **default-OFF** guest mode. When an admin enables it, anyone who can
reach the WebUI can enter **without logging in** ("以游客身份进入 / Continue as guest")
and use a restricted subset of playback/queue features. The admin chooses, per
deployment, exactly what guests may do (a set of toggles) and which bot(s) guests may
control. Guests can **never** view or change settings, manage bots, set platform
credentials, change audio quality, or see the user/audit admin panels.
This builds directly on the existing `admin | member` role + capability system
(`src/data/permissions.ts`, `requirePermission`, `useSession().can()`) and the existing
append-vs-play-next queue split (`PlayQueue.add` vs `addNext`). It does **not** rebuild
auth.
The original issue asked specifically that guest song requests go to "下一首" only.
That exact behavior is reproducible in this design by the admin turning the
"add to end" toggle **off** and the "play next" toggle **on** — it is one configuration
of a more general per-ability toggle model (chosen in brainstorm).
## Problem
Today every `/api/*` route past the session router requires a real account
(`requireAuth`). There is no anonymous/guest path: to let a friend queue a song, an
admin must create them a `member` account. The maintainer wants a low-friction,
admin-gated way to let untrusted visitors request music without an account, while
keeping all administration locked down.
## Decisions (from brainstorm)
1. **Guest = no-login.** A guest is an **anonymous, config-driven synthetic principal**
(`role: "guest"`), not a database user with a password. No favorites, no
change-password, short-lived session.
2. **Default OFF**, enforced **server-side** (the guest-session endpoint rejects when
the flag is off — never rely on hiding the button).
3. **Per-ability toggles**, not a single "mode". Every song action and control action is
its own admin switch. Default state when guest mode is first enabled: only
"add to end of queue" is ON; everything else OFF.
4. **Per-bot guest scope** (`"all"` or an explicit bot list), mirroring the existing
member bot allow-list. Guests cannot see or control out-of-scope bots — including
over WebSocket.
5. **Settings are always hidden AND server-blocked for guests** (view + change), closing
the two currently-ungated reads (`GET /api/bot/settings`, `GET /api/music/quality`).
6. **"Play now" for guests is non-destructive**: insert-next + skip to it, **never** the
existing clear-the-whole-queue `/play-song` behavior.
7. **One unified authorization gate** encapsulates admin/member/guest logic so the
existing member/admin capability system is left behavior-unchanged.
## Guest ability model
### Always allowed (baseline read-only — the point of the feature)
- Browse/search library, playlists, history, song detail, lyrics, cover art.
- See now-playing and the live queue (REST + WebSocket), **scoped to allowed bots**.
### Always denied (hard locks — not toggles)
- View **or** change any settings (idle timeout, auto-pause, theme persistence server-side, command prefix, etc.).
- Bot management (create/edit/delete/start/stop, bot config, avatar, profile).
- Music-platform login (`/api/auth/*`), audio quality (`/api/music/quality`).
- User management (`/api/users`), audit log (`/api/audit`), change-password.
### Admin-configurable toggles (`guestMode.permissions.*`, all default `false` except `addToQueue`)
| Flag | 中文 | Default | Backend route(s) gated |
|---|---|---|---|
| `addToQueue` | 添加到队列末尾 | **true** | `POST /:botId/add`, `/add-song`, `/add-by-id` |
| `playNext` | 添加到下一首 | false | `POST /:botId/play-next-song` |
| `playNow` | 立即播放(不清空队列) | false | new guest-safe play-now (insert-next + skip) |
| `skip` | 跳过当前歌曲 | false | `POST /:botId/next` |
| `transport` | 暂停/继续/进度/音量 | false | `POST /:botId/pause`, `/resume`, `/seek`, `/volume` |
| `removeClear` | 移除/清空队列 | false | `DELETE /:botId/queue/:index`, `POST /:botId/clear` |
| `playMode` | 切换播放模式 / FM | false | `POST /:botId/mode`, `/fm` |
Notes:
- Routes with **no** guest flag (e.g. `/prev`, `/stop`, `/play-song`, `/play-playlist`,
`/play-album`, `/play-at`, all of `/api/bot/*`, `/api/auth/*`, settings, users, audit)
are **never** reachable by guests — the gate denies any guest without an explicit flag.
This is the safe default: new routes are guest-denied unless deliberately opted in.
- `/play-song`, `/play-playlist`, `/play-album` call `queue.clear()` and must stay
guest-denied regardless of toggles (they would wipe everyone's queue).
## Config schema (`src/data/config.ts`)
```ts
export interface GuestPermissions {
addToQueue: boolean; // append to end
playNext: boolean; // 下一首 (insert after current)
playNow: boolean; // 立即播放: insert-next + skip-to-it (non-destructive)
skip: boolean; // skip current track
transport: boolean; // pause/resume/seek/volume
removeClear: boolean; // remove a queue item / clear the queue
playMode: boolean; // play mode (shuffle/repeat) + FM
}
export interface GuestModeConfig {
enabled: boolean; // master switch, default false
bots: "all" | string[]; // per-bot scope (botIds); default "all"
permissions: GuestPermissions;
}
// added to BotConfig:
guestMode: GuestModeConfig;
```
`getDefaultConfig()` returns:
```ts
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
},
}
```
**Merge hardening:** `loadConfig` currently does a shallow `{...defaults, ...partial}`,
which would drop `guestMode` sub-keys if a saved config only contains a partial
`guestMode`. `loadConfig` must **deep-merge `guestMode`** (and its `permissions`) over
the defaults so missing sub-keys are back-filled. Covered by a `config.test.ts` case.
**Bot deletion:** when a bot is removed, prune its id from `guestMode.bots` (if it's an
array) and persist — mirrors `PermissionStore.pruneBot(botId)` for members. Done in the
same `BotManager.removeBot` path that already prunes member access.
## Backend design
### Synthetic guest principal & session entry
- **Role union widened** to `"admin" | "member" | "guest"` (`UserRole` in
`src/data/users.ts`, the `req.user` augmentation in `requireAuth.ts`, the frontend
`User` type, and the role badge in Navbar).
- **Reserved guest user row.** A single fixed row (e.g. id `"__guest__"`, role `"guest"`,
an unusable password hash, username e.g. `"guest"`) is created idempotently by
migration. It exists only to satisfy the `sessions.userId` FK and the
`validateAndTouch` JOIN; it is excluded from user-management listings and the
last-admin guards (those count `role = 'admin'` only, so guests don't interfere).
- **Guest login endpoint:** `POST /api/session/guest`, mounted in the **public** block
(before `csrfOriginCheck`/`requireAuth`, like `/login` and `/setup`), rate-limited.
- If `config.guestMode.enabled` is false → `403 guest mode disabled`.
- Else `sessions.createSession("__guest__")` and set the same `tsmb_session` httpOnly
cookie. **Guest sessions use a short TTL** (e.g. `GUEST_SESSION_TTL_MS`, ~24h) and
**bypass `MAX_SESSIONS_PER_USER`** for the guest principal (otherwise guest #11
would evict guest #1). Expired guest sessions are already deleted on validation; an
optional periodic sweep can prune stale ones.
- **Disable = logout.** In `createRequireAuth`/`validateSession`, if a validated session
has `role === "guest"` but `config.guestMode.enabled` is now false, treat it as
unauthenticated (401). So flipping guest mode off immediately ends guest access.
- **Expose availability:** extend `GET /api/session/needs-setup` (or add a sibling
`GET /api/session/guest-config`) to return `guestAllowed: boolean` so the **public**
Login page can decide whether to show the guest button. This must not leak any other
config.
### Permission resolution
`resolvePermissionContext` gains a `guest` branch. Signature extended to receive the
live guest config:
```ts
resolvePermissionContext(role, userId, store, guestConfig?) => {
admin → { capabilities: all CAPABILITIES, bots: "all" }
member → stored caps + stored bots // unchanged
guest → {
capabilities: new Set(), // holds NO member capabilities
bots: guestConfig.bots === "all" ? "all" : new Set(guestConfig.bots),
guest: guestConfig.permissions, // resolved per-request from live config
}
}
```
`PermissionContext` and `req.user` gain an optional `guest?: GuestPermissions`. Because
`req.user` is rebuilt per request, toggling a permission or the bot scope takes effect on
the guest's next request (no re-login).
### Unified authorization gate (`src/web/middleware/authorize.ts`, new)
Replaces `requirePermission('x')` on **guest-reachable** routes:
```ts
authorize({ capability?: Capability, guestFlag?: keyof GuestPermissions })
// 401 if no req.user
// admin → next()
// guest → (req.user.guest?.[guestFlag] === true) ? next() : 403 // also 403 if no guestFlag
// member → (capability && req.user.capabilities.has(capability)) ? next() : 403
```
- Member/admin semantics are **identical** to today's `requirePermission`.
- A route with no `guestFlag` is automatically guest-denied (safe default).
- `requireBotAccess` is unchanged and already enforces the guest `bots` scope (guests
flow through `req.user.bots`).
- `requireAdmin` is unchanged (guests are non-admin → 403), so `/api/users` and
`/api/audit` stay locked.
### Route changes (`src/web/api/player.ts`, `bot.ts`, `music.ts`)
- Re-express guest-reachable player routes via `authorize({ capability, guestFlag })`:
- `/add`, `/add-song`, `/add-by-id` → `{ capability: "player.queue", guestFlag: "addToQueue" }`
- `/play-next-song` → `{ capability: "player.control", guestFlag: "playNext" }`
(members keep `player.control`; guests pass only via `playNext`)
- new guest-safe **play-now** → `{ capability: "player.control", guestFlag: "playNow" }`
- `/next` → `{ capability: "player.control", guestFlag: "skip" }`
- `/pause`,`/resume`,`/seek`,`/volume` → `{ capability: "player.control", guestFlag: "transport" }`
- `DELETE /queue/:index`, `/clear` → `{ capability: "player.queue", guestFlag: "removeClear" }`
- `/mode`, `/fm` → `{ capability: "player.control", guestFlag: "playMode" }`
- everything else stays `authorize({ capability })` (no guest flag) → guest-denied.
- **Guest-safe play-now**: a new behavior (own route, e.g. `POST /:botId/play-now`, or a
`mode:"now"` branch) that does `queue.addNext(song)` then advances to it (skip into the
inserted track) — **no `queue.clear()`**. Members/admins may also use it; the existing
destructive `/play-song` stays for the normal ▶ in non-guest UI. Exact wiring decided
in the plan.
- **Close ungated reads against guests:** `GET /api/bot/settings` and
`GET /api/music/quality` currently have no guard, so a guest could read config. Add a
small `requireNotGuest` guard (allow `admin` + `member`, deny `guest` → 403). This
**does not change member/admin behavior** — members keep their current read access; only
guests are newly denied. (Deliberately not a new member capability, to avoid touching
member semantics.)
### WebSocket (`src/web/websocket.ts`, `src/web/server.ts`)
- Guests authenticate over the WS upgrade unchanged (session cookie).
- **Add per-client bot-scope filtering** for guests: the upgrade handler already stamps
`ws.userId`; also resolve and stamp the client's bot scope (`"all"` or a Set). In
`setupWebSocket`, when sending `init` and broadcasting `stateChange` /
`botConnected/Disconnected/Removed`, **filter to bots the client may see**. For guests
with a scoped `bots` list, out-of-scope bots are omitted. Admin/member payloads are
unchanged (they resolve to `"all"` or their existing member scope — to avoid changing
member behavior, filtering may be applied **only when the client is a guest**; decided
in the plan).
### Settings write (`POST /api/bot/settings`)
Extend the existing settings writer (today only idle-timeout + auto-pause) to also accept
and persist the `guestMode` block (admin-only via `bot.manage`/`requireAdmin`), calling
`saveConfig`. Live effect: subsequent guest requests read the updated in-memory config.
## Frontend design
- **`useSession.ts`**: extend `User` with `role:'guest'` and a `guest?: GuestPermissions`
field (from `/api/session/me`). Add `isGuest` computed and `guestCan(flag)`; make `can`
guest-aware where it maps cleanly, but UI gating for guest-specific actions uses
`guestCan('addToQueue' | 'playNext' | ...)`. `canControlBot` already enforces the bot
scope and works for guests via the `bots` field.
- **Login page (`Login.vue`)**: when `guestAllowed`, show a prominent
"以游客身份进入 / Continue as guest" button calling a new `session.continueAsGuest()`
→ `POST /api/session/guest` → refresh → redirect to `?next` or home.
- **Router (`web/src/router/index.ts`)**: in the global `beforeEach`, block guests from
`/settings` and `/setup` (redirect to home). Default-off ⇒ when not a guest, behavior
is unchanged.
- **Navbar (`Navbar.vue`)**: hide the settings cog for guests; add a `游客` role badge
branch; the bot selector already filters via `canControlBot`, so scoped guests only see
allowed bots.
- **App shell (`App.vue`)**: hide the mobile `/settings` tab for guests; the mini-player
transport reduces to the guest's allowed actions.
- **SongCard / Queue / Player**: gate each action button by the matching `guestCan(flag)`
(e.g. show ▶/下一首/添加 per `playNow`/`playNext`/`addToQueue`; show skip/transport/
remove/clear/mode per their flags). Buttons a guest lacks are hidden, mirroring how
`Queue.vue` already gates on `can('player.queue')` / `can('player.control')`.
- **Settings → Guest mode admin section (`Settings.vue`)**: new admin-only panel: a
master enable switch, the 7 permission checkboxes (with 中文 labels), and a bot scope
control (an "全部机器人 / all bots" toggle + per-bot checkboxes) reusing the existing
member permission-editor bot allow-list UI. Saving calls `POST /api/bot/settings` with
the `guestMode` block.
## Defaults, migration & backward-compat
- `getDefaultConfig().guestMode.enabled = false` ⇒ **no behavior change** on upgrade;
existing installs see nothing until an admin opts in.
- Migration adds the reserved `__guest__` user row idempotently (guarded like the
existing `backfillMemberPermissions` `schema_meta` marker) and does **not** grant it
any `user_permissions` (guest authorization is config-driven, not row-driven).
- `loadConfig` deep-merges `guestMode` so older config files gain the new block with
defaults.
- Member/admin flows, capabilities, and the backfill are untouched.
## Testing (TDD)
- **Config**: `getDefaultConfig` includes `guestMode` default-off; `loadConfig`
deep-merges a partial `guestMode` (missing sub-keys back-filled); round-trips through
`saveConfig`.
- **`resolvePermissionContext` guest branch**: empty member capabilities; `bots` `"all"`
vs scoped Set; `guest` permissions object passthrough.
- **`authorize` gate**: admin bypass; member has/lacks capability → 200/403 (regression
parity with `requirePermission`); guest allowed only when the specific flag is true;
guest with no flag on a route → 403; guest on settings reads → 403.
- **Enforcement (mirror `permissions-enforcement.test.ts`)**: each toggle independently
opens exactly its route(s) for a guest and nothing else; `/play-song`/`/play-playlist`/
`/play-album` always 403 for guests; per-bot scope: guest 403 on out-of-scope `:botId`.
- **Session entry**: `POST /api/session/guest` → 403 when disabled, mints guest session
when enabled; guest session bypasses `MAX_SESSIONS_PER_USER`; disabling guest mode
invalidates existing guest sessions (401); guest TTL shorter than member TTL.
- **WS scope**: guest receives only in-scope bots' `init`/`stateChange`; reject upgrade
unchanged for no cookie.
- **Frontend** (where covered): `guestCan` gating; router blocks `/settings` for guests.
## Non-goals (YAGNI)
- No guest accounts/usernames, passwords, favorites, or persistence per guest.
- No per-guest individual identity or rate-limiting beyond the existing IP rate limits
(a basic abuse guard on `/api/session/guest` is in; richer abuse controls are future).
- No change to the `admin | member` capability semantics; guest is an additive,
config-driven third principal.
- No chat-command (TeamSpeak `!add`/`!playnext`) changes — guest mode is **WebUI-only**
(the issue is explicitly about WebUI 鉴权).
- Per-guest bot scoping beyond a single shared guest scope is out of scope (one guest
scope applies to all guests).
## Key files touched
Backend: `src/data/config.ts` (+test), `src/data/permissions.ts` (+test),
`src/data/users.ts` (role union, reserved guest row), `src/data/database.ts` (migration),
`src/data/sessions.ts` (guest TTL + cap bypass), `src/web/middleware/authorize.ts` (new,
+test), `src/web/middleware/requireNotGuest.ts` (new, small — for the config reads),
`src/web/api/session.ts` (guest endpoint, `/me`, `needs-setup`),
`src/web/api/player.ts` (re-gate + guest play-now), `src/web/api/bot.ts` (settings
read-lock + guestMode write), `src/web/api/music.ts` (quality read-lock),
`src/web/server.ts` + `src/web/websocket.ts` (WS scope), `src/web/auth/validateSession.ts`
(guest disable→401), enforcement tests.
Frontend: `web/src/composables/useSession.ts`, `web/src/views/Login.vue`,
`web/src/router/index.ts`, `web/src/components/Navbar.vue`, `web/src/App.vue`,
`web/src/components/SongCard.vue`, `web/src/components/Queue.vue`,
`web/src/components/Player.vue`, `web/src/views/Settings.vue`,
`web/src/stores/player.ts`.
@@ -0,0 +1,116 @@
# TeamSpeak chat-command permission control — design
**Origin:** User request — "给 ts 命令也加上权限控制" (give the TS chat commands permission control too, like the WebUI already has). Completes the unused `adminGroups` scaffold the original authors left behind.
**Date:** 2026-06-25
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Add permission control to **TeamSpeak chat commands** (`!play`, `!add`, `!stop`, …). Today any client in a channel with the bot can run any command; only the WebUI path is permission-gated. This adds a **binary admin gate** keyed on the sender's **TS server groups**: a fixed set of "admin" commands may be restricted to members of configured admin server-groups, while all other commands stay public. Enforcement is **opt-in and backward-compatible** — it activates only once an admin lists their server-group ID(s).
The privileged server-groups are configured in `config.adminGroups` (already declared, currently unused) and become editable from the WebUI.
## Problem
`src/bot/commands.ts` already declares `PUBLIC_COMMANDS` / `ADMIN_COMMANDS` sets and an `isAdminCommand()` helper, and `src/bot/instance.ts:325` has the stub `// TODO: Check if invoker is in adminGroups` — but none of it gates anything. `config.adminGroups: number[]` (`src/data/config.ts:21,46`) is documented as a legacy placeholder and read nowhere. So chat commands are unauthenticated: anyone can `!stop`, `!clear`, `!remove`, move the bot, change volume/mode. The WebUI, by contrast, gates everything via `authorize()` at the HTTP layer.
`executeCommand` (`instance.ts:351`) is **shared** by the chat handler and the WebUI player router; the WebUI gates at the HTTP layer, so the chat gate must live in the **chat handler**, never inside `executeCommand` (else the already-gated WebUI would be double-gated).
## Decisions (from brainstorm)
1. **Binary admin gate**, not per-group capabilities and not a whole-bot allowlist. Reuses the existing `adminGroups` scaffold.
2. **Admin command set (fixed, one source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. The set lives in one constant so reclassifying a command is a one-line change.
3. **Default = open / opt-in (backward-compatible):** when `config.adminGroups` is empty (the default), there is **no enforcement** — admin commands stay open to everyone, exactly as today. Enforcement turns on only when `adminGroups` is non-empty.
4. **Identity key = TS server groups**, matched against `adminGroups`.
5. **Fail-closed on undeterminable groups:** if an admin command arrives, enforcement is on, and the sender's groups cannot be determined (even after a fallback lookup), **deny**.
6. **Reply on deny:** the bot sends the sender a brief permission-denied message (silent denial is confusing; the bot already replies to commands).
7. **Config surface:** `adminGroups` becomes editable from an admin-only WebUI Settings section, live-applied via the existing `/api/bot/settings` endpoint; `config.json` continues to work.
## Permission model
Tier definitions live in `src/bot/commands.ts` (repurpose the existing dead sets; the admin set is the source of truth):
- **Admin commands:** `stop`, `clear`, `remove`, `move`, `vol`, `mode`.
- **Public commands:** all others (`play`, `add`, `playnext`/`pn`, `skip`/`next`, `prev`, `pause`, `resume`, `now`, `queue`/`list`, `lyrics`, `vote`, `help`, `search`/`find`, `playlist`, `album`, `artist`, `fm`).
**Enforcement rule** — a command is **allowed** iff:
1. it is a public command, **OR**
2. `config.adminGroups` is empty (enforcement off), **OR**
3. the sender's server groups ∩ `config.adminGroups` ≠ ∅.
Otherwise it is **denied** (no execution; a denial reply is sent).
Expressed as a pure, unit-testable helper (no TS/async dependency):
```ts
// returns true = allowed, false = denied
function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[]
): boolean
```
- not an admin command → `true`.
- admin command, `adminGroups.length === 0` → `true` (enforcement off).
- admin command, non-empty `adminGroups` → `true` iff any `invokerGroups` value (normalized to number/string consistently) is in `adminGroups`, else `false`.
> Note: `invokerGroups` from TS are strings; `adminGroups` are numbers. Normalize both sides (compare as the same type) to avoid `"6" !== 6` bugs.
## Identity resolution
The TS library already delivers the sender's server groups on each chat event (`TextMessage.invokerGroups: string[]` in `@honeybbq/teamspeak-client`), but the wrapper type `TS3TextMessage` (`src/ts-protocol/client.ts:58-64`) and its mapping (`client.ts:205-214`) **drop** it.
Changes:
1. Add `invokerGroups: string[]` to `TS3TextMessage` and populate it from `msg.invokerGroups` in the mapping.
2. **Availability caveat:** `invokerGroups` is populated only when the sender's client is in the bot's local cache (typically same channel / in view). For a private message from an unseen client, it is `[]`.
3. **Fallback lookup (only when needed):** in the gate, if the command is admin-gated **and** enforcement is on **and** `invokerGroups` is empty, perform a targeted lookup of the sender's groups keyed on `invokerId` (clid) — reuse the already-wrapped `getClientsInChannel()` (`client.ts:314-323`, whose `ClientInfo` carries `serverGroups`), or add a thin wrapper around the library's `getClientInfo(client, clid)` for a precise `clientinfo` query. This query is skipped entirely for public commands, when enforcement is off, and when the event already carried groups (the common "listener in the channel types `!stop`" case).
4. **Fail-closed:** if after the fallback the groups are still unknown, deny the admin command.
## Enforcement seam
In `handleTextMessage` (`src/bot/instance.ts:317`), replace the dead stub at `instance.ts:325-327` with the real check, placed after `parseCommand` succeeds and **before** `executeCommand` (`instance.ts:335`):
- compute `allowed` via `canRunCommand(parsed.name, msg.invokerGroups, this.config.adminGroups)`, performing the async fallback lookup only when the synchronous check is "deny due to empty groups on an admin command with enforcement on";
- if denied → send the denial reply to `msg` (respecting its `targetMode`/sender) and return without executing;
- if allowed → `executeCommand(parsed, msg)` as today.
`executeCommand` stays permission-agnostic, so the WebUI path is unaffected.
**Live config:** `BotInstance` already holds the shared `config` object by reference (passed through `BotInstanceOptions`); `POST /api/bot/settings` mutates that same object in place, so reading `this.config.adminGroups` in the gate reflects edits immediately — no restart, no re-wiring. (Implementation must confirm the instance reads `adminGroups` from the live `config` reference, not a copied-at-construction value.)
## Denied UX
The bot replies to the sender with a short bilingual-ish message, e.g. `⛔ 需要管理员权限(该命令仅限管理员服务器组)`, via the same reply mechanism the command handlers already use, honoring the message's `targetMode` (private vs channel). No execution occurs.
## Config surface
**Backend** (`src/web/api/bot.ts`): extend the existing settings endpoints (already admin-gated: `GET` behind `requireNotGuest`, `POST` behind `requirePermission("bot.manage")`):
- `GET /api/bot/settings` → also return `adminGroups: number[]`.
- `POST /api/bot/settings` → also accept `adminGroups`; validate it is an array of non-negative integers (filter/reject otherwise), assign to `config.adminGroups`, `saveConfig`. Reuses the in-place-mutation + `saveConfig` pattern already used for idle-timeout/auto-pause/guestMode, so it is live-applied.
**Frontend** (`web/src/views/Settings.vue`): a new admin-only section **"命令权限 / Command permissions"** (`v-if="session.isAdmin.value"`), mirroring the idle-timeout/guest-mode sections:
- a text input for comma-separated server-group IDs (parsed to `number[]`, ignoring blanks/non-numbers), a Save button calling `POST /api/bot/settings`, hydrated by the existing `loadIdleTimeout()` GET;
- hint: "仅这些组可运行 stop/clear/remove/move/vol/mode;留空 = 不限制(所有人可用)。如何查看服务器组 ID 见 README。"
**`config.json`**: `adminGroups` continues to work for file-based config.
## Testing
- **`canRunCommand` unit tests** (`src/bot/commands.test.ts` or a new file): public command always allowed; admin command with empty `adminGroups` allowed; admin command with a matching group allowed; admin command with no matching group denied; string-vs-number normalization (`["6"]` matches `[6]`).
- **Handler gate tests:** a denied admin command does NOT call `executeCommand` and triggers a denial reply; an allowed admin command (matching group) and any public command DO call `executeCommand`. (Use a fake `msg` + a `config` with `adminGroups` set; stub the reply + `executeCommand`.)
- **Fallback path:** admin command with empty `invokerGroups` + enforcement on triggers the group lookup; if the lookup yields a matching group → allowed; if it yields nothing → denied (fail-closed).
- **Settings round-trip** (`src/web/api/bot.test.ts`): `POST /api/bot/settings` persists a validated `adminGroups`; `GET` returns it; invalid values (non-array, negative, non-integer) are rejected/filtered.
- **Frontend:** `vue-tsc --noEmit` clean.
## Non-goals (YAGNI)
- No per-group capability map and no whole-bot allowlist (binary admin gate only).
- No per-command customization of the admin/public split in the UI (the set is a code constant; reclassifying is a one-line edit).
- No server-group picker UI (admin types IDs; a picker that lists the bot's visible groups is a possible future enhancement).
- No new chat *management* commands.
- No change to the WebUI authorization model or `executeCommand` semantics.
## Key files touched
Backend: `src/bot/commands.ts` (admin-set constant + `canRunCommand` helper, repurpose the dead sets; +test), `src/bot/instance.ts` (gate in `handleTextMessage`, denial reply, live `adminGroups`), `src/ts-protocol/client.ts` (surface `invokerGroups` on `TS3TextMessage`; possibly a `getClientInfo` wrapper for the fallback), `src/web/api/bot.ts` (settings read/write `adminGroups`; +test). Possibly `src/data/config.ts` (no schema change; `adminGroups` already exists).
Frontend: `web/src/views/Settings.vue` (admin-only 命令权限 section).
Docs: `README.md` (document the feature + how to find TS server-group IDs).
+565 -349
View File
File diff suppressed because it is too large. Load diff
+10 -3
View File
@@ -15,12 +15,14 @@
"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",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"koa": "^3.2.0",
@@ -30,13 +32,18 @@
"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/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
+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
+17 -6
View File
@@ -1,14 +1,16 @@
# TSMusicBot — Docker Compose
# 一键部署:docker-compose up -d
# 一键部署:docker-compose pull && docker-compose up -d
#
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)
# 无需安装任何额外软件
# 默认从 GitHub Container Registry 拉取预构建镜像(amd64 + arm64),
# 无需本地编译,无需 Node.js / 构建工具链。
# 镜像内置:Node.js, FFmpeg, Opus 编码器,原生模块均已交叉编译。
#
# 如需指定版本,把 :latest 换成具体 tag(例如 :1.4.0)。
# 如需本地构建(开发或 fork),见底部注释。
services:
tsmusicbot:
build:
context: ../..
dockerfile: scripts/docker/Dockerfile
image: ghcr.io/zhangtianyao1/teamspeak-music-bot:latest
container_name: tsmusicbot
# Use host network so the bot can reach TS3 server on LAN
# If your TS3 server is on the same machine, this is required
@@ -27,3 +29,12 @@ services:
volumes:
tsmusicbot-data:
driver: local
# ------------------------------------------------------------------
# 本地构建(仅开发 / fork 场景使用):
# 把上面的 `image:` 行删掉,换成下面的 build 块即可。
#
# build:
# context: ../..
# dockerfile: scripts/docker/Dockerfile
# ------------------------------------------------------------------
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
* Called by setup.bat after npm install --ignore-scripts.
*
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { get } from "node:https";
import { Readable } from "node:stream";
import { execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
function download(url) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 120000 }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
});
}
function log(msg) {
console.log(` [binary] ${msg}`);
}
function isValidSize(filePath, minBytes) {
try { return statSync(filePath).size >= minBytes; } catch { return false; }
}
async function downloadFfmpeg() {
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
const ffDest = join(ffDir, ffName);
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
if (existsSync(ffDest)) {
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
log("ffmpeg already exists, skipping");
return true;
}
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
log("Downloading ffmpeg...");
const buf = await download(url);
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
try { execSync(`chmod +x "${ffDest}"`); } catch {}
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
log(`ffmpeg OK (${size} MB)`);
return true;
}
async function downloadOpus() {
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
if (existsSync(opusDest)) {
if (isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus already exists, skipping");
return true;
}
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
log("Downloading @discordjs/opus...");
try {
const buf = await download(url);
mkdirSync(dirname(opusDest), { recursive: true });
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
log("@discordjs/opus OK");
return true;
} catch (err) {
log(`CDN download failed (${err.message}), trying to build from source...`);
try {
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus built from source OK");
return true;
}
log("Source build completed but .node file not found");
return false;
} catch (buildErr) {
log(`Source build failed: ${buildErr.message}`);
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
return false;
}
}
}
async function downloadBetterSqlite3() {
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
if (existsSync(dest)) {
if (isValidSize(dest, 500 * 1024)) {
log("better-sqlite3 already exists, skipping");
return true;
}
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
}
const version = "12.8.0";
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
log("Downloading better-sqlite3...");
const buf = await download(url);
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
mkdirSync(dirname(dest), { recursive: true });
await tar.extract({ cwd: pkgDir, file: tmpFile });
if (existsSync(dest)) {
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
return true;
}
log("better-sqlite3 extracted but .node file not found at expected path");
return false;
}
try {
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
if (results.some(Boolean)) {
console.log(" [binary] All downloads complete");
}
} catch (e) {
console.error(` [binary] ERROR: ${e.message}`);
process.exit(1);
}
+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()
+294 -91
View File
@@ -1,91 +1,294 @@
@echo off
title TSMusicBot Setup
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo ============================================
echo.
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: ---- Step 1: Check / install Node.js ----
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js not found. Attempting automatic installation...
echo.
:: Try winget first (available on Windows 10 1709+ and Windows 11)
where winget >nul 2>&1
if %errorlevel% equ 0 (
echo Installing Node.js via winget...
winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
if %errorlevel% neq 0 (
echo winget installation failed. Please install Node.js manually from https://nodejs.org
pause
exit /b 1
)
:: Refresh PATH so node is available in this session
call refreshenv >nul 2>&1
:: If refreshenv is not available, ask user to restart
where node >nul 2>&1
if %errorlevel% neq 0 (
echo.
echo Node.js was installed but is not yet available in this terminal.
echo Please close this window and run setup.bat again.
pause
exit /b 0
)
) else (
echo winget is not available on this system.
echo Please install Node.js 20 LTS manually from https://nodejs.org
echo After installing, close this window and run setup.bat again.
pause
exit /b 1
)
) else (
echo [OK] Node.js found.
node --version
)
echo.
:: ---- Step 2: Install npm dependencies ----
echo Installing dependencies (this may take a few minutes)...
call npm install
if %errorlevel% neq 0 (
echo.
echo npm install failed. Check the error messages above.
pause
exit /b 1
)
echo [OK] Dependencies installed.
echo.
:: ---- Step 3: Build the project ----
echo Building TypeScript project...
call npx tsc
if %errorlevel% neq 0 (
echo.
echo Build failed. Check the error messages above.
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ---- Step 4: Create default config if missing ----
if not exist "config.json" (
echo Creating default config.json...
echo Please edit config.json with your TeamSpeak server details before starting the bot.
) else (
echo [OK] config.json already exists.
)
echo.
:: ---- Done ----
echo ============================================
echo Setup complete!
echo ============================================
echo.
echo To start the bot, run: scripts\start.bat
echo.
pause
@echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror
:: - Download native binaries from CDN (避开 GitHub)
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." || (
echo [FATAL] Cannot change to project directory.
pause
exit /b 1
)
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
echo. > "%LOG_FILE%"
call :log "============================================"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " Started: %date% %time%"
call :log " Project root: %PROJECT_ROOT%"
call :log "============================================"
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo Version %SCRIPT_VERSION%
echo ============================================
echo.
echo Log file: %LOG_FILE%
echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/7" "Checking Node.js"
where node >nul 2>&1
if not errorlevel 1 goto :check_node_version
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
echo https://nodejs.org/ (official)
echo https://nodejs.cn/ (China mirror, recommended)
echo.
pause
exit /b 1
:check_node_version
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
pause
exit /b 1
)
echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/7" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found."
pause
exit /b 1
)
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%"
echo [OK] npm found: %NPM_VER%
echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/7" "Checking network"
set "USE_MIRROR=0"
set "MIRROR_REGISTRY=https://registry.npmjs.org"
echo Testing connection to npm registry...
call :log "Testing npm registry connectivity..."
ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly, using China mirror.
call :log "npm registry unreachable via ping"
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
call :log "npm registry reachable"
)
if "%USE_MIRROR%"=="1" (
echo.
echo [INFO] Using China mirror (npmmirror.com)
call :log "Switching to npmmirror.com"
set "MIRROR_REGISTRY=https://registry.npmmirror.com"
set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
) else (
set "CDN_MIRROR="
)
echo.
:: ============================================================
:: Step 4: Install backend dependencies (跳过二进制)
:: ============================================================
call :step "4/7" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
)
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
echo.
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo Check the log: %LOG_FILE%
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 4b: Download native binaries from CDN
:: ============================================================
call :step "4b/7" "Downloading native binaries"
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
) else (
echo [OK] Native binaries installed.
)
echo.
:: ============================================================
:: Step 5: Install frontend dependencies
:: ============================================================
call :step "5/7" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found."
pause
exit /b 1
)
echo Running: npm install (in web/)
echo.
pushd web >nul
call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul
if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed."
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project
:: ============================================================
call :step "6/7" "Building project"
echo Running: npm run build
echo.
call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Build failed. Check: %LOG_FILE%"
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ============================================================
:: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
:: ============================================================
call :step "7/7" "Checking PowerShell PATH"
where powershell >nul 2>&1
if errorlevel 1 (
echo [WARN] PowerShell not found in PATH.
echo Attempting to fix...
set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
if exist "!POWERSHELL_PATH!\powershell.exe" (
:: 为用户添加永久 PATH 环境变量
echo [INFO] Adding PowerShell to user PATH...
call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
echo [OK] PowerShell added to PATH. Please restart your terminal.
) else (
echo [WARN] Could not find powershell.exe on this system.
echo If you encounter playback issues with some NetEase songs,
echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
echo before running scripts\start.bat
)
) else (
echo [OK] PowerShell found in PATH.
)
echo.
:: ============================================================
:: Verify build outputs
:: ============================================================
echo Verifying build outputs...
set "BUILD_OK=1"
if not exist "dist" (
call :error "dist/ directory missing after build."
set "BUILD_OK=0"
)
if not exist "web\dist" (
call :error "web\dist/ directory missing after build."
set "BUILD_OK=0"
)
if "!BUILD_OK!"=="0" (
echo Build completed but expected output is missing.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch.
) else (
echo [OK] config.json already exists.
)
echo.
:: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================
echo Setup Complete!
echo ============================================
echo.
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo.
echo Setup log: %LOG_FILE%
echo.
pause
exit /b 0
:: ============================================================
:: Subroutines
:: ============================================================
:step
echo ---- Step %~1: %~2 ----
call :log ""
call :log "---- Step %~1: %~2 ----"
goto :eof
:error
echo.
echo [ERROR] %~1
call :log "[ERROR] %~1"
goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
#
# TSMusicBot Setup Script (Linux/macOS)
# - Auto-detect China network, switch to npmmirror
# - Download native binaries from CDN (避开 GitHub)
# - One-click setup, same as setup.bat for Windows
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_FILE="$PROJECT_DIR/setup.log"
echo "============================================"
echo " TSMusicBot - First-Time Setup (Linux)"
echo "============================================"
echo ""
echo "Log file: $LOG_FILE"
echo ""
# ---- Check Node.js ----
if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
fi
echo "[OK] Node.js $(node -v)"
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
fi
echo "[OK] npm v$(npm -v)"
echo ""
# ---- Detect China network ----
USE_MIRROR=0
MIRROR_REGISTRY="https://registry.npmjs.org"
CDN_MIRROR=""
echo "Testing connection to npm registry..."
if ping -c 1 -W 4 registry.npmjs.org &>/dev/null; then
echo "[OK] npm registry reachable."
else
echo "[WARN] Cannot reach npm registry, using China mirror."
USE_MIRROR=1
fi
if [ "$USE_MIRROR" = "1" ]; then
echo "[INFO] Using China mirror (npmmirror.com)"
MIRROR_REGISTRY="https://registry.npmmirror.com"
CDN_MIRROR="https://cdn.npmmirror.com/binaries"
export npm_config_registry="$MIRROR_REGISTRY"
fi
echo ""
# ---- Check build tools (needed for native module fallback) ----
if ! command -v gcc &>/dev/null && ! command -v clang &>/dev/null; then
echo "[INFO] No C compiler found. If CDN binaries are unavailable,"
echo " native modules may fail. Install build tools:"
echo " sudo apt install build-essential (Ubuntu/Debian)"
echo " sudo yum groupinstall 'Development Tools' (CentOS/RHEL)"
echo ""
fi
# ---- Step 1: Install dependencies (skip GitHub binaries) ----
echo "---- 1/5: Installing Node.js dependencies ----"
echo ""
cd "$PROJECT_DIR"
npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Dependencies installed."
echo ""
# ---- Step 2: Download native binaries from CDN ----
echo "---- 2/5: Downloading native binaries ----"
echo ""
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
echo "[OK] Native binaries installed."
else
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
fi
echo ""
# ---- Step 3: Install web panel dependencies ----
echo "---- 3/5: Installing web panel dependencies ----"
echo ""
if [ -f "web/package.json" ]; then
cd "$PROJECT_DIR/web"
npm install --registry="$MIRROR_REGISTRY" 2>&1 | tee -a "$LOG_FILE"
cd "$PROJECT_DIR"
echo "[OK] Web panel dependencies installed."
else
echo "[SKIP] web/package.json not found."
fi
echo ""
# ---- Step 4: Build project ----
echo "---- 4/5: Building project ----"
echo ""
npm run build 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Build succeeded."
echo ""
# ---- Step 5: Verify ----
echo "---- 5/5: Verifying build ----"
echo ""
BUILD_OK=1
if [ ! -d "dist" ]; then
echo "[ERROR] dist/ directory missing."
BUILD_OK=0
fi
if [ -d "web" ] && [ ! -d "web/dist" ]; then
echo "[ERROR] web/dist/ directory missing."
BUILD_OK=0
fi
if [ "$BUILD_OK" = "0" ]; then
echo "Build completed but expected output is missing."
exit 1
fi
echo "[OK] Build outputs verified."
echo ""
if [ ! -f "config.json" ]; then
echo "[INFO] config.json will be auto-generated on first launch."
fi
echo ""
echo "============================================"
echo " Setup Complete!"
echo "============================================"
echo ""
echo "Next steps:"
echo " 1. Run: npm start"
echo " 2. Open: http://localhost:3000"
echo ""
echo "Setup log: $LOG_FILE"
echo ""
+43 -46
View File
@@ -1,47 +1,44 @@
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat first.
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Check if dependencies are installed
if not exist "node_modules" (
echo Dependencies not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Check if build output exists
if not exist "dist" (
echo Build not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
where powershell >nul 2>&1
if errorlevel 1 (
if exist "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" (
set "PATH=%PATH%;C:\Windows\System32\WindowsPowerShell\v1.0\"
)
)
:: Start the application
node dist/index.js
pause
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat for automatic installation, or install Node.js 20+ from https://nodejs.org
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Install dependencies if needed
if not exist "node_modules" (
echo Installing dependencies...
call npm install --production
if %errorlevel% neq 0 (
echo Failed to install dependencies.
pause
exit /b 1
)
)
:: Build if dist/ doesn't exist
if not exist "dist" (
echo Building project...
call npx tsc
if %errorlevel% neq 0 (
echo Build failed.
pause
exit /b 1
)
)
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed.
echo FFmpeg is bundled via node_modules (ffmpeg-static).
echo.
:: Start the application
node dist/index.js
pause
+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())
+106
View File
@@ -0,0 +1,106 @@
// Empirically verifies the PowerShell-download workaround for jdymusic CDN
// blocks Node.js HTTP. Runs A/B against the same fresh /jdymusic/ URL:
// A) ffmpeg direct with browser UA (the previous fix in this branch)
// B) PowerShell WebClient -> temp file -> ffmpeg from file (the new fix)
// Reports bytes received + exit code + stderr-tail for each.
import { spawn } from "node:child_process";
import { mkdtempSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs } from "../dist/audio/player.js";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/test_jdymusic_powershell.mjs <jdymusic_url>");
process.exit(2);
}
if (!url.includes("/jdymusic/")) {
console.error("warning: this script targets /jdymusic/ URLs specifically");
}
const FFMPEG = "ffmpeg";
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const TIMEOUT_MS = 20_000;
function runFfmpeg(label, args, stdinSource) {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, args, { stdio: [stdinSource ?? "ignore", "pipe", "pipe"] });
let bytes = 0;
let stderrTail = "";
let killed = false;
proc.stdout.on("data", (chunk) => { bytes += chunk.length; });
proc.stderr.on("data", (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-1500); });
const timer = setTimeout(() => { killed = true; proc.kill("SIGTERM"); }, TIMEOUT_MS);
proc.on("exit", (code, signal) => {
clearTimeout(timer);
resolve({ label, bytes, code, signal, killed, stderrTail });
});
});
}
function downloadViaPowerShell(targetUrl, outFile) {
return new Promise((resolve) => {
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: targetUrl,
DL_OUT: outFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stderr = "";
ps.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
ps.on("exit", (code) => resolve({ code, stderr }));
});
}
console.log(`URL: ${url}\n`);
console.log("[A] ffmpeg direct (browser UA via -headers)");
const a = await runFfmpeg("A", buildFfmpegArgs(url, 0));
console.log(` code=${a.code} bytes=${a.bytes} killed=${a.killed}`);
console.log(` stderr-tail: ${a.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
console.log("[B] PowerShell WebClient -> temp file -> ffmpeg -i tempfile");
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-test-"));
const tempFile = join(tempDir, "song.audio");
const psStart = Date.now();
const dl = await downloadViaPowerShell(url, tempFile);
const psMs = Date.now() - psStart;
if (dl.code !== 0) {
console.log(` PowerShell download FAILED: code=${dl.code}`);
console.log(` stderr: ${dl.stderr.slice(-500)}`);
rmSync(tempDir, { recursive: true, force: true });
process.exit(1);
}
const dlSize = statSync(tempFile).size;
console.log(` PowerShell downloaded ${dlSize} bytes in ${psMs}ms`);
const b = await runFfmpeg("B", buildFfmpegArgs(tempFile, 0));
console.log(` ffmpeg-from-file: code=${b.code} bytes=${b.bytes} killed=${b.killed}`);
console.log(` stderr-tail: ${b.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
rmSync(tempDir, { recursive: true, force: true });
const aBlocked = a.bytes === 0 && !a.killed;
const bWorked = b.bytes > 100_000;
console.log(
`Verdict: direct ${aBlocked ? "BLOCKED" : "OK"} ; ` +
`powershell-then-ffmpeg ${bWorked ? "WORKED" : "FAILED"}`,
);
+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())
+73
View File
@@ -0,0 +1,73 @@
// Empirically tests whether the browser UA + Referer headers fix the
// connection resets we saw in bot.log against m701/m801.music.126.net.
//
// Spawns ffmpeg twice against the SAME fresh Netease CDN URL:
// A) old args from before the fix (no headers, -reconnect_delay_max 5)
// B) new args from after the fix (browser UA + Referer for music.126.net)
// and reports bytes received + exit code + stderr-tail for each.
import { spawn } from "node:child_process";
import { buildFfmpegArgs } from "../dist/audio/player.js";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/test_netease_ua_fix.mjs <netease_cdn_url>");
process.exit(2);
}
const FFMPEG = "ffmpeg";
const TIMEOUT_MS = 15_000;
function legacyArgs(u) {
return [
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", u,
"-f", "s16le",
"-ar", "48000",
"-ac", "2",
"-acodec", "pcm_s16le",
"-",
];
}
function runFfmpeg(label, args) {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "pipe", "pipe"] });
let bytes = 0;
let stderrTail = "";
let killed = false;
proc.stdout.on("data", (chunk) => {
bytes += chunk.length;
});
proc.stderr.on("data", (chunk) => {
stderrTail = (stderrTail + chunk.toString()).slice(-1500);
});
const timer = setTimeout(() => {
killed = true;
proc.kill("SIGTERM");
}, TIMEOUT_MS);
proc.on("exit", (code, signal) => {
clearTimeout(timer);
resolve({ label, bytes, code, signal, killed, stderrTail });
});
});
}
console.log(`URL: ${url}\n`);
const a = await runFfmpeg("A) legacy args (no UA)", legacyArgs(url));
console.log(`[A] code=${a.code} signal=${a.signal} killed=${a.killed} bytes=${a.bytes}`);
console.log(` stderr-tail:\n${a.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
const b = await runFfmpeg("B) fixed args (browser UA + Referer)", buildFfmpegArgs(url, 0));
console.log(`[B] code=${b.code} signal=${b.signal} killed=${b.killed} bytes=${b.bytes}`);
console.log(` stderr-tail:\n${b.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
const aFailed = a.bytes === 0 && !a.killed && a.code !== 0;
const bWorked = b.bytes > 100_000; // got real audio bytes
console.log(`Verdict: legacy ${aFailed ? "FAILED (no bytes, exit code 1)" : "??"} ; fixed ${bWorked ? "WORKED (received audio)" : "??"}`);
+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()
+202
View File
@@ -0,0 +1,202 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
if (idx === -1) return "";
return args[idx + 1] ?? "";
}
describe("buildFfmpegArgs", () => {
it("includes browser User-Agent and Referer for Netease CDN URLs", () => {
const url = "http://m701.music.126.net/some/path/song.mp3?vuutv=abc";
const args = buildFfmpegArgs(url, 0);
const headers = getHeadersArg(args);
expect(headers).toContain("User-Agent:");
expect(headers).toContain("Mozilla/5.0");
expect(headers).toContain("Referer: https://music.163.com/");
});
it("keeps Bilibili Referer + UA for bilibili URLs", () => {
const url = "https://upos-sz-mirrorcoso1.bilivideo.com/foo/bar.mp3";
const args = buildFfmpegArgs(url, 0);
const headers = getHeadersArg(args);
expect(headers).toContain("Referer: https://www.bilibili.com");
expect(headers).toContain("User-Agent: Mozilla/5.0");
});
it("does not set custom headers for unknown URLs", () => {
const url = "https://example.com/song.mp3";
const args = buildFfmpegArgs(url, 0);
expect(args).not.toContain("-headers");
});
it("includes resilient reconnect flags for all URLs", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).toContain("-reconnect");
expect(args).toContain("-reconnect_streamed");
expect(args).toContain("-reconnect_delay_max");
expect(args).toContain("-reconnect_on_network_error");
expect(args).toContain("-reconnect_on_http_error");
const idx = args.indexOf("-reconnect_delay_max");
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
});
it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => {
const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0);
const idx = args.indexOf("-reconnect_at_eof");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("1");
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
});
it("inserts -ss before -i when seekSeconds > 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
const ssIdx = args.indexOf("-ss");
const iIdx = args.indexOf("-i");
expect(ssIdx).toBeGreaterThan(-1);
expect(args[ssIdx + 1]).toBe("42");
expect(ssIdx).toBeLessThan(iIdx);
});
it("does not insert -ss when seekSeconds is 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).not.toContain("-ss");
});
it("omits HTTP-only flags when input is a local file path", () => {
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
expect(args).not.toContain("-reconnect");
expect(args).not.toContain("-reconnect_at_eof");
expect(args).not.toContain("-reconnect_on_network_error");
expect(args).not.toContain("-reconnect_on_http_error");
expect(args).not.toContain("-headers");
expect(args).toContain("-i");
expect(args[args.indexOf("-i") + 1]).toBe("C:/temp/song.mp3");
});
it("ends args with the input URL and PCM output spec", () => {
const url = "https://example.com/song.mp3";
const args = buildFfmpegArgs(url, 0);
const iIdx = args.indexOf("-i");
expect(args[iIdx + 1]).toBe(url);
expect(args).toContain("-f");
expect(args).toContain("s16le");
expect(args[args.length - 1]).toBe("-");
});
});
describe("volumeToFactor (#84 smooth volume curve)", () => {
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
expect(volumeToFactor(0)).toBe(0);
expect(volumeToFactor(100)).toBe(1);
});
it("clamps out-of-range input", () => {
expect(volumeToFactor(-20)).toBe(0);
expect(volumeToFactor(150)).toBe(1);
});
it("is strictly monotonic across the whole range (no dead zone)", () => {
for (let v = 0; v < 100; v++) {
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
}
});
it("removes the old flat 80-99 dead zone", () => {
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
});
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
});
it("keeps the low range gentle", () => {
expect(volumeToFactor(50)).toBeLessThan(0.12);
});
});
describe("shouldUsePowerShellDownload", () => {
const jdymusicUrl =
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
const newCdnUrl =
"http://m801.music.126.net/20260507/abc/jd-musicrep-ts/obj/xyz/song.mp3?vuutv=tok";
const ymusicUrl =
"http://m801.music.126.net/20260507/abc/ymusic/obj/xyz/song.mp3?vuutv=tok";
it("returns true for /jdymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "win32")).toBe(true);
});
it("returns false for /jdymusic/ URL on linux", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "linux")).toBe(false);
});
it("returns false for /jdymusic/ URL on darwin", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "darwin")).toBe(false);
});
it("returns false for new-format /jd-musicrep-ts/ URL on win32", () => {
expect(shouldUsePowerShellDownload(newCdnUrl, "win32")).toBe(false);
});
it("returns false for /ymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(ymusicUrl, "win32")).toBe(false);
});
it("returns false for unrelated URLs", () => {
expect(shouldUsePowerShellDownload("https://example.com/x.mp3", "win32")).toBe(false);
});
});
describe("cleanupTempDir", () => {
it("removes a directory and its contents", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
writeFileSync(join(dir, "song.mp3"), "fake-bytes");
expect(existsSync(dir)).toBe(true);
cleanupTempDir(dir);
expect(existsSync(dir)).toBe(false);
});
it("does not throw when directory does not exist", () => {
const missing = join(tmpdir(), "tsbot-test-does-not-exist-xyz");
expect(() => cleanupTempDir(missing)).not.toThrow();
});
it("does not throw when called twice", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
cleanupTempDir(dir);
expect(() => cleanupTempDir(dir)).not.toThrow();
});
});
describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
const MAX_EMPTY = 250; // ~5s near-end threshold
const MAX_STALL = 3000; // ~60s far-from-end watchdog
it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => {
expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => {
// This is the core regression: a brief underrun mid-song must not end the track.
expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => {
// The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence.
expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true);
});
it("never ends before any threshold", () => {
expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
});
+414 -181
View File
@@ -1,15 +1,18 @@
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
import { accessSync, chmodSync, constants } from "node:fs";
import { accessSync, chmodSync, constants, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Logger } from "../logger.js";
// ffmpeg-static is a CJS module that exports the path to the bundled ffmpeg binary.
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
/** Ensure the given binary has execute permission. */
/** 全局 PID 追踪器,防止进程在类实例切换时沦为孤儿进程 ( */
const globalActivePids = new Set<number>();
function isExecutable(binPath: string): boolean {
try {
accessSync(binPath, constants.X_OK);
@@ -25,7 +28,6 @@ function isExecutable(binPath: string): boolean {
}
}
/** Test if an ffmpeg binary actually works by running -version. */
function ffmpegWorks(bin: string): boolean {
try {
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" });
@@ -35,32 +37,114 @@ function ffmpegWorks(bin: string): boolean {
}
}
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
const resolvedFfmpeg: string = (() => {
// 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.
if (ffmpegWorks("ffmpeg")) return "ffmpeg";
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;
}
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. */
function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
// Old jdymusic CDN paths (e.g. /jdymusic/obj/...) RST direct Node-stack
// requests on Windows; same URL works when fetched via WinHTTP. Empirically,
// /jd-musicrep-ts/ and /ymusic/ paths do not have this restriction.
export function shouldUsePowerShellDownload(
url: string,
platform: string = process.platform,
): boolean {
return platform === "win32" && url.includes("/jdymusic/");
}
export function cleanupTempDir(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort
}
}
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const isHttp = /^https?:\/\//i.test(url);
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
args.push(
"-headers",
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
} else if (isHttp && (url.includes("music.126.net") || url.includes("music.163.com"))) {
args.push(
"-headers",
`Referer: https://music.163.com/\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
}
if (isHttp) {
args.push(
"-reconnect", "1",
// Long B站 streams sit on a CDN whose session/token can close the
// connection mid-file (premature EOF). Without this, FFmpeg treats that
// EOF as end-of-input and stops ~partway through (see #89); with it, it
// re-issues a Range request from the current offset to finish the stream.
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "30",
"-reconnect_on_network_error", "1",
"-reconnect_on_http_error", "4xx,5xx",
);
}
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
}
/**
* Decide whether to end the current track when FFmpeg is still alive but has
* produced no decodable audio for `emptyAttempts` consecutive frame ticks.
*
* - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF.
* - Far from the end we wait much longer (`maxStallAttempts`) before giving up,
* so a transient buffer underrun on a healthy stream does NOT cause a false
* skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session
* expired mid-playback, #89) still recovers by advancing instead of going
* permanently silent.
*/
export function shouldEndOnStall(
emptyAttempts: number,
isNearEnd: boolean,
maxEmptyAttempts: number,
maxStallAttempts: number,
): boolean {
if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true;
if (emptyAttempts >= maxStallAttempts) return true;
return false;
}
/**
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
*
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
* the low end gentle but ramps smoothly toward full loudness near the top, so the
* slider feels proportional with no dead zone and no discontinuity at 100.
*/
export function volumeToFactor(volume: number): number {
const x = Math.max(0, Math.min(100, volume)) / 100;
return 0.2 * x + 0.8 * Math.pow(x, 8);
}
export interface PlayerEvents {
frame: (opusFrame: Buffer) => void;
trackEnd: () => void;
@@ -82,14 +166,26 @@ export class AudioPlayer extends EventEmitter {
private nextFrameTime = 0;
private currentUrl = "";
private seekOffset = 0;
private framesPlayed = 0; // ground truth: number of 20ms frames sent
private framesPlayed = 0;
private sessionId = 0;
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
private static readonly BUFFER_HIGH_WATER = 640 * 1024;
private static readonly BUFFER_LOW_WATER = 256 * 1024;
private ffmpegPaused = false;
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
private spawnFailed = false;
private consecutiveFailures = 0;
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
private downloader: ChildProcess | null = null;
private currentTempDir: string | null = null;
private emptyFrameAttempts = 0;
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
// Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio
// for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream
// as dead and advance instead of staying silent forever. Set high so a normal
// transient underrun never trips it.
private static readonly MAX_STALL_ATTEMPTS = 3000;
private currentSongDuration = 0; // 当前歌曲总时长(秒)
constructor(logger: Logger) {
super();
@@ -97,113 +193,265 @@ export class AudioPlayer extends EventEmitter {
this.logger = logger;
}
play(url: string, seekSeconds = 0): void {
play(url: string, seekSeconds = 0, songDuration = 0): void {
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
this.stop();
this.sessionId++;
const playSessionId = this.sessionId;
const currentSessionId = this.sessionId;
this.currentUrl = url;
this.seekOffset = seekSeconds;
this.framesPlayed = 0;
this.healthyFrames = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
this.emptyFrameAttempts = 0;
this.currentSongDuration = songDuration;
// Prevent rapid-fire spawn attempts when ffmpeg is broken
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error(
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
);
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
this.state = "idle";
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
this.emit("error", new Error("ffmpeg unavailable"));
return;
}
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
const args: string[] = [];
// BiliBili CDN requires Referer header for audio playback
if (url.includes("bilivideo") || url.includes("bilibili")) {
args.push(
"-headers",
"Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
);
if (shouldUsePowerShellDownload(url)) {
this.playViaPowerShellDownload(url, seekSeconds, currentSessionId);
return;
}
args.push(
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
);
if (seekSeconds > 0) {
args.push("-ss", String(seekSeconds));
}
args.push(
"-i", url,
"-f", "s16le",
"-ar", "48000",
"-ac", "2",
"-acodec", "pcm_s16le",
"-",
);
const args = buildFfmpegArgs(url, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const currentPid = this.ffmpeg.pid;
if (currentPid) {
globalActivePids.add(currentPid);
this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned");
}
// Prevent stream errors from crashing the process
this.ffmpeg.stdout!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stdout error");
});
this.ffmpeg.stderr!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stderr error");
});
let gotFirstData = false;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (!gotFirstData) {
gotFirstData = true;
this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received");
// 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 (
if (this.sessionId !== currentSessionId) {
return;
}
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
// Backpressure: pause FFmpeg stdout when buffer is too large
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
this.ffmpegPaused = true;
}
});
this.ffmpeg.on("close", (code, signal) => {
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed");
if (this.sessionId === playSessionId) {
this.ffmpeg = null; // Signal frame loop that no more data is coming
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
// 只有当前会话的进程结束才置空变量
if (this.sessionId === currentSessionId) {
this.ffmpeg = null;
}
});
this.ffmpeg.on("error", (err) => {
this.logger.error({ err }, "FFmpeg error");
if (this.sessionId === playSessionId) {
if (this.sessionId === currentSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
// Log FFmpeg stderr at info level for debugging playback issues
this.ffmpeg.stderr!.on("data", (data: Buffer) => {
const msg = data.toString().trimEnd();
// Log important FFmpeg messages at info level
if (msg.includes("Error") || msg.includes("error") || msg.includes("HTTP") || msg.includes("Opening") || msg.includes("Stream")) {
this.logger.info({ ffmpegStderr: msg }, "FFmpeg stderr");
} else {
this.logger.debug({ stderr: msg }, "FFmpeg stderr");
this.state = "playing";
this.startFrameLoop();
}
private playViaPowerShellDownload(url: string, seekSeconds: number, sessionId: number): void {
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-"));
const tempFile = join(tempDir, "song.audio");
this.currentTempDir = tempDir;
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
this.logger.debug({ sessionId, tempFile }, "Downloading via PowerShell (jdymusic CDN)");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: url,
DL_OUT: tempFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
this.downloader = ps;
let stderrTail = "";
ps.stderr!.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-500);
});
ps.on("exit", (code, signal) => {
if (this.sessionId !== sessionId) {
cleanupTempDir(tempDir);
return;
}
this.downloader = null;
if (code !== 0) {
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
this.spawnFailed = true;
this.consecutiveFailures++;
this.state = "idle";
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", new Error(`PowerShell download exited ${code}`));
return;
}
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
});
ps.on("error", (err) => {
if (this.sessionId !== sessionId) return;
this.downloader = null;
this.spawnFailed = true;
this.consecutiveFailures++;
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", err);
});
// Mark playing but DO NOT start the frame loop here — the loop's
// "no ffmpeg + empty buffer → trackEnd" branch would fire on the very
// first tick, before the PowerShell download even completes. The
// frame loop is started inside spawnFfmpegFromFile() once ffmpeg is
// alive and producing PCM.
this.state = "playing";
}
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
if (this.sessionId !== sessionId) {
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
return;
}
const args = buildFfmpegArgs(tempFile, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const currentPid = this.ffmpeg.pid;
if (currentPid) {
globalActivePids.add(currentPid);
this.logger.debug({ pid: currentPid, sessionId }, "FFmpeg spawned (from temp file)");
}
const tempDirToCleanup = this.currentTempDir;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (this.sessionId !== sessionId) return;
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
this.ffmpegPaused = true;
}
});
this.state = "playing";
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
if (this.sessionId === sessionId) {
this.ffmpeg = null;
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
}
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === sessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
// Now that ffmpeg is producing PCM, run the frame loop.
this.startFrameLoop();
}
stop(): void {
// 3. 递增 ID 是最有效的逻辑“隔离墙”
this.sessionId++;
this.frameLoopRunning = false;
// 立即清空缓冲区,确保切歌瞬间静音 (
this.pcmBuffer = Buffer.alloc(0);
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
if (this.downloader) {
const ps = this.downloader;
this.downloader = null;
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
}
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
this.healthyFrames = 0;
}
private forceCleanup(proc: ChildProcess, pid: number): void {
if (!globalActivePids.has(pid)) return;
try {
proc.kill("SIGTERM");
} catch (e) { /* ignore */ }
const killTimeout = setTimeout(() => {
try {
process.kill(pid, 0);
process.kill(pid, "SIGKILL");
} catch (e) {
} finally {
globalActivePids.delete(pid);
}
}, 1500);
proc.unref();
proc.once("exit", () => {
clearTimeout(killTimeout);
globalActivePids.delete(pid);
});
}
private startFrameLoop(): void {
if (this.frameLoopRunning) return;
this.frameLoopRunning = true;
@@ -213,50 +461,91 @@ export class AudioPlayer extends EventEmitter {
private scheduleNextFrame(): void {
if (!this.frameLoopRunning) return;
const loopSessionId = this.sessionId;
this.nextFrameTime += FRAME_DURATION_MS;
const now = performance.now();
const delay = Math.max(0, this.nextFrameTime - now);
const delay = Math.max(0, this.nextFrameTime - performance.now());
setTimeout(() => {
// Discard callback from a stale play session
if (loopSessionId !== this.sessionId) return;
if (!this.frameLoopRunning) return;
// 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 (
if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return;
if (this.state === "playing") {
this.sendNextFrame();
} else if (this.state === "paused") {
this.nextFrameTime = performance.now();
if (this.state === "playing") this.sendNextFrame();
else if (this.state === "paused") this.nextFrameTime = performance.now();
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
const elapsed = this.getElapsed();
const isNearEnd = this.currentSongDuration > 0
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
: true; // 未知时长时保守处理
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.emptyFrameAttempts++;
// End the track when FFmpeg has gone silent: quickly if we're near the
// end (normal EOF), or after a much longer stall window if we're not
// (a dead/expired stream — #89 — so playback recovers instead of going
// permanently silent).
if (
shouldEndOnStall(
this.emptyFrameAttempts,
isNearEnd,
AudioPlayer.MAX_EMPTY_ATTEMPTS,
AudioPlayer.MAX_STALL_ATTEMPTS,
)
) {
this.logger.info({
sessionId: this.sessionId,
emptyAttempts: this.emptyFrameAttempts,
bufferSize: this.pcmBuffer.length,
elapsed: Math.round(elapsed),
duration: this.currentSongDuration,
remaining: Math.round(this.currentSongDuration - elapsed),
nearEnd: isNearEnd,
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
return;
}
} else {
// 成功获取数据或FFmpeg已结束,重置计数器
this.emptyFrameAttempts = 0;
}
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
if (this.spawnFailed) {
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
} else {
this.consecutiveFailures = 0; // Reset on successful track completion
if (!this.spawnFailed) {
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
}
return;
}
this.scheduleNextFrame();
}, delay);
}
private sendNextFrame(): void {
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
// Backpressure: resume FFmpeg stdout when buffer drains below low-water mark
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.resume();
this.ffmpegPaused = false;
@@ -267,94 +556,38 @@ export class AudioPlayer extends EventEmitter {
const opusFrame = this.encoder.encode(adjusted);
this.emit("frame", opusFrame);
this.framesPlayed++;
if (this.framesPlayed === 1) {
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted");
}
// Log every ~10 seconds (500 frames * 20ms = 10s)
if (this.framesPlayed % 500 === 0) {
this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress");
this.healthyFrames++;
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
this.consecutiveFailures = 0;
this.healthyFrames = 0;
}
} catch (err) {
this.logger.error({ err }, "Error encoding/sending audio frame");
this.emit("error", err as Error);
}
}
private applyVolume(pcm: Buffer): Buffer {
if (this.volume === 100) return Buffer.from(pcm);
const factor = this.volume / 100;
const factor = volumeToFactor(this.volume);
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
if (factor >= 1) return Buffer.from(pcm);
const out = Buffer.alloc(pcm.length);
for (let i = 0; i < pcm.length; i += 2) {
let sample = pcm.readInt16LE(i);
sample = Math.round(sample * factor);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
out.writeInt16LE(sample, i);
let sample = Math.round(pcm.readInt16LE(i) * factor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
/** Actual elapsed time in seconds (ground truth from frame count) */
getElapsed(): number {
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
}
seek(seconds: number): void {
if (!this.currentUrl) return;
this.logger.info({ seek: seconds }, "Seeking");
this.play(this.currentUrl, seconds);
}
getSeekOffset(): number {
return this.seekOffset;
}
pause(): void {
if (this.state === "playing") {
this.state = "paused";
this.logger.debug("Playback paused");
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void {
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
this.play(this.currentUrl, seconds, this.currentSongDuration);
}
}
resume(): void {
if (this.state === "paused") {
this.state = "playing";
this.nextFrameTime = performance.now();
this.logger.debug("Playback resumed");
}
}
stop(): void {
this.sessionId++;
this.frameLoopRunning = false;
if (this.ffmpeg) {
this.ffmpeg.kill("SIGTERM");
this.ffmpeg = null;
}
this.pcmBuffer = Buffer.alloc(0);
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
}
/** Reset the consecutive failure counter (e.g. after user action) */
resetFailures(): void {
this.consecutiveFailures = 0;
}
setVolume(vol: number): void {
this.volume = Math.max(0, Math.min(100, vol));
}
getVolume(): number {
return this.volume;
}
getState(): PlayerState {
return this.state;
}
}
pause(): void { if (this.state === "playing") this.state = "paused"; }
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
resetFailures(): void { this.consecutiveFailures = 0; }
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
getVolume(): number { return this.volume; }
getState(): PlayerState { return this.state; }
}
+441
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"));
@@ -123,4 +261,307 @@ describe("PlayQueue", () => {
queue.playAt(2);
expect(queue.current()?.id).toBe("3");
});
describe("history-aware prev", () => {
it("walks back through played indices in random mode", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.add(makeSong("e"));
// Force a deterministic random sequence: a → c → e
queue.playAt(0);
queue.playAt(2);
queue.playAt(4);
expect(queue.current()?.id).toBe("e");
// prev pops back through history: e → c → a
expect(queue.prev()?.id).toBe("c");
expect(queue.prev()?.id).toBe("a");
});
it("returns null when history is empty in random mode", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.playAt(0);
// No further moves → history is empty (only 'a' is current, never pushed)
expect(queue.prev()).toBeNull();
});
it("preserves sequential prev when history is empty", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.play();
queue.next(); // currentIndex = 1
// Sequential next() pushed 0 to history → prev pops back to 0
expect(queue.prev()?.id).toBe("a");
});
it("clears history on play()", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.playAt(0);
queue.playAt(1);
queue.play(); // resets to index 0 and clears history
expect(queue.prev()).toBeNull();
});
it("clears history on clear()", () => {
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play();
queue.next();
queue.clear();
queue.add(makeSong("c"));
queue.play();
// History was wiped — no prev path available beyond index 0
expect(queue.prev()).toBeNull();
});
it("clears history on setMode()", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play();
queue.next();
// Mode change resets context
queue.setMode(PlayMode.Random);
expect(queue.prev()).toBeNull();
});
it("drops history entries pointing at a removed song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.playAt(0);
queue.playAt(1); // history: [0]
queue.playAt(2); // history: [0, 1]
// Remove song at index 1 → history entry 1 dropped
queue.remove(1);
// queue is now [a, c], history should be [0]
// current was at 2 → after remove shifts to 1 → song "c"
expect(queue.current()?.id).toBe("c");
expect(queue.prev()?.id).toBe("a");
});
it("does not push to history on prev itself", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.playAt(0);
queue.playAt(1);
queue.playAt(2); // history: [0, 1]
queue.prev(); // pops 1, history: [0]
queue.prev(); // pops 0, history: []
expect(queue.prev()).toBeNull(); // no fallback target in random mode
});
it("caps history at HISTORY_LIMIT (50) entries, dropping oldest", () => {
queue.setMode(PlayMode.Random);
// Build a queue large enough to overflow HISTORY_LIMIT
for (let i = 0; i < 60; i++) queue.add(makeSong(`s${i}`));
// Walk through 60 explicit picks → 59 pushes to history
// (playAt pushes the previous currentIndex; first call has -1
// which pushHistory rejects). After 60 playAts, history holds
// the last 50 of those 59 entries.
for (let i = 0; i < 60; i++) queue.playAt(i);
// Walk back through history. The first prev returns whatever the
// 50th-most-recent push was (= index 9, since pushes 0..58 happened
// and the oldest 9 fell off). We can verify by counting prevs that
// succeed before history exhausts and prev returns null in random.
let count = 0;
while (queue.prev() !== null) {
count++;
if (count > 100) break; // safety
}
expect(count).toBe(50);
});
});
describe("addNext", () => {
it("appends when queue is empty (no current)", () => {
queue.addNext(makeSong("a"));
expect(queue.size()).toBe(1);
expect(queue.list()[0].id).toBe("a");
});
it("appends when nothing is currently playing (currentIndex < 0)", () => {
queue.add(makeSong("a"));
queue.add(makeSong("b"));
// No play() yet → currentIndex still -1
queue.addNext(makeSong("c"));
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c"]);
});
it("inserts at currentIndex+1 mid-queue", () => {
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.play(); // current = 0 (a)
queue.next(); // current = 1 (b)
queue.addNext(makeSong("x"));
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
expect(queue.current()?.id).toBe("b"); // current unchanged
});
it("makes the inserted song play next when next() is called", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play(); // current = 0 (a)
queue.addNext(makeSong("x"));
expect(queue.next()?.id).toBe("x");
});
it("shifts playedIndices entries > currentIndex by +1", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.playAt(2); // current = 2 (c), played = {2}
queue.playAt(3); // current = 3 (d), played = {2, 3}
queue.playAt(2); // current = 2 (c), played = {2, 3}
// Now insert after c — d's index 3 should become 4
queue.addNext(makeSong("x"));
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "x", "d"]);
// After addNext: currentIndex still 2; played should be {2, 4}
// (the previously-played 'd' is now at index 4)
// Verify by removing 'x' (index 3) — d should remain played at index 3
queue.remove(3);
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "d"]);
});
it("shifts history entries > currentIndex by +1", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.playAt(0); // current = 0
queue.playAt(3); // current = 3 (d), history = [0]
queue.playAt(1); // current = 1 (b), history = [0, 3]
queue.addNext(makeSong("x"));
// Insert at index 2 → entries > 1 shift +1 → history becomes [0, 4]
// queue: [a, b, x, c, d]; d is now at index 4
// prev → pop 4 → song at index 4 = d
expect(queue.prev()?.id).toBe("d");
// prev again → pop 0 → song at index 0 = a
expect(queue.prev()?.id).toBe("a");
});
it("idle player + stale currentIndex: insertion target is currentIndex+1, not size-1", () => {
// Reproduces the scenario where the player has gone idle but the
// queue still has a non-negative currentIndex (e.g., after natural
// track end without queue.clear()).
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.play(); // current = 0 (a)
queue.next(); // current = 1 (b)
// Simulate idle-with-stale-currentIndex: the player has gone idle
// but queue still points at b.
// Caller pre-captures insertedAt:
const insertedAt = queue.getCurrentIndex() + 1; // = 2
queue.addNext(makeSong("x"));
// queue is now [a, b, x, c, d]
// size-1 would be 4 (d) — WRONG.
// insertedAt is 2 (x) — RIGHT.
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
expect(queue.size() - 1).toBe(4); // proves size-1 strategy would pick d
const promoted = queue.playAt(insertedAt);
expect(promoted?.id).toBe("x");
});
});
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
// songs repeated often while others were starved. It should behave like a
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
describe("random-loop shuffle bag (issue #70)", () => {
it("plays every song exactly once per cycle before repeating", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 12;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const cycle1 = [queue.current()!.id];
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
const cycle2: string[] = [];
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
// Each cycle is a full permutation of all N songs — zero repeats within
// a cycle, and both cycles cover the same complete set.
expect(new Set(cycle1).size).toBe(N);
expect(new Set(cycle2).size).toBe(N);
expect(new Set(cycle1)).toEqual(new Set(cycle2));
});
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 6;
const CYCLES = 20;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const counts = new Map<string, number>();
counts.set(queue.current()!.id, 1);
for (let i = 0; i < CYCLES * N - 1; i++) {
const id = queue.next()!.id;
counts.set(id, (counts.get(id) ?? 0) + 1);
}
// Shuffle bag => each song plays exactly CYCLES times. True random
// would skew heavily.
for (let i = 0; i < N; i++) {
expect(counts.get(`s${i}`)).toBe(CYCLES);
}
});
it("does not replay the same song across a cycle boundary", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 5;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
// Walk to the last song of cycle 1, then cross into cycle 2.
for (let i = 0; i < N - 1; i++) queue.next();
const lastOfCycle1 = queue.current()!.id;
const firstOfCycle2 = queue.next()!.id;
expect(firstOfCycle2).not.toBe(lastOfCycle1);
});
it("includes a song added mid-cycle within the current cycle", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // A
queue.next(); // B — both originals now played this cycle
queue.add(makeSong("C")); // added mid-cycle, still unplayed
// C is the only unplayed song, so it must come next (not a reshuffle).
expect(queue.next()?.id).toBe("C");
});
it("keeps looping forever with multiple songs (never returns null)", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
for (let i = 0; i < 30; i++) {
expect(queue.next()).not.toBeNull();
}
});
});
});
+154 -23
View File
@@ -10,7 +10,7 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
@@ -20,6 +20,18 @@ export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
private history: number[] = [];
private forwardStack: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
if (idx < 0 || idx >= this.songs.length) return;
this.history.push(idx);
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
this.history.shift();
}
}
add(song: QueuedSong): void {
this.songs.push(song);
@@ -29,6 +41,34 @@ export class PlayQueue {
this.songs.push(...songs);
}
/**
* Insert a song to play immediately after the current one. Falls
* through to plain push when nothing is playing yet (currentIndex < 0
* or queue empty), so the existing "add → idle bot starts playing"
* flow continues to work.
*
* Shifts playedIndices and history entries > currentIndex by +1 so
* their references stay valid after the splice.
*/
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
this.songs.push(song);
return;
}
const insertAt = this.currentIndex + 1;
this.songs.splice(insertAt, 0, song);
const shifted = new Set<number>();
for (const i of this.playedIndices) {
shifted.add(i > this.currentIndex ? i + 1 : i);
}
this.playedIndices = shifted;
this.history = this.history.map((i) =>
i > this.currentIndex ? i + 1 : i,
);
}
remove(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
const [removed] = this.songs.splice(index, 1);
@@ -36,28 +76,54 @@ 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;
// Same shift logic for history — drop entries pointing at the
// removed song; shift entries > index down by 1.
this.history = this.history
.filter((idx) => idx !== index)
.map((idx) => (idx > index ? idx - 1 : idx));
return removed;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
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.pushHistory(this.currentIndex);
// Reset the Random-mode "unplayed" pool — explicit picks restart
// shuffle from this point. History tracking is independent and
// unaffected by this clear.
this.playedIndices.clear();
this.forwardStack = [];
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
}
@@ -68,47 +134,101 @@ export class PlayQueue {
case PlayMode.Sequential: {
const nextIndex = this.currentIndex + 1;
if (nextIndex >= this.songs.length) return null;
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
return this.songs[nextIndex];
}
case PlayMode.Loop: {
this.pushHistory(this.currentIndex);
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
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);
this.currentIndex = nextIndex;
return this.songs[nextIndex];
}
case PlayMode.Random:
case PlayMode.RandomLoop: {
if (this.songs.length === 1) {
this.currentIndex = 0;
return this.songs[0];
// 优先回到前进栈记录的位置(prev 退回的歌)
if (this.forwardStack.length > 0) {
const target = this.forwardStack.pop()!;
if (target !== this.currentIndex) {
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
return this.songs[target];
}
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
// Shuffle bag: pick uniformly from the songs not yet played this
// cycle, so every song plays once before any repeats (NetEase/QQ
// style). Songs added mid-cycle aren't in playedIndices, so they're
// naturally eligible within the current cycle.
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) {
// Cycle complete.
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
// 随机循环:reshuffle and keep going forever.
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
this.playedIndices = new Set([0]);
return this.songs[0];
}
// Start a fresh cycle: every song is eligible again, but exclude
// the song that just played from THIS pick only, so it doesn't
// repeat back-to-back across the boundary. It stays eligible for
// the rest of the new cycle, so every song still plays exactly once.
this.playedIndices = new Set();
for (let i = 0; i < this.songs.length; i++) {
if (i !== this.currentIndex) unplayed.push(i);
}
}
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
}
}
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// 记录当前位置到前进栈,供 next 优先返回
if (this.currentIndex >= 0 && this.forwardStack.length < PlayQueue.HISTORY_LIMIT) {
this.forwardStack.push(this.currentIndex);
}
// Preferred: pop from the back-stack so prev means "the song I
// actually played before this one," not "the previous array slot."
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices = new Set([...this.history, this.currentIndex]);
return this.songs[idx];
}
// Stale entry (song removed) — keep popping.
}
// Fallback: no history to walk back through. In Sequential we
// can still meaningfully step the index backward; in random
// modes there's nothing useful to return.
if (this.mode === PlayMode.Random || this.mode === PlayMode.RandomLoop) {
return null;
}
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
// In Sequential mode, don't wrap around
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
@@ -136,9 +256,20 @@ export class PlayQueue {
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
}
getCurrentIndex(): number {
return this.currentIndex;
}
/** Number of songs not yet played in Random mode. */
unplayedCount(): number {
return this.songs.length - this.playedIndices.size;
}
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import {
decideOccupancyAction,
occupancyFromClientList,
shouldResumeOnReturn,
} from "./auto-pause.js";
describe("decideOccupancyAction", () => {
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
describe("occupancyFromClientList", () => {
it("returns null when the query failed (0 clients — bot itself is always present)", () => {
// This is the bug fix: a clientlist timeout makes getClientsInChannel()
// return [], which must be treated as "unknown", NOT as an empty channel.
expect(occupancyFromClientList(0)).toBeNull();
});
it("returns 0 other users when only the bot is in the channel", () => {
expect(occupancyFromClientList(1)).toBe(0);
});
it("excludes the bot itself from the count", () => {
expect(occupancyFromClientList(2)).toBe(1);
expect(occupancyFromClientList(5)).toBe(4);
});
it("never yields a negative count (guards the -1 that caused false pauses)", () => {
expect(occupancyFromClientList(-3)).toBeNull();
});
});
describe("shouldResumeOnReturn (event-driven auto-resume)", () => {
it("resumes when we auto-paused and are still paused", () => {
// The reported gap: someone returns after an auto-pause. clientlist can't
// confirm it (it times out while they're present), so we resume from the
// clientEnter event alone.
expect(shouldResumeOnReturn(true, "paused")).toBe(true);
});
it("does NOT resume a track the user paused by hand", () => {
expect(shouldResumeOnReturn(false, "paused")).toBe(false);
});
it("does nothing if already playing (e.g. the bot's own enter at connect)", () => {
// autoPaused is cleared to false on connect, so the bot's own clientEnter
// is a no-op; this also covers the playing/auto-paused-flag-stale case.
expect(shouldResumeOnReturn(false, "playing")).toBe(false);
expect(shouldResumeOnReturn(true, "playing")).toBe(false);
});
it("does nothing when idle (nothing to resume)", () => {
expect(shouldResumeOnReturn(true, "idle")).toBe(false);
expect(shouldResumeOnReturn(false, "idle")).toBe(false);
});
});
+67
View File
@@ -0,0 +1,67 @@
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Convert a channel client-list length into the number of *other* users, or
* `null` when occupancy can't be determined.
*
* A connected bot is always a member of its own channel, so a valid query
* returns at least 1 (the bot itself). A length of 0 therefore does NOT mean
* "empty channel" — it means the underlying `clientlist` query failed (e.g. the
* full-client `clientlist` command times out when other clients are present,
* and `getClientsInChannel()` returns `[]` on error). Treating that failure as
* "empty" is what caused playback to auto-pause within seconds whenever a
* listener was actually in the channel. When the result is indeterminate we
* return `null` so callers skip the auto-pause/idle decision entirely rather
* than mis-reading an unknown state as empty.
*/
export function occupancyFromClientList(clientCount: number): number | null {
if (clientCount <= 0) return null; // query failed → occupancy unknown
return clientCount - 1; // exclude the bot itself
}
/**
* Decide what auto-pause should do given channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
/**
* Whether a client-presence push event (a `clientEnter`) should trigger an
* auto-resume, WITHOUT consulting a clientlist query.
*
* Why event-driven: the full-client `clientlist`/`channellist` commands time
* out whenever ≥2 clients are connected to the server (a library limitation) —
* which is exactly the moment a listener returns. So occupancy cannot be
* re-queried to confirm the return; we must act on the push event itself.
* This is sound because the bot only ever auto-pauses while it is alone on the
* server (the sole state in which the occupancy query succeeds and pause
* fires). Therefore, while `autoPaused` is true, the only way occupancy can
* return is a fresh connection — delivered reliably as `clientEnter`.
*
* Gating on `autoPaused` (not merely "paused") guarantees we never revive a
* track the user paused by hand, and makes the bot's own `clientEnter` at
* connect a no-op (autoPaused is cleared to false on connect). This predicate
* NEVER pauses — pause stays on the authoritative clientlist path.
*/
export function shouldResumeOnReturn(
autoPaused: boolean,
playerState: PlayerStateName,
): boolean {
return autoPaused && playerState === "paused";
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseCommand } from "./commands.js";
import { parseCommand, canRunCommand, isAdminCommand } from "./commands.js";
describe("Command Parser", () => {
it("parses simple command", () => {
@@ -60,3 +60,36 @@ describe("Command Parser", () => {
expect(result!.args).toBe("3");
});
});
describe("isAdminCommand classification", () => {
it("treats stop/clear/remove/move/vol/mode as admin", () => {
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
expect(isAdminCommand(c)).toBe(true);
}
});
it("treats follow and play as NOT admin", () => {
expect(isAdminCommand("follow")).toBe(false);
expect(isAdminCommand("play")).toBe(false);
});
});
describe("canRunCommand", () => {
it("allows any public command regardless of groups", () => {
expect(canRunCommand("play", [], [6])).toBe(true);
expect(canRunCommand("follow", [], [6])).toBe(true);
});
it("allows admin command when enforcement is off (empty adminGroups)", () => {
expect(canRunCommand("stop", [], [])).toBe(true);
});
it("allows admin command when an invoker group matches (string vs number)", () => {
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
expect(canRunCommand("stop", [6], [6])).toBe(true);
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
});
it("denies admin command when no invoker group matches", () => {
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
});
it("denies admin command when invoker has no groups and enforcement is on", () => {
expect(canRunCommand("clear", [], [6])).toBe(false);
});
});
+27 -6
View File
@@ -5,13 +5,13 @@ export interface ParsedCommand {
flags: Set<string>;
}
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
]);
/**
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
* for which commands the permission gate restricts; reclassifying a command is
* a one-line edit here. Everything not in this set is public.
*/
export const ADMIN_COMMANDS = new Set([
"stop", "clear", "move", "vol", "mode", "follow", "remove",
"stop", "clear", "remove", "move", "vol", "mode",
]);
export function parseCommand(
@@ -58,3 +58,24 @@ export function parseCommand(
export function isAdminCommand(commandName: string): boolean {
return ADMIN_COMMANDS.has(commandName);
}
/**
* Decide whether a chat command may run, given the invoker's TS server groups
* and the configured admin groups. Pure + synchronous so it is trivially unit
* tested and reused by the async gate in BotInstance.
*
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
* strings before comparison so "6" matches 6.
*/
export function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[],
): boolean {
if (!isAdminCommand(commandName)) return true;
if (adminGroups.length === 0) return true;
const admin = new Set(adminGroups.map((g) => String(g)));
return invokerGroups.some((g) => admin.has(String(g)));
}
+212
View File
@@ -0,0 +1,212 @@
import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE } from "./instance.js";
import type { TS3TextMessage } from "../ts-protocol/client.js";
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
// reads avatars, etc.), and runExclusive only touches a single private field
// (`playGate`). So we exercise the ACTUAL shipped method via its prototype,
// bound to a minimal object carrying just that field. This proves the real
// serializer logic without standing up a full bot.
type Gate = { playGate: Promise<unknown> };
const runExclusive = BotInstance.prototype.runExclusive as <T>(
this: Gate,
fn: () => Promise<T>,
) => Promise<T>;
function makeGate(): Gate {
return { playGate: Promise.resolve() };
}
/** An explicit, timer-free deferred so ordering is deterministic. */
function deferred<T = void>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("BotInstance.runExclusive — serialization", () => {
it("does not start fnB until fnA settles", async () => {
const gate = makeGate();
const order: string[] = [];
const gateA = deferred();
const pA = runExclusive.call(gate, async () => {
order.push("A-start");
await gateA.promise; // suspend A until we explicitly release it
order.push("A-end");
});
const pB = runExclusive.call(gate, async () => {
order.push("B-start");
order.push("B-end");
});
// Give the microtask queue a chance: B must NOT have started while A is
// still suspended on gateA.
await Promise.resolve();
await Promise.resolve();
expect(order).toEqual(["A-start"]);
gateA.resolve();
await pA;
await pB;
expect(order).toEqual(["A-start", "A-end", "B-start", "B-end"]);
});
it("runs fnB even if fnA rejects (chain survives rejection)", async () => {
const gate = makeGate();
const order: string[] = [];
const gateA = deferred();
const pA = runExclusive.call(gate, async () => {
order.push("A-start");
await gateA.promise;
throw new Error("A blew up");
});
const pB = runExclusive.call(gate, async () => {
order.push("B-start");
order.push("B-end");
return "B-result";
});
await Promise.resolve();
await Promise.resolve();
expect(order).toEqual(["A-start"]);
gateA.reject(new Error("A blew up"));
await expect(pA).rejects.toThrow("A blew up");
// B still runs, only after A has fully settled.
await expect(pB).resolves.toBe("B-result");
expect(order).toEqual(["A-start", "B-start", "B-end"]);
});
it("preserves call order across three serialized tasks", async () => {
const gate = makeGate();
const order: string[] = [];
const tasks = ["X", "Y", "Z"];
const promises = tasks.map((t) =>
runExclusive.call(gate, async () => {
order.push(`${t}-start`);
await Promise.resolve();
order.push(`${t}-end`);
}),
);
await Promise.all(promises);
expect(order).toEqual([
"X-start",
"X-end",
"Y-start",
"Y-end",
"Z-start",
"Z-end",
]);
});
});
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
* The gate methods live on the prototype and are attached here so calls like
* `this.isCommandAllowed(...)` resolve against this same object. */
function makeGateCtx(opts: {
adminGroups?: number[];
lookupGroups?: string[];
lookupThrows?: boolean;
}) {
const ctx: any = {
config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] },
logger: { info: vi.fn(), error: vi.fn() },
tsClient: {
sendTextMessage: vi.fn(async () => {}),
getClientServerGroups: vi.fn(async () => {
if (opts.lookupThrows) throw new Error("query failed");
return opts.lookupGroups ?? [];
}),
},
executeCommand: vi.fn(async () => null),
isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed,
lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups,
};
return ctx;
}
function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage {
return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups };
}
const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as (
this: unknown,
msg: TS3TextMessage,
) => Promise<void>;
describe("BotInstance.handleTextMessage — command permission gate", () => {
it("runs a public command with no group lookup, even under enforcement", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!play 晴天", ["6"]));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("runs an admin command with no lookup when enforcement is off", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.getClientServerGroups).not.toHaveBeenCalled();
});
it("allows an enforced admin command when the live lookup returns a matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1);
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("denies an enforced admin command when the live lookup has no matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("fails closed when the live lookup returns no groups", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: [] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("fails closed when the live lookup throws", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupThrows: true });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("ignores stale event groups: a demoted sender (cached match) is denied by the live lookup", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["8"] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["6"]));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("uses live groups, not stale event groups: a freshly-promoted sender is allowed", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["8"]));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("resolves out-of-channel senders server-wide: empty event groups but a matching live group → allowed", async () => {
const ctx = makeGateCtx({ adminGroups: [6], lookupGroups: ["6"] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.tsClient.getClientServerGroups).toHaveBeenCalledTimes(1);
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
});
Regular → Executable
+726 -59
View File
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { BotManager } from "./manager.js";
import { createDatabase, type BotDatabase } from "../data/database.js";
import { createPermissionStore } from "../data/permissions.js";
import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { MusicProvider } from "../music/provider.js";
import type { AvatarStore } from "../data/avatars.js";
// removeBot only calls logger.info; provide the full shape it could touch.
const stubLogger = {
info() {},
warn() {},
error() {},
debug() {},
child() {
return stubLogger;
},
} as unknown as Logger;
describe("BotManager.removeBot — guest scope pruning", () => {
const dirs: string[] = [];
let db: BotDatabase;
function makeTmpConfigPath(): string {
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-manager-test-"));
dirs.push(dir);
return join(dir, "config.json");
}
function makeManager(config: BotConfig, configPath: string): BotManager {
db = createDatabase(":memory:");
const permissions = createPermissionStore(db.db);
saveConfig(configPath, config);
return new BotManager(
{} as unknown as MusicProvider,
{} as unknown as MusicProvider,
{} as unknown as MusicProvider,
db,
config,
stubLogger,
{} as unknown as AvatarStore,
permissions,
configPath
);
}
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
for (const d of dirs) {
rmSync(d, { recursive: true, force: true });
}
dirs.length = 0;
});
it("prunes a deleted bot from guestMode.bots (array) and persists", async () => {
const configPath = makeTmpConfigPath();
const config = getDefaultConfig();
config.guestMode.bots = ["botA", "botB"];
const manager = makeManager(config, configPath);
await manager.removeBot("botA");
expect(config.guestMode.bots).toEqual(["botB"]);
// Persisted file must also reflect the prune.
expect(loadConfig(configPath).guestMode.bots).toEqual(["botB"]);
});
it('leaves guestMode.bots === "all" unchanged (no crash, no change)', async () => {
const configPath = makeTmpConfigPath();
const config = getDefaultConfig();
config.guestMode.bots = "all";
const manager = makeManager(config, configPath);
await manager.removeBot("botA");
expect(config.guestMode.bots).toBe("all");
expect(loadConfig(configPath).guestMode.bots).toBe("all");
});
});
+170 -9
View File
@@ -1,13 +1,55 @@
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 { saveConfig, type BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.js";
import type { PermissionStore } from "../data/permissions.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;
@@ -15,18 +57,30 @@ export interface CreateBotParams {
queryPort?: number;
nickname: string;
defaultChannel?: string;
channelId?: 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 localProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private permissions: PermissionStore;
private configPath: string;
constructor(
neteaseProvider: MusicProvider,
@@ -34,14 +88,30 @@ export class BotManager {
bilibiliProvider: MusicProvider,
database: BotDatabase,
config: BotConfig,
logger: Logger
logger: Logger,
avatarStore: AvatarStore,
permissions: PermissionStore,
configPath: string,
localProvider?: MusicProvider
) {
super();
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider;
// Let the local provider see which uploads are still referenced by any
// bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{
setInUseResolver: (resolver: () => Set<string>) => void;
}>;
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
this.database = database;
this.config = config;
this.logger = logger;
this.avatarStore = avatarStore;
this.permissions = permissions;
this.configPath = configPath;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -56,17 +126,25 @@ export class BotManager {
queryPort: params.queryPort ?? 10011,
nickname: params.nickname,
defaultChannel: params.defaultChannel,
channelId: params.channelId,
channelPassword: params.channelPassword,
serverPassword: params.serverPassword,
serverProtocol: params.serverProtocol,
ts6ApiKey: params.ts6ApiKey,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
this.database.saveBotInstance({
id,
@@ -75,8 +153,12 @@ export class BotManager {
serverPort: params.serverPort,
nickname: params.nickname,
defaultChannel: params.defaultChannel ?? "",
channelId: params.channelId ?? "",
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 +172,13 @@ export class BotManager {
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
this.permissions.pruneBot(id);
// Prune the deleted bot from the guest scope allow-list (mirrors permissions.pruneBot).
if (Array.isArray(this.config.guestMode.bots) && this.config.guestMode.bots.includes(id)) {
this.config.guestMode.bots = this.config.guestMode.bots.filter((b) => b !== id);
saveConfig(this.configPath, this.config);
}
this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed");
}
@@ -105,7 +194,11 @@ export class BotManager {
serverPort: params.serverPort ?? existing.serverPort,
nickname: params.nickname ?? existing.nickname,
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
channelId: params.channelId ?? existing.channelId,
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);
@@ -127,16 +220,75 @@ export class BotManager {
return Array.from(this.bots.values());
}
async startBot(id: string): Promise<void> {
const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`);
await bot.connect();
/** Local upload ids still referenced by any bot's queue. The local provider
* uses this to avoid deleting a file another queue/bot is still using. */
getReferencedLocalSongIds(): Set<string> {
const ids = new Set<string>();
for (const bot of this.bots.values()) {
for (const song of bot.getQueueManager().list()) {
if (song.platform === "local") ids.add(song.id);
}
}
return ids;
}
// Mark as autoStart so it reconnects on Docker restart, and persist identity
async startBot(id: string): Promise<void> {
const oldBot = this.bots.get(id);
if (!oldBot) throw new Error(`Bot ${id} not found`);
// 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,
channelId: saved.channelId || 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,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
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 +307,36 @@ 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,
channelId: saved.channelId || 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,
localProvider: this.localProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(saved.id, bot);
this.emit("botInstance", bot);
// Only auto-connect bots that have autoStart enabled
if (saved.autoStart) {
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BotProfileManager } from "./profile.js";
import type { TS3Client } from "../ts-protocol/client.js";
import type { QueuedSong } from "../audio/queue.js";
function makeMockTs(): TS3Client & {
uploadCalls: Buffer[];
clearCalls: number;
} {
const calls: Buffer[] = [];
let clears = 0;
const ts: any = {
uploadCalls: calls,
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
calls.push(Buffer.concat(chunks));
}),
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
if (/client_flag_avatar=$/.test(cmd)) clears++;
}),
};
return ts;
}
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
const cfgOff = { ...cfgOn, avatarEnabled: false };
const fakeSong: QueuedSong = {
id: "1",
name: "X",
artist: "Y",
album: "Z",
platform: "netease",
url: "u",
coverUrl: "c",
duration: 100,
};
const flush = () => new Promise((r) => setImmediate(r));
describe("BotProfileManager custom avatar precedence", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("setCustomAvatar uploads immediately on a fresh idle bot (sync on)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1, 2, 3]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("setCustomAvatar uploads immediately when sync is off (always idle)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([7]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
});
it("setCustomAvatar while playing + sync on does NOT push (cover wins)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
// Simulate the bot playing a song. We can't actually run updateAvatar's
// full HTTP fetch path, but onSongChange records currentSong before
// updateAvatar runs, which is enough for this assertion.
void pm.onSongChange(fakeSong);
await flush();
const uploadsBefore = ts.uploadCalls.length;
pm.setCustomAvatar(Buffer.from([42]));
await flush();
expect(ts.uploadCalls.length).toBe(uploadsBefore); // no new upload
});
it("setCustomAvatar while playing + sync off DOES push (sync-off is idle)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
void pm.onSongChange(fakeSong);
await flush();
const uploadsBefore = ts.uploadCalls.length;
pm.setCustomAvatar(Buffer.from([42]));
await flush();
expect(ts.uploadCalls.length).toBe(uploadsBefore + 1);
});
it("setCustomAvatar(null) while idle clears the TS3 avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1]));
await flush();
const clearsBefore = ts.clearCalls;
pm.setCustomAvatar(null);
await flush();
expect(ts.clearCalls).toBe(clearsBefore + 1);
});
it("on stop with custom avatar set + sync on, restores custom (does not clear)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1, 2, 3, 4]));
await flush();
const clearsBefore = ts.clearCalls;
await pm.onSongChange(null);
expect(ts.uploadCalls.at(-1)?.equals(Buffer.from([1, 2, 3, 4]))).toBe(true);
expect(ts.clearCalls).toBe(clearsBefore); // no extra clear
});
it("on stop with no custom avatar, falls back to clear", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
expect(ts.uploadCalls.length).toBe(0);
});
it("on connect with custom avatar set + sync ON, applies custom (spec matrix row 1)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([5, 5]));
await flush();
ts.uploadCalls.length = 0; // reset
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([5, 5]))).toBe(true);
});
it("on connect with custom avatar set + sync OFF, applies custom", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([9, 9]));
await flush();
ts.uploadCalls.length = 0;
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
});
it("on connect with no custom avatar, does not touch avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
});
+500
View File
@@ -0,0 +1,500 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import axios from "axios";
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
import { HttpQueryError } from "../ts-protocol/http-query.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;
private customAvatar: Buffer | null = null;
/**
* Tracks the last song handed to onSongChange. null means stopped/idle.
* Used by setCustomAvatar to decide whether the new buffer should be
* pushed immediately (idle) or wait for the next stop event (playing).
*/
private currentSong: QueuedSong | null = null;
/** 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 ---
/**
* Set/clear the persistent idle avatar. Pass null to remove.
*
* If the bot is currently in an idle state (no song playing OR
* avatarEnabled is off), the new buffer is pushed to TS3 right away;
* otherwise the cover-art sync is in charge until the next stop event,
* at which point clearAvatar restores from this.customAvatar.
*/
setCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
const idle = this.currentSong === null || !this.config.avatarEnabled;
if (!idle) return;
const gen = ++this.generation;
if (buffer && buffer.length > 0) {
void this.applyIdleAvatar(gen);
} else {
void this.clearAvatar(gen);
}
}
/**
* 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;
this.currentSong = song;
// 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.currentSong = null;
this.permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
// No song is playing on a fresh connect, so the matrix says the
// custom avatar should be visible regardless of avatarEnabled.
if (this.customAvatar) {
const gen = this.generation;
void this.applyIdleAvatar(gen);
}
}
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.
const start = Date.now();
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
this.logger.info(
{ bytes: imageBuffer.length, elapsedMs: Date.now() - start },
"Avatar updated",
);
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
/**
* Three-step upload. Each step is logged so the log can tell us whether
* a broken/loading avatar on the client is from:
* (a) init failing (no permission)
* (b) file transfer hanging on TCP 30033
* (c) client_flag_avatar not applying
* If (b) happens, the avatar MD5 would still be set in the past — leaving
* clients showing a placeholder. The flag is now only set after the TCP
* transfer resolves.
*/
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
const host = this.tsClient.getHost();
this.logger.debug({ bytes: imageBuffer.length, host }, "Avatar: init file transfer");
const info = await this.tsClient.fileTransferInitUpload(
0n, "/avatar", "", BigInt(imageBuffer.length), true,
);
this.logger.debug({ bytes: imageBuffer.length }, "Avatar: uploading file data");
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
const md5 = createHash("md5").update(imageBuffer).digest("hex");
this.logger.debug({ md5 }, "Avatar: setting client_flag_avatar");
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
}
private async clearAvatar(gen: number): Promise<void> {
if (this.customAvatar && this.customAvatar.length > 0) {
await this.applyIdleAvatar(gen);
return;
}
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
}
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async applyIdleAvatar(gen: number): Promise<void> {
if (!this.customAvatar || this.customAvatar.length === 0) return;
if (this.permDenied.avatar) return;
try {
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
if (this.generation !== gen) return;
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
} 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) {
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
// be misreported as success.
const result = await httpQuery.clientUpdate({ client_description: text });
this.logger.info({ status: result.status }, "Description updated");
} 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.
*
* Values are collected as raw strings/numbers. The TS6 HTTP path
* forwards them as JSON (the server expects real spaces, not `\s`);
* the TS3 wire path escapes them on the fly. Previously the code
* escaped upfront and then split the escaped string to build the
* JSON body, so TS6 received literal backslashes and silently
* rejected the update.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
const rawProps: Record<string, string | number> = {};
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
rawProps.client_nickname = this.defaultNickname;
} else {
const nickname = this.buildNickname(song);
if (nickname) {
rawProps.client_nickname = nickname;
}
}
}
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
rawProps.client_away = 0;
} else {
rawProps.client_away = 1;
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
}
}
if (Object.keys(rawProps).length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
const result = await httpQuery.clientUpdate(rawProps);
this.logger.info(
{ status: result.status, props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
} else {
// TS3 wire protocol: escape string values inline.
// sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
const parts = Object.entries(rawProps).map(([k, v]) =>
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
);
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
this.logger.info(
{ props: Object.keys(rawProps) },
"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();
const status = err instanceof HttpQueryError ? err.status : undefined;
const body = err instanceof HttpQueryError ? err.body : undefined;
// Disable the feature for this session on unrecoverable errors:
// - permission / insufficient → server denies the action
// - invalid parameter → command not supported by this protocol
// - HTTP 401/403 → TS6 server rejects the API key/role
// - HTTP 400 → bad parameter; retrying on every song change is wasteful
const isUnrecoverable =
msg.includes("permission") ||
msg.includes("insufficient") ||
msg.includes("invalid parameter") ||
status === 400 ||
status === 401 ||
status === 403;
if (isUnrecoverable) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, status, body, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, status, body, err }, "Profile update failed");
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
expect(parseSongRef("Die For You")).toBeNull();
expect(parseSongRef("周杰伦 晴天")).toBeNull();
expect(parseSongRef("")).toBeNull();
// A bare number is NOT treated as an id (a song may be named "2002").
expect(parseSongRef("2002")).toBeNull();
});
it("parses an explicit id: prefix with no platform (defer to flags)", () => {
expect(parseSongRef("id:185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("ID: 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
});
it("strips trailing punctuation from a pasted id:", () => {
expect(parseSongRef("id:185868.")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id:185868)")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
});
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
// These reuse ?id= but are not songs — they should fall through to search,
// not misresolve to getSongDetail(collectionId) and error "no song".
expect(parseSongRef("https://music.163.com/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/#/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/album?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/artist?id=185858")).toBeNull();
// A genuine song URL is still parsed.
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses NetEase song URLs", () => {
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("https://music.163.com/#/song?id=185868&userid=1")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("music.163.com/song/185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses QQ song URLs", () => {
expect(parseSongRef("https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
expect(parseSongRef("https://y.qq.com/n/yqq/song/abc.html?songmid=004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
});
it("parses BiliBili BV ids (bare or in a URL)", () => {
expect(parseSongRef("BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://www.bilibili.com/video/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://b23.tv/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
});
});
describe("parseSelectionIndex (#90 pick from last search)", () => {
it("parses #N tokens (1-based)", () => {
expect(parseSelectionIndex("#1")).toBe(1);
expect(parseSelectionIndex("#2")).toBe(2);
expect(parseSelectionIndex("# 3")).toBe(3);
expect(parseSelectionIndex(" #10 ")).toBe(10);
});
it("rejects non-selections", () => {
expect(parseSelectionIndex("2")).toBeNull();
expect(parseSelectionIndex("#0")).toBeNull();
expect(parseSelectionIndex("#-1")).toBeNull();
expect(parseSelectionIndex("Die For You")).toBeNull();
expect(parseSelectionIndex("#2 extra")).toBeNull();
expect(parseSelectionIndex("")).toBeNull();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single
* most-popular search hit (issue #90).
*
* Two mechanisms:
* - A song reference: an explicit id / platform URL → play that exact song.
* - A selection index: "#N" → the Nth result of the previous !search.
*/
export interface SongRef {
id: string;
/**
* Platform inferred from a URL. `null` means the platform wasn't encoded in
* the reference (e.g. a bare `id:`), so the caller should fall back to the
* command's flags / default provider.
*/
platform: "netease" | "qq" | "bilibili" | null;
}
/**
* Detect an explicit song reference in a query. Recognizes:
* - `id:<id>` → platform from flags/default
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
* Returns `null` for a plain search term (the common case).
*/
export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return null;
// Explicit "id:<id>" — platform decided by the command's flags/default.
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
// a "BV..." token never collides with them).
const bv = /BV[0-9A-Za-z]{8,12}/.exec(q);
if (bv && (/^BV[0-9A-Za-z]{8,12}$/.test(q) || /bilibili\.com|b23\.tv/i.test(q))) {
return { id: bv[0], platform: "bilibili" };
}
// NetEase song URL. Only treat `id=N` as a SONG id when the URL is not a
// collection page (playlist/album/artist/toplist/djradio) — those reuse the
// same `id=` param but are NOT songs; getSongDetail() would 404 them into a
// confusing "no song" error instead of falling back to a normal search.
if (/music\.163\.com/i.test(q) && !/(playlist|album|artist|toplist|djradio)/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/song\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
// QQ song URL.
if (/y\.qq\.com/i.test(q)) {
const m = /songDetail\/([0-9A-Za-z]+)/.exec(q) ?? /[?&]songmid=([0-9A-Za-z]+)/i.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
return null;
}
/**
* Detect a "#N" selection token (1-based) referencing the previous !search.
* Returns the positive integer, or `null` when the query isn't a selection.
*/
export function parseSelectionIndex(raw: string): number | null {
const m = /^#\s*(\d+)$/.exec((raw ?? "").trim());
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createAuditStore, type AuditStore } from "./audit.js";
describe("AuditStore", () => {
let botDb: BotDatabase;
let audit: AuditStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
audit = createAuditStore(botDb.db);
});
afterEach(() => botDb.close());
it("records and lists entries newest-first", async () => {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.created",
});
await new Promise((r) => setTimeout(r, 5));
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.deleted",
});
const list = audit.list(10, 0);
expect(list).toHaveLength(2);
expect(list[0].action).toBe("user.deleted");
expect(list[1].action).toBe("user.created");
});
it("supports limit and offset", () => {
for (let i = 0; i < 5; i++) {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: null, targetUsername: null,
action: "user.password_changed",
});
}
expect(audit.list(2, 0)).toHaveLength(2);
expect(audit.list(2, 4)).toHaveLength(1);
expect(audit.list(10, 10)).toHaveLength(0);
});
it("stores nullable fields correctly", () => {
audit.record({
actorId: null, actorUsername: null,
targetUserId: "x", targetUsername: "deleted-user",
action: "admin.first_created",
});
const e = audit.list(1, 0)[0];
expect(e.actorId).toBeNull();
expect(e.actorUsername).toBeNull();
expect(e.targetUserId).toBe("x");
});
});
+58
View File
@@ -0,0 +1,58 @@
import type Database from "better-sqlite3";
export type AuditAction =
| "admin.first_created"
| "user.created"
| "user.deleted"
| "user.password_reset"
| "user.password_changed"
| "user.role_changed"
| "user.permissions_changed";
export interface AuditEntry {
id: number;
timestamp: number;
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditRecordInput {
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditStore {
record(input: AuditRecordInput): void;
list(limit: number, offset: number): AuditEntry[];
}
export function createAuditStore(db: Database.Database): AuditStore {
const insertStmt = db.prepare(
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
);
const listStmt = db.prepare(
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
);
return {
record(input) {
insertStmt.run(
Date.now(),
input.actorId,
input.actorUsername,
input.targetUserId,
input.targetUsername,
input.action
);
},
list(limit, offset) {
return listStmt.all(limit, offset) as AuditEntry[];
},
};
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAvatarStore } from "./avatars.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
});
describe("createAvatarStore", () => {
it("write returns a relative path under the store dir", () => {
const store = createAvatarStore(dir);
const buf = Buffer.from("fake-png");
const rel = store.write("bot-1", "image/png", buf);
expect(rel).toBe("bot-1.png");
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
});
it("write picks correct extension for jpeg / webp", () => {
const store = createAvatarStore(dir);
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
});
it("write rejects unsupported MIME types", () => {
const store = createAvatarStore(dir);
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(/unsupported/i);
});
it("read returns the bytes for an existing file", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("hello"));
const buf = store.read("bot-1.png");
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
});
it("read returns null when path is missing", () => {
const store = createAvatarStore(dir);
expect(store.read("missing.png")).toBeNull();
});
it("remove deletes the file (idempotent)", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("x"));
store.remove("bot-1.png");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(() => store.remove("bot-1.png")).not.toThrow();
});
it("write replaces any existing file for the same botId regardless of old extension", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("old"));
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
expect(rel).toBe("bot-1.jpg");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export interface AvatarStore {
/** Returns the relative path written (e.g. "bot-1.png"). */
write(botId: string, mime: string, buffer: Buffer): string;
read(relPath: string): Buffer | null;
remove(relPath: string): void;
getDir(): string;
}
export function createAvatarStore(dir: string): AvatarStore {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return {
write(botId, mime, buffer) {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
for (const name of readdirSync(dir)) {
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
}
const rel = `${botId}.${ext}`;
writeFileSync(join(dir, rel), buffer);
return rel;
},
read(relPath) {
const full = join(dir, relPath);
if (!existsSync(full)) return null;
return readFileSync(full);
},
remove(relPath) {
rmSync(join(dir, relPath), { force: true });
},
getDir() {
return dir;
},
};
}
+156 -3
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { getDefaultConfig, loadConfig, saveConfig } from "./config.js";
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
describe("config", () => {
const dirs: string[] = [];
@@ -49,6 +49,159 @@ describe("config", () => {
// defaults should fill in the rest
expect(loaded.theme).toBe("dark");
expect(loaded.commandPrefix).toBe("!");
expect(loaded.autoPauseOnEmpty).toBe(true);
// auto-pause defaults OFF (occupancy detection is unreliable on some servers)
expect(loaded.autoPauseOnEmpty).toBe(false);
});
// --- #86: config.json must live under (and be created in) the persisted data dir ---
it("first run writes config.json into the data dir and reads it back", () => {
const root = makeTmpDir();
const dataDir = join(root, "data");
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
// Boot sequence: load (missing -> defaults) then save.
const config = loadConfig(configPath);
saveConfig(configPath, config);
expect(existsSync(configPath)).toBe(true);
// A subsequent hand-edited file under the SAME persisted path is honored.
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
expect(loadConfig(configPath).webPort).toBe(9999);
});
it("migrates a legacy root config into the data dir, preserving values", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(true);
expect(existsSync(newPath)).toBe(true);
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
const loaded = loadConfig(newPath);
expect(loaded.webPort).toBe(4242);
expect(loaded.publicUrl).toBe("http://x");
});
it("does NOT overwrite an existing data-dir config during migration", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(false); // new location wins, untouched
expect(loadConfig(newPath).webPort).toBe(2222);
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
});
it("migration is a no-op when there is no legacy config", () => {
const root = makeTmpDir();
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
expect(migrated).toBe(false);
});
});
describe("guestMode config", () => {
it("defaults to disabled, all-bots, append-only", () => {
const c = getDefaultConfig();
expect(c.guestMode.enabled).toBe(false);
expect(c.guestMode.bots).toBe("all");
expect(c.guestMode.permissions).toEqual({
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
playCollection: false,
});
});
it("deep-merges a partial guestMode so missing sub-keys are back-filled", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify({ guestMode: { enabled: true, permissions: { playNext: true } } }));
const c = loadConfig(p);
expect(c.guestMode.enabled).toBe(true);
expect(c.guestMode.bots).toBe("all"); // back-filled
expect(c.guestMode.permissions.playNext).toBe(true);
expect(c.guestMode.permissions.addToQueue).toBe(true); // back-filled default
expect(c.guestMode.permissions.skip).toBe(false); // back-filled default
rmSync(dir, { recursive: true, force: true });
});
// --- B1: loadConfig must sanitize a hand-edited/legacy/corrupt guestMode ---
function loadGuestMode(raw: unknown) {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify(raw));
try {
return loadConfig(p).guestMode;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("bots normalization", () => {
it("a numeric bots value falls back to the default \"all\" (no crash)", () => {
const gm = loadGuestMode({ guestMode: { bots: 5 } });
expect(gm.bots).toBe("all");
});
it("an array bots value is filtered to strings only", () => {
const gm = loadGuestMode({ guestMode: { bots: ["a", 2, "b"] } });
expect(gm.bots).toEqual(["a", "b"]);
});
it("the literal \"all\" is preserved", () => {
const gm = loadGuestMode({ guestMode: { bots: "all" } });
expect(gm.bots).toBe("all");
});
});
describe("permissions coercion", () => {
it("a non-boolean truthy flag is coerced to false; a real true stays true", () => {
const gm = loadGuestMode({ guestMode: { permissions: { skip: 1, playNext: true } } });
expect(gm.permissions.skip).toBe(false);
expect(gm.permissions.playNext).toBe(true);
});
it("a string permissions value yields defaults with no numeric index keys", () => {
const gm = loadGuestMode({ guestMode: { permissions: "hacked" } });
// all known flags present at their defaults
expect(gm.permissions).toEqual({
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
playCollection: false,
});
// no garbage index keys leaked from spreading a string
expect((gm.permissions as unknown as Record<string, unknown>)["0"]).toBeUndefined();
});
});
});
describe("adminGroups normalization", () => {
function loadAdminGroups(raw: unknown) {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify(raw));
try {
return loadConfig(p).adminGroups;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("defaults to [] when absent", () => {
expect(loadAdminGroups({})).toEqual([]);
});
it("keeps valid non-negative integers", () => {
expect(loadAdminGroups({ adminGroups: [6, 8] })).toEqual([6, 8]);
});
it("filters out negatives, non-integers and non-numbers", () => {
expect(loadAdminGroups({ adminGroups: [6, -1, 2.5, "8", null] })).toEqual([6]);
});
it("a non-array value falls back to the default [] (no crash)", () => {
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]);
});
});
Regular → Executable
+122 -3
View File
@@ -1,5 +1,13 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
import { dirname } from "node:path";
import type { BotAccess, GuestPermissions } from "./permissions.js";
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
export interface GuestModeConfig {
enabled: boolean;
bots: BotAccess; // "all" | string[]
permissions: GuestPermissions;
}
export interface BotConfig {
webPort: number;
@@ -13,6 +21,18 @@ export interface BotConfig {
adminGroups: number[];
autoReturnDelay: number;
autoPauseOnEmpty: boolean;
idleTimeoutMinutes: number;
/** Enable uploading and playback of server-stored local audio files. */
localAudioEnabled: boolean;
// Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000"
publicUrl: string;
// When true, Express trusts X-Forwarded-* headers from a reverse proxy
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
// behind HTTPS-terminating proxies.
trustProxy: boolean;
guestMode: GuestModeConfig;
}
export function getDefaultConfig(): BotConfig {
@@ -27,7 +47,28 @@ export function getDefaultConfig(): BotConfig {
adminPassword: "",
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
// Default OFF: occupancy detection relies on the full-client `clientlist`
// command, which is unreliable on some servers (it can time out when other
// clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false,
idleTimeoutMinutes: 0,
localAudioEnabled: true,
publicUrl: "",
trustProxy: false,
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true,
playNext: false,
playNow: false,
skip: false,
transport: false,
removeClear: false,
playMode: false,
playCollection: false,
},
},
};
}
@@ -36,7 +77,53 @@ export function loadConfig(path: string): BotConfig {
try {
const raw = readFileSync(path, "utf-8");
const partial = JSON.parse(raw) as Partial<BotConfig>;
return { ...defaults, ...partial };
// Normalize/sanitize guestMode on load. The WRITE path (POST /api/bot/settings)
// sanitizes too, but a hand-edited/legacy/corrupt config.json reaches the gate
// directly — so coerce it here as well, mirroring that write-path logic.
const partialGm = (partial.guestMode ?? {}) as Partial<GuestModeConfig>;
const gm: GuestModeConfig = {
...defaults.guestMode,
...partialGm,
// bots → "all" | string[]; anything else falls back to the default ("all").
bots:
partialGm.bots === "all"
? "all"
: Array.isArray(partialGm.bots)
? partialGm.bots.filter((id): id is string => typeof id === "string")
: defaults.guestMode.bots,
// permissions → defaults, then spread ONLY a plain object, then strict-coerce
// each known flag to a boolean (drops index keys + non-boolean values).
permissions: { ...defaults.guestMode.permissions },
};
const partialPerms = partialGm.permissions;
if (
partialPerms !== null &&
typeof partialPerms === "object" &&
!Array.isArray(partialPerms)
) {
Object.assign(gm.permissions, partialPerms);
}
for (const f of GUEST_PERMISSION_FLAGS) {
gm.permissions[f] = gm.permissions[f] === true;
}
// Sanitize adminGroups on load too: the WebUI write path filters it, but a
// hand-edited / legacy / corrupt config.json reaches the command gate
// directly. Keep only non-negative integers; a non-array falls back to the
// default []. Mirrors the guestMode sanitization above.
const adminGroups = Array.isArray(partial.adminGroups)
? partial.adminGroups.filter(
(g): g is number => typeof g === "number" && Number.isInteger(g) && g >= 0,
)
: defaults.adminGroups;
return {
...defaults,
...partial,
adminGroups,
guestMode: gm,
};
} catch {
return defaults;
}
@@ -46,3 +133,35 @@ export function saveConfig(path: string, config: BotConfig): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
}
/**
* One-time migration for the config location fix (#86).
*
* Older versions wrote config.json to the app/repo ROOT, which is NOT inside the
* persisted data directory (the Docker volume is mounted at data/). That meant the
* file never landed in the volume on first run and a manually-placed data/config.json
* was ignored. config.json now lives under the data dir alongside the DB/cookies/logs.
*
* If a legacy root-level config exists and the new data-dir config does not yet exist,
* move it so existing local installs keep their customized settings. Best-effort:
* any failure is swallowed and loadConfig falls back to defaults.
*
* @returns true if a legacy config was migrated, false otherwise.
*/
export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean {
try {
if (legacyPath === newPath) return false;
if (existsSync(newPath)) return false; // new location already populated — leave it
if (!existsSync(legacyPath)) return false; // nothing to migrate
mkdirSync(dirname(newPath), { recursive: true });
copyFileSync(legacyPath, newPath); // copy first (works across filesystems)
try {
rmSync(legacyPath);
} catch {
/* leave the legacy file if it can't be removed; the new one wins */
}
return true;
} catch {
return false;
}
}
+85 -1
View File
@@ -1,5 +1,9 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
import { createUserStore, GUEST_USER_ID } from "./users.js";
describe("database", () => {
let botDb: BotDatabase;
@@ -23,6 +27,30 @@ describe("database", () => {
expect(names).toContain("bot_instances");
});
it("creates users and sessions tables on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
const names = tables.map((t) => t.name);
expect(names).toContain("users");
expect(names).toContain("sessions");
const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userCols.map((c) => c.name).sort();
expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "role", "updatedAt", "username"]);
const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
const sessionColNames = sessionCols.map((c) => c.name).sort();
expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]);
});
it("creates user_audit table on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
expect(tables.map((t) => t.name)).toContain("user_audit");
});
it("records and retrieves play history", () => {
botDb.addPlayHistory({
botId: "bot1",
@@ -58,14 +86,18 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: true,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(instance);
const instances = botDb.getBotInstances();
expect(instances).toHaveLength(1);
expect(instances[0]).toEqual(instance);
expect(instances[0]).toMatchObject(instance);
expect(instances[0].autoStart).toBe(true);
// Test upsert
@@ -84,12 +116,64 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
expect(botDb.deleteBotInstance("bot1")).toBe(true);
expect(botDb.getBotInstances()).toHaveLength(0);
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
});
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
});
});
describe("guest principal migration", () => {
it("creates exactly one reserved guest row, idempotently", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-db-"));
const p = join(dir, "t.db");
const a = createDatabase(p); a.db.close();
const b = createDatabase(p); // run again — must not duplicate
const row = b.db.prepare("SELECT id, role FROM users WHERE id = ?").get(GUEST_USER_ID) as { id: string; role: string } | undefined;
expect(row?.role).toBe("guest");
const n = (b.db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='guest'").get() as { n: number }).n;
expect(n).toBe(1);
b.db.close();
rmSync(dir, { recursive: true, force: true });
});
it("guest row does not break first-run detection (countUsers excludes it)", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-db2-"));
const p = join(dir, "t.db");
const d = createDatabase(p);
const users = createUserStore(d.db);
expect(users.countUsers()).toBe(0); // guest excluded → still needs setup
d.db.close();
rmSync(dir, { recursive: true, force: true });
});
});
+288 -6
View File
@@ -1,4 +1,6 @@
import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
export interface PlayHistoryEntry {
botId: string;
@@ -6,7 +8,7 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
coverUrl: string;
}
@@ -22,11 +24,47 @@ export interface BotInstance {
serverPort: number;
nickname: string;
defaultChannel: string;
channelId: 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 FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -34,16 +72,58 @@ export interface BotDatabase {
saveBotInstance(instance: BotInstance): void;
getBotInstances(): BotInstance[];
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean;
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 ''");
}
if (!names.includes("channelId")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN channelId 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`);
}
}
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name);
if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
}
}
function initTables(db: Database.Database): void {
@@ -67,18 +147,122 @@ function initTables(db: Database.Database): void {
serverPort INTEGER NOT NULL,
nickname TEXT NOT NULL,
defaultChannel TEXT NOT NULL,
channelId TEXT NOT NULL DEFAULT '',
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
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL,
lastSeenAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
CREATE TABLE IF NOT EXISTS user_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
actorId TEXT,
actorUsername TEXT,
targetUserId TEXT,
targetUsername TEXT,
action TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
CREATE TABLE IF NOT EXISTS favorite_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId TEXT NOT NULL,
platform TEXT NOT NULL,
playlistId TEXT NOT NULL,
name TEXT NOT NULL,
coverUrl TEXT NOT NULL DEFAULT '',
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(userId, platform, playlistId)
);
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
`);
}
/**
* One-time backfill: existing `member` users created before the
* account-permissions feature are granted full access (all 5 capabilities +
* the `bots.all` marker), exactly once per database. Admins are skipped (they
* bypass permission checks). New members created after this runs are not
* affected — they get the basic tier via POST /api/users. A marker row in
* `schema_meta` makes this idempotent.
*/
export function backfillMemberPermissions(db: Database.Database): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (done) return;
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tokens = [...CAPABILITIES, BOTS_ALL];
const tx = db.transaction(() => {
for (const m of members) {
for (const t of tokens) insCap.run(m.id, t);
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length));
});
tx();
}
/**
* Ensure the reserved guest principal exists. Idempotent via the PK on
* `users.id`. This row only backs login-less guest sessions; it is excluded
* from countUsers()/listUsers() so it never interferes with first-run setup
* or the user-management UI, and holds an unusable password hash.
*/
export function ensureGuestUser(db: Database.Database): void {
const now = Date.now();
db.prepare(
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')"
).run(GUEST_USER_ID, GUEST_USERNAME, now, now);
}
export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initTables(db);
migrateSchema(db);
backfillMemberPermissions(db);
ensureGuestUser(db);
const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
@@ -90,16 +274,20 @@ 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, channelId, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelId, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
serverAddress = excluded.serverAddress,
serverPort = excluded.serverPort,
nickname = excluded.nickname,
defaultChannel = excluded.defaultChannel,
channelId = excluded.channelId,
channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart,
serverProtocol = excluded.serverProtocol,
ts6ApiKey = excluded.ts6ApiKey,
serverPassword = excluded.serverPassword,
identity = excluded.identity
`);
@@ -107,6 +295,45 @@ 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
`);
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
const insertFavorite = db.prepare(`
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
`);
const deleteFavorite = db.prepare(`
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
const selectFavorites = db.prepare(`
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
`);
const checkFavorited = db.prepare(`
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
return {
db,
@@ -133,6 +360,10 @@ export function createDatabase(dbPath: string): BotDatabase {
return rows.map((r) => ({
...r,
autoStart: r.autoStart === 1,
serverProtocol: r.serverProtocol ?? "",
ts6ApiKey: r.ts6ApiKey ?? "",
serverPassword: r.serverPassword ?? "",
channelId: r.channelId ?? "",
identity: r.identity ?? undefined,
}));
},
@@ -142,6 +373,57 @@ 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,
});
},
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
},
setCustomAvatarPath(botId, path) {
updateCustomAvatar.run(path, botId);
},
addFavorite(userId, playlist) {
insertFavorite.run({ userId, ...playlist });
},
removeFavorite(userId, playlistId, platform) {
const result = deleteFavorite.run(userId, playlistId, platform);
return result.changes > 0;
},
getFavorites(userId) {
return selectFavorites.all(userId) as FavoritePlaylist[];
},
isFavorited(userId, playlistId, platform) {
const row = checkFavorited.get(userId, playlistId, platform);
return row !== undefined;
},
close() {
db.close();
},
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js";
import { createPermissionStore, CAPABILITIES } from "./permissions.js";
describe("backfillMemberPermissions", () => {
let dbFile: string;
let db: BotDatabase;
function fresh() {
dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
}
afterEach(() => {
db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.rmSync(dbFile + s, { force: true });
} catch {}
}
});
it("grants existing members full access + bots.all, skips admins, once", () => {
fresh();
// simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
const ins = db.db.prepare(
"INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)"
);
ins.run("m1", "mem", "x", now, now, "member");
ins.run("a1", "adm", "x", now, now, "admin");
backfillMemberPermissions(db.db);
const store = createPermissionStore(db.db);
expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort());
expect(store.getBotAccess("m1")).toBe("all");
expect(store.getCapabilities("a1")).toEqual([]);
expect(store.getBotAccess("a1")).toEqual([]);
});
it("is idempotent — running again does not change or re-grant", () => {
fresh();
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
db.db
.prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)")
.run("m1", "mem", "x", now, now, "member");
backfillMemberPermissions(db.db);
// member restricted afterwards
createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] });
// second run must NOT re-grant (marker present)
backfillMemberPermissions(db.db);
expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES, resolvePermissionContext } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
});
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
describe("resolvePermissionContext guest branch", () => {
const noStore = {
getCapabilities: () => [],
getBotAccess: () => [] as string[],
setPermissions: () => {},
pruneBot: () => {},
};
it("guest has no member capabilities and exposes the guest permissions + bots", () => {
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
bots: ["bot1"],
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: true, transport: false, removeClear: false, playMode: false,
playCollection: false,
},
});
expect([...ctx.capabilities]).toEqual([]);
expect(ctx.bots).toBeInstanceOf(Set);
expect((ctx.bots as Set<string>).has("bot1")).toBe(true);
expect(ctx.guest?.addToQueue).toBe(true);
expect(ctx.guest?.skip).toBe(true);
});
it("guest with bots:'all' resolves to 'all'", () => {
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
bots: "all",
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
playCollection: false,
},
});
expect(ctx.bots).toBe("all");
});
it("exposes the 8 canonical flags", () => {
expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual(
["addToQueue", "playCollection", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
);
});
});
+123
View File
@@ -0,0 +1,123 @@
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface GuestPermissions {
addToQueue: boolean;
playNext: boolean;
playNow: boolean;
skip: boolean;
transport: boolean;
removeClear: boolean;
playMode: boolean;
/** Load + play an entire playlist/album (clears the queue). Issue #103. */
playCollection: boolean;
}
export const GUEST_PERMISSION_FLAGS = [
"addToQueue",
"playNext",
"playNow",
"skip",
"transport",
"removeClear",
"playMode",
"playCollection",
] as const;
export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
guest?: GuestPermissions;
}
export function resolvePermissionContext(
role: "admin" | "member" | "guest",
userId: string,
store: PermissionStore,
guest?: { bots: BotAccess; permissions: GuestPermissions }
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
if (role === "guest") {
const bots = guest?.bots ?? [];
return {
capabilities: new Set<string>(),
bots: bots === "all" ? "all" : new Set(bots),
guest: guest?.permissions,
};
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
+199
View File
@@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.js";
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER, GUEST_SESSION_TTL_MS } from "./sessions.js";
function sha256(token: string) {
return createHash("sha256").update(token).digest("hex");
}
describe("SessionStore", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let userId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
userId = u.id;
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
const { token } = sessions.createSession(userId);
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
expect(row.id).toBe(sha256(token));
expect(row.id).not.toBe(token);
});
it("validateAndTouch returns the user for a fresh token", () => {
const { token } = sessions.createSession(userId);
const result = sessions.validateAndTouch(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(userId);
expect(result!.username).toBe("alice");
expect(result!.role).toBe("admin");
});
it("validateAndTouch returns null and deletes the row for an expired session", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
expect(sessions.validateAndTouch(token)).toBeNull();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
});
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
sessions.validateAndTouch(token);
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
expect(after.lastSeenAt).toBe(before.lastSeenAt);
});
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
sessions.validateAndTouch(token);
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
expect(row.lastSeenAt).toBe(Date.now());
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
});
it("deleteSession removes the row", () => {
const { token } = sessions.createSession(userId);
sessions.deleteSession(token);
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
expect(sessions.validateAndTouch(token)).toBeNull();
});
it("deleteAllForUser keeps the exceptToken session", () => {
const a = sessions.createSession(userId);
const b = sessions.createSession(userId);
sessions.deleteAllForUser(userId, a.token);
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
expect(sessions.validateAndTouch(b.token)).toBeNull();
});
it("cleanupExpired removes only expired rows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
sessions.createSession(userId); // expires later
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
sessions.createSession(userId); // fresh
sessions.cleanupExpired();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(1);
});
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
// Create MAX + 2 sessions for the same user.
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
tokens.push(sessions.createSession(userId).token);
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
}
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
// The first two should have been evicted, the last MAX remain
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
});
it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => {
// better-sqlite3 transactions are serialised at the engine level. Calling
// createSession N times sequentially via Promise.all proves atomic check+insert.
const N = MAX_SESSIONS_PER_USER + 3;
await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId))));
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
});
});
describe("guest sessions", () => {
let botDb: BotDatabase;
let sessions: SessionStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
sessions = createSessionStore(botDb.db);
// Create the synthetic guest user row to satisfy the sessions FK.
botDb.db
.prepare("INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')")
.run(Date.now(), Date.now());
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => {
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) {
tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token);
}
// The first token must STILL validate (not evicted).
expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest");
const n = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n;
expect(n).toBe(MAX_SESSIONS_PER_USER + 3);
});
it("ttlMs sets a shorter expiry than the default", () => {
const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50);
});
it("validateAndTouch refreshes a guest session to GUEST_SESSION_TTL_MS (1d), not SESSION_TTL_MS (7d)", () => {
const { token } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
// Force the touch branch: backdate lastSeenAt past the touch interval.
botDb.db
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = '__guest__'")
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
const result = sessions.validateAndTouch(token);
expect(result?.role).toBe("guest");
const row = botDb.db
.prepare("SELECT expiresAt FROM sessions WHERE userId = '__guest__'")
.get() as { expiresAt: number };
// Should refresh to ~now + 1 day, NOT now + 7 days.
expect(row.expiresAt).toBeGreaterThan(Date.now() + GUEST_SESSION_TTL_MS - 5000);
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 5000);
// Sanity: well below the 7d window.
expect(row.expiresAt).toBeLessThan(Date.now() + SESSION_TTL_MS);
});
it("validateAndTouch still refreshes a non-guest (admin) session to SESSION_TTL_MS (7d) on touch", () => {
botDb.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('admin1','adminuser','!',?,?, 'admin')")
.run(Date.now(), Date.now());
const { token } = sessions.createSession("admin1");
botDb.db
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = 'admin1'")
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
const result = sessions.validateAndTouch(token);
expect(result?.role).toBe("admin");
const row = botDb.db
.prepare("SELECT expiresAt FROM sessions WHERE userId = 'admin1'")
.get() as { expiresAt: number };
// Refreshes to ~now + 7 days, NOT the 1d guest window.
expect(row.expiresAt).toBeGreaterThan(Date.now() + SESSION_TTL_MS - 5000);
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + SESSION_TTL_MS + 5000);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { createHash, randomBytes } from "node:crypto";
import type Database from "better-sqlite3";
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const MAX_SESSIONS_PER_USER = 10;
export interface SessionValidation {
userId: string;
username: string;
role: "admin" | "member" | "guest";
}
export interface SessionStore {
createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number };
validateAndTouch(rawToken: string): SessionValidation | null;
deleteSession(rawToken: string): void;
deleteAllForUser(userId: string, exceptToken?: string): void;
cleanupExpired(): void;
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function createSessionStore(db: Database.Database): SessionStore {
const insertStmt = db.prepare(
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
);
const selectStmt = db.prepare(`
SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username, u.role
FROM sessions s INNER JOIN users u ON u.id = s.userId
WHERE s.id = ?
`);
const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
const touchStmt = db.prepare(
"UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?"
);
const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?");
const deleteAllForUserExceptStmt = db.prepare(
"DELETE FROM sessions WHERE userId = ? AND id != ?"
);
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?");
const deleteOldestForUserStmt = db.prepare(
"DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)"
);
return {
createSession(userId, opts) {
// Cap concurrent sessions per user — oldest gets evicted on overflow.
// Wrap the count → delete → insert in a transaction so concurrent logins
// for the same user can't both pass the cap check and both insert,
// ending up 1 over cap (race window between count and insert).
const token = randomBytes(32).toString("base64url");
const id = hashToken(token);
const now = Date.now();
const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
const tx = db.transaction(() => {
if (!opts?.skipCap) {
const existing = (countForUserStmt.get(userId) as { n: number }).n;
if (existing >= MAX_SESSIONS_PER_USER) {
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
}
}
insertStmt.run(id, userId, now, expiresAt, now);
});
tx();
return { token, expiresAt };
},
validateAndTouch(rawToken) {
if (!rawToken) return null;
const id = hashToken(rawToken);
const row = selectStmt.get(id) as
| { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string; role: string }
| undefined;
if (!row) return null;
const now = Date.now();
if (row.expiresAt < now) {
deleteByIdStmt.run(id);
return null;
}
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
// Refresh against the role's own TTL — guests are short-lived (1d) and
// must NOT be bumped to the member/admin 7d window on touch.
const ttl = row.role === "guest" ? GUEST_SESSION_TTL_MS : SESSION_TTL_MS;
touchStmt.run(now, now + ttl, id);
}
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" };
},
deleteSession(rawToken) {
deleteByIdStmt.run(hashToken(rawToken));
},
deleteAllForUser(userId, exceptToken) {
if (exceptToken) {
deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken));
} else {
deleteAllForUserStmt.run(userId);
}
},
cleanupExpired() {
cleanupStmt.run(Date.now());
},
};
}
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js";
describe("UserStore", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers is 0 on a fresh db", () => {
expect(users.countUsers()).toBe(0);
});
it("createUser stores the user and bumps countUsers", async () => {
const u = await users.createUser("alice", "pw-hunter2", "member");
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
expect(u.username).toBe("alice");
expect(users.countUsers()).toBe(1);
});
it("findByUsername is case-insensitive and returns null for missing", async () => {
await users.createUser("Alice", "pw-alice", "member");
expect(users.findByUsername("ALICE")).not.toBeNull();
expect(users.findByUsername("alice")).not.toBeNull();
expect(users.findByUsername("bob")).toBeNull();
});
it("createUser rejects duplicate usernames (case-insensitive)", async () => {
await users.createUser("Alice", "pw-alice", "member");
await expect(users.createUser("alice", "pw-alice-2", "member")).rejects.toBeInstanceOf(UsernameTakenError);
});
it("verifyPassword accepts correct password and rejects wrong one", async () => {
await users.createUser("alice", "correct-horse-battery-staple", "member");
const row = users.findByUsername("alice");
expect(row).not.toBeNull();
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true);
expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false);
});
it("changePassword updates the hash so the old password no longer verifies", async () => {
const u = await users.createUser("alice", "old-pw-pw", "member");
await users.changePassword(u.id, "new-pw-pw");
const row = users.findByUsername("alice");
expect(await users.verifyPassword("old-pw-pw", row!.passwordHash)).toBe(false);
expect(await users.verifyPassword("new-pw-pw", row!.passwordHash)).toBe(true);
});
it("listUsers returns id+username+createdAt ascending, no password hash", async () => {
await users.createUser("alice", "pw-alice", "member");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
expect(list).toHaveLength(2);
expect(list[0].username).toBe("alice");
expect(list[1].username).toBe("bob");
expect(list[0]).not.toHaveProperty("passwordHash");
expect(list[0].id).toMatch(/^[0-9a-f-]{36}$/);
expect(typeof list[0].createdAt).toBe("number");
});
it("deleteUser removes the row and returns true; returns false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.deleteUser(u.id)).toBe(true);
expect(users.countUsers()).toBe(0);
expect(users.deleteUser("not-a-real-id")).toBe(false);
});
it("createFirstUser succeeds on empty db, returns null when a user already exists", async () => {
const a = await users.createFirstUser("alice", "pw-alice");
expect(a).not.toBeNull();
expect(a!.username).toBe("alice");
const b = await users.createFirstUser("bob", "pw-bob-bob");
expect(b).toBeNull();
expect(users.countUsers()).toBe(1);
});
it("createFirstUser is race-safe: concurrent calls produce exactly one user", async () => {
const [a, b, c] = await Promise.all([
users.createFirstUser("alice", "pw-alice"),
users.createFirstUser("bob", "pw-bob-bob"),
users.createFirstUser("charlie", "pw-charlie-pw"),
]);
const created = [a, b, c].filter((u) => u !== null);
expect(created).toHaveLength(1);
expect(users.countUsers()).toBe(1);
});
it("createFirstUser always creates an admin", async () => {
const u = await users.createFirstUser("alice", "pw-alice");
expect(u).not.toBeNull();
expect(u!.role).toBe("admin");
});
it("countAdmins reflects only role=admin", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
expect(users.countUsers()).toBe(2);
expect(users.countAdmins()).toBe(1);
});
it("setRole changes the role and returns true; false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.setRole(u.id, "admin")).toBe(true);
expect(users.findById(u.id)!.role).toBe("admin");
expect(users.setRole("nope", "admin")).toBe(false);
});
it("listUsers includes role", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
const alice = list.find((u) => u.username === "alice")!;
const bob = list.find((u) => u.username === "bob")!;
expect(alice.role).toBe("admin");
expect(bob.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'would_orphan' for the only admin being demoted", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("would_orphan");
expect(users.findById(alice.id)!.role).toBe("admin"); // unchanged
});
it("setRoleIfNotLastAdmin allows demotion when another admin exists", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("ok");
expect(users.findById(alice.id)!.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'not_found' for unknown id", () => {
expect(users.setRoleIfNotLastAdmin("not-a-real-id", "member")).toBe("not_found");
});
it("setRoleIfNotLastAdmin: concurrent demotions of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
// Concurrent demotion of both
const [r1, r2] = await Promise.all([
Promise.resolve(users.setRoleIfNotLastAdmin(alice.id, "member")),
Promise.resolve(users.setRoleIfNotLastAdmin(bob.id, "member")),
]);
// Exactly one should succeed; the other gets "would_orphan"
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
// System retains at least one admin
expect(users.countAdmins()).toBe(1);
});
it("deleteUserIfNotLastAdmin returns 'would_orphan' for the only admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.deleteUserIfNotLastAdmin(alice.id)).toBe("would_orphan");
expect(users.findById(alice.id)).not.toBeNull();
});
it("deleteUserIfNotLastAdmin allows deleting a member at any count", async () => {
await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "member");
expect(users.deleteUserIfNotLastAdmin(bob.id)).toBe("ok");
expect(users.findById(bob.id)).toBeNull();
});
it("deleteUserIfNotLastAdmin: concurrent deletes of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
const [r1, r2] = await Promise.all([
Promise.resolve(users.deleteUserIfNotLastAdmin(alice.id)),
Promise.resolve(users.deleteUserIfNotLastAdmin(bob.id)),
]);
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
expect(users.countAdmins()).toBe(1);
});
});
describe("guest row exclusion", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers and listUsers ignore the reserved guest row", async () => {
await users.createUser("alice", "password123", "member");
// Insert the reserved guest row directly (mirrors the migration).
botDb.db.prepare(
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')"
).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now());
expect(users.countUsers()).toBe(1); // alice only
expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false);
});
it("setRoleIfNotLastAdmin refuses to re-role the reserved guest principal", () => {
// The guest row is seeded by createDatabase via ensureGuestUser.
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest"); // sanity
expect(users.setRoleIfNotLastAdmin(GUEST_USER_ID, "admin")).toBe("not_found");
// The guest row's role is unchanged.
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
});
it("deleteUserIfNotLastAdmin refuses to delete the reserved guest principal", () => {
expect(users.findById(GUEST_USER_ID)).not.toBeNull(); // sanity
expect(users.deleteUserIfNotLastAdmin(GUEST_USER_ID)).toBe("not_found");
// The guest row still exists.
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
});
});
+176
View File
@@ -0,0 +1,176 @@
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member" | "guest";
/** Reserved synthetic principal for login-less guest sessions. The username is
* non-ASCII so it can never collide with an API-created account (which is
* validated against ^[A-Za-z0-9_\-.]{3,32}$). */
export const GUEST_USER_ID = "__guest__";
export const GUEST_USERNAME = "游客";
export interface UserRow {
id: string;
username: string;
passwordHash: string;
createdAt: number;
updatedAt: number;
role: UserRole;
}
export interface UserStore {
countUsers(): number;
countAdmins(): number;
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
createFirstUser(username: string, password: string): Promise<UserRow | null>;
findByUsername(username: string): UserRow | null;
findById(id: string): UserRow | null;
verifyPassword(plain: string, hash: string): Promise<boolean>;
changePassword(userId: string, newPassword: string): Promise<void>;
setRole(userId: string, role: UserRole): boolean;
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
deleteUser(id: string): boolean;
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
}
export class UsernameTakenError extends Error {
constructor(username: string) {
super(`username taken: ${username}`);
this.name = "UsernameTakenError";
}
}
export function createUserStore(db: Database.Database): UserStore {
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
const insertStmt = db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
);
const findByUsernameStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE"
);
const findByIdStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE id = ?"
);
const updatePasswordStmt = db.prepare(
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
);
const updateRoleStmt = db.prepare(
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
);
const listUsersStmt = db.prepare(
"SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
);
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
return {
countUsers() {
return (countStmt.get() as { n: number }).n;
},
countAdmins() {
return (countAdminsStmt.get() as { n: number }).n;
},
async createUser(username, password, role) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
try {
insertStmt.run(id, username, hash, now, now, role);
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new UsernameTakenError(username);
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role };
},
async createFirstUser(username, password) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
const run = db.transaction(() => {
const count = (countStmt.get() as { n: number }).n;
if (count !== 0) return null;
try {
insertStmt.run(id, username, hash, now, now, "admin");
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
return null;
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow;
});
return run();
},
findByUsername(username) {
return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null;
},
findById(id) {
return (findByIdStmt.get(id) as UserRow | undefined) ?? null;
},
verifyPassword(plain, hash) {
return bcrypt.compare(plain, hash);
},
async changePassword(userId, newPassword) {
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
updatePasswordStmt.run(hash, Date.now(), userId);
},
setRole(userId, role) {
const result = updateRoleStmt.run(role, Date.now(), userId);
return result.changes > 0;
},
setRoleIfNotLastAdmin(id, newRole) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
if (row.role === newRole) return "ok" as const; // no-op
if (row.role === "admin" && newRole === "member") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
updateRoleStmt.run(newRole, Date.now(), id);
return "ok" as const;
});
return tx();
},
listUsers() {
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
},
deleteUser(id) {
const result = deleteUserStmt.run(id);
return result.changes > 0;
},
deleteUserIfNotLastAdmin(id) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
if (row.role === "admin") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
deleteUserStmt.run(id);
return "ok" as const;
});
return tx();
},
};
}
Regular → Executable
+30 -4
View File
@@ -1,26 +1,38 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfig, saveConfig } from "./data/config.js";
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js";
import { createDatabase } from "./data/database.js";
import { createLogger } from "./logger.js";
import { createApiServerManager } from "./music/api-server.js";
import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const DATA_DIR = path.join(ROOT_DIR, "data");
const CONFIG_PATH = path.join(ROOT_DIR, "config.json");
// config.json lives under the persisted data dir (the Docker volume) alongside the
// DB/cookies/logs, so it survives container restarts and manual edits take effect
// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once.
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json");
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
const LOG_DIR = path.join(DATA_DIR, "logs");
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
// Migrate a pre-#86 root-level config.json into the data dir so existing
// installs keep their settings; no-op if already migrated or none exists.
migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH);
const config = loadConfig(CONFIG_PATH);
saveConfig(CONFIG_PATH, config);
@@ -44,8 +56,10 @@ async function main() {
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR);
const neteaseCookie = cookieStore.load("netease");
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
const qqCookie = cookieStore.load("qq");
@@ -53,13 +67,19 @@ async function main() {
const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const permissions = createPermissionStore(db.db);
const botManager = new BotManager(
neteaseProvider,
qqProvider,
bilibiliProvider,
db,
config,
logger
logger,
avatarStore,
permissions,
CONFIG_PATH,
localProvider
);
await botManager.loadSavedBots();
@@ -69,8 +89,11 @@ async function main() {
neteaseProvider,
qqProvider,
bilibiliProvider,
localProvider,
database: db,
avatarStore,
config,
configPath: CONFIG_PATH,
logger,
cookieStore,
staticDir: STATIC_DIR,
@@ -78,7 +101,10 @@ async function main() {
await webServer.start();
logger.info({ webPort: config.webPort }, "TSMusicBot started");
logger.info(`WebUI: http://localhost:${config.webPort}`);
const publicUrl = (config.publicUrl ?? "").trim().replace(/\/+$/, "");
logger.info(
`WebUI: ${publicUrl || `http://localhost:${config.webPort}`}`
);
const shutdown = () => {
logger.info("Shutting down...");
+35 -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,29 @@ 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;
// The module's export structure varies between versions:
// 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app
// older: module itself may be the Koa app
const candidate = qqModule.default ?? qqModule;
const koaApp = typeof candidate.listen === "function"
? candidate
: candidate.default ?? null;
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 +113,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 {
+69 -8
View File
@@ -1,7 +1,9 @@
import { createHash } from "node:crypto";
import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
LyricLine,
SearchResult,
@@ -15,6 +17,16 @@ const BILIBILI_HEADERS = {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
// Permutation used by B站 to derive the wbi mixin key from img_key+sub_key.
const WBI_MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
20, 34, 44, 52,
];
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -24,6 +36,8 @@ export class BiliBiliProvider implements MusicProvider {
private cidCache = new Map<string, number>();
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
private buvidInitialized = false;
private wbiMixinKey = "";
private wbiKeyFetchedAt = 0;
constructor() {
this.api = axios.create({
@@ -62,6 +76,50 @@ export class BiliBiliProvider implements MusicProvider {
return combined ? { Cookie: combined } : {};
}
/**
* Fetch wbi img_key/sub_key from /x/web-interface/nav and derive the
* mixin key used to sign search params. Required since B站 moved the
* search endpoint behind wbi signing — unsigned /search/type now
* returns an anti-bot HTML page.
*/
private async ensureWbiKeys(): Promise<void> {
if (this.wbiMixinKey && Date.now() - this.wbiKeyFetchedAt < WBI_KEY_TTL_MS) {
return;
}
const res = await this.api.get("/x/web-interface/nav", {
headers: this.cookieHeaders,
validateStatus: () => true, // nav returns -101 when not logged in but still includes wbi_img
});
const wbi = res.data?.data?.wbi_img;
const imgUrl: string = wbi?.img_url ?? "";
const subUrl: string = wbi?.sub_url ?? "";
const imgKey = imgUrl.split("/").pop()?.split(".")[0] ?? "";
const subKey = subUrl.split("/").pop()?.split(".")[0] ?? "";
if (!imgKey || !subKey) {
throw new Error("Bilibili wbi keys unavailable");
}
const raw = imgKey + subKey;
this.wbiMixinKey = WBI_MIXIN_KEY_ENC_TAB.map((i) => raw[i] ?? "")
.join("")
.slice(0, 32);
this.wbiKeyFetchedAt = Date.now();
}
/** Sign params for wbi-protected endpoints. Returns a new params object including wts and w_rid. */
private signWbi(params: Record<string, string | number>): Record<string, string> {
const withTs: Record<string, string> = {};
for (const [k, v] of Object.entries(params)) withTs[k] = String(v);
withTs.wts = String(Math.floor(Date.now() / 1000));
const sorted = Object.keys(withTs)
.sort()
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(withTs[k])}`)
.join("&");
withTs.w_rid = createHash("md5")
.update(sorted + this.wbiMixinKey)
.digest("hex");
return withTs;
}
setQuality(quality: string): void {
this.quality = quality;
}
@@ -91,12 +149,14 @@ export class BiliBiliProvider implements MusicProvider {
async search(query: string, limit = 20): Promise<SearchResult> {
await this.ensureBuvidCookie();
const res = await this.api.get("/x/web-interface/search/type", {
params: {
search_type: "video",
keyword: query,
page_size: limit,
},
await this.ensureWbiKeys();
const signed = this.signWbi({
search_type: "video",
keyword: query,
page_size: limit,
});
const res = await this.api.get("/x/web-interface/wbi/search/type", {
params: signed,
headers: this.cookieHeaders,
});
@@ -172,7 +232,7 @@ export class BiliBiliProvider implements MusicProvider {
return this.cidCache.get(bvid) ?? null;
}
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
const cid = await this.getCid(songId);
if (!cid) return null;
@@ -194,7 +254,8 @@ export class BiliBiliProvider implements MusicProvider {
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
);
return best.baseUrl ?? best.base_url ?? null;
const biliUrl = best.baseUrl ?? best.base_url;
return biliUrl ? { url: biliUrl } : null;
} catch {
return null;
}
+221
View File
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalMusicProvider } from "./local.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "local-audio-test-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// Seed real files + an index.json so we can exercise the cleanup lifecycle
// without invoking the ffmpeg duration probe that uploadAudio runs.
function makeRecord(id: string, bytes = 16) {
const filePath = join(dir, `${id}.mp3`);
writeFileSync(filePath, Buffer.alloc(bytes, 1));
return {
id,
name: id,
artist: "本地上传",
album: "本地音乐",
duration: 0,
coverUrl: "",
platform: "local" as const,
filePath,
originalName: `${id}.mp3`,
uploadedAt: "1970-01-01T00:00:00.000Z",
size: bytes,
mimeType: "audio/mpeg",
};
}
function seed(records: ReturnType<typeof makeRecord>[]) {
writeFileSync(join(dir, "index.json"), JSON.stringify(records), "utf8");
}
describe("LocalMusicProvider cleanup lifecycle", () => {
it("sweep keeps referenced and never-played files, deletes only played+unreferenced", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
const c = makeRecord("c");
seed([a, b, c]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // "a" still sits in a queue somewhere
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played, but referenced
await p.getSongUrl("b"); // played and unreferenced
// "c" was never played (e.g. uploaded but not queued)
const deleted = p.sweepUnreferenced();
expect(deleted).toEqual(["b"]);
expect(existsSync(a.filePath)).toBe(true); // referenced → kept
expect(existsSync(b.filePath)).toBe(false); // played + unreferenced → deleted
expect(existsSync(c.filePath)).toBe(true); // never played → kept
});
it("a played song still in the queue survives the sweep and stays replayable (loop / prev)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
const refs = new Set<string>(["a"]); // loop queue still references it
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // first pass plays it
p.sweepUnreferenced(); // "playback_finished" sweep
expect(existsSync(a.filePath)).toBe(true);
expect((await p.getSongUrl("a"))?.url).toBe(a.filePath); // next loop pass works
});
it("re-playing a queued local song does not delete it (play-song order)", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
// Mirror the fixed endpoint order: the song is (re)added to the queue
// BEFORE the sweep runs, so it is referenced when we sweep.
const refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a"); // played once
p.sweepUnreferenced(); // sweep fired after the replay re-queued it
expect(existsSync(a.filePath)).toBe(true);
expect(await p.getSongUrl("a")).not.toBeNull();
});
it("deletes a played file once it leaves every queue", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
let refs = new Set<string>(["a"]);
p.setInUseResolver(() => refs);
await p.getSongUrl("a");
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(true); // still queued
refs = new Set<string>(); // queue cleared
p.sweepUnreferenced();
expect(existsSync(a.filePath)).toBe(false); // now removed
expect(await p.getSongUrl("a")).toBeNull();
});
it("never deletes anything when the reference resolver throws", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir);
p.setInUseResolver(() => {
throw new Error("manager unavailable");
});
await p.getSongUrl("a");
expect(p.sweepUnreferenced()).toEqual([]);
expect(existsSync(a.filePath)).toBe(true);
});
});
describe("LocalMusicProvider upload validation", () => {
it("rejects a spoofed Content-Type with a non-audio extension", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("malicious"),
originalName: "evil.exe",
mimeType: "application/octet-stream",
}),
).rejects.toThrow();
});
it("rejects an unknown extension even when the mime claims audio", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("x"),
originalName: "evil.html",
mimeType: "audio/mpeg",
}),
).rejects.toThrow();
});
it("rejects an empty file", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow();
});
});
describe("LocalMusicProvider quota", () => {
it("evicts oldest unreferenced uploads beyond maxFiles", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]); // newest-first: b newer than a
const p = new LocalMusicProvider(dir, { maxFiles: 2 });
p.setInUseResolver(() => new Set<string>());
// Upload a third valid file → over the 2-file cap → evict the oldest ("a").
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(false); // oldest evicted
expect(existsSync(b.filePath)).toBe(true);
const result = await p.search("");
expect(result.songs.map((s) => s.id).sort()).not.toContain("a");
});
it("does not evict a referenced upload even when over the cap", async () => {
const a = makeRecord("a");
const b = makeRecord("b");
seed([b, a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // "a" is queued
await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
expect(existsSync(a.filePath)).toBe(true); // protected: still queued
});
it("never evicts the just-uploaded file, even when every older file is referenced", async () => {
const a = makeRecord("a");
seed([a]);
const p = new LocalMusicProvider(dir, { maxFiles: 1 });
p.setInUseResolver(() => new Set<string>(["a"])); // the only older file is queued
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 7),
originalName: "c.mp3",
mimeType: "audio/mpeg",
});
// The returned song must actually exist and be playable — not a phantom.
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir);
const longName = "x".repeat(300) + ".mp3";
// Must not throw the "unsupported format" error — the extension survives.
const song = await p.uploadAudio({
buffer: Buffer.alloc(16, 1),
originalName: longName,
mimeType: "audio/mpeg",
});
expect(song.id).toBeTruthy();
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
+391
View File
@@ -0,0 +1,391 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import crypto from "node:crypto";
import type {
Album,
AuthStatus,
LyricLine,
MusicProvider,
Playlist,
PlaylistDetail,
QrCodeResult,
SearchResult,
Song,
SongUrlResult,
} from "./provider.js";
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
const AUDIO_EXTENSIONS = new Set([
".mp3",
".flac",
".wav",
".m4a",
".aac",
".ogg",
".opus",
".webm",
".wma",
".alac",
".aiff",
".ape",
]);
const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
export interface LocalMusicProviderOptions {
/** Max number of uploaded files kept on disk (oldest unreferenced evicted). */
maxFiles?: number;
/** Max total bytes of uploaded files kept on disk. */
maxTotalBytes?: number;
}
interface LocalSongRecord extends Song {
filePath: string;
originalName: string;
uploadedAt: string;
size: number;
mimeType: string;
}
function safeFileName(name: string): string {
const base = path.basename(name || "audio")
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, " ")
.trim();
if (!base) return "audio";
// Cap the total length but ALWAYS preserve the extension — truncating the
// whole string would drop a trailing ".mp3" on a long filename and make the
// file fail extension validation.
const ext = path.extname(base);
const stem = ext ? base.slice(0, base.length - ext.length) : base;
const safeStem = stem.slice(0, Math.max(1, 160 - ext.length)) || "audio";
return `${safeStem}${ext}`;
}
function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
}
async function probeDurationSeconds(filePath: string): Promise<number> {
return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
resolve(0);
}, 5000);
ffmpeg.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
ffmpeg.on("error", () => {
clearTimeout(timeout);
resolve(0);
});
ffmpeg.on("close", () => {
clearTimeout(timeout);
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!match) {
resolve(0);
return;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const total = hours * 3600 + minutes * 60 + seconds;
resolve(Number.isFinite(total) ? Math.round(total) : 0);
});
});
}
export class LocalMusicProvider implements MusicProvider {
readonly platform = "local" as const;
private readonly uploadDir: string;
private readonly indexPath: string;
private records: LocalSongRecord[] = [];
private readonly maxFiles: number;
private readonly maxTotalBytes: number;
/** Ids that have been resolved for playback at least once; only these are
* eligible for reference-aware cleanup, so freshly uploaded files that are
* not yet queued/played survive in the search list. */
private playedIds = new Set<string>();
/** Returns the set of local song ids still referenced by any bot's queue.
* Deletion never removes a file whose id this set contains. */
private inUseResolver: () => Set<string> = () => new Set<string>();
/** Ids with an in-flight retry-delete scheduled (file briefly locked, e.g.
* ffmpeg on Windows still releasing a just-stopped track). */
private retrying = new Set<string>();
constructor(uploadDir: string, options: LocalMusicProviderOptions = {}) {
this.uploadDir = uploadDir;
this.indexPath = path.join(uploadDir, "index.json");
this.maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
mkdirSync(uploadDir, { recursive: true });
this.loadIndex();
}
/** Wire the resolver the BotManager uses to report which uploads are still
* queued anywhere. Must be set before any cleanup can delete files. */
setInUseResolver(resolver: () => Set<string>): void {
this.inUseResolver = resolver;
}
private referencedIds(): Set<string> | null {
try {
return this.inUseResolver() ?? new Set<string>();
} catch {
// Resolver failure → references unknown → refuse to delete anything.
return null;
}
}
private loadIndex(): void {
try {
const raw = readFileSync(this.indexPath, "utf8");
const parsed = JSON.parse(raw) as LocalSongRecord[];
this.records = Array.isArray(parsed)
? parsed.filter((r) => r && typeof r.id === "string" && typeof r.filePath === "string")
: [];
} catch {
this.records = [];
}
}
private saveIndex(): void {
writeFileSync(this.indexPath, JSON.stringify(this.records, null, 2), "utf8");
}
async uploadAudio(input: {
buffer: Buffer;
originalName: string;
mimeType?: string;
}): Promise<Song> {
const originalName = safeFileName(input.originalName || "audio");
const ext = path.extname(originalName).toLowerCase();
// Validate by the (sanitised) file extension only — never trust the
// client-supplied Content-Type. This also guarantees the STORED extension
// is one of the known audio types, so a spoofed header cannot persist an
// arbitrary-extension blob on disk.
if (!AUDIO_EXTENSIONS.has(ext)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
}
if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空");
}
const id = crypto.randomUUID();
const storedName = `${id}${ext}`;
const filePath = path.join(this.uploadDir, storedName);
writeFileSync(filePath, input.buffer);
const duration = await probeDurationSeconds(filePath);
const song: LocalSongRecord = {
id,
name: titleFromFileName(originalName),
artist: "本地上传",
album: "本地音乐",
duration,
coverUrl: "",
platform: "local",
filePath,
originalName,
uploadedAt: new Date().toISOString(),
size: input.buffer.length,
mimeType: input.mimeType || "application/octet-stream",
};
this.records.unshift(song);
this.saveIndex();
// Never evict the file we just accepted, even if every older file is still
// queued — returning success for a file we deleted would be a phantom entry.
this.enforceQuota(id);
return this.toSong(song);
}
private toSong(record: LocalSongRecord): Song {
const { filePath: _filePath, originalName: _originalName, uploadedAt: _uploadedAt, size: _size, mimeType: _mimeType, ...song } = record;
return song;
}
async search(query: string, limit = 20): Promise<SearchResult> {
const q = query.trim().toLowerCase();
const songs = this.records
.filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit)
.map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] };
}
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
const record = this.records.find((r) => r.id === songId);
if (!record || !existsSync(record.filePath)) return null;
// A song that is actually resolved for playback becomes eligible for
// cleanup once it is no longer referenced by any queue.
this.playedIds.add(songId);
return { url: record.filePath };
}
async getSongDetail(songId: string): Promise<Song | null> {
const record = this.records.find((r) => r.id === songId);
return record && existsSync(record.filePath) ? this.toSong(record) : null;
}
/**
* Reference-aware cleanup: delete only files that have been played at least
* once AND are no longer referenced by any bot's queue. Safe to call after
* any queue mutation — a file still queued anywhere (loop replay, prev,
* the song being re-started, the same upload queued on another bot) is kept.
* Returns the ids that were deleted.
*/
sweepUnreferenced(): string[] {
const inUse = this.referencedIds();
if (!inUse) return [];
const deleted: string[] = [];
for (let i = this.records.length - 1; i >= 0; i--) {
const r = this.records[i];
if (!this.playedIds.has(r.id) || inUse.has(r.id)) continue;
if (this.unlinkRecordAt(i)) {
deleted.push(r.id);
} else {
// File still locked (e.g. ffmpeg just-stopped on Windows) — keep the
// record and retry shortly; never orphan it or abort the rest.
this.scheduleRetry(r.id);
}
}
if (deleted.length) this.saveIndex();
return deleted;
}
/** Evict oldest, never-referenced uploads until under the file-count and
* total-byte caps. Bounds disk use from uploads that are never played.
* `protectId` is never evicted (the file just uploaded in this same call). */
private enforceQuota(protectId?: string): void {
if (this.records.length <= this.maxFiles &&
this.totalBytes() <= this.maxTotalBytes) {
return;
}
const inUse = this.referencedIds();
if (!inUse) return; // can't safely evict without knowing references
let count = this.records.length;
let bytes = this.totalBytes();
let changed = false;
for (let i = this.records.length - 1;
i >= 0 && (count > this.maxFiles || bytes > this.maxTotalBytes);
i--) {
const r = this.records[i];
if (inUse.has(r.id) || r.id === protectId) continue; // never evict these
const size = r.size || 0;
if (this.unlinkRecordAt(i)) {
count--;
bytes -= size;
changed = true;
}
}
if (changed) this.saveIndex();
}
/**
* Delete the backing file for records[index] and drop the record from memory.
* Deletes the FILE FIRST, then mutates state only on success, so a failed
* unlink leaves the record intact (file + index stay consistent) instead of
* orphaning the file. Returns true if the file is gone (deleted or already
* absent), false if it is still present (locked). Never throws; does NOT
* persist the index — callers batch saveIndex().
*/
private unlinkRecordAt(index: number): boolean {
const r = this.records[index];
try {
rmSync(r.filePath, { force: true });
} catch {
// rmSync force:true only swallows ENOENT; EBUSY/EPERM/EACCES throw. If
// the file genuinely vanished anyway, fall through and drop the record.
if (existsSync(r.filePath)) return false;
}
this.records.splice(index, 1);
this.playedIds.delete(r.id);
this.retrying.delete(r.id);
return true;
}
/** Schedule a bounded, non-blocking retry to delete a briefly-locked file.
* Uses unref'd timers so it never keeps the process alive. */
private scheduleRetry(id: string, attempt = 1): void {
if (attempt === 1 && this.retrying.has(id)) return;
this.retrying.add(id);
const MAX_ATTEMPTS = 6;
const timer = setTimeout(() => {
const index = this.records.findIndex((r) => r.id === id);
if (index < 0) { this.retrying.delete(id); return; } // already removed
const inUse = this.referencedIds();
if (!inUse || inUse.has(id)) { this.retrying.delete(id); return; } // unknown or re-queued
if (this.unlinkRecordAt(index)) {
this.saveIndex();
} else if (attempt < MAX_ATTEMPTS) {
this.scheduleRetry(id, attempt + 1);
} else {
this.retrying.delete(id); // give up; next sweep/quota will retry
}
}, 500 * attempt);
if (typeof timer.unref === "function") timer.unref();
}
private totalBytes(): number {
return this.records.reduce((n, r) => n + (r.size || 0), 0);
}
setQuality(_quality: string): void {
// 本地文件按原始音质播放。
}
getQuality(): string {
return "original";
}
async getPlaylistSongs(_playlistId: string): Promise<Song[]> {
return [];
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getAlbumSongs(_albumId: string): Promise<Song[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
throw new Error("Local music does not require login");
}
async checkQrCodeStatus(_key: string): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {
// no-op
}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return { loggedIn: true, nickname: "本地音乐" };
}
async getPlaylistDetail(_playlistId: string): Promise<PlaylistDetail | null> {
return null;
}
}
+65 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics } from "./netease.js";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,68 @@ describe("NetEase adapter", () => {
expect(lines[0].text).toBe("Hello world");
expect(lines[0].translation).toBe("你好世界");
});
it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
const raw = [
{
id: 42,
name: "Album A",
picUrl: "https://x/p.jpg",
artists: [{ name: "Artist X" }, { name: "Featured Y" }],
size: 12,
},
{
id: 99,
name: "Album B",
picUrl: "",
artists: [],
},
];
expect(mapNeteaseAlbums(raw)).toEqual([
{ id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
{ id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
]);
});
it("mapNeteaseAlbums returns [] for empty/null input", () => {
expect(mapNeteaseAlbums([])).toEqual([]);
expect(mapNeteaseAlbums(null as any)).toEqual([]);
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
});
it("mapNeteaseSongs maps fee to vip flag (1/4 = vip, 0/8 = free)", () => {
const raw = [
{ id: 1, name: "VIP", ar: [{ name: "A" }], al: { name: "Al", picUrl: "p" }, dt: 180000, fee: 1 },
{ id: 2, name: "Album-only", ar: [], al: { name: "Al", picUrl: "" }, dt: 0, fee: 4 },
{ id: 3, name: "Free", ar: [], al: {}, dt: 0, fee: 0 },
{ id: 4, name: "Free low-quality", ar: [], al: {}, dt: 0, fee: 8 },
];
const out = mapNeteaseSongs(raw);
expect(out[0].vip).toBe(true);
expect(out[1].vip).toBe(true);
expect(out[2].vip).toBe(false);
expect(out[3].vip).toBe(false); // fee=8 plays in full (low quality), NOT vip
});
it("mapNeteaseSongs accepts artists/album/duration aliases (personal_fm shape)", () => {
const out = mapNeteaseSongs([
{ id: 9, name: "FM", artists: [{ name: "B" }], album: { name: "Al2", picUrl: "p2" }, duration: 200000, fee: 0 },
]);
expect(out[0]).toMatchObject({ artist: "B", album: "Al2", coverUrl: "p2", vip: false });
});
it("parseNeteaseTrial maps freeTrialInfo to trial seconds", () => {
// 无试听(VIP/免费)
expect(parseNeteaseTrial({})).toBeUndefined();
expect(parseNeteaseTrial({ freeTrialInfo: null })).toBeUndefined();
// 标准秒
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30 } })).toBe(30);
expect(parseNeteaseTrial({ freeTrialInfo: { start: 5, end: 35 } })).toBe(30);
// 别名容忍 begin/trialBegin
expect(parseNeteaseTrial({ freeTrialInfo: { begin: 0, end: 30 } })).toBe(30);
// 毫秒兜底(end>1000)
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 30000 } })).toBe(30);
// 异常 end<=start
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
});
});
+76 -63
View File
@@ -2,11 +2,14 @@ import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
PlaylistDetail,
LyricLine,
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -54,6 +57,45 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
return lines.sort((a, b) => a.time - b.time);
}
export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => ({
id: String(a.id),
name: a.name ?? "",
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
coverUrl: a.picUrl ?? "",
songCount: a.size ?? 0,
platform: "netease",
}));
}
export function mapNeteaseSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? s.artists ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? s.album?.name ?? "",
duration: Math.round((s.dt ?? s.duration ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? s.album?.picUrl ?? "",
platform: "netease",
// fee: 0=free, 1=VIP, 4=album-only, 8=free low-quality (plays in full, NOT vip)
vip: s.fee === 1 || s.fee === 4,
}));
}
/** 解析网易云 freeTrialInfo → 试听秒数;无片段(VIP/免费)返回 undefined。
* 真实字段 {start,end} 单位秒;容忍 begin/trialBegin 别名 + 毫秒兜底(end>1000)。 */
export function parseNeteaseTrial(item: any): number | undefined {
const t = item?.freeTrialInfo;
if (!t || typeof t !== "object") return undefined;
const start = Number(t.start ?? t.begin ?? t.trialBegin ?? 0);
const end = Number(t.end ?? t.trialEnd);
if (!Number.isFinite(end) || end <= start) return undefined;
const secs = end > 1000 ? (end - start) / 1000 : end - start;
return Math.round(secs);
}
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
export const NETEASE_QUALITY_LEVELS = [
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
@@ -90,7 +132,7 @@ export class NeteaseProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const [songRes, playlistRes] = await Promise.all([
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
@@ -98,23 +140,16 @@ export class NeteaseProvider implements MusicProvider {
params: {
keywords: query,
type: 1000,
limit: 5,
limit: 10,
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
}),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
(s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
})
);
const songs: Song[] = mapNeteaseSongs(songRes.data?.result?.songs);
const playlists: Playlist[] = (
playlistRes.data?.result?.playlists ?? []
@@ -126,47 +161,34 @@ export class NeteaseProvider implements MusicProvider {
platform: "netease",
}));
return { songs, playlists, albums: [] };
const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
const level = quality ?? this.quality;
const res = await this.api.get("/song/url/v1", {
params: { id: songId, level, ...this.cookieParams },
});
return res.data?.data?.[0]?.url ?? null;
const item = res.data?.data?.[0];
const url = item?.url;
if (!url) return null;
return { url, trialDuration: parseNeteaseTrial(item) };
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.api.get("/song/detail", {
params: { ids: songId, ...this.cookieParams },
});
const s = res.data?.songs?.[0];
if (!s) return null;
return {
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
};
return mapNeteaseSongs(res.data?.songs)[0] ?? null;
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get("/playlist/track/all", {
params: { id: playlistId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.songs);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
@@ -186,15 +208,7 @@ export class NeteaseProvider implements MusicProvider {
const res = await this.api.get("/album", {
params: { id: albumId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.songs);
}
async getLyrics(songId: string): Promise<LyricLine[]> {
@@ -293,30 +307,29 @@ export class NeteaseProvider implements MusicProvider {
const res = await this.api.get("/personal_fm", {
params: { ...this.cookieParams },
});
return (res.data?.data ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.artists ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? "",
duration: Math.round((s.duration ?? 0) / 1000),
coverUrl: s.album?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.data);
}
async getDailyRecommendSongs(): Promise<Song[]> {
const res = await this.api.get("/recommend/songs", {
params: { ...this.cookieParams },
});
return (res.data?.data?.dailySongs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(" / "),
album: s.al?.name ?? "",
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? "",
platform: "netease",
}));
return mapNeteaseSongs(res.data?.data?.dailySongs);
}
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
const res = await this.api.get("/playlist/detail", {
params: { id: playlistId, ...this.cookieParams },
});
const p = res.data?.playlist;
if (!p) return null;
return {
id: String(p.id),
name: p.name ?? "",
description: p.description ?? "",
coverUrl: p.coverImgUrl ?? "",
songCount: p.trackCount ?? 0,
};
}
async getUserPlaylists(): Promise<Playlist[]> {
+24 -5
View File
@@ -5,19 +5,37 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
}
export interface SongWithUrl extends Song {
url: string;
}
/** getSongUrl 解析结果。trialDuration 缺省 = 完整可播放(VIP 账号 / 免费曲)。 */
export interface SongUrlResult {
url: string;
/** 试听片段时长(秒)。VIP/免费曲为 undefined → 调用方回退完整 duration。 */
trialDuration?: number;
}
export interface Playlist {
id: string;
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
}
export interface PlaylistDetail {
id: string;
name: string;
description: string;
coverUrl: string;
songCount: number;
}
export interface Album {
@@ -26,7 +44,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
}
export interface LyricLine {
@@ -54,10 +72,10 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili";
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local";
search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<string | null>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void;
getQuality(): string;
getSongDetail(songId: string): Promise<Song | null>;
@@ -77,4 +95,5 @@ export interface MusicProvider {
getPersonalFm?(): Promise<Song[]>;
getDailyRecommendSongs?(): Promise<Song[]>;
getUserPlaylists?(): Promise<Playlist[]>;
getPlaylistDetail?(playlistId: string): Promise<PlaylistDetail | null>;
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => {
const out = mapQqSongs([
{
mid: "001abc",
name: "Radar Song",
singer: [{ name: "Singer A" }, { name: "Singer B" }],
album: { name: "Album A", mid: "alb001" },
interval: 243,
},
]);
expect(out).toEqual([
{
id: "001abc",
name: "Radar Song",
artist: "Singer A / Singer B",
album: "Album A",
duration: 243,
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
platform: "qq",
vip: false,
},
]);
});
it("mapQqSongs maps pay field to vip flag", () => {
const out = mapQqSongs([
{ mid: "v1", name: "VIP playplay", singer: [], album: {}, interval: 100, pay: { payplay: 1, paytrackprice: 0 } },
{ mid: "v2", name: "VIP trackprice", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 1 } },
{ mid: "f1", name: "Free", singer: [], album: {}, interval: 100, pay: { payplay: 0, paytrackprice: 0 } },
{ mid: "f2", name: "No pay field", singer: [], album: {}, interval: 100 },
]);
expect(out[0].vip).toBe(true);
expect(out[1].vip).toBe(true);
expect(out[2].vip).toBe(false);
expect(out[3].vip).toBe(false);
});
it("parseQqTrial maps isTryout/tryout to trial seconds", () => {
// 非试听(VIP/免费)
expect(parseQqTrial({ isTryout: 0 })).toBeUndefined();
expect(parseQqTrial({})).toBeUndefined();
// 试听(秒)
expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30 })).toBe(30);
expect(parseQqTrial({ tryout: true, begin: 0, end: 45 })).toBe(45);
// 毫秒兜底
expect(parseQqTrial({ isTryout: 1, tryBegin: 0, tryEnd: 30000 })).toBe(30);
// 异常
expect(parseQqTrial({ isTryout: 1, tryEnd: 0 })).toBeUndefined();
});
it("mapQqAlbums maps albumMID-style raw entries", () => {
const raw = [
{
albumMID: "abc",
albumName: "Aero",
singerName: "Singer A",
},
{
albumMID: "xyz",
albumName: "Beta",
singer: [{ name: "Singer B" }, { name: "Singer C" }],
},
];
const out = mapQqAlbums(raw);
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
id: "abc",
name: "Aero",
artist: "Singer A",
platform: "qq",
});
expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
expect(out[1].artist).toBe("Singer B / Singer C");
expect(out[1].coverUrl).toContain("xyz");
});
it("mapQqAlbums returns [] for empty/null input", () => {
expect(mapQqAlbums([])).toEqual([]);
expect(mapQqAlbums(null as any)).toEqual([]);
expect(mapQqAlbums(undefined as any)).toEqual([]);
});
it("mapQqAlbums falls back to albumPic when no albumMID", () => {
const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
const out = mapQqAlbums(raw);
expect(out[0].coverUrl).toBe("https://x/p.jpg");
expect(out[0].id).toBe("");
});
});
+554 -70
View File
@@ -2,19 +2,109 @@ import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
PlaylistDetail,
LyricLine,
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// batch). Was broken ca. 2026-05 due to two upstream API changes:
// 1. searchid param must NOT be present (causes all lists to be empty)
// 2. num_per_page must be >= 10 (lower values return empty)
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
const qqMusicuApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
// endpoint, song + album only, no playlist support).
const qqSearchApi = axios.create({
baseURL: "https://c.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({
baseURL: "https://c.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com/" },
});
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s) => {
const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? "";
return {
id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""),
name: s.title ?? s.name ?? s.songname ?? "",
artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
album: s.album?.name ?? s.album?.title ?? s.albumname ?? "",
duration: s.interval ?? Math.round((s.duration ?? 0) / 1000),
coverUrl: albumMid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
: "",
platform: "qq" as const,
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
};
}).filter((s) => s.id);
}
/** 解析 QQ 试听标记 → 试听秒数;非试听(VIP/免费)返回 undefined。
* 字段 isTryout===1 / tryout===true + tryBegin/tryEnd;容忍 begin/start 别名 + 毫秒兜底。 */
export function parseQqTrial(playUrl: any): number | undefined {
if (!playUrl || typeof playUrl !== "object") return undefined;
if (playUrl.isTryout !== 1 && playUrl.tryout !== true) return undefined;
const begin = Number(playUrl.tryBegin ?? playUrl.begin ?? playUrl.start ?? 0);
const end = Number(playUrl.tryEnd ?? playUrl.end);
if (!Number.isFinite(end) || end <= begin) return undefined;
const secs = end > 1000 ? (end - begin) / 1000 : end - begin;
return Math.round(secs);
}
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => {
const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
const artist = a.singerName
?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
const coverUrl = id
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
: (a.albumPic ?? "");
return {
id,
name: a.albumName ?? a.title ?? "",
artist,
coverUrl,
songCount: a.song_count ?? a.songCount ?? 0,
platform: "qq" as const,
};
});
}
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
hash = (hash + (hash << 5) + pSkey.charCodeAt(i)) | 0;
}
return hash & 0x7fffffff;
}
export class QQMusicProvider implements MusicProvider {
readonly platform = "qq" as const;
private api: AxiosInstance;
private cookie = "";
private quality = "exhigh";
private radarPage = 1;
constructor(baseUrl: string) {
this.api = axios.create({
@@ -35,38 +125,215 @@ export class QQMusicProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {};
}
async search(query: string, limit = 20): Promise<SearchResult> {
const res = await this.api.get("/getSearchByKey", {
params: { key: query, pageSize: limit, ...this.cookieParams },
});
const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map(
(s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
})
);
return { songs, playlists: [], albums: [] };
private get directCookieHeaders(): Record<string, string> {
return this.cookie ? { Cookie: this.cookie } : {};
}
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
return playUrl?.url || null;
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie);
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
return {
comm: {
ct: 24,
cv: 4747474,
platform: "yqq.json",
uin: uinMatch ? uinMatch[1] : "0",
g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381,
format: "json",
inCharset: "utf-8",
outCharset: "utf-8",
notice: 0,
need_new_code: 1,
},
req_0: { module, method, param },
};
}
async search(query: string, limit = 20): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit);
if (primary) return primary;
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit);
}
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
*
* Two upstream API changes (2026-05) required fixes:
* 1. Omit `searchid` — its presence now causes all lists to be empty.
* 2. `num_per_page` >= 10 — lower values return empty.
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string,
limit: number
): Promise<SearchResult | null> {
try {
const numPerPage = Math.max(10, Math.min(limit, 50));
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 },
},
req_playlist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const songList: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const songs = mapQqSongs(songList);
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
const playlists: Playlist[] = playlistList.map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.title ?? "",
coverUrl: p.imgurl ?? p.logo ?? "",
songCount: p.songnum ?? p.song_count ?? 0,
platform: "qq" as const,
}));
return { songs, playlists, albums };
} catch {
return null;
}
}
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp(
query: string,
limit: number
): Promise<SearchResult> {
const songParams = {
w: query,
format: "json",
p: 1,
n: Math.min(limit, 50),
type: 0,
cr: 1,
};
const albumParams = {
w: query,
format: "json",
p: 1,
n: 5,
t: 8,
cr: 1,
};
const [songRes, albumRes] = await Promise.allSettled([
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
]);
const songList: any[] =
songRes.status === "fulfilled"
? (songRes.value.data?.data?.song?.list ?? [])
: [];
const songs = mapQqSongs(songList);
const albumList: any[] =
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
try {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
} catch {
// try with songid
try {
const res = await this.api.get("/getMusicPlay", {
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
});
const playUrl = res.data?.data?.playUrl?.[songId];
if (playUrl?.url) return { url: playUrl.url, trialDuration: parseQqTrial(playUrl) };
} catch {
// ignore
}
}
return null;
}
/**
* Batch-check which song mids are actually streamable. QQ playlists
* (especially collected ones) frequently contain a majority of songs
* that return result=104003 ("no copyright/region restricted") for the
* current user — a sequential retry loop wastes time guessing.
*
* The wrapper's /getMusicPlay accepts a comma-separated songmid list
* and resolves all of them in a single upstream call. We chunk to keep
* the URL well under typical 8KB query-string limits and to keep per-
* request latency bounded (~2-3s per 100 mids).
*
* Returns:
* - non-null Set: authoritative result. Empty Set means all songs are
* unplayable; non-empty means filter to those mids.
* - null: every chunk failed. Caller should fall back to sequential
* retry rather than treating as "all unplayable".
*/
async getPlayableSongIds(songIds: string[]): Promise<Set<string> | null> {
if (songIds.length === 0) return new Set();
const CHUNK = 100; // ~14 chars/mid * 100 + commas ≈ 1.5KB
const playable = new Set<string>();
let allChunksFailed = true;
for (let i = 0; i < songIds.length; i += CHUNK) {
const slice = songIds.slice(i, i + CHUNK);
try {
const res = await this.api.get("/getMusicPlay", {
params: { songmid: slice.join(","), quality: this.quality, ...this.cookieParams },
});
const playUrlMap: Record<string, { url?: string }> | undefined =
res.data?.data?.playUrl;
if (!playUrlMap) continue; // chunk-level failure, try next
allChunksFailed = false;
for (const [mid, info] of Object.entries(playUrlMap)) {
if (info?.url) playable.add(mid);
}
} catch {
// chunk-level failure — keep going so a transient error on one
// chunk doesn't poison the whole batch.
}
}
return allChunksFailed ? null : playable;
}
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 +354,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[]> {
@@ -98,17 +376,22 @@ export class QQMusicProvider implements MusicProvider {
});
const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
return mapQqSongs(cdlist[0].songlist ?? []);
}
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
const res = await this.api.get("/getSongListDetail", {
params: { disstid: playlistId, ...this.cookieParams },
});
const cd = res.data?.response?.cdlist?.[0];
if (!cd) return null;
return {
id: String(cd.disstid ?? cd.dissid ?? ""),
name: cd.dissname ?? "",
description: cd.desc ?? "",
coverUrl: cd.logo ?? "",
songCount: cd.songnum ?? cd.total_song_num ?? 0,
};
}
async getRecommendPlaylists(): Promise<Playlist[]> {
@@ -128,17 +411,7 @@ export class QQMusicProvider implements MusicProvider {
const res = await this.api.get("/getAlbumInfo", {
params: { albummid: albumId, ...this.cookieParams },
});
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
return mapQqSongs(res.data?.response?.data?.list ?? []);
}
async getLyrics(songId: string): Promise<LyricLine[]> {
@@ -152,34 +425,60 @@ 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 {
this.cookie = cookie;
// Reset radar pagination so a re-login (different account) starts from the
// first page rather than inheriting the previous account's cursor.
this.radarPage = 1;
}
getCookie(): string {
@@ -188,20 +487,205 @@ 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", {
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, ...this.cookieParams },
});
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 {
return { loggedIn: false };
}
}
async getDailyRecommendSongs(): Promise<Song[]> {
// QQ has no per-user daily list; use newsong.NewSongServer (新歌速递)
// as the closest analogue. Returns ~20 newly-released songs.
try {
const res = await this.api.get("/getNewSongs", {
params: { ...this.cookieParams },
});
if (res.data?.response?.data) {
const list: any[] = res.data?.response?.new_song?.data?.songlist ?? [];
return list.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
vip: s.pay?.payplay === 1 || s.pay?.paytrackprice === 1 || false,
}));
} catch {
return [];
}
}
async getPersonalFm(): Promise<Song[]> {
const radarSongs = await this.getRadarRecommendSongs();
if (radarSongs.length > 0) return radarSongs;
return this.getGuessRecommendSongs();
}
private async getRadarRecommendSongs(): Promise<Song[]> {
try {
const page = this.radarPage;
const res = await qqMusicuApi.post(
"/cgi-bin/musicu.fcg",
this.buildMusicuPayload(
"music.recommend.TrackRelationServer",
"GetRadarSong",
{
Page: page,
ReqType: 0,
FavSongs: [],
EntranceSongs: [],
}
),
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
);
const tracks = (res.data?.req_0?.data?.VecSongs ?? [])
.map((item: any) => item?.Track)
.filter(Boolean);
const songs = mapQqSongs(tracks);
if (songs.length > 0) {
this.radarPage = page + 1;
}
return songs;
} catch {
return [];
}
}
private async getGuessRecommendSongs(): Promise<Song[]> {
try {
const res = await qqMusicuApi.post(
"/cgi-bin/musicu.fcg",
this.buildMusicuPayload(
"music.radioProxy.MbTrackRadioSvr",
"get_radio_track",
{
id: 99,
num: 5,
from: 0,
scene: 0,
song_ids: [],
}
),
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
);
return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []);
} catch {
return [];
}
}
async getUserPlaylists(): Promise<Playlist[]> {
if (!this.cookie) return [];
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
const uin = uinMatch ? uinMatch[1] : "";
if (!uin) return [];
// Created and collected playlists come from two separate QQ endpoints.
// Run them in parallel and concatenate (created first, then collected),
// matching the order shown in the QQ Music desktop app.
const [created, collected] = await Promise.all([
this.fetchCreatedPlaylists(uin),
this.fetchCollectedPlaylists(uin),
]);
return [...created, ...collected];
}
private async fetchCreatedPlaylists(uin: string): Promise<Playlist[]> {
try {
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, ...this.cookieParams },
});
if (res.data?.response?.code !== 0) return [];
return (res.data?.response?.data?.playlists ?? []).map((p: any) => {
// fcg_get_profile_homepage returns title/picurl/subtitle ("X首 Y次播放").
const subtitle: string = p.subtitle ?? "";
const songCountFromSubtitle = parseInt(subtitle.match(/(\d+)\s*首/)?.[1] ?? "0", 10);
return {
loggedIn: true,
nickname: res.data.response.data.nickname,
avatarUrl: res.data.response.data.headpic,
id: String(p.dissid ?? p.id ?? ""),
name: p.title ?? p.dissname ?? p.name ?? "",
coverUrl: p.picurl ?? p.imgurl ?? p.coverUrl ?? "",
songCount: p.song_count ?? p.listennum ?? songCountFromSubtitle,
platform: "qq",
};
});
} catch {
return [];
}
}
private async fetchCollectedPlaylists(uin: string): Promise<Playlist[]> {
// c.y.qq.com fav endpoint: reqtype=3 returns collected playlists (cdlist).
// Requires g_tk derived from the p_skey cookie.
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
if (!pSkeyMatch) return [];
const gtk = computeGtk(pSkeyMatch[1]);
const PAGE_SIZE = 30;
const MAX_PAGES = 10; // 300-playlist hard cap; should cover any sane user
const all: Playlist[] = [];
try {
for (let page = 0; page < MAX_PAGES; page++) {
const sin = page * PAGE_SIZE;
const ein = sin + PAGE_SIZE - 1;
const res = await qqFavApi.get("/fav/fcgi-bin/fcg_get_profile_order_asset.fcg", {
params: {
ct: 20,
cid: 205360956,
userid: uin,
reqtype: 3,
sin,
ein,
g_tk: gtk,
format: "json",
},
headers: { Cookie: this.cookie },
});
if (res.data?.code !== 0) break;
const list: any[] = res.data?.data?.cdlist ?? [];
for (const p of list) {
all.push({
id: String(p.dissid ?? ""),
name: p.dissname ?? "",
coverUrl: p.logo ?? "",
songCount: p.songnum ?? 0,
platform: "qq",
});
}
// Stop when upstream signals no more pages, or when this page is
// short (also indicates end). has_more is the canonical signal.
const hasMore = res.data?.data?.has_more === 1 || res.data?.data?.has_more === true;
if (!hasMore || list.length < PAGE_SIZE) break;
}
} catch {
// ignore
// Return whatever we got so far on partial failure rather than dropping
// earlier pages.
}
return { loggedIn: false };
return all;
}
}
+233
View File
@@ -0,0 +1,233 @@
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,
SongUrlResult,
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<SongUrlResult | 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 ? { url: 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)",
};
}
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from "vitest";
import pino from "pino";
import { TS3Client } from "./client.js";
/**
* Integration "smoke test" for the admin-command gate's group resolution.
*
* It drives the REAL TS3Client.getClientServerGroups → library getClientInfo
* path against a stubbed underlying client, so it exercises the actual
* `clientinfo clid=<id>` query string and the real `client_servergroups`
* parsing — the pieces that were previously only verified by reading the code.
*
* What this CANNOT cover (inherently server-side, needs a live TS server):
* whether a real server returns groups for a client in a DIFFERENT channel.
* The stub models the server-wide answer (groups returned regardless of
* channel); the failure modes below confirm we fail closed when it doesn't.
*/
function makeClient(): TS3Client {
return new TS3Client(
{ host: "localhost", port: 9987, queryPort: 10011, nickname: "TestBot" },
pino({ level: "silent" }),
);
}
/** Inject a fake low-level client carrying a canned clientinfo response. */
function withFakeClient(
ts: TS3Client,
respond: (cmd: string) => Record<string, string>[] | Promise<Record<string, string>[]>,
): string[] {
const calls: string[] = [];
const fake = {
execCommandWithResponse: vi.fn(async (cmd: string) => {
calls.push(cmd);
return respond(cmd);
}),
};
(ts as unknown as { client: unknown }).client = fake;
return calls;
}
describe("TS3Client.getClientServerGroups — live query + parse smoke test", () => {
it("issues `clientinfo clid=<id>` and parses comma-separated client_servergroups", async () => {
const ts = makeClient();
const calls = withFakeClient(ts, () => [
{ client_nickname: "Alice", cid: "99", client_servergroups: "6,8" },
]);
const groups = await ts.getClientServerGroups(5);
expect(groups).toEqual(["6", "8"]);
// Exact query the bot sends to resolve a sender's groups, by client id.
expect(calls[0]).toBe("clientinfo clid=5");
});
it("parses a single-group response", async () => {
const ts = makeClient();
withFakeClient(ts, () => [{ client_servergroups: "6" }]);
expect(await ts.getClientServerGroups(5)).toEqual(["6"]);
});
it("returns [] when the client carries no server groups (empty field)", async () => {
const ts = makeClient();
withFakeClient(ts, () => [{ client_nickname: "Bob", client_servergroups: "" }]);
expect(await ts.getClientServerGroups(7)).toEqual([]);
});
it("returns [] when the server-groups field is absent", async () => {
const ts = makeClient();
withFakeClient(ts, () => [{ client_nickname: "Carol" }]);
expect(await ts.getClientServerGroups(7)).toEqual([]);
});
it("fails closed (returns []) when the query throws / client id is unknown", async () => {
const ts = makeClient();
withFakeClient(ts, () => {
throw new Error("invalid clientID");
});
expect(await ts.getClientServerGroups(999)).toEqual([]);
});
it("returns [] when not connected (no underlying client)", async () => {
const ts = makeClient();
expect(await ts.getClientServerGroups(5)).toEqual([]);
});
});
+245 -23
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,52 @@ import {
listChannels,
listClients,
clientMove,
getClientInfo,
fileTransferDeleteFile,
type Identity,
type TextMessage,
type ClientInfo,
type ClientLeftViewEvent,
type ClientMovedEvent,
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;
channelId?: string; // Numeric channel ID (takes precedence over defaultChannel)
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 {
@@ -32,6 +62,24 @@ export interface TS3TextMessage {
invokerUid: string;
message: string;
targetMode: number; // 1=private, 2=channel, 3=server
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
}
/**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender
* is in the bot's client-view cache; otherwise it is []. Used by the chat
* command permission gate.
*/
export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
return {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
invokerGroups: msg.invokerGroups ?? [],
};
}
export class TS3Client extends EventEmitter {
@@ -40,6 +88,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 +103,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 +127,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;
@@ -91,6 +210,9 @@ export class TS3Client extends EventEmitter {
};
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, {
// Forward server password to the protocol library so it can be
// included in clientinit for password-protected servers
serverPassword: this.options.serverPassword,
logger: {
debug: (msg) => this.logger.debug(msg),
info: (msg) => this.logger.info(msg),
@@ -100,14 +222,7 @@ export class TS3Client extends EventEmitter {
});
this.client.on("textMessage", (msg: TextMessage) => {
const tsMsg: TS3TextMessage = {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
};
this.emit("textMessage", tsMsg);
this.emit("textMessage", toTS3TextMessage(msg));
});
this.client.on("disconnected", (err) => {
@@ -121,16 +236,41 @@ export class TS3Client extends EventEmitter {
{ nickname: info.nickname, id: info.id },
"Client entered"
);
this.emit("clientEnter", info);
});
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
this.logger.debug({ id: ev.id }, "Client left");
this.emit("clientLeave", ev);
});
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
this.logger.debug(
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
"Client moved"
);
this.emit("clientMoved", ev);
});
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) {
// Join channel by numeric ID (takes precedence) or by name
if (this.options.channelId) {
await this.joinChannel(this.options.channelId, this.options.channelPassword);
} else if (this.options.defaultChannel) {
await this.joinChannel(
this.options.defaultChannel,
this.options.channelPassword
@@ -143,6 +283,17 @@ export class TS3Client extends EventEmitter {
async joinChannel(channelName: string, password?: string): Promise<void> {
if (!this.client) return;
const isNumeric = /^\d+$/.test(channelName);
if (isNumeric) {
try {
await clientMove(this.client, this.clientId, BigInt(channelName), password);
this.logger.info({ channelName }, "Joined channel");
} catch (err) {
this.logger.error({ err, channelName }, "Failed to join channel");
}
return;
}
try {
const channels = await listChannels(this.client);
const channel = channels.find((ch) => ch.name === channelName);
@@ -152,12 +303,7 @@ export class TS3Client extends EventEmitter {
return;
}
await clientMove(
this.client,
this.clientId,
channel.id,
password
);
await clientMove(this.client, this.clientId, channel.id, password);
this.logger.info(
{ channelName, cid: channel.id.toString() },
"Joined channel"
@@ -188,6 +334,76 @@ export class TS3Client extends EventEmitter {
}
}
/**
* Resolve a client's CURRENT server groups by client id, server-wide (works
* regardless of channel/view) via a targeted `clientinfo` query. The raw
* `client_servergroups` field is a comma-separated list (same field
* `listClients` parses). Returns [] if the client can't be resolved or the
* query fails, so callers fail closed.
*/
async getClientServerGroups(clid: number): Promise<string[]> {
if (!this.client) return [];
try {
const info = await getClientInfo(this.client, clid);
// `client_servergroups`: comma-separated server-group ids (verified in
// @honeybbq/teamspeak-client dist/index.mjs; listClients parses the same).
const raw = info.client_servergroups ?? "";
return raw ? raw.split(",") : [];
} catch {
return [];
}
}
// --- 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 +441,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 {
+223
View File
@@ -0,0 +1,223 @@
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;
}
/**
* Thrown when the TS6 HTTP Query returns a non-2xx status.
*
* The previous implementation silently ignored the status code, so a 400
* (bad parameter) or 403 (insufficient permission) looked identical to
* success in logs. Callers that rely on the response being applied —
* nickname / description / away-status updates — should catch this and
* surface it rather than log "updated" for a request that was rejected.
*/
export class HttpQueryError extends Error {
readonly status: number;
readonly body: unknown;
readonly path: string;
constructor(path: string, status: number, body: unknown) {
const bodySnippet = (() => {
if (body == null) return "";
const s = typeof body === "string" ? body : JSON.stringify(body);
return s.length > 200 ? s.slice(0, 200) + "\u2026" : s;
})();
super(
`TS6 HTTP Query ${path} failed: status=${status}${
bodySnippet ? ` body=${bodySnippet}` : ""
}`,
);
this.name = "HttpQueryError";
this.status = status;
this.body = body;
this.path = path;
}
}
/**
* 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, nickname, away).
*
* Throws HttpQueryError on non-2xx responses. The TS6 server returns
* 400 for invalid parameters and 403 for insufficient permissions;
* prior to this check the errors were silently dropped and callers
* logged a false "updated" success.
*/
async clientUpdate(
properties: Record<string, string | number>,
sid = 1,
): Promise<HttpQueryResult> {
const path = `/1/clientupdate?sid=${sid}`;
const result = await this.request("POST", path, properties);
if (result.status < 200 || result.status >= 300) {
throw new HttpQueryError(path, result.status, result.body);
}
return result;
}
/** 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();
});
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { toTS3TextMessage } from "./client.js";
import type { TextMessage } from "@honeybbq/teamspeak-client";
function makeMsg(over: Partial<TextMessage> = {}): TextMessage {
return {
invokerName: "Alice",
invokerUID: "uid-abc",
message: "!stop",
invokerGroups: ["6", "8"],
targetMode: 2,
targetID: 0n,
invokerID: 5,
...over,
};
}
describe("toTS3TextMessage", () => {
it("maps core fields and stringifies invokerID", () => {
const r = toTS3TextMessage(makeMsg());
expect(r.invokerName).toBe("Alice");
expect(r.invokerId).toBe("5");
expect(r.invokerUid).toBe("uid-abc");
expect(r.message).toBe("!stop");
expect(r.targetMode).toBe(2);
});
it("preserves the sender's server groups", () => {
expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]);
});
it("defaults missing invokerGroups to an empty array", () => {
const partial = {
invokerName: "Bob",
invokerUID: "u",
message: "!stop",
targetMode: 1,
targetID: 0n,
invokerID: 7,
} as unknown as TextMessage;
expect(toTS3TextMessage(partial).invokerGroups).toEqual([]);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createAuditRouter } from "./audit.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("audit router", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
for (let i = 0; i < 3; i++) {
audit.record({
actorId: alice.id, actorUsername: "alice",
targetUserId: "x", targetUsername: "x",
action: "user.created",
});
}
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use("/api/audit", createAuditRouter(audit));
});
afterEach(() => botDb.close());
it("requires auth", async () => {
const res = await request(app).get("/api/audit");
expect(res.status).toBe(401);
});
it("returns entries newest-first", async () => {
const res = await request(app).get("/api/audit").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.entries).toHaveLength(3);
});
it("honors limit query param", async () => {
const res = await request(app).get("/api/audit?limit=1").set("Cookie", cookie);
expect(res.body.entries).toHaveLength(1);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { Router } from "express";
import type { AuditStore } from "../../data/audit.js";
export function createAuditRouter(audit: AuditStore): Router {
const router = Router();
router.get("/", (req, res) => {
const limit = clampInt(req.query.limit, 1, 500, 100);
const offset = clampInt(req.query.offset, 0, 100_000, 0);
res.json({ entries: audit.list(limit, offset) });
});
return router;
}
function clampInt(v: unknown, min: number, max: number, def: number): number {
const n = typeof v === "string" ? parseInt(v, 10) : NaN;
if (!Number.isFinite(n)) return def;
return Math.min(Math.max(n, min), max);
}
+21 -6
View File
@@ -1,7 +1,10 @@
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";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
export function createAuthRouter(
neteaseProvider: MusicProvider,
@@ -11,13 +14,17 @@ 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;
}
router.get("/status", async (req, res) => {
router.get("/status", requireNotGuest, async (req, res) => {
try {
const platform = req.query.platform as string;
const provider = getProvider(platform);
@@ -30,7 +37,7 @@ export function createAuthRouter(
}
});
router.post("/qrcode", async (req, res) => {
router.post("/qrcode", requirePermission("platform.auth"), async (req, res) => {
try {
const { platform } = req.body;
const provider = getProvider(platform);
@@ -43,7 +50,7 @@ export function createAuthRouter(
}
});
router.get("/qrcode/status", async (req, res) => {
router.get("/qrcode/status", requireNotGuest, async (req, res) => {
try {
const { key, platform } = req.query;
if (!key) {
@@ -72,7 +79,7 @@ export function createAuthRouter(
}
});
router.post("/sms/send", async (req, res) => {
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
try {
const { phone } = req.body;
if (!phone) {
@@ -92,7 +99,7 @@ export function createAuthRouter(
}
});
router.post("/sms/verify", async (req, res) => {
router.post("/sms/verify", requirePermission("platform.auth"), async (req, res) => {
try {
const { phone, code } = req.body;
if (!phone || !code) {
@@ -113,12 +120,20 @@ export function createAuthRouter(
}
});
router.post("/cookie", (req, res) => {
router.post("/cookie", requirePermission("platform.auth"), (req, res) => {
const { platform, cookie } = req.body;
if (!cookie) {
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
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more