Compare commits

...
148 Commits
Author SHA1 Message Date
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
101 changed files with 17413 additions and 836 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
+1
View File
@@ -6,5 +6,6 @@ dist/
config.json
cookies/
.superpowers/
.worktrees/
/bin/
scripts/navbar_bigger.png
+108 -7
View File
@@ -23,6 +23,7 @@
## 功能特性
- **WebUI 鉴权(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员),bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
@@ -155,6 +156,49 @@ sudo ./scripts/install.sh
>
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `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`:除"用户管理"和"操作审计"外的所有功能(适合给团队成员开通播放权)。
- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。
- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。
**如何重置忘记的管理员密码**:
如果你忘记了管理员密码,可以直接编辑 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.adminPassword` / `adminGroups`**:这两个配置项在旧版本中预留但从未实际启用(TS-side admin 命令权限的占位字段)。保留以避免破坏旧 `config.json`,但不再影响任何行为。可以放心忽略。
### Windows 用户
```
@@ -221,10 +265,16 @@ sudo systemctl start tsmusicbot
### 首次配置
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 页面说明
@@ -235,7 +285,7 @@ sudo systemctl start tsmusicbot
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -253,10 +303,13 @@ sudo systemctl start tsmusicbot
| `!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(网易云,自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
@@ -423,6 +476,18 @@ pip install -U yt-dlp
}
```
> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。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 吗?**
@@ -462,6 +527,21 @@ A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:You
**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:成员可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**不能**:管理其他用户、查看操作审计日志、降级或删除管理员。
**Q:如何把某个用户从成员升级为管理员?**
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
**Q:登录之后多久会自动退出?**
A:登录态有效期 7 天,活跃使用会滚动续期(每次受保护请求都会刷新过期时间)。同一账号最多保持 10 个并发会话(多设备登录时超过的会自动剔除最旧的会话)。
**Q:部署到公网后如何防止暴力登录?**
A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部署建议同时在反向代理(nginx `limit_req` / Caddy 等)层加一层限流,并启用 HTTPS。反向代理部署务必设置 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
## 参与贡献
1. Fork 本仓库
@@ -476,6 +556,20 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
### 最新版本
**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.adminPassword` / `adminGroups` 字段保留以兼容旧 `config.json`,但不再影响任何行为。
### v0.x — Bot Profile 自动更新与协议层升级
**机器人形象自动更新(Bot Profile)**
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
@@ -485,6 +579,13 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
- **竞争条件防护**: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,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,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)—— 这是预期行为。
+218
View File
@@ -14,8 +14,10 @@
"@koa/router": "^15.4.0",
"@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",
@@ -29,10 +31,14 @@
"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"
@@ -667,6 +673,29 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@paralleldrive/cuid2/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -1152,6 +1181,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1194,6 +1230,23 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1240,6 +1293,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
@@ -1285,6 +1345,30 @@
"@types/node": "*"
}
},
"node_modules/@types/superagent": {
"version": "8.1.10",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz",
"integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cookiejar": "^2.1.5",
"@types/methods": "^1.1.4",
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/supertest": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
"integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/methods": "^1.1.4",
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -1501,6 +1585,13 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -1608,6 +1699,12 @@
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
@@ -2011,6 +2108,16 @@
"node": ">= 0.8"
}
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2076,6 +2183,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -2085,6 +2211,13 @@
"node": ">=6.6.0"
}
},
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
@@ -2265,6 +2398,17 @@
"node": ">=8"
}
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -2596,6 +2740,13 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2744,6 +2895,24 @@
"node": ">= 0.6"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5671,6 +5840,55 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+6
View File
@@ -19,8 +19,10 @@
"@koa/router": "^15.4.0",
"@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",
@@ -34,10 +36,14 @@
"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"
+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 编码器)无需安装任何额外软件
# FFmpeg 使用系统包管理器安装,确保容器内兼容性
# 默认从 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);
}
+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
+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"}`,
);
+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)" : "??"}`);
+135
View File
@@ -0,0 +1,135 @@
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 } 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("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_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("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();
});
});
+354 -198
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,43 +37,72 @@ function ffmpegWorks(bin: string): boolean {
}
}
/**
* Resolved once at module load.
*
* Priority: system FFmpeg → bundled ffmpeg-static.
*
* System-installed FFmpeg is always compatible with the running OS/container,
* while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker
* (passes `ffmpeg -version` but crashes during actual audio processing due to
* incompatible glibc or missing shared libraries).
*/
const resolvedFfmpeg: string = (() => {
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// 2. Fall back to bundled ffmpeg-static binary
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
// it may return a Windows .exe path if node_modules was copied cross-platform.
if (ffmpegWorks("ffmpeg")) return "ffmpeg";
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
const onWindows = process.platform === "win32";
if (ffmpegPath && (onWindows === isWinPath)) {
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
return ffmpegPath;
}
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath;
}
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return "ffmpeg";
})();
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
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",
"-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;
}
export interface PlayerEvents {
frame: (opusFrame: Buffer) => void;
trackEnd: () => void;
@@ -93,14 +124,21 @@ 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帧循环(增加容错)
private currentSongDuration = 0; // 当前歌曲总时长(秒)
constructor(logger: Logger) {
super();
@@ -108,113 +146,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;
@@ -224,50 +414,80 @@ 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++;
// 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束
if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) {
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)
}, "FFmpeg stopped outputting data near end, 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;
@@ -278,101 +498,37 @@ 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 = (this.volume / 100) * 0.2;
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;
// Reject NaN/Infinity/negative — the HTTP layer validates too, but a
// bad value here would poison seekOffset and leave getElapsed()
// returning NaN until the track ends.
if (!Number.isFinite(seconds) || seconds < 0) {
this.logger.warn({ seek: seconds }, "Ignoring invalid seek position");
return;
}
this.logger.info({ seek: seconds }, "Seeking");
this.play(this.currentUrl, seconds);
}
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; }
}
+223
View File
@@ -261,4 +261,227 @@ 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");
});
});
});
+124 -22
View File
@@ -21,6 +21,17 @@ export class PlayQueue {
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);
@@ -30,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);
@@ -48,6 +87,12 @@ export class PlayQueue {
}
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;
}
@@ -55,11 +100,15 @@ export class PlayQueue {
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];
@@ -67,7 +116,12 @@ export class PlayQueue {
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];
@@ -80,45 +134,86 @@ 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: {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.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];
}
}
// 前进栈为空,走纯随机逻辑
if (this.mode === PlayMode.Random) {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
} else {
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
return this.songs[0];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
}
}
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 {
@@ -153,6 +248,8 @@ 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);
}
@@ -161,4 +258,9 @@ export class PlayQueue {
getCurrentIndex(): number {
return this.currentIndex;
}
/** Number of songs not yet played in Random mode. */
unplayedCount(): number {
return this.songs.length - this.playedIndices.size;
}
}
+1
View File
@@ -8,6 +8,7 @@ export interface ParsedCommand {
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
"artist",
]);
export const ADMIN_COMMANDS = new Set([
Regular → Executable
+264 -18
View File
@@ -16,6 +16,7 @@ import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
import type { AvatarStore } from "../data/avatars.js";
export interface BotInstanceOptions {
id: string;
@@ -28,6 +29,7 @@ export interface BotInstanceOptions {
database: BotDatabase;
config: BotConfig;
logger: Logger;
avatarStore: AvatarStore;
}
export interface BotStatus {
@@ -57,11 +59,15 @@ export class BotInstance extends EventEmitter {
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private connected = false;
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
private isAdvancing = false;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
private profileManager: BotProfileManager;
private isFmMode = false;
constructor(options: BotInstanceOptions) {
super();
@@ -74,6 +80,7 @@ export class BotInstance extends EventEmitter {
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
this.avatarStore = options.avatarStore;
this.tsClient = new TS3Client(options.tsOptions, this.logger);
this.player = new AudioPlayer(this.logger);
@@ -87,6 +94,17 @@ export class BotInstance extends EventEmitter {
options.tsOptions.nickname,
);
// Best-effort: a corrupted/locked avatar file must not block bot startup.
try {
const relPath = this.database.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
}
} catch (err) {
this.logger.warn({ err }, "Failed to load custom avatar — skipping");
}
this.setupPlayerEvents();
this.setupTsEvents();
}
@@ -131,6 +149,10 @@ export class BotInstance extends EventEmitter {
this.disconnectEmitted = true;
this.emit("disconnected");
});
this.tsClient.on("connected", () => {
this._startIdlePoller();
});
}
async connect(): Promise<void> {
@@ -149,6 +171,7 @@ export class BotInstance extends EventEmitter {
}
disconnect(): void {
this._cancelIdleTimer();
this.player.stop();
this.connected = false;
if (!this.disconnectEmitted) {
@@ -158,6 +181,48 @@ export class BotInstance extends EventEmitter {
this.tsClient.disconnect();
}
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
updateIdleTimeout(minutes: number): void {
this.config.idleTimeoutMinutes = minutes;
if (minutes === 0) this._cancelIdleTimer();
}
private _startIdlePoller(): void {
// 每 30 秒检查一次频道人数
const poll = async () => {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
const userCount = clients.length - 1; // 排除 bot 自身
if (userCount <= 0) {
this._scheduleIdleCheck();
} else {
this._cancelIdleTimer();
}
} catch { /* ignore */ }
setTimeout(poll, 30_000);
};
setTimeout(poll, 30_000);
}
private _scheduleIdleCheck(): void {
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
const minutes = this.config.idleTimeoutMinutes ?? 0;
if (!this.connected || minutes <= 0) return;
this.idleTimer = setTimeout(() => {
if (!this.connected) return;
this.logger.info({ idleMinutes: minutes }, "Channel empty, disconnecting due to idle timeout");
this.disconnect();
}, minutes * 60 * 1000);
}
private _cancelIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand(
msg.message,
@@ -204,12 +269,15 @@ export class BotInstance extends EventEmitter {
const AUDIO_COMMANDS = new Set([
"play",
"add",
"playnext",
"pn",
"next",
"skip",
"prev",
"playlist",
"album",
"fm",
"artist",
]);
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
throw new Error("Bot is not connected to TeamSpeak");
@@ -219,6 +287,9 @@ export class BotInstance extends EventEmitter {
return this.cmdPlay(cmd);
case "add":
return this.cmdAdd(cmd);
case "playnext":
case "pn":
return this.cmdPlayNext(cmd);
case "pause":
return this.cmdPause();
case "resume":
@@ -249,6 +320,8 @@ export class BotInstance extends EventEmitter {
return this.cmdAlbum(cmd);
case "fm":
return this.cmdFm();
case "artist":
return this.cmdArtist(cmd);
case "vote":
return this.cmdVote(msg);
case "lyrics":
@@ -307,7 +380,7 @@ export class BotInstance extends EventEmitter {
return false;
}
song.url = url;
this.player.play(url);
this.player.play(url, 0, song.duration);
this.database.addPlayHistory({
botId: this.id,
songId: song.id,
@@ -338,6 +411,7 @@ export class BotInstance extends EventEmitter {
const song = result.songs[0];
this.queue.clear();
this.isFmMode = false;
this.queue.add({ ...song, platform: provider.platform });
this.queue.play();
@@ -374,6 +448,39 @@ export class BotInstance extends EventEmitter {
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
}
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playnext <song name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0)
return `No results found for: ${cmd.args}`;
const song = result.songs[0];
const wasIdle = this.player.getState() === "idle";
// Capture the slot addNext WILL insert at, before mutating the queue.
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
// at currentIndex+1. Using size-1 after addNext was wrong when the
// queue had stale currentIndex>=0 while the player was idle (e.g.,
// after natural track end without queue.clear()).
const insertedAt =
this.queue.getCurrentIndex() < 0
? this.queue.size()
: this.queue.getCurrentIndex() + 1;
this.queue.addNext({ ...song, platform: provider.platform });
if (wasIdle) {
this.queue.playAt(insertedAt);
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
this.emit("stateChange");
if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`;
}
this.emit("stateChange");
return `Up next: ${song.name} - ${song.artist}`;
}
private cmdPause(): string {
this.player.pause();
this.emit("stateChange");
@@ -389,6 +496,7 @@ export class BotInstance extends EventEmitter {
private cmdStop(): string {
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
@@ -405,13 +513,17 @@ export class BotInstance extends EventEmitter {
}
private async cmdPrev(): Promise<string> {
const prev = this.queue.prev();
if (prev) {
// Retry-skip up to 4 attempts: history can include failed songs
// that playNext's auto-advance retry-skipped past, so a single
// prev would otherwise land on an unplayable song and leave the
// queue's currentIndex stuck mid-failure.
for (let i = 0; i < 4; i++) {
const prev = this.queue.prev();
if (!prev) return "No previous song";
const ok = await this.resolveAndPlay(prev);
if (!ok) return "Cannot play previous song";
return `Now playing: ${prev.name} - ${prev.artist}`;
if (ok) return `Now playing: ${prev.name} - ${prev.artist}`;
}
return "No previous song";
return "Cannot play any previous songs (all failed to resolve)";
}
private cmdVol(cmd: ParsedCommand): string {
@@ -442,6 +554,7 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
@@ -473,13 +586,48 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist ID or URL>";
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 songs = await provider.getPlaylistSongs(id);
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 });
}
@@ -490,12 +638,31 @@ export class BotInstance extends EventEmitter {
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !album <album ID>";
if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags);
const songs = await provider.getAlbumSongs(cmd.args);
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let albumId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use directly
albumId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
const albums = result.albums ?? [];
if (albums.length === 0)
return `No albums found for: ${cmd.args}`;
albumId = albums[0].id;
}
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found";
this.queue.clear();
this.isFmMode = false;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -517,12 +684,61 @@ export class BotInstance extends EventEmitter {
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.queue.setMode(PlayMode.Random);
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 ?? ""}`;
}
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"}`;
}
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");
}
}
private async cmdVote(msg?: TS3TextMessage): Promise<string> {
if (!msg) return "Vote can only be used in TeamSpeak";
this.voteSkipUsers.add(msg.invokerUid);
@@ -574,15 +790,20 @@ export class BotInstance extends EventEmitter {
`${p}play -b <song> — Search from BiliBili`,
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
`${p}add <song> — Add to queue`,
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
`${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`,
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
`${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <id> — Load playlist`,
`${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
`${p}album <id> — Load album`,
`${p}fm — Personal FM (NetEase)`,
`${p}artist <name> — Play songs by artist (loop)`,
`${p}artist -q <name> — Artist loop from QQ Music`,
`${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`,
`${p}now — Current song info`,
@@ -590,17 +811,25 @@ export class BotInstance extends EventEmitter {
].join("\n");
}
private async playNext(): Promise<void> {
if (this.isAdvancing || !this.connected) return;
/**
* Advance the queue and play the next song. If the resolved URL fails
* (e.g., copyright/region restrictions for QQ), skips up to `maxRetries`
* more songs looking for a playable one. Public so REST endpoints that
* seed the queue can fall back to this retry-skip behavior.
*
* Returns true if a song actually started playing, false otherwise.
*/
async playNext(maxRetries = 3): Promise<boolean> {
if (this.isAdvancing || !this.connected) return false;
this.isAdvancing = true;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
let started = false;
if (next) {
let started = await this.resolveAndPlay(next);
started = await this.resolveAndPlay(next);
if (!started) {
// Skip to next if URL resolve fails (up to 3 retries)
for (let i = 0; i < 3 && this.connected; i++) {
for (let i = 0; i < maxRetries && this.connected; i++) {
const retry = this.queue.next();
if (!retry) break;
if (await this.resolveAndPlay(retry)) {
@@ -612,12 +841,29 @@ export class BotInstance extends EventEmitter {
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
} else if (this.isFmMode && this.queue.unplayedCount() <= 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 {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
// Queue exhausted — in FM Random mode, refill and continue
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
started = await this.resolveAndPlay(refillNext);
}
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
this.emit("stateChange");
return started;
} finally {
this.isAdvancing = false;
}
+10 -3
View File
@@ -11,6 +11,7 @@ import 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";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
@@ -74,6 +75,7 @@ export class BotManager extends EventEmitter {
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
constructor(
neteaseProvider: MusicProvider,
@@ -81,7 +83,8 @@ export class BotManager extends EventEmitter {
bilibiliProvider: MusicProvider,
database: BotDatabase,
config: BotConfig,
logger: Logger
logger: Logger,
avatarStore: AvatarStore
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -91,6 +94,7 @@ export class BotManager extends EventEmitter {
this.database = database;
this.config = config;
this.logger = logger;
this.avatarStore = avatarStore;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -117,6 +121,7 @@ export class BotManager extends EventEmitter {
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(id, bot);
@@ -229,10 +234,11 @@ export class BotManager extends EventEmitter {
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
@@ -279,10 +285,11 @@ export class BotManager extends EventEmitter {
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(saved.id, bot);
+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);
});
});
+117 -24
View File
@@ -2,6 +2,7 @@ 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";
@@ -24,6 +25,13 @@ export class BotProfileManager {
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 = {
@@ -57,6 +65,26 @@ export class BotProfileManager {
// --- 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).
@@ -70,6 +98,7 @@ export class BotProfileManager {
*/
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
@@ -90,6 +119,7 @@ export class BotProfileManager {
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.currentSong = null;
this.permDenied = {
avatar: false,
description: false,
@@ -98,6 +128,12 @@ export class BotProfileManager {
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 {
@@ -135,24 +171,45 @@ export class BotProfileManager {
// 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("Avatar updated");
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"]),
@@ -161,7 +218,6 @@ export class BotProfileManager {
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
@@ -170,6 +226,18 @@ export class BotProfileManager {
}
}
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 {
@@ -178,7 +246,11 @@ export class BotProfileManager {
: "";
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
await httpQuery.clientUpdate({ client_description: text });
// 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
@@ -193,8 +265,8 @@ export class BotProfileManager {
),
5000,
);
this.logger.info("Description updated");
}
this.logger.info("Description updated");
} catch (err) {
this.handleFeatureError("description", err);
}
@@ -204,18 +276,25 @@ export class BotProfileManager {
* 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 parts: string[] = [];
const rawProps: Record<string, string | number> = {};
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
parts.push(`client_nickname=${escapeTS3(this.defaultNickname)}`);
rawProps.client_nickname = this.defaultNickname;
} else {
const nickname = this.buildNickname(song);
if (nickname) {
parts.push(`client_nickname=${escapeTS3(nickname)}`);
rawProps.client_nickname = nickname;
}
}
}
@@ -223,31 +302,38 @@ export class BotProfileManager {
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
parts.push("client_away=0");
rawProps.client_away = 0;
} else {
parts.push(`client_away=1 client_away_message=${escapeTS3("\u7B49\u5F85\u64AD\u653E")}`);
rawProps.client_away = 1;
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
}
}
if (parts.length === 0) return;
if (Object.keys(rawProps).length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: build a properties object
const props: Record<string, string | number> = {};
for (const part of parts) {
const eq = part.indexOf("=");
if (eq > 0) props[part.slice(0, eq)] = part.slice(eq + 1);
}
await httpQuery.clientUpdate(props);
// 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 {
// Use sendCommandNoWait: the TS3 full-client protocol often
// 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)",
);
}
this.logger.info("Client properties updated (nickname + away)");
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
@@ -387,21 +473,28 @@ export class BotProfileManager {
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
if (
// - 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")
) {
msg.includes("invalid parameter") ||
status === 400 ||
status === 401 ||
status === 403;
if (isUnrecoverable) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, reason: msg },
{ feature, status, body, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, err }, "Profile update failed");
this.logger.warn({ feature, status, body, err }, "Profile update failed");
}
}
}
+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");
});
});
+57
View File
@@ -0,0 +1,57 @@
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";
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;
},
};
}
Regular → Executable
+12
View File
@@ -13,6 +13,15 @@ export interface BotConfig {
adminGroups: number[];
autoReturnDelay: number;
autoPauseOnEmpty: boolean;
idleTimeoutMinutes: number;
// 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;
}
export function getDefaultConfig(): BotConfig {
@@ -28,6 +37,9 @@ export function getDefaultConfig(): BotConfig {
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
idleTimeoutMinutes: 0,
publicUrl: "",
trustProxy: false,
};
}
+47 -1
View File
@@ -23,6 +23,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",
@@ -68,7 +92,7 @@ describe("database", () => {
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
@@ -98,4 +122,26 @@ describe("database", () => {
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: "",
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();
});
});
+55
View File
@@ -60,6 +60,8 @@ export interface BotDatabase {
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;
close(): void;
}
@@ -92,6 +94,15 @@ function migrateSchema(db: Database.Database): void {
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 {
@@ -122,12 +133,45 @@ function initTables(db: Database.Database): void {
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);
`);
}
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);
@@ -179,6 +223,9 @@ export function createDatabase(dbPath: string): BotDatabase {
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 = ?`);
return {
db,
@@ -242,6 +289,14 @@ export function createDatabase(dbPath: string): BotDatabase {
});
},
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);
},
close() {
db.close();
},
+128
View File
@@ -0,0 +1,128 @@
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 } 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);
});
});
+104
View File
@@ -0,0 +1,104 @@
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 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";
}
export interface SessionStore {
createSession(userId: string): { 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) {
// 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 + SESSION_TTL_MS;
const tx = db.transaction(() => {
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) {
touchStmt.run(now, now + SESSION_TTL_MS, id);
}
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" };
},
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());
},
};
}
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, UsernameTakenError, 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);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member";
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");
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 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 === 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 === "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
+11 -2
View File
@@ -8,6 +8,7 @@ import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js";
@@ -18,6 +19,7 @@ const 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 STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
@@ -46,6 +48,7 @@ async function main() {
const bilibiliProvider = new BiliBiliProvider();
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");
@@ -59,7 +62,8 @@ async function main() {
bilibiliProvider,
db,
config,
logger
logger,
avatarStore
);
await botManager.loadSavedBots();
@@ -70,7 +74,9 @@ async function main() {
qqProvider,
bilibiliProvider,
database: db,
avatarStore,
config,
configPath: CONFIG_PATH,
logger,
cookieStore,
staticDir: STATIC_DIR,
@@ -78,7 +84,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...");
+8 -1
View File
@@ -76,7 +76,14 @@ export function createApiServerManager(
);
} else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
const koaApp = qqModule.default ?? qqModule;
// 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", () =>
+65 -6
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
@@ -15,6 +16,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 +35,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 +75,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 +148,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,
});
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics } from "./netease.js";
import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,32 @@ 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([]);
});
});
+37 -3
View File
@@ -3,10 +3,12 @@ import type {
MusicProvider,
Song,
Playlist,
PlaylistDetail,
LyricLine,
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -54,6 +56,18 @@ 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",
}));
}
// 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 +104,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,10 +112,13 @@ 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(
@@ -126,7 +143,9 @@ 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> {
@@ -319,6 +338,21 @@ export class NeteaseProvider implements MusicProvider {
}));
}
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[]> {
// First get user ID from login status
const statusRes = await this.api.get("/login/status", {
+9
View File
@@ -20,6 +20,14 @@ export interface Playlist {
platform: "netease" | "qq" | "bilibili" | "youtube";
}
export interface PlaylistDetail {
id: string;
name: string;
description: string;
coverUrl: string;
songCount: number;
}
export interface Album {
id: string;
name: string;
@@ -77,4 +85,5 @@ export interface MusicProvider {
getPersonalFm?(): Promise<Song[]>;
getDailyRecommendSongs?(): Promise<Song[]>;
getUserPlaylists?(): Promise<Playlist[]>;
getPlaylistDetail?(playlistId: string): Promise<PlaylistDetail | null>;
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums } from "./qq.js";
describe("QQ adapter", () => {
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("");
});
});
+381 -26
View File
@@ -3,13 +3,70 @@ import type {
MusicProvider,
Song,
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 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;
@@ -36,33 +93,201 @@ export class QQMusicProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const res = await this.api.get("/getSearchByKey", {
params: { key: query, pageSize: limit, ...this.cookieParams },
});
// 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;
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: [] };
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit);
}
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;
/** 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: Song[] = songList.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",
}));
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: Song[] = 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 ?? s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
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<string | 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 playUrl.url;
} 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 playUrl.url;
} 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> {
@@ -115,18 +340,35 @@ 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),
id: String(s.mid ?? 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
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
}
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[]> {
const res = await this.api.get("/getSongLists", {
params: { categoryId: 10000000, pageSize: 10, ...this.cookieParams },
@@ -255,4 +497,117 @@ export class QQMusicProvider implements MusicProvider {
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 },
});
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",
}));
} 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 {
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 {
// Return whatever we got so far on partial failure rather than dropping
// earlier pages.
}
return all;
}
}
+3
View File
@@ -188,6 +188,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),
+46 -2
View File
@@ -14,6 +14,38 @@ export interface HttpQueryResult {
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.
*
@@ -157,12 +189,24 @@ export class TS6HttpQuery {
});
}
/** Update client properties (e.g., description) */
/**
* 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> {
return this.request("POST", `/1/clientupdate?sid=${sid}`, properties);
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 */
+56
View File
@@ -0,0 +1,56 @@
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 { 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 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));
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);
}
Regular → Executable
+92 -1
View File
@@ -1,12 +1,18 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js";
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
logger: Logger
configPath: string,
logger: Logger,
botDb: BotDatabase,
avatarStore: AvatarStore,
): Router {
const router = Router();
@@ -34,6 +40,70 @@ export function createBotRouter(
res.json(saved);
});
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 exists =
botManager.getBot(req.params.id) ||
botDb.getBotInstances().some((b) => b.id === req.params.id);
if (!exists) {
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] as string;
const buf = Buffer.from(m[2] ?? "", "base64");
if (buf.length === 0) {
res.status(400).json({ error: "empty image" });
return;
}
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);
botManager.getBot(req.params.id)?.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);
botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(null);
res.status(204).end();
});
router.post("/", async (req, res) => {
try {
const {
@@ -115,6 +185,27 @@ export function createBotRouter(
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/bot/settings — 读取全局 bot 行为设置
router.get("/settings", (_req, res) => {
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
});
// POST /api/bot/settings — 保存全局 bot 行为设置
router.post("/settings", (req, res) => {
const { idleTimeoutMinutes } = req.body;
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
return;
}
config.idleTimeoutMinutes = idleTimeoutMinutes;
saveConfig(configPath, config);
// 通知所有 bot 实例更新定时器
for (const bot of botManager.getAllBots()) {
bot.updateIdleTimeout(idleTimeoutMinutes);
}
res.json({ ok: true });
});
return router;
}
+19 -29
View File
@@ -52,14 +52,20 @@ export function createMusicRouter(
]);
const songs = [
...(neteaseResult.status === "fulfilled"
? neteaseResult.value.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 });
res.json({ songs, albums, playlists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
@@ -168,32 +174,16 @@ export function createMusicRouter(
router.get("/playlist/:id/detail", async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
// Use the playlist songs endpoint to get basic info,
// but we also need detail info (name, cover, description).
// For netease, we access the underlying API directly.
const nProvider = provider as any;
if (nProvider.api) {
const cookieParams = nProvider.cookie
? { cookie: nProvider.cookie }
: {};
const detailRes = await nProvider.api.get("/playlist/detail", {
params: { id: req.params.id, ...cookieParams },
});
const p = detailRes.data?.playlist;
if (p) {
res.json({
playlist: {
id: String(p.id),
name: p.name,
description: p.description ?? "",
coverUrl: p.coverImgUrl ?? "",
songCount: p.trackCount ?? 0,
},
});
return;
}
if (!provider.getPlaylistDetail) {
res.status(501).json({ error: "Not supported by this provider" });
return;
}
res.status(404).json({ error: "Playlist not found" });
const detail = await provider.getPlaylistDetail(req.params.id);
if (!detail) {
res.status(404).json({ error: "Playlist not found" });
return;
}
res.json({ playlist: detail });
} catch (err) {
logger.error({ err }, "Get playlist detail failed");
res.status(500).json({ error: (err as Error).message });
+181 -13
View File
@@ -249,9 +249,32 @@ export function createPlayerRouter(
return;
}
// QQ-specific optimization: many users' QQ playlists contain a
// large fraction of songs that return result=104003 (region/copyright
// restricted). Batch-resolve URLs once and only queue the playable
// ones, otherwise the playback retry loop wastes time guessing.
let queueable: { id: string }[] = songs;
const totalCount = songs.length;
const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
if (typeof qqLike.getPlayableSongIds === "function") {
const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
if (playable !== null) {
// Authoritative answer from upstream — even an empty set means
// "we know none are playable", short-circuit immediately rather
// than wasting 20+ retries.
queueable = songs.filter((s: { id: string }) => playable.has(s.id));
}
// If null, the batch endpoint itself errored — fall through to
// the sequential retry path, which still has a chance.
}
if (queueable.length === 0) {
res.json({ ok: false, message: `歌单 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
return;
}
const queue = bot.getQueueManager();
queue.clear();
for (const song of songs) {
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
}
@@ -265,47 +288,192 @@ export function createPlayerRouter(
first = queue.play();
}
if (first) {
await bot.resolveAndPlay(first);
// If the first picked song can't resolve (e.g., QQ song with no
// streaming entitlement → result 104003), fall back to playNext's
// retry-skip behavior. Use a higher retry budget than the default
// trackEnd auto-advance because user-initiated playlist plays
// commonly have long contiguous runs of unplayable songs.
let started = first ? await bot.resolveAndPlay(first) : false;
if (first && !started) {
started = await bot.playNext(20);
}
res.json({ message: `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}` });
const playing = queue.current();
const loadedMsg = queueable.length < totalCount
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
: `已加载 ${queueable.length} 首`;
if (started && playing) {
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
} else {
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
}
} catch (err) {
logger.error({ err }, "Play playlist failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-by-id", async (req, res) => {
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
router.post("/:botId/play-album", async (req, res) => {
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
const { albumId, platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const song = await provider.getSongDetail(songId);
if (!song) {
res.json({ message: "Song not found" });
// Stop current playback
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) {
res.json({ message: "Album is empty" });
return;
}
// QQ-specific optimization: batch-resolve playable IDs to avoid
// wasting retries on region/copyright-restricted tracks.
let queueable: { id: string }[] = songs;
const totalCount = songs.length;
const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
if (typeof qqLike.getPlayableSongIds === "function") {
const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
if (playable !== null) {
queueable = songs.filter((s: { id: string }) => playable.has(s.id));
}
}
if (queueable.length === 0) {
res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
return;
}
const queue = bot.getQueueManager();
queue.clear();
queue.add({ ...song, platform: provider.platform });
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
}
const mode = queue.getMode();
let first;
if (mode === "random" || mode === "rloop") {
const idx = Math.floor(Math.random() * queue.size());
first = queue.playAt(idx);
} else {
first = queue.play();
}
let started = first ? await bot.resolveAndPlay(first) : false;
if (first && !started) {
started = await bot.playNext(20);
}
const playing = queue.current();
const loadedMsg = queueable.length < totalCount
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
: `已加载 ${queueable.length} 首`;
if (started && playing) {
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
} else {
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
}
} catch (err) {
logger.error({ err }, "play-album failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
if (!song || !song.id || !song.platform) {
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
const queue = bot.getQueueManager();
queue.clear();
queue.add(song);
queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
res.json({ message: `Cannot play: ${song.name}` });
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return;
}
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Insert a single song to play right after the current one.
// If nothing is playing, behaves like /play-song (start immediately).
router.post("/:botId/play-next-song", async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
if (!song || !song.id || !song.platform) {
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
// Capture the slot addNext WILL insert at, before mutating the queue.
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
// at currentIndex+1. Using size-1 after addNext was wrong when the
// queue had stale currentIndex>=0 while the player was idle (e.g.,
// after natural track end without queue.clear()).
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
if (wasIdle) {
// Promote the just-added song to current and start it.
queue.playAt(insertedAt);
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return;
}
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
return;
}
res.json({ ok: true, message: `已加入下一首:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/add-song", async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
if (!song || !song.id || !song.platform) {
res.status(400).json({ error: "song object with id and platform is required" });
return;
}
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song);
// If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
queue.playAt(queue.size() - 1);
bot.getPlayer().resetFailures();
await bot.resolveAndPlay(queue.current()!);
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
return;
}
res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createSessionRouter } from "./session.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
const audit = createAuditStore(botDb.db);
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" })));
return app;
}
function extractCookie(res: request.Response): string {
const header = res.headers["set-cookie"];
const arr = Array.isArray(header) ? header : header ? [header] : [];
const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
if (!found) throw new Error("no session cookie set");
return found.split(";")[0]; // "tsmb_session=xxxx"
}
describe("session router", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let app: express.Express;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
app = makeApp(botDb, users, sessions);
});
afterEach(() => botDb.close());
it("GET /needs-setup returns true on an empty db", async () => {
const res = await request(app).get("/api/session/needs-setup");
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsSetup: true });
});
it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => {
const setupRes = await request(app)
.post("/api/session/setup")
.send({ username: "alice", password: "hunter2-hunter2" });
expect(setupRes.status).toBe(200);
expect(setupRes.body.username).toBe("alice");
extractCookie(setupRes);
const needs = await request(app).get("/api/session/needs-setup");
expect(needs.body).toEqual({ needsSetup: false });
});
it("POST /setup returns 409 once a user already exists", async () => {
await users.createUser("admin", "pw-admin-pw", "admin");
const res = await request(app)
.post("/api/session/setup")
.send({ username: "alice", password: "pw" });
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: "already initialized" });
});
it("POST /login returns 401 with constant-time delay on bad credentials", async () => {
await users.createUser("alice", "correct-pw-pw", "admin");
const start = Date.now();
const res = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "wrong" });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "invalid credentials" });
expect(Date.now() - start).toBeGreaterThanOrEqual(200);
}, 10_000);
it("POST /login sets a session cookie on success", async () => {
await users.createUser("alice", "pw-alice", "admin");
const res = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
expect(res.status).toBe(200);
expect(res.body.username).toBe("alice");
extractCookie(res);
});
it("GET /me returns the current user when cookie is present, 401 otherwise", async () => {
await users.createUser("alice", "pw-alice", "admin");
const loginRes = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes);
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.status).toBe(200);
expect(me.body.username).toBe("alice");
const anon = await request(app).get("/api/session/me");
expect(anon.status).toBe(401);
});
it("POST /logout deletes the session and clears the cookie", async () => {
await users.createUser("alice", "pw-alice", "admin");
const loginRes = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes);
const logout = await request(app).post("/api/session/logout").set("Cookie", cookie);
expect(logout.status).toBe(204);
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.status).toBe(401);
});
it("POST /change-password requires old password and invalidates other sessions", async () => {
const u = await users.createUser("alice", "old-pw-pw", "admin");
const cookieA = extractCookie(
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
);
const cookieB = extractCookie(
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
);
const wrongOld = await request(app)
.post("/api/session/change-password")
.set("Cookie", cookieA)
.send({ oldPassword: "WRONG", newPassword: "newpassword" });
expect(wrongOld.status).toBe(401);
const ok = await request(app)
.post("/api/session/change-password")
.set("Cookie", cookieA)
.send({ oldPassword: "old-pw-pw", newPassword: "newpassword" });
expect(ok.status).toBe(204);
const meA = await request(app).get("/api/session/me").set("Cookie", cookieA);
expect(meA.status).toBe(200);
const meB = await request(app).get("/api/session/me").set("Cookie", cookieB);
expect(meB.status).toBe(401);
expect(u.id).toBe(meA.body.id);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
const FAILED_LOGIN_DELAY_MS = 250;
function setSessionCookie(res: Response, token: string): void {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: res.req.secure,
path: "/",
maxAge: SESSION_TTL_MS,
});
}
function clearSessionCookie(res: Response): void {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isValidUsername(v: unknown): v is string {
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
}
function isValidPassword(v: unknown): v is string {
return typeof v === "string" && v.length >= 8 && v.length <= 200;
}
function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`));
if (!match) return null;
return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1));
}
export function createSessionRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger
): Router {
const router = Router();
const requireAuthInline = (req: Request, res: Response, next: NextFunction) => {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
clearSessionCookie(res);
res.status(401).json({ error: "unauthenticated" });
return;
}
req.user = { id: result.userId, username: result.username, role: result.role };
const token = extractSessionToken(req.headers.cookie);
if (token) setSessionCookie(res, token);
next();
};
router.get("/needs-setup", (_req, res) => {
res.json({ needsSetup: users.countUsers() === 0 });
});
router.post("/setup", async (req, res) => {
const { username, password } = req.body ?? {};
if (users.countUsers() !== 0) {
res.status(409).json({ error: "already initialized" });
return;
}
if (!isValidUsername(username) || !isValidPassword(password)) {
res.status(400).json({ error: "invalid username or password" });
return;
}
try {
const user = await users.createFirstUser(username, password);
if (!user) {
res.status(409).json({ error: "already initialized" });
return;
}
const { token } = sessions.createSession(user.id);
setSessionCookie(res, token);
try {
audit.record({
actorId: user.id, actorUsername: user.username,
targetUserId: user.id, targetUsername: user.username,
action: "admin.first_created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed");
}
logger.info({ userId: user.id, username }, "First admin created");
res.json({ id: user.id, username: user.username, role: user.role });
} catch (err) {
logger.error({ err }, "setup failed");
res.status(500).json({ error: "internal" });
}
});
router.post("/login", async (req, res) => {
const { username, password } = req.body ?? {};
if (typeof username !== "string" || typeof password !== "string") {
res.status(400).json({ error: "invalid request" });
return;
}
const user = users.findByUsername(username);
const ok = user ? await users.verifyPassword(password, user.passwordHash) : false;
if (!user || !ok) {
await delay(FAILED_LOGIN_DELAY_MS);
res.status(401).json({ error: "invalid credentials" });
return;
}
const { token } = sessions.createSession(user.id);
setSessionCookie(res, token);
res.json({ id: user.id, username: user.username, role: user.role });
});
router.post("/logout", (req, res) => {
const token = parseTokenFromCookie(req.headers.cookie);
if (token) {
sessions.deleteSession(token);
}
clearSessionCookie(res);
res.status(204).end();
});
router.get("/me", requireAuthInline, (req, res) => {
res.json(req.user);
});
router.post("/change-password", requireAuthInline, async (req, res) => {
const { oldPassword, newPassword } = req.body ?? {};
if (typeof oldPassword !== "string") {
res.status(400).json({ error: "invalid request" });
return;
}
const u = users.findById(req.user!.id);
if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) {
await delay(FAILED_LOGIN_DELAY_MS);
res.status(401).json({ error: "invalid credentials" });
return;
}
if (!isValidPassword(newPassword)) {
res.status(400).json({ error: "invalid request" });
return;
}
await users.changePassword(u.id, newPassword);
const currentToken = parseTokenFromCookie(req.headers.cookie);
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
try {
audit.record({
actorId: u.id, actorUsername: u.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.password_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.password_changed" }, "audit insert failed");
}
res.status(204).end();
});
return router;
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createUsersRouter } from "./users.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
const requireAuth = createRequireAuth(sessions);
const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" })));
return app;
}
describe("users router", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let app: express.Express;
let aliceId: string;
let aliceCookie: string;
let bobId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
app = makeApp(botDb, users, sessions);
const alice = await users.createUser("alice", "pw-alice", "admin");
aliceId = alice.id;
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
const bob = await users.createUser("bob", "pw-bob-bob", "member");
bobId = bob.id;
});
afterEach(() => botDb.close());
it("requires auth for all routes", async () => {
expect((await request(app).get("/api/users")).status).toBe(401);
expect((await request(app).post("/api/users").send({ username: "x", password: "yyyyyyyy" })).status).toBe(401);
expect((await request(app).delete(`/api/users/${bobId}`)).status).toBe(401);
});
it("GET / lists users with id+username+createdAt, no password hash", async () => {
const res = await request(app).get("/api/users").set("Cookie", aliceCookie);
expect(res.status).toBe(200);
expect(res.body.users).toHaveLength(2);
for (const u of res.body.users) {
expect(u).toHaveProperty("id");
expect(u).toHaveProperty("username");
expect(u).toHaveProperty("createdAt");
expect(u).not.toHaveProperty("passwordHash");
}
});
it("POST / creates a user", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "charlie", password: "charlie-pw" });
expect(res.status).toBe(201);
expect(res.body.username).toBe("charlie");
expect(users.countUsers()).toBe(3);
});
it("POST / returns 409 on duplicate username", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "BOB", password: "another-pw" });
expect(res.status).toBe(409);
});
it("POST / returns 400 on invalid input", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "x", password: "short" });
expect(res.status).toBe(400);
});
it("DELETE /:id removes the user and their sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app).delete(`/api/users/${bobId}`).set("Cookie", aliceCookie);
expect(res.status).toBe(204);
expect(users.countUsers()).toBe(1);
expect(sessions.validateAndTouch(bobToken)).toBeNull();
});
it("DELETE /:id of self returns 400", async () => {
const res = await request(app).delete(`/api/users/${aliceId}`).set("Cookie", aliceCookie);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "cannot delete self" });
expect(users.countUsers()).toBe(2);
});
it("DELETE /:id of nonexistent returns 404", async () => {
const res = await request(app).delete(`/api/users/not-a-real-id`).set("Cookie", aliceCookie);
expect(res.status).toBe(404);
});
it("POST /:id/reset-password updates the hash and invalidates target's sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "bob-new-pw" });
expect(res.status).toBe(204);
expect(sessions.validateAndTouch(bobToken)).toBeNull();
const bob = users.findByUsername("bob");
expect(await users.verifyPassword("bob-new-pw", bob!.passwordHash)).toBe(true);
expect(await users.verifyPassword("pw-bob-bob", bob!.passwordHash)).toBe(false);
});
it("POST /:id/reset-password 404 on unknown user", async () => {
const res = await request(app)
.post(`/api/users/not-a-real-id/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "anything-here" });
expect(res.status).toBe(404);
});
it("POST /:id/reset-password 400 on short password", async () => {
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "short" });
expect(res.status).toBe(400);
});
it("returns 201 even if audit insert fails (POST /api/users)", async () => {
// Build a broken audit store that throws on record()
const brokenAudit = {
record: () => { throw new Error("simulated disk-full"); },
list: () => [],
};
// Reassemble app with the broken audit
const localApp = express();
localApp.use(express.json());
localApp.use(cookieParser());
localApp.use("/api", createRequireAuth(sessions));
localApp.use(
"/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }))
);
const res = await request(localApp)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "charlie", password: "charlie-pw" });
expect(res.status).toBe(201);
expect(users.countUsers()).toBe(3);
});
it("POST /:id/reset-password on self preserves the actor's current session", async () => {
// Alice resets her OWN password
const res = await request(app)
.post(`/api/users/${aliceId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "alice-new-pw" });
expect(res.status).toBe(204);
// Alice's CURRENT session should still work
// (we'd need a protected endpoint to verify; use GET /api/users which is already mounted)
const followUp = await request(app).get("/api/users").set("Cookie", aliceCookie);
expect(followUp.status).toBe(200);
// The password hash IS updated (sanity check)
const alice = users.findById(aliceId);
expect(await users.verifyPassword("alice-new-pw", alice!.passwordHash)).toBe(true);
});
it("POST /:id/reset-password on another user does NOT preserve any of target's sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "bob-new-pw" });
expect(res.status).toBe(204);
// Bob's session should be dead
expect(sessions.validateAndTouch(bobToken)).toBeNull();
});
it("POST / defaults new user to role=member when role omitted", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("member");
});
it("POST / accepts role=admin", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw", role: "admin" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("admin");
expect(users.countAdmins()).toBe(2);
});
it("PATCH /:id/role can change role between admin and member", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(204);
expect(users.findById(bobId)!.role).toBe("admin");
});
it("PATCH /:id/role blocks demoting the last admin", async () => {
// alice is the only admin. Demoting her would leave 0 admins. Block.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "cannot demote last admin" });
});
it("PATCH /:id/role allows demoting an admin when other admins exist", async () => {
// Promote bob first
users.setRole(bobId, "admin");
// Now both are admins. Demoting alice should work.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(204);
});
it("PATCH /:id/role 400 on invalid role", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "superuser" });
expect(res.status).toBe(400);
});
it("PATCH /:id/role 404 on unknown user", async () => {
const res = await request(app)
.patch(`/api/users/not-a-real-id/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(404);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { Router } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import { UsernameTakenError } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
import { extractSessionToken } from "../auth/validateSession.js";
function isValidUsername(v: unknown): v is string {
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
}
function isValidPassword(v: unknown): v is string {
return typeof v === "string" && v.length >= 8 && v.length <= 200;
}
export function createUsersRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger
): Router {
const router = Router();
router.get("/", (_req, res) => {
res.json({ users: users.listUsers() });
});
router.post("/", async (req, res) => {
const { username, password, role: roleInput } = req.body ?? {};
if (!isValidUsername(username) || !isValidPassword(password)) {
res.status(400).json({ error: "invalid username or password" });
return;
}
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
try {
const u = await users.createUser(username, password, role);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
}
logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
res.status(201).json({ id: u.id, username: u.username, role: u.role });
} catch (err) {
if (err instanceof UsernameTakenError) {
res.status(409).json({ error: "username taken" });
return;
}
logger.error({ err }, "createUser failed");
res.status(500).json({ error: "internal" });
}
});
router.delete("/:id", (req, res) => {
const targetId = req.params.id;
// Snapshot target's username BEFORE deletion for audit
const target = users.findById(targetId);
if (!target) {
res.status(404).json({ error: "not found" });
return;
}
if (targetId === req.user!.id) {
res.status(400).json({ error: "cannot delete self" });
return;
}
const result = users.deleteUserIfNotLastAdmin(targetId);
if (result === "not_found") {
res.status(404).json({ error: "not found" });
return;
}
if (result === "would_orphan") {
res.status(400).json({ error: "cannot delete last admin" });
return;
}
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
sessions.deleteAllForUser(targetId);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.deleted",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.deleted" }, "audit insert failed");
}
logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted");
res.status(204).end();
});
router.post("/:id/reset-password", async (req, res) => {
const { newPassword } = req.body ?? {};
if (!isValidPassword(newPassword)) {
res.status(400).json({ error: "invalid password" });
return;
}
const targetId = req.params.id;
const target = users.findById(targetId);
if (!target) {
res.status(404).json({ error: "not found" });
return;
}
await users.changePassword(targetId, newPassword);
// Invalidate all sessions for the target user (except current actor's if it's the same user)
const exceptToken = targetId === req.user!.id
? (extractSessionToken(req.headers.cookie) ?? undefined)
: undefined;
sessions.deleteAllForUser(targetId, exceptToken);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.password_reset",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.password_reset" }, "audit insert failed");
}
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
res.status(204).end();
});
router.patch("/:id/role", (req, res) => {
const targetId = req.params.id;
const { role: newRole } = req.body ?? {};
if (newRole !== "admin" && newRole !== "member") {
res.status(400).json({ error: "invalid role" });
return;
}
// Snapshot the target's old role and username for audit (BEFORE the atomic update,
// so we record what actually changed; if the user is gone we'll skip audit).
const targetBefore = users.findById(targetId);
if (!targetBefore) {
res.status(404).json({ error: "not found" });
return;
}
const result = users.setRoleIfNotLastAdmin(targetId, newRole);
if (result === "not_found") {
res.status(404).json({ error: "not found" });
return;
}
if (result === "would_orphan") {
res.status(400).json({ error: "cannot demote last admin" });
return;
}
// Only audit when the role actually changed
if (targetBefore.role !== newRole) {
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: targetBefore.id, targetUsername: targetBefore.username,
action: "user.role_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
}
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
}
res.status(204).end();
});
return router;
}
+38
View File
@@ -0,0 +1,38 @@
import type { SessionStore, SessionValidation } from "../../data/sessions.js";
export const SESSION_COOKIE_NAME = "tsmb_session";
/**
* Validate the session cookie carried on an arbitrary HTTP-like header bag.
* Used by Express middleware (req.headers.cookie) AND by the raw WebSocket
* upgrade handler (req.headers.cookie) — they share this exact behavior.
*/
export function validateSessionFromHeaders(
rawCookieHeader: string | undefined,
sessions: SessionStore
): SessionValidation | null {
if (!rawCookieHeader) return null;
const token = parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
if (!token) return null;
return sessions.validateAndTouch(token);
}
export function extractSessionToken(rawCookieHeader: string | undefined): string | null {
if (!rawCookieHeader) return null;
return parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
}
function parseCookie(header: string, name: string): string | null {
for (const part of header.split(";")) {
const trimmed = part.trim();
const eq = trimmed.indexOf("=");
if (eq < 1) continue;
if (trimmed.slice(0, eq) !== name) continue;
try {
return decodeURIComponent(trimmed.slice(eq + 1));
} catch {
return null;
}
}
return null;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { csrfOriginCheck } from "./csrf.js";
describe("csrfOriginCheck middleware", () => {
let app: express.Express;
beforeEach(() => {
app = express();
app.use(csrfOriginCheck);
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/", (_req, res) => res.json({ ok: true }));
});
it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => {
const res = await request(app).get("/");
expect(res.status).toBe(200);
});
it("rejects POST without Origin or Referer", async () => {
const res = await request(app).post("/");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "bad origin" });
});
it("accepts POST when Origin host matches request host", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://example.com");
expect(res.status).toBe(200);
});
it("rejects POST when Origin host does not match request host", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://evil.com");
expect(res.status).toBe(403);
});
it("accepts POST when Referer host matches and Origin is absent", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Referer", "https://example.com/some/path");
expect(res.status).toBe(200);
});
it("rejects POST when Referer host does not match", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Referer", "https://evil.com/some/path");
expect(res.status).toBe(403);
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { Request, Response, NextFunction } from "express";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* Same-origin CSRF protection. For mutating requests, the Origin or Referer
* header must indicate a host equal to the request's own host.
*
* SameSite=Lax on the session cookie blocks classic cross-site form posts;
* this header check covers the remaining attack surface.
*/
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method)) {
next();
return;
}
const expectedHost = req.get("host");
const originHeader = req.get("origin");
const refererHeader = req.get("referer");
const headerHost = hostOf(originHeader) ?? hostOf(refererHeader);
if (!headerHost || !expectedHost || headerHost !== expectedHost) {
res.status(403).json({ error: "bad origin" });
return;
}
next();
}
function hostOf(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).host;
} catch {
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { createRateLimit } from "./rateLimit.js";
describe("createRateLimit", () => {
let app: express.Express;
beforeEach(() => {
app = express();
// capacity=3, refill=1/sec → first 3 succeed, then 429 until refill.
app.use(createRateLimit({ capacity: 3, refillPerSec: 1 }));
app.get("/", (_req, res) => res.json({ ok: true }));
});
it("allows up to capacity bursts then rejects with 429", async () => {
expect((await request(app).get("/")).status).toBe(200);
expect((await request(app).get("/")).status).toBe(200);
expect((await request(app).get("/")).status).toBe(200);
const denied = await request(app).get("/");
expect(denied.status).toBe(429);
expect(denied.body).toEqual({ error: "rate limit exceeded" });
expect(denied.headers["retry-after"]).toBeDefined();
});
it("uses per-key buckets when keyFn is provided", async () => {
const customApp = express();
customApp.use(
createRateLimit({
capacity: 1,
refillPerSec: 0.001,
keyFn: (req) => req.get("x-user") ?? "anon",
})
);
customApp.get("/", (_req, res) => res.json({ ok: true }));
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(200);
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(429);
// Different user, separate bucket → still has a token.
expect((await request(customApp).get("/").set("X-User", "bob")).status).toBe(200);
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
interface Bucket {
tokens: number;
lastRefillMs: number;
}
interface RateLimitOptions {
/** Bucket capacity (max burst). */
capacity: number;
/** Tokens refilled per second. */
refillPerSec: number;
/** Optional key function; defaults to req.ip. */
keyFn?: (req: Request) => string;
}
/**
* In-memory token-bucket rate limiter.
*
* Each unique key (default: req.ip) gets its own bucket. Refills continuously
* at `refillPerSec` up to `capacity`. Each request consumes 1 token; if no
* token is available, returns 429 with Retry-After.
*
* Buckets evict themselves after 10 minutes of inactivity to bound memory.
*/
export function createRateLimit(options: RateLimitOptions): RequestHandler {
const buckets = new Map<string, Bucket>();
const EVICT_AFTER_MS = 10 * 60 * 1000;
// Periodic eviction to bound memory under attack.
const evict = setInterval(() => {
const cutoff = Date.now() - EVICT_AFTER_MS;
for (const [k, b] of buckets) {
if (b.lastRefillMs < cutoff) buckets.delete(k);
}
}, 60_000);
// Unref the timer so it doesn't keep the process alive in tests.
if (typeof (evict as { unref?: () => void }).unref === "function") {
(evict as { unref: () => void }).unref();
}
const keyFn = options.keyFn ?? ((req) => req.ip ?? "unknown");
return function rateLimit(req: Request, res: Response, next: NextFunction): void {
const key = keyFn(req);
const now = Date.now();
let b = buckets.get(key);
if (!b) {
b = { tokens: options.capacity, lastRefillMs: now };
buckets.set(key, b);
}
const elapsedSec = (now - b.lastRefillMs) / 1000;
b.tokens = Math.min(options.capacity, b.tokens + elapsedSec * options.refillPerSec);
b.lastRefillMs = now;
if (b.tokens < 1) {
const waitSec = Math.ceil((1 - b.tokens) / options.refillPerSec);
res.setHeader("Retry-After", String(waitSec));
res.status(429).json({ error: "rate limit exceeded" });
return;
}
b.tokens -= 1;
next();
};
}
+50
View File
@@ -0,0 +1,50 @@
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 { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("requireAdmin middleware", () => {
let botDb: BotDatabase;
let app: express.Express;
let adminCookie: string;
let memberCookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin-pw", "admin");
const member = await users.createUser("member", "pw-member-pw", "member");
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions));
app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
});
afterEach(() => botDb.close());
it("rejects unauthenticated requests with 401", async () => {
const res = await request(app).get("/admin-only");
expect(res.status).toBe(401);
});
it("rejects member with 403", async () => {
const res = await request(app).get("/admin-only").set("Cookie", memberCookie);
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "forbidden" });
});
it("allows admin", async () => {
const res = await request(app).get("/admin-only").set("Cookie", adminCookie);
expect(res.status).toBe(200);
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { Request, Response, NextFunction } from "express";
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: "unauthenticated" });
return;
}
if (req.user.role !== "admin") {
res.status(403).json({ error: "forbidden" });
return;
}
next();
}
+69
View File
@@ -0,0 +1,69 @@
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 { createRequireAuth } from "./requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("requireAuth middleware", () => {
let botDb: BotDatabase;
let app: express.Express;
let validToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions));
app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user });
});
});
afterEach(() => {
botDb.close();
});
it("rejects requests without a session cookie", async () => {
const res = await request(app).get("/protected");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "unauthenticated" });
});
it("rejects requests with an unknown session cookie", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`);
expect(res.status).toBe(401);
});
it("allows requests with a valid session cookie and attaches req.user", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.user.username).toBe("alice");
expect(res.body.user.role).toBe("admin");
});
it("rolls the cookie max-age forward on successful auth", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
expect(res.status).toBe(200);
const setCookieHeaders = res.headers["set-cookie"];
const arr = Array.isArray(setCookieHeaders) ? setCookieHeaders : setCookieHeaders ? [setCookieHeaders] : [];
const refreshed = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
expect(refreshed).toBeDefined();
expect(refreshed!).toMatch(/Max-Age=\d+/);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import {
validateSessionFromHeaders,
extractSessionToken,
SESSION_COOKIE_NAME,
} from "../auth/validateSession.js";
declare module "express-serve-static-core" {
interface Request {
user?: { id: string; username: string; role: "admin" | "member" };
}
}
export function createRequireAuth(sessions: SessionStore): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
req.user = { id: result.userId, username: result.username, role: result.role };
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();
};
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
/**
* The clickjacking-defence middleware is mounted at the top of
* `createWebServer` in `server.ts`. This test asserts the exact behavior
* we expect from that middleware in isolation. The wiring inside
* `server.ts` is verified by code review (git diff).
*/
describe("security headers (anti-clickjacking)", () => {
function buildApp() {
const app = express();
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
next();
});
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/", (_req, res) => res.json({ ok: true }));
return app;
}
it("sets X-Frame-Options: DENY on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.status).toBe(200);
expect(res.headers["x-frame-options"]).toBe("DENY");
});
it("sets Content-Security-Policy frame-ancestors 'none' on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
});
it("sets both headers on POST responses too", async () => {
const res = await request(buildApp()).post("/");
expect(res.headers["x-frame-options"]).toBe("DENY");
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
});
});
Regular → Executable
+121 -7
View File
@@ -1,6 +1,7 @@
import express from "express";
import http from "node:http";
import path from "node:path";
import cookieParser from "cookie-parser";
import { WebSocketServer } from "ws";
import type { BotManager } from "../bot/manager.js";
import type { MusicProvider } from "../music/provider.js";
@@ -8,11 +9,25 @@ import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { CookieStore } from "../music/auth.js";
import type { AvatarStore } from "../data/avatars.js";
import { createBotRouter } from "./api/bot.js";
import { createMusicRouter } from "./api/music.js";
import { createPlayerRouter } from "./api/player.js";
import { createAuthRouter } from "./api/auth.js";
import { createSessionRouter } from "./api/session.js";
import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js";
import { csrfOriginCheck } from "./middleware/csrf.js";
import { createRateLimit } from "./middleware/rateLimit.js";
import { validateSessionFromHeaders } from "./auth/validateSession.js";
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export interface WebServerOptions {
port: number;
@@ -22,8 +37,10 @@ export interface WebServerOptions {
bilibiliProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
logger: Logger;
cookieStore?: CookieStore;
avatarStore: AvatarStore;
staticDir?: string;
}
@@ -37,11 +54,62 @@ export function createWebServer(options: WebServerOptions): WebServer {
const server = http.createServer(app);
const logger = options.logger.child({ component: "web" });
app.use(express.json());
if (options.config.trustProxy) {
app.set("trust proxy", true);
}
// Security headers: prevent the WebUI from being embedded in a third-party
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
// of X-Frame-Options; both are set for compatibility across browsers.
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
next();
});
app.use(express.json({ limit: "400kb" }));
app.use(cookieParser());
const users = createUserStore(options.database.db);
const sessions = createSessionStore(options.database.db);
const audit = createAuditStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
app.get("/api/config/public-url", (_req, res) => {
const raw = (options.config.publicUrl ?? "").trim();
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
});
// Anti-DoS: throttle expensive (bcrypt) auth endpoints.
// 5 req per minute per IP for /login (capacity 5, refill 5/60 = ~0.083/sec).
// 3 req per minute per IP for /setup (more limited; first-run is rare).
const loginLimit = createRateLimit({ capacity: 5, refillPerSec: 5 / 60 });
const setupLimit = createRateLimit({ capacity: 3, refillPerSec: 3 / 60 });
app.use("/api/session/login", loginLimit);
app.use("/api/session/setup", setupLimit);
app.use("/api/session", createSessionRouter(users, sessions, audit, logger));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions);
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// ─── Protected routes ───────────────────────────────────────────────────
app.use(
"/api/bot",
createBotRouter(options.botManager, options.config, logger)
createBotRouter(
options.botManager,
options.config,
options.configPath,
logger,
options.database,
options.avatarStore,
)
);
app.use(
"/api/music",
@@ -55,11 +123,11 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
);
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
// ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) {
app.use(express.static(options.staticDir));
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
@@ -71,22 +139,68 @@ export function createWebServer(options: WebServerOptions): WebServer {
logger.error({ err }, "HTTP server error");
});
const wss = new WebSocketServer({ server, path: "/ws" });
// ─── WebSocket with manual upgrade auth ────────────────────────────────
const wss = new WebSocketServer({ noServer: true });
wss.on("error", (err) => {
logger.error({ err }, "WebSocket server error");
});
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") {
socket.destroy();
return;
}
const reqHost = req.headers.host;
const originHeader = req.headers.origin;
if (originHeader) {
let originHost: string | null = null;
try {
originHost = new URL(originHeader).host;
} catch {
// fall through; treat as missing/invalid origin
}
if (!originHost || originHost !== reqHost) {
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
}
const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
if (!result) {
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 unknown as { userId: string }).userId = result.userId;
wss.emit("connection", ws, req);
});
});
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
// ─── Session cleanup interval ──────────────────────────────────────────
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
return {
async start(): Promise<void> {
return new Promise((resolve) => {
server.listen(options.port, () => {
logger.info({ port: options.port }, "Web server started");
cleanupTimer = setInterval(() => {
try {
sessions.cleanupExpired();
} catch (err) {
logger.error({ err }, "session cleanup failed");
}
}, SESSION_CLEANUP_INTERVAL_MS);
resolve();
});
});
},
stop(): void {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
cleanupWs();
wss.close();
server.close();
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { WebSocketServer, WebSocket as WSClient } from "ws";
import { AddressInfo } from "node:net";
import { createDatabase, type BotDatabase } from "../data/database.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js";
function buildServer(sessions: ReturnType<typeof createSessionStore>) {
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
wss.on("connection", (ws) => ws.send("hello"));
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") return socket.destroy();
const r = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
if (!r) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});
return { server, wss };
}
describe("WebSocket auth at upgrade", () => {
let botDb: BotDatabase;
let httpServer: http.Server;
let port: number;
let validToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
const { server } = buildServer(sessions);
httpServer = server;
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
port = (httpServer.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
botDb.close();
});
it("rejects upgrade without cookie (server-side close before open)", async () => {
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`);
const result = await new Promise<string>((resolve) => {
ws.on("open", () => resolve("opened"));
ws.on("unexpected-response", (_req, res) => resolve(`status:${res.statusCode}`));
ws.on("error", () => resolve("error"));
});
expect(result).toMatch(/^status:401$|^error$/);
});
it("accepts upgrade with a valid cookie", async () => {
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`, {
headers: { Cookie: `${SESSION_COOKIE_NAME}=${validToken}` },
});
const msg = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
});
expect(msg).toBe("hello");
ws.close();
});
});
-57
View File
@@ -797,9 +797,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -821,9 +818,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -845,9 +839,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -869,9 +860,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -893,9 +881,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -917,9 +902,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1088,9 +1070,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1105,9 +1084,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1122,9 +1098,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1139,9 +1112,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1156,9 +1126,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1173,9 +1140,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1190,9 +1154,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1207,9 +1168,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1224,9 +1182,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1241,9 +1196,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1258,9 +1210,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1275,9 +1224,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1292,9 +1238,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+303 -2
View File
@@ -5,32 +5,152 @@
<RouterView />
</main>
<Player />
<Toast />
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
<div class="m-player-progress">
<div class="m-player-progress-fill" :style="{ width: mobileProgressPct + '%' }" />
</div>
<CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" />
<div class="m-player-info">
<div class="m-player-name">{{ currentSong.name }}</div>
<div class="m-player-artist">{{ currentSong.artist }}</div>
</div>
<div class="m-player-controls" @click.stop>
<button class="m-player-btn" @click="playerStore.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="m-player-btn" @click="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
<Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="m-player-btn" @click="playerStore.next()">
<Icon icon="mdi:skip-next" />
</button>
<button class="m-player-btn" @click="cycleMobileMode">
<Icon :icon="mobileModeIcon" />
</button>
<button class="m-player-btn" @click="toggleMobileQueue">
<Icon icon="mdi:playlist-music" />
</button>
<button class="m-player-btn" @click="toggleMobileVolume">
<Icon icon="mdi:volume-high" />
</button>
</div>
<div v-if="mobileVolumeOpen" class="m-volume-popover" @click.stop>
<Icon icon="mdi:volume-high" class="m-volume-icon" />
<input
type="range"
min="0"
max="100"
:value="mobileVolume"
class="m-volume-slider"
@input="onMobileVolumeChange"
/>
<span class="m-volume-value">{{ mobileVolume }}</span>
</div>
</div>
<!-- Mobile bottom tab bar -->
<nav class="m-tabbar">
<RouterLink to="/" class="m-tab" :class="{ active: route.path === '/' }">
<Icon icon="mdi:home" class="tab-icon" />
<span class="tab-label">发现</span>
</RouterLink>
<RouterLink to="/search" class="m-tab" :class="{ active: route.path === '/search' }">
<Icon icon="mdi:magnify" class="tab-icon" />
<span class="tab-label">搜索</span>
</RouterLink>
<RouterLink to="/library" class="m-tab" :class="{ active: route.path === '/library' }">
<Icon icon="mdi:music-box-multiple" class="tab-icon" />
<span class="tab-label">音乐库</span>
</RouterLink>
<RouterLink to="/settings" class="m-tab" :class="{ active: route.path.startsWith('/settings') }">
<Icon icon="mdi:cog" class="tab-icon" />
<span class="tab-label">设置</span>
</RouterLink>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js';
import { useWebSocket } from './composables/useWebSocket.js';
import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue';
import Queue from './components/Queue.vue';
const playerStore = usePlayerStore();
const theme = computed(() => playerStore.theme);
const route = useRoute();
const router = useRouter();
const { connect } = useWebSocket();
const currentSong = computed(() => playerStore.currentSong);
const mobileVolume = computed(() => playerStore.activeBot?.volume ?? 75);
const mobileMode = computed(() => playerStore.activeBot?.playMode ?? 'seq');
const mobileModeOrder = ['seq', 'loop', 'random', 'rloop'];
const mobileModeIcons: Record<string, string> = {
seq: 'mdi:arrow-right',
loop: 'mdi:repeat',
random: 'mdi:shuffle',
rloop: 'mdi:repeat-once',
};
const mobileModeIcon = computed(() => mobileModeIcons[mobileMode.value] ?? mobileModeIcons.seq);
const mobileVolumeOpen = ref(false);
const mobileQueueOpen = ref(false);
const mobileProgressPct = ref(0);
let syncTimer: ReturnType<typeof setInterval> | null = null;
let mobileRaf: number | null = null;
function updateMobileProgress() {
const duration = currentSong.value?.duration ?? 0;
mobileProgressPct.value = duration > 0
? Math.min((playerStore.elapsed / duration) * 100, 100)
: 0;
mobileRaf = requestAnimationFrame(updateMobileProgress);
}
function onMobileVolumeChange(e: Event) {
const volume = Number((e.target as HTMLInputElement).value);
playerStore.setVolume(volume);
}
function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
}
function toggleMobileQueue() {
mobileQueueOpen.value = !mobileQueueOpen.value;
if (mobileQueueOpen.value) mobileVolumeOpen.value = false;
}
function cycleMobileMode() {
const currentIndex = mobileModeOrder.indexOf(mobileMode.value);
const nextMode = mobileModeOrder[(currentIndex + 1) % mobileModeOrder.length] ?? mobileModeOrder[0];
mobileVolumeOpen.value = false;
mobileQueueOpen.value = false;
playerStore.setMode(nextMode);
}
onMounted(() => {
playerStore.loadTheme();
connect();
playerStore.fetchBots();
// Periodically sync elapsed time from server (ground truth)
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress);
});
onUnmounted(() => {
if (syncTimer) clearInterval(syncTimer);
if (mobileRaf !== null) cancelAnimationFrame(mobileRaf);
});
</script>
@@ -47,5 +167,186 @@ onUnmounted(() => {
@media (max-width: 1336px) {
padding: 80px 5vw 80px;
}
@media (max-width: 768px) {
padding: 72px 16px 200px;
}
}
// Mobile mini player
.m-player {
position: fixed;
left: 8px;
right: 8px;
bottom: 68px;
height: 58px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
z-index: 95;
cursor: pointer;
@media (min-width: 769px) {
display: none;
}
}
.mobile-queue {
display: none;
@media (max-width: 768px) {
display: flex;
}
}
.m-player-progress {
position: absolute;
top: 0;
left: 10px;
right: 10px;
height: 2px;
}
.m-player-progress-fill {
height: 2px;
background: var(--color-primary);
border-radius: 1px;
}
.m-player-info {
flex: 1;
min-width: 0;
}
.m-player-controls {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.m-player-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-player-artist {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-player-btn {
width: 28px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
opacity: 0.85;
flex-shrink: 0;
}
.m-volume-popover {
position: absolute;
right: 8px;
bottom: calc(100% + 8px);
display: flex;
align-items: center;
gap: 8px;
width: min(260px, calc(100vw - 32px));
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-dropdown);
cursor: default;
}
.m-volume-icon {
flex: 0 0 auto;
font-size: 18px;
color: var(--text-secondary);
}
.m-volume-slider {
flex: 1 1 auto;
min-width: 0;
height: 4px;
appearance: none;
background: var(--border-color);
border-radius: 2px;
outline: none;
&::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: var(--color-primary);
border-radius: 50%;
}
}
.m-volume-value {
flex: 0 0 30px;
font-size: 12px;
color: var(--text-secondary);
text-align: right;
font-variant-numeric: tabular-nums;
}
// Mobile bottom tab bar
.m-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom, 0);
background: var(--bg-navbar);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-top: 1px solid var(--border-color);
z-index: 100;
@media (min-width: 769px) {
display: none;
}
}
.m-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 14px;
color: var(--text-tertiary);
text-decoration: none;
font-family: inherit;
&.active {
color: var(--color-primary);
}
.tab-icon {
font-size: 22px;
}
.tab-label {
font-size: 10px;
font-weight: 500;
}
}
</style>
+49
View File
@@ -0,0 +1,49 @@
import router from '../router/index.js';
import { useSession } from '../composables/useSession.js';
let installed = false;
const nativeFetch: typeof window.fetch = window.fetch.bind(window);
/**
* Wraps fetch so every call:
* - sends cookies (`credentials: 'same-origin'`)
* - on 401 from /api/*: clear local session, redirect to /login
*
* Always uses the captured native fetch, never the (possibly wrapped) global.
*/
export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const merged: RequestInit = {
credentials: 'same-origin',
...init,
headers: { ...(init.headers ?? {}) },
};
return nativeFetch(input, merged).then(async (res) => {
if (res.status === 401 && shouldTriggerRefresh(input)) {
const session = useSession();
await session.refresh();
const current = router.currentRoute.value;
if (current.name !== 'login' && current.name !== 'first-run') {
await router.replace({ name: 'login', query: { next: current.fullPath } });
}
}
return res;
});
}
function shouldTriggerRefresh(input: RequestInfo | URL): boolean {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return url.startsWith('/api/') && !url.startsWith('/api/session/');
}
/**
* Replaces window.fetch with apiFetch so existing call sites do not need to be touched.
* Call once at app startup.
*/
export function installApiClient(): void {
if (installed) return;
installed = true;
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
return apiFetch(input, init ?? {});
}) as typeof window.fetch;
(window as unknown as { __originalFetch?: typeof fetch }).__originalFetch = nativeFetch;
}
+99
View File
@@ -0,0 +1,99 @@
<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; }
.btn-sm {
padding: 6px 14px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
transition: all var(--transition-fast);
&:hover { background: var(--color-primary); color: white; }
}
.btn-danger {
&:hover { background: #f44336; color: white; }
}
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
.hint.error { color: #f44336; opacity: 1; }
</style>
+59
View File
@@ -0,0 +1,59 @@
<template>
<AvatarUpload v-model="avatarDataUrl" />
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue';
import axios from 'axios';
import AvatarUpload from './AvatarUpload.vue';
const props = defineProps<{ botId: string }>();
const avatarDataUrl = ref<string | null>(null);
// Stays true until the watcher queued by the load-time assignment has run,
// so the initial null → loaded-data-url transition does not fire a redundant
// PUT echoing the just-fetched bytes back to the server.
let initializing = true;
async function loadCurrent() {
try {
const res = await axios.get(`/api/bot/${props.botId}/avatar`, { responseType: 'blob' });
const blob = res.data as Blob;
avatarDataUrl.value = await blobToDataUrl(blob);
} catch (err: any) {
if (err?.response?.status !== 404) {
console.warn('failed to load avatar', err);
}
avatarDataUrl.value = null;
} finally {
// Wait for the watcher's flush queue to drain (it'll see initializing=true
// and bail), then release for real user-driven changes.
await nextTick();
initializing = false;
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
watch(avatarDataUrl, async (newVal, oldVal) => {
if (initializing) return;
if (newVal === oldVal) return;
try {
if (newVal && newVal.startsWith('data:')) {
await axios.put(`/api/bot/${props.botId}/avatar`, { dataUrl: newVal });
} else if (newVal === null) {
await axios.delete(`/api/bot/${props.botId}/avatar`);
}
} catch (err) {
console.warn('avatar update failed', err);
}
});
onMounted(loadCurrent);
</script>
+415 -111
View File
@@ -5,6 +5,7 @@
<div class="nav-links">
<RouterLink to="/" class="nav-link" active-class="active">发现</RouterLink>
<RouterLink to="/search" class="nav-link" active-class="active">搜索</RouterLink>
<RouterLink to="/library" class="nav-link" active-class="active">音乐库</RouterLink>
<RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
</div>
@@ -19,69 +20,213 @@
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
</button>
<div v-if="dropdownOpen" class="bot-dropdown">
<div class="bot-dropdown-header">机器人</div>
<div
v-for="bot in store.bots"
:key="bot.id"
class="bot-dropdown-row"
class="bot-card"
:class="{ active: bot.id === store.activeBotId }"
>
<button
class="bot-dropdown-item"
:class="{ active: bot.id === store.activeBotId }"
@click="selectBot(bot.id)"
>
<div class="bot-card-head" @click="bot.connected ? selectBot(bot.id) : undefined">
<span class="bot-dot" :class="{ online: bot.connected }" />
<span class="bot-dropdown-name">{{ bot.name }}</span>
<span class="bot-card-name">{{ bot.name }}</span>
<span v-if="bot.id === store.activeBotId" class="bot-current-badge">当前</span>
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span>
</button>
<button
class="bot-power-btn"
:class="{ online: bot.connected }"
:title="bot.connected ? `停止 ${bot.name}` : `启动 ${bot.name}`"
:disabled="togglingBots[bot.id]"
@click.stop="togglePower(bot)"
>
<Icon :icon="bot.connected ? 'mdi:power' : 'mdi:power-off'" />
</button>
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)">
<Icon icon="mdi:link-variant" />
</button>
</div>
<div class="bot-card-controls">
<button
v-if="bot.connected"
class="bot-ctrl-btn danger"
:disabled="togglingBots[bot.id]"
@click.stop="togglePower(bot)"
>
<Icon icon="mdi:link-off" /> 断开
</button>
<button
v-else
class="bot-ctrl-btn primary"
:disabled="togglingBots[bot.id]"
@click.stop="togglePower(bot)"
>
<Icon icon="mdi:link-variant" /> 连接
</button>
<button
v-if="bot.playing || bot.paused"
class="bot-ctrl-btn"
:disabled="!bot.connected"
@click.stop="store.pause()"
>
<Icon icon="mdi:stop" /> 停止
</button>
<button
v-else
class="bot-ctrl-btn"
:disabled="!bot.connected"
@click.stop="store.resume()"
>
<Icon icon="mdi:play" /> 播放
</button>
<button
class="bot-ctrl-btn"
:disabled="!bot.connected || (!bot.playing && !bot.paused)"
@click.stop="store.next()"
title="下一首"
>
<Icon icon="mdi:skip-next" />
</button>
<button class="bot-ctrl-btn" @click.stop="copyBotLink(bot.id)" title="复制链接">
<Icon icon="mdi:link-variant" />
</button>
</div>
</div>
<div class="bot-dropdown-divider" />
<div class="bot-dropdown-hint">点击切换 · 🔗 复制专属链接</div>
</div>
</div>
<RouterLink to="/settings" class="settings-btn">
<Icon icon="mdi:cog" />
</RouterLink>
<div v-if="session.currentUser.value" class="nav-user">
<span class="nav-user-name">{{ session.currentUser.value.username }}</span>
<span class="nav-user-role" :class="`role-${session.currentUser.value.role}`">
{{ session.currentUser.value.role === 'admin' ? '管理员' : '成员' }}
</span>
<button class="nav-user-logout" @click="onLogout" title="退出">
<Icon icon="mdi:logout" />
</button>
</div>
</div>
</nav>
<div v-if="linkDialog.open" class="link-dialog-backdrop" @click="closeLinkDialog">
<div class="link-dialog" @click.stop>
<div class="link-dialog-title">{{ linkDialog.name }} 的专属链接</div>
<div class="link-dialog-hint">选中文本并按 Ctrl/Cmd+C 复制,或点击下方按钮</div>
<input
ref="linkInputRef"
class="link-dialog-input"
:value="linkDialog.url"
readonly
@focus="($event.target as HTMLInputElement).select()"
/>
<div class="link-dialog-actions">
<button class="link-dialog-btn primary" @click="copyLinkFromDialog">
{{ linkDialog.copied ? '已复制' : '复制链接' }}
</button>
<button class="link-dialog-btn" @click="closeLinkDialog">关闭</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
const store = usePlayerStore();
const session = useSession();
const navRouter = useRouter();
async function onLogout() {
await session.logout();
navRouter.replace({ name: 'login' });
}
const activeBot = computed(() => store.activeBot);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
const togglingBots = ref<Record<string, boolean>>({});
const linkInputRef = ref<HTMLInputElement | null>(null);
const publicBaseUrl = ref<string | null>(null);
const linkDialog = reactive({
open: false,
url: '',
name: '',
copied: false,
});
function selectBot(id: string) {
store.setActiveBotId(id);
dropdownOpen.value = false;
}
function copyBotLink(id: string) {
const url = `${window.location.origin}/bot/${id}`;
navigator.clipboard.writeText(url).then(() => {
dropdownOpen.value = false;
});
function resolveBaseUrl(): string {
const base = publicBaseUrl.value;
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
return window.location.origin;
}
async function tryClipboardWrite(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
async function copyBotLink(id: string) {
const bot = store.bots.find((b) => b.id === id);
const url = `${resolveBaseUrl()}/bot/${id}`;
linkDialog.url = url;
linkDialog.name = bot?.name ?? '机器人';
linkDialog.copied = false;
linkDialog.open = true;
dropdownOpen.value = false;
// Try to copy silently; user can still manually select if it fails.
const ok = await tryClipboardWrite(url);
if (ok) linkDialog.copied = true;
await nextTick();
linkInputRef.value?.focus();
linkInputRef.value?.select();
}
async function copyLinkFromDialog() {
const ok = await tryClipboardWrite(linkDialog.url);
if (ok) {
linkDialog.copied = true;
} else {
linkInputRef.value?.focus();
linkInputRef.value?.select();
}
}
function closeLinkDialog() {
linkDialog.open = false;
}
async function loadPublicBaseUrl() {
try {
const res = await fetch('/api/config/public-url');
if (!res.ok) return;
const data = (await res.json()) as { publicUrl?: string | null };
if (data.publicUrl) publicBaseUrl.value = data.publicUrl;
} catch {
// ignore — fall back to window.location.origin
}
}
async function togglePower(bot: { id: string; connected: boolean; name: string }) {
@@ -108,6 +253,7 @@ function onClickOutside(e: MouseEvent) {
onMounted(() => {
document.addEventListener('click', onClickOutside);
loadPublicBaseUrl();
});
onUnmounted(() => {
@@ -131,6 +277,11 @@ onUnmounted(() => {
@media (max-width: 1336px) {
padding: 0 5vw;
}
@media (max-width: 768px) {
padding: 0 16px;
height: 52px;
}
}
.logo {
@@ -138,11 +289,20 @@ onUnmounted(() => {
font-weight: 700;
color: var(--color-primary);
margin-right: 40px;
@media (max-width: 768px) {
font-size: 17px;
margin-right: 0;
}
}
.nav-links {
display: flex;
gap: 24px;
@media (max-width: 768px) {
display: none;
}
}
.nav-link {
@@ -170,7 +330,7 @@ onUnmounted(() => {
opacity: 0.6;
&.online {
background: rgba(51, 94, 234, 0.15);
background: var(--color-primary-15);
color: var(--color-primary);
opacity: 1;
}
@@ -199,12 +359,25 @@ onUnmounted(() => {
background: var(--bg-card);
border-color: var(--color-primary);
}
@media (max-width: 768px) {
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
min-height: 32px;
gap: 6px;
border-radius: var(--radius-full);
}
}
.bot-state-mini {
font-size: 14px;
&.playing { color: #22c55e; }
&.paused { color: #eab308; }
&.playing { color: var(--color-online); }
&.paused { color: var(--color-paused); }
@media (max-width: 768px) {
display: none;
}
}
.bot-chevron {
@@ -215,6 +388,10 @@ onUnmounted(() => {
&.rotated {
transform: rotate(180deg);
}
@media (max-width: 768px) {
display: none;
}
}
.bot-dot {
@@ -225,7 +402,12 @@ onUnmounted(() => {
flex-shrink: 0;
&.online {
background: #22c55e;
background: var(--color-online);
}
@media (max-width: 768px) {
width: 8px;
height: 8px;
}
}
@@ -234,96 +416,83 @@ onUnmounted(() => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 768px) {
max-width: 80px;
}
}
.bot-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 200px;
min-width: 320px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 6px;
box-shadow: var(--shadow-dropdown);
z-index: 200;
@media (max-width: 768px) {
position: fixed;
top: 52px;
left: 8px;
right: 8px;
min-width: auto;
}
}
.bot-dropdown-item {
.bot-dropdown-header {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
padding: 6px 10px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bot-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: var(--radius-sm);
margin-bottom: 4px;
border: 1px solid transparent;
transition: background var(--transition-fast);
&.active {
background: var(--color-primary-12);
border-color: rgba(99, 102, 241, 0.25);
}
}
.bot-card-head {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
transition: background var(--transition-fast);
&:hover {
background: var(--hover-bg);
}
&.active {
background: rgba(51, 94, 234, 0.12);
color: var(--color-primary);
}
}
.bot-dropdown-row {
display: flex;
align-items: center;
gap: 2px;
}
.bot-dropdown-name {
.bot-card-name {
flex: 1;
font-size: 13px;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bot-link-btn {
.bot-current-badge {
font-size: 10px;
font-weight: 700;
color: var(--color-primary);
padding: 2px 6px;
border-radius: 4px;
background: var(--color-primary-15);
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 15px;
opacity: 0.4;
transition: opacity var(--transition-fast), background var(--transition-fast);
cursor: pointer;
&:hover {
opacity: 1;
background: var(--hover-bg);
}
}
.bot-power-btn {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 16px;
opacity: 0.5;
color: var(--text-tertiary);
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
&:hover:not(:disabled) {
opacity: 1;
background: var(--hover-bg);
}
&:disabled {
opacity: 0.25;
cursor: wait;
}
&.online {
color: #22c55e;
opacity: 0.9;
}
}
.bot-playing-badge,
@@ -331,25 +500,25 @@ onUnmounted(() => {
.bot-idle-badge,
.bot-offline-badge {
font-size: 11px;
padding: 1px 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
flex-shrink: 0;
}
.bot-playing-badge {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
background: var(--color-online-15);
color: var(--color-online);
}
.bot-paused-badge {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
background: var(--color-paused-15);
color: var(--color-paused);
}
.bot-idle-badge {
background: rgba(51, 94, 234, 0.12);
color: var(--color-primary);
background: var(--hover-bg);
color: var(--text-secondary);
}
.bot-offline-badge {
@@ -357,17 +526,47 @@ onUnmounted(() => {
color: var(--text-tertiary);
}
.bot-dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 4px 0;
.bot-card-controls {
display: flex;
gap: 6px;
}
.bot-dropdown-hint {
padding: 4px 12px 6px;
.bot-ctrl-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--hover-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
&:hover:not(:disabled) {
background: var(--bg-card);
border-color: var(--color-primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&.primary {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
&.danger {
color: #ef4444;
}
}
.settings-btn {
@@ -375,5 +574,110 @@ onUnmounted(() => {
opacity: 0.6;
transition: opacity var(--transition-fast);
&:hover { opacity: 1; }
@media (max-width: 768px) {
display: none;
}
}
.link-dialog-backdrop {
position: fixed;
inset: 0;
background: var(--bg-modal-scrim);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.link-dialog {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
min-width: 360px;
max-width: 90vw;
box-shadow: var(--shadow-modal);
}
.link-dialog-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 6px;
}
.link-dialog-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.link-dialog-input {
width: 100%;
padding: 10px 12px;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: inherit;
user-select: all;
-webkit-user-select: all;
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
.link-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.link-dialog-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--hover-bg);
color: inherit;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
&:hover {
background: var(--bg-card);
}
&.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
&:hover {
filter: brightness(1.08);
}
}
}
.nav-user {
display: flex; align-items: center; gap: 8px; margin-left: 12px;
color: var(--text-secondary); font-size: 13px;
}
.nav-user-logout {
height: 28px; width: 28px; display: grid; place-items: center;
border: 0; background: transparent; color: var(--text-secondary); cursor: pointer;
border-radius: var(--radius-sm);
&:hover { background: var(--bg-secondary); color: var(--text-primary); }
}
.nav-user-role {
font-size: 11px; padding: 2px 6px; border-radius: 4px;
font-weight: 500;
}
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
</style>
+8 -4
View File
@@ -206,6 +206,10 @@ function cycleMode() {
left: 0;
right: 0;
z-index: 100;
@media (max-width: 768px) {
display: none;
}
}
.player-bar {
@@ -326,12 +330,12 @@ function cycleMode() {
.bot-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 0 5px;
background: rgba(51, 94, 234, 0.15);
background: var(--color-primary-15);
color: var(--color-primary);
border-radius: 3px;
border-radius: var(--radius-xs);
line-height: 16px;
white-space: nowrap;
flex-shrink: 0;
+26 -1
View File
@@ -3,6 +3,14 @@
<div class="queue-header">
<h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span>
<button
v-if="botQueue.length > 0"
class="clear-btn"
@click="clearAndStop"
title="清空队列并停止播放"
>
<Icon icon="mdi:stop-circle-outline" />
</button>
<button class="close-btn" @click="$emit('close')">
<Icon icon="mdi:close" />
</button>
@@ -70,6 +78,15 @@ async function removeSong(index: number) {
// Ignore
}
}
async function clearAndStop() {
try {
await store.stop();
await store.fetchQueue();
} catch {
// Ignore
}
}
</script>
<style lang="scss" scoped>
@@ -117,6 +134,14 @@ async function removeSong(index: number) {
&:hover { opacity: 1; }
}
.clear-btn {
font-size: 18px;
opacity: 0.6;
transition: opacity var(--transition-fast);
color: var(--text-primary);
&:hover { opacity: 1; }
}
.queue-empty {
padding: 40px 20px;
text-align: center;
@@ -146,7 +171,7 @@ async function removeSong(index: number) {
}
&.active {
background: rgba(51, 94, 234, 0.1);
background: var(--color-primary-10);
}
}
+27 -13
View File
@@ -18,6 +18,9 @@
<button class="action-btn" @click.stop="$emit('play')" title="播放">
<Icon icon="mdi:play" />
</button>
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<Icon icon="mdi:playlist-play" />
</button>
<button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
<Icon icon="mdi:playlist-plus" />
</button>
@@ -28,15 +31,17 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import CoverArt from './CoverArt.vue';
import { Song } from '../stores/player.js';
defineProps<{
song: { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string };
song: Song;
index: number;
active?: boolean;
}>();
defineEmits<{
play: [];
playNext: [];
add: [];
}>();
@@ -63,7 +68,7 @@ function formatDuration(seconds: number): string {
}
&.active {
background: rgba(51, 94, 234, 0.1);
background: var(--color-primary-10);
}
}
@@ -96,31 +101,31 @@ function formatDuration(seconds: number): string {
.platform-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: 3px;
border-radius: var(--radius-xs);
line-height: 1.4;
}
.badge-netease {
background: rgba(232, 17, 35, 0.15);
color: #e81123;
background: var(--brand-netease-15);
color: var(--brand-netease);
}
.badge-qq {
background: rgba(18, 183, 106, 0.15);
color: #12b76a;
background: var(--brand-qq-15);
color: var(--brand-qq);
}
.badge-bilibili {
background: rgba(0, 161, 214, 0.15);
color: #00a1d6;
background: var(--brand-bilibili-15);
color: var(--brand-bilibili);
}
.badge-youtube {
background: rgba(255, 0, 0, 0.12);
color: #ff0000;
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
.song-artist {
@@ -151,6 +156,15 @@ function formatDuration(seconds: number): string {
transition: opacity var(--transition-fast);
}
// Touch devices have no :hover, so the parent-hover-reveals-actions
// pattern leaves all action buttons invisible. Always show on coarse-
// pointer (touch) inputs — this is also where bigger tap targets matter.
@media (pointer: coarse) {
.song-actions {
opacity: 1;
}
}
.action-btn {
font-size: 18px;
padding: 4px;
+94
View File
@@ -0,0 +1,94 @@
<template>
<div v-if="sources.length > 0" class="source-tabs">
<template v-if="sources.length >= 2">
<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>
</template>
<span v-else class="source-tab-label" :title="`数据来自${LABELS[sources[0]]}`">
{{ LABELS[sources[0]] }}
</span>
</div>
</template>
<script setup lang="ts">
import type { Source } from '../stores/player.js';
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);
}
}
// Single-source mode: not interactive, but tells the user which platform
// they're looking at instead of leaving them guessing.
.source-tab-label {
padding: 4px 10px;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
color: var(--text-tertiary);
background: var(--hover-bg);
border-radius: var(--radius-sm);
}
@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>
+106
View File
@@ -0,0 +1,106 @@
<template>
<Transition name="toast">
<div v-if="visible && current" class="toast" :class="`toast--${current.type}`">
<Icon :icon="current.type === 'error' ? 'mdi:alert-circle' : 'mdi:information'" class="toast-icon" />
<span class="toast-msg">{{ current.message }}</span>
<button class="toast-close" @click="visible = false" aria-label="关闭">
<Icon icon="mdi:close" />
</button>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
const store = usePlayerStore();
const visible = ref(false);
const current = ref<{ id: number; message: string; type: 'error' | 'info' } | null>(null);
let timer: ReturnType<typeof setTimeout> | null = null;
watch(
() => store.notification?.id,
() => {
if (!store.notification) return;
current.value = store.notification;
visible.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
visible.value = false;
}, current.value.type === 'error' ? 5000 : 3000);
}
);
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
</script>
<style lang="scss" scoped>
.toast {
position: fixed;
left: 50%;
bottom: calc(var(--player-height) + 24px);
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
max-width: min(90vw, 480px);
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-dropdown);
font-size: var(--fs-body);
color: var(--text-primary);
z-index: 1000;
@media (max-width: 768px) {
bottom: calc(var(--player-height) + 60px); // sit above mobile tabbar
}
}
.toast--error {
border-color: var(--brand-netease);
.toast-icon { color: var(--brand-netease); }
}
.toast--info {
border-color: var(--color-primary);
.toast-icon { color: var(--color-primary); }
}
.toast-icon {
font-size: 18px;
flex-shrink: 0;
}
.toast-msg {
flex: 1;
line-height: 1.4;
}
.toast-close {
flex-shrink: 0;
font-size: 18px;
color: var(--text-tertiary);
background: transparent;
border: none;
cursor: pointer;
&:hover { color: var(--text-primary); }
}
.toast-enter-active,
.toast-leave-active {
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
</style>
+108
View File
@@ -0,0 +1,108 @@
import { ref, computed, readonly } from "vue";
interface User {
id: string;
username: string;
role: 'admin' | 'member';
}
const currentUser = ref<User | null>(null);
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
const ready = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const POLL_INTERVAL_MS = 60_000;
function ensurePollStarted() {
if (pollTimer !== null) return;
pollTimer = setInterval(() => {
if (currentUser.value !== null) {
// Best-effort refresh; ignore errors (network blips etc.)
refreshMe().catch(() => {});
}
}, POLL_INTERVAL_MS);
}
function stopPoll() {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function refreshNeedsSetup(): Promise<void> {
const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" });
if (res.ok) {
const body = await res.json();
needsSetup.value = Boolean(body.needsSetup);
}
}
async function refreshMe(): Promise<void> {
const res = await fetch("/api/session/me", { credentials: "same-origin" });
if (res.status === 200) {
currentUser.value = (await res.json()) as User;
} else {
currentUser.value = null;
}
}
async function refresh(): Promise<void> {
await refreshNeedsSetup();
if (needsSetup.value) {
currentUser.value = null;
} else {
await refreshMe();
}
ready.value = true;
ensurePollStarted();
}
async function login(username: string, password: string): Promise<void> {
const res = await fetch("/api/session/login", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `login failed (${res.status})`);
}
currentUser.value = (await res.json()) as User;
}
async function setup(username: string, password: string): Promise<void> {
const res = await fetch("/api/session/setup", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `setup failed (${res.status})`);
}
currentUser.value = (await res.json()) as User;
needsSetup.value = false;
}
async function logout(): Promise<void> {
stopPoll();
await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" });
currentUser.value = null;
}
export function useSession() {
return {
currentUser: readonly(currentUser),
needsSetup: readonly(needsSetup),
isAuthenticated: computed(() => currentUser.value !== null),
isAdmin: computed(() => currentUser.value?.role === 'admin'),
ready: readonly(ready),
refresh,
login,
logout,
setup,
};
}
+4
View File
@@ -2,7 +2,11 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router/index.js';
import { installApiClient } from './api/http.js';
import './styles/global.scss';
import './styles/mobile.scss';
installApiClient();
const app = createApp(App);
app.use(createPinia());
+46 -34
View File
@@ -1,50 +1,62 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useSession } from '../composables/useSession.js';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/Home.vue'),
},
{
path: '/search',
name: 'search',
component: () => import('../views/Search.vue'),
},
{ path: '/', name: 'home', component: () => import('../views/Home.vue') },
{ path: '/search', name: 'search', component: () => import('../views/Search.vue') },
{ path: '/library', name: 'library', component: () => import('../views/Library.vue') },
{
path: '/playlist/:id',
name: 'playlist',
component: () => import('../views/Playlist.vue'),
meta: { kind: 'playlist' },
},
{
path: '/lyrics',
name: 'lyrics',
component: () => import('../views/Lyrics.vue'),
},
{
path: '/history',
name: 'history',
component: () => import('../views/History.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/Settings.vue'),
},
{
path: '/setup',
name: 'setup',
component: () => import('../views/Setup.vue'),
},
{
// Per-bot URL: /bot/:id — sets active bot then redirects to home
path: '/bot/:id',
name: 'bot',
component: () => import('../views/BotRedirect.vue'),
path: '/album/:id',
name: 'album',
component: () => import('../views/Playlist.vue'),
meta: { kind: 'album' },
},
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
{ path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') },
{ path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') },
{ path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') },
// Auth views
{ path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { public: true } },
{ path: '/first-run', name: 'first-run', component: () => import('../views/FirstRunSetup.vue'), meta: { public: true } },
],
});
const PUBLIC_NAMES = new Set(['login', 'first-run']);
router.beforeEach(async (to) => {
const session = useSession();
if (!session.ready.value) {
await session.refresh();
}
if (session.needsSetup.value && to.name !== 'first-run') {
return { name: 'first-run' };
}
if (!session.needsSetup.value && to.name === 'first-run') {
return { name: 'home' };
}
if (PUBLIC_NAMES.has(to.name as string)) {
if (to.name === 'login' && session.isAuthenticated.value) {
return { name: 'home' };
}
return true;
}
if (!session.isAuthenticated.value) {
return { name: 'login', query: { next: to.fullPath } };
}
return true;
});
export default router;
+145 -24
View File
@@ -8,9 +8,11 @@ export interface Song {
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq';
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
}
export type Source = 'netease' | 'qq';
export interface BotStatus {
id: string;
name: string;
@@ -54,12 +56,17 @@ export const usePlayerStore = defineStore('player', {
timings: {} as Record<string, TimingState>,
theme: 'dark' as 'dark' | 'light',
// Home page cache
recommendPlaylists: [] as PlaylistItem[],
dailySongs: [] as Song[],
userPlaylists: [] as PlaylistItem[],
// 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,
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
}),
getters: {
@@ -91,6 +98,13 @@ export const usePlayerStore = defineStore('player', {
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
},
/** Sources that are currently logged in. Order: netease before qq. */
availableSources(): Source[] {
const s: Source[] = [];
if (this.authStatus.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq');
return s;
},
},
actions: {
@@ -259,6 +273,30 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
notify(message: string, type: 'error' | 'info' = 'info') {
this.notification = { id: Date.now(), message, type };
},
async playSong(song: Song) {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
if (res.data?.ok === false && res.data?.message) {
this.notify(res.data.message, 'error');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
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');
}
// Refresh queue so the inserted item shows up in the side panel
this.fetchQueue();
},
async addToQueue(query: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
@@ -269,9 +307,27 @@ export const usePlayerStore = defineStore('player', {
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
},
async addSong(song: Song) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
},
async playPlaylist(playlistId: string, platform = 'netease') {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async playAlbum(albumId: string, platform = 'netease') {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
@@ -330,39 +386,104 @@ export const usePlayerStore = defineStore('player', {
async setVolume(volume: number) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/volume`, { volume });
const bot = this.bots.find((b) => b.id === this.activeBotId);
if (bot) bot.volume = volume;
},
async setMode(mode: string) {
if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/mode`, { mode });
const bot = this.bots.find((b) => b.id === this.activeBotId);
if (bot) bot.playMode = mode;
},
async fetchHomeData() {
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch.
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
]);
const newAuth = {
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
};
const authChanged =
newAuth.netease !== this.authStatus.netease || newAuth.qq !== this.authStatus.qq;
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
// Cache hit only if auth is unchanged AND within TTL.
if (
!authChanged &&
this.lastFetchTime > 0 &&
Date.now() - this.lastFetchTime < HOME_CACHE_TTL
) {
return;
}
const [playlistRes, dailyRes, userRes, biliRes] = await Promise.allSettled([
axios.get('/api/music/recommend/playlists'),
axios.get('/api/music/recommend/songs'),
axios.get('/api/music/user/playlists'),
axios.get('/api/music/bilibili/popular?limit=12'),
// 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,
]);
if (playlistRes.status === 'fulfilled') {
this.recommendPlaylists = playlistRes.value.data.playlists;
}
if (dailyRes.status === 'fulfilled') {
this.dailySongs = dailyRes.value.data.songs;
}
if (userRes.status === 'fulfilled') {
this.userPlaylists = userRes.value.data.playlists;
}
if (biliRes.status === 'fulfilled') {
this.bilibiliPopular = biliRes.value.data.songs;
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
this.recommendPlaylists.netease =
neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
this.dailySongs.netease =
neDaily.status === 'fulfilled' ? (neDaily.value.data.songs ?? []) : [];
this.userPlaylists.netease =
neUserPL.status === 'fulfilled' ? (neUserPL.value.data.playlists ?? []) : [];
this.recommendPlaylists.qq =
qqRecPL.status === 'fulfilled' ? (qqRecPL.value.data.playlists ?? []) : [];
this.dailySongs.qq =
qqDaily.status === 'fulfilled' ? (qqDaily.value.data.songs ?? []) : [];
this.userPlaylists.qq =
qqUserPL.status === 'fulfilled' ? (qqUserPL.value.data.playlists ?? []) : [];
// bilibili popular: keep previous value on failure (it's an anonymous endpoint
// unrelated to user auth state, and stale popular results are harmless)
if (bili.status === 'fulfilled') {
this.bilibiliPopular = bili.value.data.songs ?? [];
}
this.lastFetchTime = Date.now();
// Only mark as fetched if at least the auth-status calls succeeded —
// a fully failed fetch (network blip / server down) should NOT be
// cached for 5 minutes, otherwise the user has to hard-reload to
// recover when connectivity returns.
const authOk =
neAuthRes.status === 'fulfilled' || qqAuthRes.status === 'fulfilled';
if (authOk) {
this.lastFetchTime = Date.now();
}
},
},
});
+42
View File
@@ -0,0 +1,42 @@
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);
// typeof null === 'object' and typeof [] === 'object' — both pass a
// naive check but neither is a valid record. Reject explicitly so a
// corrupted / manipulated value can't smuggle wrong shapes through.
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed as Partial<Record<TabKey, Source>>;
} 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
}
}
File diff suppressed because it is too large. Load diff
+65 -2
View File
@@ -1,33 +1,95 @@
// YesPlayMusic-inspired design tokens
// TSMusicBot Design System — canonical tokens
:root {
// Fonts
// Typography
--font-primary: 'Barlow', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, 'Cascadia Code', monospace;
--fs-hero: 22px;
--fs-h1: 20px;
--fs-h2: 17px;
--fs-h3: 16px;
--fs-body: 14px;
--fs-sm: 13px;
--fs-xs: 12px;
--fs-2xs: 11px;
--fs-micro: 10px;
--fw-regular: 400;
--fw-medium: 500;
--fw-semi: 600;
--fw-bold: 700;
--fw-black: 800;
--lh-tight: 1.2;
--lh-normal: 1.4;
--lh-body: 1.5;
// Spacing
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 36px;
--space-10: 80px;
// Layout
--navbar-height: 56px;
--player-height: 56px;
--page-gutter: 10vw;
// Radius
--radius-xs: 3px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-full: 9999px;
// Transitions
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
--ease-cinematic: cubic-bezier(0.25, 0.1, 0.25, 1);
// Shadows
--shadow-dropdown: 0 8px 24px rgba(0, 0, 0, 0.2);
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.35);
// Accent
--color-primary: #335eea;
--color-primary-bg: #eaeffd;
--color-primary-15: rgba(51, 94, 234, 0.15);
--color-primary-10: rgba(51, 94, 234, 0.10);
--color-primary-12: rgba(51, 94, 234, 0.12);
--gradient-fm: linear-gradient(135deg, #335eea, #6366f1);
// Semantic (theme-invariant)
--color-online: #22c55e;
--color-online-15: rgba(34, 197, 94, 0.15);
--color-paused: #eab308;
--color-paused-15: rgba(234, 179, 8, 0.15);
// Platform brand
--brand-netease: #e81123;
--brand-netease-15: rgba(232, 17, 35, 0.15);
--brand-qq: #12b76a;
--brand-qq-15: rgba(18, 183, 106, 0.15);
--brand-bilibili: #00a1d6;
--brand-bilibili-15: rgba(0, 161, 214, 0.15);
--brand-youtube: #ff0000;
--brand-youtube-12: rgba(255, 0, 0, 0.12);
}
// Dark theme (default)
:root,
[data-theme='dark'] {
--bg-primary: #222222;
--bg-secondary: #323232;
--bg-card: rgba(255, 255, 255, 0.04);
--bg-navbar: rgba(34, 34, 34, 0.86);
--bg-modal-scrim: rgba(0, 0, 0, 0.55);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.58);
--text-tertiary: rgba(255, 255, 255, 0.38);
@@ -40,6 +102,7 @@
--bg-secondary: #f5f5f7;
--bg-card: rgba(0, 0, 0, 0.02);
--bg-navbar: rgba(255, 255, 255, 0.86);
--bg-modal-scrim: rgba(0, 0, 0, 0.35);
--text-primary: #000000;
--text-secondary: rgba(0, 0, 0, 0.58);
--text-tertiary: rgba(0, 0, 0, 0.38);
+75
View File
@@ -0,0 +1,75 @@
<template>
<div class="auth-page">
<form class="auth-card" @submit.prevent="submit">
<h1>首次使用</h1>
<p class="auth-hint">创建管理员账号。该账号将拥有 WebUI 的全部权限。</p>
<label>
<span>用户名</span>
<input v-model="username" type="text" autocomplete="username" autofocus required />
</label>
<label>
<span>密码 (≥8 位)</span>
<input v-model="password" type="password" autocomplete="new-password" minlength="8" required />
</label>
<label>
<span>再次输入密码</span>
<input v-model="confirm" type="password" autocomplete="new-password" minlength="8" required />
</label>
<p v-if="error" class="auth-error">{{ error }}</p>
<button type="submit" :disabled="loading">{{ loading ? '创建中…' : '创建管理员' }}</button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSession } from '../composables/useSession.js';
const username = ref('');
const password = ref('');
const confirm = ref('');
const error = ref('');
const loading = ref(false);
const router = useRouter();
const session = useSession();
async function submit() {
error.value = '';
if (password.value !== confirm.value) {
error.value = '两次输入的密码不一致';
return;
}
loading.value = true;
try {
await session.setup(username.value, password.value);
router.replace('/');
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
</script>
<style scoped lang="scss">
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-primary); }
.auth-card {
width: 360px; padding: 32px; background: var(--bg-secondary);
border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 12px;
box-shadow: var(--shadow-dropdown);
}
.auth-card h1 { margin: 0; font-size: 20px; color: var(--text-primary); }
.auth-hint { margin: 0 0 4px; font-size: 12px; color: var(--text-secondary); }
.auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text-secondary); }
.auth-card input {
height: 36px; padding: 0 10px; border-radius: var(--radius-sm);
background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);
}
.auth-card button {
height: 38px; border-radius: var(--radius-sm); border: 0;
background: var(--color-primary); color: #fff; font-weight: 500; cursor: pointer;
}
.auth-card button:disabled { opacity: 0.6; cursor: progress; }
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; }
</style>
+6 -3
View File
@@ -19,8 +19,9 @@
: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)"
@play="store.playSong(song)"
@playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/>
</div>
</div>
@@ -35,7 +36,9 @@ import SongCard from '../components/SongCard.vue';
const store = usePlayerStore();
const history = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
import { Song } from '../stores/player.js';
const history = ref<Song[]>([]);
const loading = ref(true);
onMounted(async () => {
+76 -17
View File
@@ -34,14 +34,17 @@
</section>
<!-- 每日推荐 -->
<section class="section" v-if="store.dailySongs.length > 0">
<h2 class="section-title">每日推荐</h2>
<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.slice(0, 12)"
v-for="song in (store.dailySongs[dailySourceSafe] ?? []).slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playById(song.id, song.platform)"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
@@ -51,11 +54,14 @@
</section>
<!-- 推荐歌单 -->
<section class="section" v-if="store.recommendPlaylists.length > 0">
<h2 class="section-title">推荐歌单</h2>
<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"
v-for="playlist in (store.recommendPlaylists[recommendSourceSafe] ?? [])"
:key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale"
@@ -67,10 +73,11 @@
</section>
<!-- 我的歌单 -->
<section class="section" v-if="store.userPlaylists.length > 0">
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ store.userPlaylists.length }}</span>
<span v-if="currentUserPlaylists.length > 0" class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
@@ -85,12 +92,12 @@
</RouterLink>
</div>
<button
v-if="store.userPlaylists.length > USER_PLAYLIST_LIMIT"
v-if="currentUserPlaylists.length > USER_PLAYLIST_LIMIT"
class="expand-btn"
@click="userPlaylistsExpanded = !userPlaylistsExpanded"
>
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${store.userPlaylists.length} 个歌单` }}
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${currentUserPlaylists.length} 个歌单` }}
</button>
</section>
@@ -105,7 +112,7 @@
v-for="song in store.bilibiliPopular.slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playById(song.id, song.platform)"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
@@ -117,19 +124,60 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song } from '../stores/player.js';
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 work anonymously on netease, so it's always available;
// QQ requires login. This intentionally differs from dailyAvailable/userAvailable.
const recommendAvailable = computed<Source[]>(() => {
const s: Source[] = ['netease'];
if (store.authStatus.qq) s.push('qq');
return s;
});
const dailyAvailable = computed<Source[]>(() => store.availableSources);
const userAvailable = computed<Source[]>(() => store.availableSources);
// 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.
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
? store.userPlaylists
: store.userPlaylists.slice(0, USER_PLAYLIST_LIMIT)
? currentUserPlaylists.value
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
);
async function playFm() {
@@ -179,6 +227,10 @@ onMounted(() => {
.section {
margin-bottom: 36px;
@media (max-width: 768px) {
margin-bottom: 28px;
}
}
.section-title {
@@ -188,6 +240,11 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 768px) {
font-size: 18px;
margin-bottom: 12px;
}
}
.section-count {
@@ -223,7 +280,7 @@ onMounted(() => {
justify-content: center;
width: 24px;
height: 24px;
background: #00a1d6;
background: var(--brand-bilibili);
color: white;
border-radius: 4px;
font-size: 14px;
@@ -317,6 +374,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 768px) { grid-template-columns: repeat(3, 1fr); gap: 14px; }
}
.daily-card {
@@ -348,6 +406,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 768px) { grid-template-columns: repeat(3, 1fr); gap: 14px; }
}
.playlist-card {
+189
View File
@@ -0,0 +1,189 @@
<template>
<div class="library-page">
<h1 class="page-title">音乐库</h1>
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span v-if="currentUserPlaylists.length > 0" 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)"
@playNext="store.playNextSong(song)"
@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>
<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[]>(() => store.availableSources);
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>
<style lang="scss" scoped>
.page-title {
font-size: var(--fs-hero);
font-weight: var(--fw-bold);
margin-bottom: 24px;
}
.section {
margin-bottom: 36px;
}
.section-title {
font-size: var(--fs-hero);
font-weight: var(--fw-bold);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.section-count {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-tertiary);
}
.playlist-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 24px;
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 900px) { grid-template-columns: repeat(3, 1fr); }
}
.playlist-card {
cursor: pointer;
display: block;
text-decoration: none;
color: inherit;
}
.playlist-name {
margin-top: 8px;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.playlist-count {
font-size: var(--fs-xs);
color: var(--text-tertiary);
margin-top: 2px;
}
.song-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.empty {
text-align: center;
padding: 40px;
color: var(--text-tertiary);
font-size: var(--fs-body);
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--text-tertiary);
font-size: var(--fs-body);
}
.empty-icon {
font-size: 48px;
opacity: 0.3;
margin-bottom: 16px;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="auth-page">
<form class="auth-card" @submit.prevent="submit">
<h1>登录 TSMusicBot</h1>
<label>
<span>用户名</span>
<input v-model="username" type="text" autocomplete="username" autofocus required />
</label>
<label>
<span>密码</span>
<input v-model="password" type="password" autocomplete="current-password" required />
</label>
<p v-if="error" class="auth-error">{{ error }}</p>
<button type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSession } from '../composables/useSession.js';
const username = ref('');
const password = ref('');
const error = ref('');
const loading = ref(false);
const router = useRouter();
const route = useRoute();
const session = useSession();
async function submit() {
error.value = '';
loading.value = true;
try {
await session.login(username.value, password.value);
const rawNext = typeof route.query.next === 'string' ? route.query.next : '/';
const next = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/';
router.replace(next);
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
</script>
<style scoped lang="scss">
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
}
.auth-card {
width: 360px;
padding: 32px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: var(--shadow-dropdown);
}
.auth-card h1 { margin: 0 0 8px; font-size: 20px; color: var(--text-primary); }
.auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text-secondary); }
.auth-card input {
height: 36px; padding: 0 10px; border-radius: var(--radius-sm);
background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);
}
.auth-card button {
height: 38px; border-radius: var(--radius-sm); border: 0;
background: var(--color-primary); color: #fff; font-weight: 500; cursor: pointer;
}
.auth-card button:disabled { opacity: 0.6; cursor: progress; }
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; }
</style>
+54 -26
View File
@@ -31,13 +31,14 @@
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playById(song.id, song.platform)"
@add="store.addToQueueById(song.id, song.platform)"
@play="store.playSong(song)"
@playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/>
</div>
</template>
<div v-else class="loading">歌单不存在或加载失败</div>
<div v-else class="loading">{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败</div>
</div>
</template>
@@ -53,6 +54,8 @@ import SongCard from '../components/SongCard.vue';
const store = usePlayerStore();
const route = useRoute();
import { Song } from '../stores/player.js';
interface PlaylistDetail {
id: string;
name: string;
@@ -61,44 +64,69 @@ interface PlaylistDetail {
songCount: number;
}
interface SongItem {
id: string;
name: string;
artist: string;
album: string;
duration: number;
coverUrl: string;
platform: string;
}
const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<SongItem[]>([]);
const songs = ref<Song[]>([]);
const loading = ref(true);
async function playAll() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
await store.playPlaylist(id, platform);
if (kind === 'album') {
await store.playAlbum(id, platform);
} else {
await store.playPlaylist(id, platform);
}
}
onMounted(async () => {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
try {
const [detailRes, songsRes] = await Promise.all([
axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
]);
playlist.value = detailRes.data.playlist;
songs.value = songsRes.data.songs;
} catch (err: any) {
console.error('Failed to load playlist:', err?.response?.status, err?.message);
const detailUrl = kind === 'album'
? `/api/music/album/${id}/detail`
: `/api/music/playlist/${id}/detail`;
const songsUrl = kind === 'album'
? `/api/music/album/${id}`
: `/api/music/playlist/${id}`;
// allSettled, not Promise.all — if detail 404s but songs is fine
// (e.g., a QQ playlist whose detail endpoint flaked but the song
// list resolved), we still want to show the songs rather than
// the "不存在" empty state. For albums, detail always 404s — that
// is intentional; the fallback stub below handles it.
const [detailRes, songsRes] = await Promise.allSettled([
axios.get(detailUrl, { params: { platform } }),
axios.get(songsUrl, { params: { platform } }),
]);
const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null;
const songList = songsRes.status === 'fulfilled' ? (songsRes.value.data?.songs ?? []) : [];
if (detail) {
playlist.value = detail;
} else if (songList.length > 0) {
// Fall back to a stub built from the route + first song. For albums,
// every song's `album` field carries the real album name.
const fallbackName = kind === 'album'
? (songList[0]?.album || '专辑')
: '歌单';
playlist.value = {
id,
name: fallbackName,
description: '',
coverUrl: songList[0]?.coverUrl ?? '',
songCount: songList.length,
};
} else {
playlist.value = null;
songs.value = [];
} finally {
loading.value = false;
if (detailRes.status === 'rejected') {
console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
}
}
songs.value = songList;
loading.value = false;
});
</script>
+278 -22
View File
@@ -20,56 +20,197 @@
<div v-if="loading" class="loading">搜索中...</div>
<div v-else-if="results.length > 0" class="results">
<SongCard
v-for="(song, i) in results"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playById(song.id, song.platform)"
@add="store.addToQueueById(song.id, song.platform)"
/>
</div>
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<div class="source-bar">
<button
class="source-btn"
:class="{ active: selectedSource === 'netease' }"
@click="selectedSource = 'netease'"
>网易云</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'qq' }"
@click="selectedSource = 'qq'"
>QQ</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
</div>
<div v-else-if="searched" class="empty">
未找到相关结果
</div>
<div class="tab-bar">
<button
class="tab"
:class="{ active: activeTab === 'songs' }"
@click="activeTab = 'songs'"
>
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
>
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
>
歌单<span class="tab-count">{{ filteredPlaylists.length }}</span>
</button>
</div>
<section v-if="activeTab === 'albums' && filteredAlbums.length" class="result-section">
<div class="card-grid">
<router-link
v-for="al in filteredAlbums"
: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 }}
<span class="platform-badge" :class="badgeClass(al.platform)">{{ badgeLabel(al.platform) }}</span>
</div>
<div class="card-sub">{{ al.artist }}</div>
</router-link>
</div>
</section>
<section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
<div class="card-grid">
<router-link
v-for="pl in filteredPlaylists"
: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 }}
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
</div>
</router-link>
</div>
</section>
<section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
<SongCard
v-for="(song, i) in filteredSongs"
: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>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue';
import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const route = useRoute();
const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
function loadSource(): 'netease' | 'qq' | 'bilibili' {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
const query = ref((route.query.q as string) || '');
const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
const filteredSongs = computed(() =>
allSongs.value.filter((s) => s.platform === selectedSource.value)
);
const filteredAlbums = computed(() =>
allAlbums.value.filter((a) => a.platform === selectedSource.value)
);
const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
);
// Persist source preference
watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
});
// B站 has no albums/playlists — force songs tab when switching to B站
watch(selectedSource, (src) => {
if (src === 'bilibili' && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
searched.value = true;
activeTab.value = 'songs';
router.replace({ query: { q: query.value } });
try {
const res = await axios.get('/api/music/search/all', {
params: { q: query.value },
});
results.value = res.data.songs;
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? [];
} catch {
results.value = [];
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally {
loading.value = false;
}
}
function badgeLabel(platform: string): string {
if (platform === 'qq') return 'QQ';
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
return '网易云';
}
function badgeClass(platform: string): string {
if (platform === 'qq') return 'badge-qq';
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
return 'badge-netease';
}
onMounted(() => {
if (query.value) doSearch();
});
@@ -138,4 +279,119 @@ onMounted(() => {
flex-direction: column;
gap: 2px;
}
.source-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.source-btn {
padding: 5px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
color: var(--color-primary);
background: rgba(51, 94, 234, 0.12);
}
}
.tab-bar {
display: flex;
gap: 6px;
margin-bottom: 24px;
padding: 4px;
background: var(--bg-card);
border-radius: var(--radius-md);
width: fit-content;
}
.tab {
padding: 8px 20px;
border-radius: calc(var(--radius-md) - 2px);
font-size: 14px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: transparent;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
background: var(--color-primary);
color: #fff;
.tab-count { opacity: 0.85; }
}
}
.tab-count {
margin-left: 5px;
opacity: 0.55;
font-weight: var(--fw-regular);
font-size: 13px;
&::before { content: '('; }
&::after { content: ')'; }
}
.result-section {
margin-bottom: 32px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px 28px;
}
.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; }
}
.platform-badge {
vertical-align: middle;
flex-shrink: 0;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
}
.badge-netease {
background: var(--brand-netease-15);
color: var(--brand-netease);
}
.badge-qq {
background: var(--brand-qq-15);
color: var(--brand-qq);
}
.badge-bilibili {
background: var(--brand-bilibili-15);
color: var(--brand-bilibili);
}
.badge-youtube {
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
</style>
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more