Compare commits

...
Author SHA1 Message Date
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
saopig1andClaude Opus 4.7 f76500cfb2 fix(player): set browser UA + Referer for Netease CDN to stop auto-skip
Netease's m701/m801.music.126.net CDN RST connections from FFmpeg's
default Lavf UA. The bot's frame loop treats an early ffmpeg exit as
trackEnd and advances the queue, which surfaced as songs auto-skipping
mid-playlist. Add a browser UA + music.163.com Referer for these URLs,
extract args into a tested buildFfmpegArgs(), and harden reconnect
flags (delay_max 5 -> 30, plus reconnect_on_network_error /
reconnect_on_http_error). Verified by A/B running ffmpeg against the
same fresh CDN URL: legacy args got 0 bytes + "End of file" reading
HTTP response; fixed args streamed the full track to completion.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pre-existing bug exposed by the QQ source tab.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also resolve leftover merge conflict marker in instance.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:52:27 +08:00
TIANYAO ZHANG 0408f6ce3c Merge pull request #33 from NeoPecos/feature/idle-timeout
添加闲时自动退出管理功能
2026-04-13 20:46:58 +08:00
TIANYAO ZHANG 47b69a1f95 Merge pull request #29 from stanoswald/bugfix/pass-pwd-in-client
Forward server password to client opetion
2026-04-13 20:44:43 +08:00
NeoPecos 0e992f2f5f merge: resolve conflict, keep idleTimer and profileManager 2026-04-13 17:43:04 +08:00
NeoPecos 7785cc972b feat: add idle timeout setting to auto-disconnect bot when channel is empty 2026-04-13 17:27:51 +08:00
StanOswald 4b412e6277 Forward server password to client opetion 2026-04-13 02:04:05 +08:00
TIANYAO ZHANG f0a0ae5f2c Merge pull request #28 from ZHANGTIANYAO1/dev
Dev
2026-04-13 00:53:12 +08:00
saopig1andClaude Opus 4.6 a56dc5bc85 docs(readme): document bot profile auto-update feature
Add profile feature to features list, architecture tree, and changelog.

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

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

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

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

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

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

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

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

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

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

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

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 14:53:15 +00:00
54 changed files with 7732 additions and 608 deletions

No files matched your search

+4 -1
View File
@@ -13,7 +13,10 @@
"mcp__Claude_Preview__*", "mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*", "mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*", "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": [ "deny": [
"Bash(git push * main)", "Bash(git push * main)",
+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 config.json
cookies/ cookies/
.superpowers/ .superpowers/
.worktrees/
/bin/ /bin/
scripts/navbar_bigger.png scripts/navbar_bigger.png
+25 -4
View File
@@ -34,6 +34,7 @@
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放 - **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确) - **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化 - **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制 - **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲 - **播放历史** — 自动记录所有播放过的歌曲
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期) - **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
@@ -41,7 +42,8 @@
## 截图 ## 截图
> *截图即将添加* > <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
## 快速开始 ## 快速开始
@@ -252,9 +254,11 @@ sudo systemctl start tsmusicbot
| `!vol <0-100>` | 设置音量 | | `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 | | `!queue` | 查看播放队列 |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 | | `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <ID>` | 加载歌单 | | `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!album <ID>` | 加载专辑 | | `!album <ID>` | 加载专辑 |
| `!fm` | 私人 FM(网易云) | | `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!lyrics` | 显示当前歌词 | | `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 | | `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 | | `!vote` | 投票跳过当前歌曲 |
@@ -288,7 +292,8 @@ teamspeak-music-bot/
│ ├── bot/ # 机器人核心 │ ├── bot/ # 机器人核心
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限) │ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源) │ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
│ │ └── manager.ts # 多实例生命周期管理 │ │ ├── manager.ts # 多实例生命周期管理
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
│ ├── data/ # 数据层 │ ├── data/ # 数据层
│ │ ├── config.ts # JSON 配置文件 │ │ ├── config.ts # JSON 配置文件
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化) │ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
@@ -473,6 +478,22 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
### 最新版本 ### 最新版本
**机器人形象自动更新(Bot Profile)**
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
**新命令 & FM 修复**
- **新增 `!artist <歌手名>` 命令**:搜索指定歌手的歌曲并循环播放,支持 `-q`(QQ 音乐)/ `-b`(B站)/ `-y`(YouTube)平台切换。一次加载最多 50 首,自动按歌手名过滤并设为 Loop 模式。
- **歌单模糊搜索**:`!playlist` 现在支持歌单名称模糊搜索(如 `!playlist 华语经典`),自动匹配公开歌单 + 个人歌单(网易云 + QQ)。纯数字 ID 和 URL 解析保持兼容。
- **修复 `!fm` 播放中断**:私人 FM 几首歌后静音的 bug 已修复。新增自动续播机制(队列低位自动拉取新歌),播放器健康帧追踪防止临时 URL 失败导致永久静音。
- **QQ 音乐个人歌单**:QQ Music provider 新增 `getUserPlaylists` 支持,登录后可通过 `!playlist -q <名称>` 模糊搜索个人歌单。
**协议层 & 稳定性** **协议层 & 稳定性**
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。 - **升级 `@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,124 @@
# Bot Profile Manager — Design Spec
## Goal
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
## Features
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|---|---------|---------------|-----------------|---------------|---------------|
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
## Architecture
```
resolveAndPlay() success / stop / clear / playNext exhausted
↓
BotInstance → BotProfileManager.onSongChange(song | null)
├─ updateAvatar(coverUrl | null) [fire-and-forget]
├─ updateDescription(song | null)
├─ updateNickname(song | null)
├─ updateAwayStatus(song | null)
├─ updateChannelDescription(song | null)
└─ sendNowPlayingMessage(song) [only when song != null]
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
BotInstance.disconnect() → (no action needed, server cleans up)
```
## File Changes
| File | Change |
|------|--------|
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
## TS3Client Layer Extensions
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
```typescript
execCommand(cmd: string): Promise<void>
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
fileTransferInitUpload(channelID: bigint, path: string, password: string,
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
```
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
## BotProfileManager Detail
```typescript
interface ProfileConfig {
avatarEnabled: boolean; // default true
descriptionEnabled: boolean; // default true
nicknameEnabled: boolean; // default true
awayStatusEnabled: boolean; // default true
channelDescEnabled: boolean; // default true
nowPlayingMsgEnabled: boolean; // default true
}
```
### Permission Handling
- Each feature has an independent `permDenied: boolean` flag.
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
- On `onConnect()` → reset all flags (new connection may have different permissions).
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
### Nickname Truncation
- Format: `♪ {songInfo} | {defaultNickname}`
- TS3 max nickname: 30 characters
- If total > 30: truncate songInfo, keep defaultNickname
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
### Avatar Upload Flow (TS3)
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
3. `uploadFileData(host, info, Readable.from(buffer))`
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
### Fire-and-Forget
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
## Database Migration
```sql
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
```
## Web API
```
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
```
## Constraints
- All profile operations are async, never block playback
- Uses existing `axios` dependency for image download
- MD5 via Node.js built-in `crypto`
- No new npm dependencies required
@@ -0,0 +1,125 @@
# Design: FM Bug Fix + Artist Loop + Playlist Fuzzy Search
Date: 2026-04-27
## Overview
Three features for the TeamSpeak Music Bot:
1. New `!artist <name>` command — loop playback filtered by artist
2. Fuzzy playlist name search in existing `!playlist` command
3. Fix `!fm` audio dropout bug (no sound after a few songs but status shows playing)
---
## Feature 1: `!artist` Command
### Behavior
`!artist <歌手名> [-q|-b|-y]` searches for songs by the artist, loads them into the queue, sets the queue mode to `Loop`, and starts playing.
### Flow
1. Parse command with optional platform flags (`-q`, `-b`, `-y`)
2. Call `provider.search(歌手名, 50)` to get up to 50 results
3. Filter results: only keep songs where `song.artist` contains the search query (case-insensitive)
4. If filtered list is empty, fall back to unfiltered search results (up to 20)
5. Clear current queue, add filtered songs, set mode to `Loop`
6. Play first song via `resolveAndPlay`
### Key Decisions
- **Why Loop mode?** The user said "循环播放" (loop playback). After the artist's songs are exhausted, they should restart.
- **Why filter client-side?** The search API doesn't support artist-only filtering. We search broadly then narrow down.
- **Why 50 results?** The default limit is 20, but for prolific artists we want more coverage. 50 balances API response size with coverage.
### Files Changed
- `src/bot/commands.ts`: Register `artist` in PUBLIC_COMMANDS, update help text
- `src/bot/instance.ts`: New `cmdArtist()` method
---
## Feature 2: Playlist Fuzzy Search
### Behavior
`!playlist <name or ID>` now accepts both playlist IDs and playlist names. When the input is not a pure numeric ID, it searches for matching playlists and uses the top result.
### Flow
1. Parse input — if it's a pure numeric ID or contains a URL with an ID, use existing logic
2. Otherwise, call `provider.search(input)` which already returns `playlists[]` in the result
3. Also call `provider.getUserPlaylists()` if the provider supports it (logged-in state)
4. Client-side fuzzy match user playlists: `playlist.name` contains input (case-insensitive)
5. Merge results: public search results first (sorted by API relevance), then user matches
6. Take the first playlist, load its songs, play
### Key Decisions
- **Why public search first?** It's already sorted by relevance from the API. User playlists are a secondary source.
- **Why client-side matching for user playlists?** The `getUserPlaylists()` API returns all user playlists without a search parameter, so we must filter locally.
- **Backward compatibility:** Numeric IDs and URL parsing are unchanged.
### Files Changed
- `src/bot/instance.ts`: Modify `cmdPlaylist()` to add search fallback
- `src/bot/commands.ts`: Update help text
---
## Feature 3: FM Bug Fix
### Root Cause Analysis
The `!fm` bug manifests as: audio stops after a few songs, but `!now` shows a playing song and the song name keeps changing.
`getPersonalFm()` returns only ~3 songs per API call. After those are consumed:
- In `Sequential` mode: `queue.next()` returns null → `player.stop()` is called → playback stops entirely. This does NOT match "歌还在轮播" (songs still rotating).
- In `Loop` mode (if user changed mode): the same 3 songs loop, but URLs may expire, causing silent playback failures.
The most likely scenario for "no audio but status shows playing + song names changing":
1. FM songs have URLs that resolve but don't produce playable audio (copyright/region restrictions)
2. ffmpeg spawns, connects to the URL, gets an HTTP error or silent stream
3. ffmpeg exits quickly (clean exit or error)
4. The frame loop detects ffmpeg gone + buffer empty → emits `trackEnd`
5. `playNext()` advances to the next song
6. This rapid cycle (spawn → fail → advance) makes it appear that songs are "playing and rotating" but with no audio
7. After 3 consecutive ffmpeg spawn failures, `consecutiveFailures >= MAX_CONSECUTIVE_FAILURES` → player refuses to spawn new ffmpeg processes
8. After that, `resolveAndPlay` still sets state via `player.play()` which immediately emits "error" → `playNext()` skips to next → cycle continues with no ffmpeg at all
### Fix Strategy
**Fix 1 — FM auto-refill (primary fix):**
- In `cmdFm()`, set queue mode to `RandomLoop` so the queue never "runs out"
- Add a `refillFm()` method that fetches more FM songs and appends to queue
- Hook into the `trackEnd` flow: when queue has ≤ 2 songs remaining and we're in FM mode, trigger a refill
- Track FM state with a boolean flag `isFmMode` on the instance
**Fix 2 — Reset consecutive failures on successful playback (safety net):**
- Reset `consecutiveFailures` when a track plays successfully for at least N frames (e.g., 50 frames = 1 second)
- This prevents transient URL failures from accumulating toward the hard limit
**Fix 3 — FM refill before queue exhaustion:**
- After `playNext()` successfully starts a song, check if `isFmMode` and `queue.size() - currentIndex <= 2`
- If so, fire an async refill (don't block playback)
### Files Changed
- `src/bot/instance.ts`: Modify `cmdFm()`, add `refillFm()`, add FM state tracking, modify `playNext()` to check for FM refill
- `src/audio/player.ts`: Add `framesPlayed` threshold check to reset `consecutiveFailures`
---
## Implementation Order
1. **FM bug fix** first — it's a bug fix affecting current users
2. **Playlist fuzzy search** — small change, quick win
3. **Artist loop** — new feature, depends on queue/player being stable
---
## Testing
- FM: Verify songs keep playing beyond the initial 3-song batch, verify auto-refill works
- Playlist: Test with numeric ID (backward compat), test with playlist name (fuzzy search)
- Artist: Test with known artist names, test edge case (no results), test with platform flags
@@ -0,0 +1,189 @@
# Multi-Source Tabs for Recommend / User Playlists / Daily Songs
**Date:** 2026-05-06
**Status:** Spec — pending implementation
## Problem
Home 和 Library 页面的"推荐歌单 / 每日推荐 / 我的歌单"这三类内容当前硬编码只走网易云。当用户同时登录了网易云和 QQ 音乐时,无法在 Web UI 上看到 QQ 侧的对应内容、也无法切换查看。
## Goal
在以下 4 个 section 上提供"网易云 / QQ"来源切换 tab,桌面端和移动端均可用:
- `Home.vue` — 推荐歌单
- `Home.vue` — 每日推荐
- `Home.vue` — 我的歌单
- `Library.vue` — 我的歌单
切换为纯前端动作(数据已预先 fetch),无加载闪烁。各 section 的选择独立持久化。
## Out of Scope
- 私人 FM(QQ 无对应概念)
- B 站热门(独立第三来源,不属于网易/QQ 切换语义)
- 最近播放(bot 维度的播放历史,与音乐源无关)
- Library 现有的"我的收藏"段落 —— 当前调用的 `/api/music/user/liked` 端点不存在,是死代码,本次顺手移除
- 登录状态实时同步(用户在 Settings 登录后需手动刷新 Home/Library 才能看到 QQ tab)
- Tab 排序、隐藏、拖动等高级配置
## Non-functional Constraints
- 桌面端(>768px)和移动端(≤768px)布局均可用,tab 与 section title 同行排布;空间不足时允许 flex-wrap
- Tab 触控区域有效高度 ≥36px
- 现有 5 分钟 home data cache 行为保留
- 不引入新的后端端点(后端已通过 `?platform=` 参数支持多源)
## Architecture
### 数据层(`web/src/stores/player.ts`)
字段从单平台改为按 platform 切分:
```ts
// 前
recommendPlaylists: PlaylistItem[]
userPlaylists: PlaylistItem[]
dailySongs: Song[]
// 后
recommendPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
userPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
dailySongs: { netease: Song[]; qq: Song[] }
// 新增
authStatus: { netease: boolean; qq: boolean }
```
`fetchHomeData()` 改写:
1. 并发调用 `/api/auth/status?platform=netease` 与 `?platform=qq`,写入 `authStatus`
2. 网易云的三类数据照常 fetch(推荐歌单匿名可访问;每日推荐和我的歌单需登录,未登录时 API 自然返回空或失败,`Promise.allSettled` 已隔离)
3. QQ 的三类数据**仅在 QQ 登录时** fetch,未登录则为空数组
4. B 站热门保持原样
5. 5 分钟缓存 TTL 不变
### UI 组件
新增 `web/src/components/SourceTabs.vue`:
```vue
<SourceTabs v-model="activeSource" :sources="availableSources" />
```
Props:
- `sources: ('netease' | 'qq')[]` — 由父组件根据 auth 状态过滤后传入
- `modelValue: 'netease' | 'qq'` — v-model 绑定
行为:
- `sources.length < 2` 时组件**自身不渲染**(返回空),父组件无需 v-if 包装
- 文字标签:`{ netease: '网易云', qq: 'QQ' }`
- 视觉:水平排列,激活态用主色(`var(--color-primary)`)下划线 + 加粗,未激活态使用次要文字色
- 紧贴 section-title 右侧,使用 `display: inline-flex`,移动端 padding/font-size 缩小
### 各 section 接入模板
```vue
<section v-if="recommendAvailable.length > 0" class="section">
<h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in store.recommendPlaylists[recommendSource]"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
</RouterLink>
</div>
</section>
```
每个 section 在 `<script setup>` 维护两个值:
- `recommendSource: Ref<'netease' | 'qq'>` — 当前选中
- `recommendAvailable: ComputedRef<('netease' | 'qq')[]>` — 该 section 在当前登录状态下有哪些 source 可选
`recommendAvailable` 计算规则:
| Section | netease 加入条件 | qq 加入条件 |
|---|---|---|
| Home 推荐歌单 | 总是(公开数据) | `authStatus.qq` |
| Home 每日推荐 | `authStatus.netease` | `authStatus.qq` |
| Home 我的歌单 | `authStatus.netease` | `authStatus.qq` |
| Library 我的歌单 | `authStatus.netease` | `authStatus.qq` |
#### "我的歌单"展开按钮兼容
Home 的"我的歌单"现有 `USER_PLAYLIST_LIMIT = 20` 折叠/展开。改造后:
```ts
const visibleUserPlaylists = computed(() => {
const all = store.userPlaylists[userSource.value] ?? [];
return userPlaylistsExpanded.value ? all : all.slice(0, USER_PLAYLIST_LIMIT);
});
```
切换 source 时折叠态保留(不需 reset)。
### 持久化
localStorage 键统一为一个 JSON:
```
key: "source-tabs"
value: {
"home.recommend": "qq",
"home.daily": "netease",
"home.user": "qq",
"library.user": "netease"
}
```
读:组件 mount 时一次性读 + 解析。
写:在 v-model 的 setter 里 `watch` 一次写回。
不存在的键 / 解析失败 / 老用户没这个 key —— 默认值 `"netease"`。
### 边界与回退
| 情况 | 行为 |
|---|---|
| 选的 source 不在 `available` 里(例:选了 QQ,登出后回到页面) | 渲染时 fallback 到 `available[0]`,不修改 localStorage(保留用户偏好,下次登回来仍生效) |
| `recommendPlaylists.qq` 为空数组(API 失败 or 无数据) | tab 仍可切,切过去显示空 grid(无错误提示,符合现有"空数据隐藏 section"语义;section 顶层 v-if 检查的是 `available.length > 0`,单 platform 数据为空不影响 section 显隐) |
| QQ provider 不支持 `getDailyRecommendSongs`(501) | `Promise.allSettled` 已捕获,`dailySongs.qq` 保持 `[]`,等同上一行 |
| 用户在 Settings 登录 QQ 后切回 Home | 不自动 refetch / 不自动出 tab —— 用户需手动刷新页面(保留 5 分钟缓存语义) |
| 网易云和 QQ 都没登录 | `available` 为空时 section 隐藏(沿用现有逻辑) |
| Library "我的收藏" 段落 | 整段移除(含 template、script 中的 `liked` ref、对应 axios 调用) |
## 修改文件清单
新增:
- `web/src/components/SourceTabs.vue` — 共享 tab 组件
修改:
- `web/src/stores/player.ts` — 多平台 state、`authStatus`、`fetchHomeData` 重写
- `web/src/views/Home.vue` — 三个 section 接入 SourceTabs,对应 ref + computed
- `web/src/views/Library.vue` — 我的歌单接入 SourceTabs;移除我的收藏死代码
不改:
- 后端(API 已支持 `?platform=`)
- `Search.vue` / `Playlist.vue` / `Settings.vue` 等其他页面
## 测试方案
- 手动:在两种登录组合下访问 Home 和 Library,验证 tab 显隐、切换、刷新后持久化
- 仅网易登录 → 不显示 tab
- 两边登录 → 显示 tab,切换后刷新页面来源不变
- QQ 登录 → 网易登出 → 网易 tab 消失,若上次选的是网易则 fallback 到 QQ
- 移动端(DevTools 768px 以下):tab 与 section-title 同行不溢出,触控区域可点
- TypeScript 类型检查通过:`npx tsc --noEmit` + `npm run build:web`
- 单元测试:现有 vitest 套件不应回归(store 改动不破坏其他用法)
## 风险
- store 字段类型变更(数组 → 对象)会影响所有读取这三个字段的地方。需 grep 确认没有遗漏的消费者。
- localStorage 解析失败的容错必须周全,避免一次脏数据导致整个页面白屏。
@@ -0,0 +1,226 @@
# History-aware `prev` + "Play Next" Insert
**Date:** 2026-05-06
**Status:** Spec — pending implementation
## Problem
Two queue/playback gaps surfaced in real use:
1. In `PlayMode.Random` and `PlayMode.RandomLoop`, `!prev` does not play the
actually-previously-played song. It just walks `currentIndex - 1` in the
underlying array — but in random modes `currentIndex` jumps non-sequentially,
so the "previous" array slot has no relationship to play history.
2. `!add` / web "添加到队列" appends to the queue tail. There is no way to
say "play this song right after the current one." Users want a "下一首
播放" affordance comparable to Spotify "Add to Queue (next up)" or Apple
Music "Play Next".
## Goals
- `prev` walks back through the actual play history regardless of mode.
- A new "Play Next" path inserts a song at `currentIndex + 1`, available
via web UI button and TS3 chat command.
- Both features are usable on desktop and mobile web.
## Out of Scope
- Forward/redo through prev'd songs (user would need to push next manually,
which picks a fresh random in random modes — acceptable simplification).
- Reordering songs already in the queue ("move to next" inside Queue.vue).
- Persisting play history across bot restarts (in-memory only).
## Non-functional Constraints
- History capped at 50 entries to bound memory.
- `addNext` must keep `playedIndices` and `history` index references valid
after insertion (shift all indices > current by +1).
- New Toast UX from the previous round still applies (failures surface).
- TypeScript and existing test suite must not regress.
## Architecture
### A. History-aware `prev`
**`src/audio/queue.ts`** — `PlayQueue` gains a back-stack:
```ts
private history: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
if (idx < 0) return;
this.history.push(idx);
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
this.history.shift();
}
}
```
Mutators call `pushHistory(this.currentIndex)` **before** changing `currentIndex`:
| Method | History action |
|---|---|
| `play()` | `this.history = []` (fresh playback) |
| `playAt(idx)` | `pushHistory(currentIndex)`, then set `currentIndex = idx` |
| `next()` | `pushHistory(currentIndex)`, then advance per mode |
| `prev()` | **Pop** from history → `currentIndex = popped`. If empty, fall back to existing `currentIndex - 1` (which keeps Sequential's wrap behavior; Random returns null). `prev` itself does NOT push to history. |
| `clear()` | `this.history = []` |
| `setMode(m)` | `this.history = []` (mode change resets context) |
| `remove(idx)` | Drop matching entries from history; shift any entry `> idx` by `-1`. Same logic as the existing `playedIndices` rebuild. |
**`prev()` rewrite:**
```ts
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// History-driven path (preferred when we have one)
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices.add(idx);
return this.songs[idx];
}
// popped index is stale (song removed) — keep popping
}
// Fallback: old index-based prev
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
```
### B. Play Next (insert after current)
**`PlayQueue.addNext(song)`:**
```ts
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
this.songs.push(song);
return;
}
const insertAt = this.currentIndex + 1;
this.songs.splice(insertAt, 0, song);
// Shift any tracked index > currentIndex by +1
const shifted = new Set<number>();
for (const i of this.playedIndices) {
shifted.add(i > this.currentIndex ? i + 1 : i);
}
this.playedIndices = shifted;
this.history = this.history.map((i) => (i > this.currentIndex ? i + 1 : i));
}
```
**Backend endpoint** — `src/web/api/player.ts`:
```
POST /api/player/:botId/play-next-song
body: { song: Song }
```
Behavior:
- If queue is empty or `currentIndex < 0`: `queue.addNext(song)` (which falls
through to plain push), then `queue.play()`, then `resolveAndPlay`. Same
semantics as a successful `/play-song` — message: "正在播放:…"
- Otherwise: `queue.addNext(song)`, no resolveAndPlay. Message: "已加入下一首:…"
- Returns `{ ok: boolean, message: string }` matching the convention
established in the previous round.
**Bot command** — `src/bot/instance.ts`:
Register `!playnext <query>` (alias `!pn`):
- Mirror of `cmdPlay`'s search step
- On match: `queue.addNext(song)`. If no current playback, fall through to
`resolveAndPlay`.
- Reply with `已加入下一首:<name>` or `正在播放:<name>` accordingly
**Frontend store action** — `web/src/stores/player.ts`:
```ts
async playNextSong(song: Song) {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
}
```
**Frontend SongCard** — `web/src/components/SongCard.vue`:
Add a third action button between the existing "play" and "add to queue":
```vue
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<Icon icon="mdi:playlist-play" />
</button>
```
Add `playNext: []` to `defineEmits`.
**Caller updates** — `Home.vue`, `Library.vue`, `Search.vue`, `History.vue`,
`Playlist.vue`: each `<SongCard>` usage adds
`@playNext="store.playNextSong(song)"`.
**Queue.vue** is intentionally **not** updated — clicking "play next" on a
song already in the queue would create a confusing duplicate.
## Edge Cases
| Case | Behavior |
|---|---|
| `prev` with empty history in Sequential mode | Walks `currentIndex - 1`; returns null at index 0 (existing) |
| `prev` with empty history in Random/RandomLoop | Returns null (no past to recover) |
| Repeated `prev` past start of history | Pops what's there, then falls back to index walk; eventually null |
| `addNext` while `currentIndex == -1` (nothing played yet) | Falls through to push; queue.play() will pick it as first |
| `addNext` while playing and queue size = 1 | Inserts at index 1; current index unchanged; next() will advance to it |
| `remove` removes a song whose index is in history | Entry dropped; shifted accordingly |
| Mode switched mid-playback | History cleared (intentional — mode change is a context boundary) |
| `addNext` then `prev` | Inserted song was never played → not in history; prev pops the previously-played song, NOT the just-inserted one |
## Files Touched
- `src/audio/queue.ts` — history field, `pushHistory`, `addNext`, rewritten `prev`, mutator updates
- `src/audio/queue.test.ts` (or add if missing) — unit tests for history behavior + addNext shift logic
- `src/bot/instance.ts` — register `!playnext` / `!pn` command handler
- `src/web/api/player.ts` — new `/play-next-song` route
- `web/src/stores/player.ts` — `playNextSong` action
- `web/src/components/SongCard.vue` — third action button + emit
- `web/src/views/Home.vue` — wire `@playNext`
- `web/src/views/Library.vue` — wire `@playNext`
- `web/src/views/Search.vue` — wire `@playNext`
- `web/src/views/History.vue` — wire `@playNext`
- `web/src/views/Playlist.vue` — wire `@playNext`
## Test Plan
**Unit (vitest, `queue.test.ts`):**
- prev with empty history in Sequential: walks back, null at index 0
- prev with empty history in Random: returns null
- next → next → next → prev pops correctly; prev again pops earlier
- prev after `clear()` returns null (history reset)
- prev after `setMode()` returns null (history reset)
- `remove(idx)` drops from history and shifts entries > idx
- `addNext` while empty: appends
- `addNext` while playing index 2 in a 5-song queue: ends up at index 3, currentIndex still 2, queue size 6
- `addNext` then `next()`: plays the inserted song
- `addNext` shifts existing playedIndices and history correctly
**Integration (manual smoke):**
- Random mode: play 4 songs, hit `prev` 3 times → walks back through history
- Click "下一首播放" on a search result → next song after current is the chosen one
- `!playnext 七里香` → bot replies "已加入下一首:..."; current keeps playing; next song is 七里香
- `!playnext` while idle → starts playing immediately
**Regression:**
- Existing 161 source-tree tests still pass.
- TypeScript `tsc --noEmit` and `npm run build:web` clean.
+17 -6
View File
@@ -1,14 +1,16 @@
# TSMusicBot — Docker Compose # TSMusicBot — Docker Compose
# 一键部署:docker-compose up -d # 一键部署:docker-compose pull && docker-compose up -d
# #
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)无需安装任何额外软件 # 默认从 GitHub Container Registry 拉取预构建镜像(amd64 + arm64),
# FFmpeg 使用系统包管理器安装,确保容器内兼容性 # 无需本地编译,无需 Node.js / 构建工具链。
# 镜像内置:Node.js, FFmpeg, Opus 编码器,原生模块均已交叉编译。
#
# 如需指定版本,把 :latest 换成具体 tag(例如 :1.4.0)。
# 如需本地构建(开发或 fork),见底部注释。
services: services:
tsmusicbot: tsmusicbot:
build: image: ghcr.io/zhangtianyao1/teamspeak-music-bot:latest
context: ../..
dockerfile: scripts/docker/Dockerfile
container_name: tsmusicbot container_name: tsmusicbot
# Use host network so the bot can reach TS3 server on LAN # Use host network so the bot can reach TS3 server on LAN
# If your TS3 server is on the same machine, this is required # If your TS3 server is on the same machine, this is required
@@ -27,3 +29,12 @@ services:
volumes: volumes:
tsmusicbot-data: tsmusicbot-data:
driver: local driver: local
# ------------------------------------------------------------------
# 本地构建(仅开发 / fork 场景使用):
# 把上面的 `image:` 行删掉,换成下面的 build 块即可。
#
# build:
# context: ../..
# dockerfile: scripts/docker/Dockerfile
# ------------------------------------------------------------------
+280 -50
View File
@@ -1,91 +1,321 @@
@echo off @echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup title TSMusicBot Setup
echo ============================================
echo TSMusicBot - First-Time Setup (Windows) :: ============================================================
echo ============================================ :: TSMusicBot Setup Script (Robust Edition)
echo. :: - Auto-detect China network, switch to npmmirror
:: - Strict error checking at every step
:: - Detailed logging to setup.log
:: - Skip already-completed steps on re-run
:: ============================================================
set "SCRIPT_VERSION=2.0"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
:: Resolve project root (one level up from scripts/) :: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." 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/6" "Checking Node.js"
:: ---- Step 1: Check / install Node.js ----
where node >nul 2>&1 where node >nul 2>&1
if %errorlevel% neq 0 ( if errorlevel 1 (
echo Node.js not found. Attempting automatic installation... call :error "Node.js not found in PATH."
echo. echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
echo - https://nodejs.org/ ^(official^)
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
echo.
echo After installation:
echo 1. Close this window completely
echo 2. Open a NEW Command Prompt
echo 3. Run scripts\setup.bat again
echo.
pause
exit /b 1
)
:: Try winget first (available on Windows 10 1709+ and Windows 11) :: Check Node version >= 20
where winget >nul 2>&1 for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a"
if %errorlevel% equ 0 ( for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do (
echo Installing Node.js via winget... for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b"
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 :: Robust version parse
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
set "NODE_VER_NUM=%NODE_VER:v=%"
for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") 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%."
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
pause pause
exit /b 1 exit /b 1
) )
:: Refresh PATH so node is available in this session echo.
call refreshenv >nul 2>&1
:: If refreshenv is not available, ask user to restart :: ============================================================
where node >nul 2>&1 :: Step 2: Check npm
if %errorlevel% neq 0 ( :: ============================================================
echo. call :step "2/6" "Checking npm"
echo Node.js was installed but is not yet available in this terminal.
echo Please close this window and run setup.bat again. where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found. This is unusual since Node.js is installed."
echo Please reinstall Node.js to fix this.
pause pause
exit /b 0 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/6" "Checking network"
set "USE_MIRROR=0"
:: Try reaching npm registry with a short timeout
echo Testing connection to registry.npmjs.org...
call :log "Testing npm registry connectivity..."
:: Use curl if available (more reliable than ping for HTTPS)
where curl >nul 2>&1
if not errorlevel 1 (
curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul
set /p HTTP_CODE=<"%TEMP%\npmtest.txt"
del "%TEMP%\npmtest.txt" >nul 2>&1
if "!HTTP_CODE!"=="200" (
echo [OK] npm registry reachable.
call :log "npm registry HTTP 200 OK"
) else ( ) else (
echo winget is not available on this system. echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
echo Please install Node.js 20 LTS manually from https://nodejs.org call :log "npm registry returned: !HTTP_CODE!"
echo After installing, close this window and run setup.bat again. set "USE_MIRROR=1"
pause
exit /b 1
) )
) else ( ) else (
echo [OK] Node.js found. :: Fallback to ping
node --version ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly.
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
)
)
if "%USE_MIRROR%"=="1" (
echo.
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)...
call :log "Switching to npmmirror.com"
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1
call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1
call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
echo [OK] Mirror configured.
) )
echo. echo.
:: ---- Step 2: Install npm dependencies ---- :: ============================================================
echo Installing dependencies (this may take a few minutes)... :: Step 4: Install backend dependencies
call npm install :: ============================================================
if %errorlevel% neq 0 ( call :step "4/6" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
call :log "Existing node_modules detected, running npm install to verify"
)
echo Running: npm install ^(this can take 5-15 minutes on slow networks^)
echo Press Ctrl+C to abort.
echo.
call npm install >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo.
echo Common causes:
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
echo - Native module compile failure ^(missing Python/VS Build Tools^)
echo - Disk space full
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm install --verbose
echo. echo.
echo npm install failed. Check the error messages above.
pause pause
exit /b 1 exit /b 1
) )
echo [OK] Dependencies installed. echo [OK] Backend dependencies installed.
echo. echo.
:: ---- Step 3: Build the project ---- :: ============================================================
echo Building TypeScript project... :: Step 5: Install frontend dependencies
call npx tsc :: ============================================================
if %errorlevel% neq 0 ( call :step "5/6" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found. Repository may be incomplete."
echo Please re-clone the repository:
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
pause
exit /b 1
)
echo Running: npm install ^(in web/ directory^)
echo.
pushd web >nul
call npm install >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul
if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)."
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%\web"
echo npm install --verbose
echo.
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project (backend + frontend)
:: ============================================================
call :step "6/6" "Building project"
echo Running: npm run build
echo.
call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Build failed."
echo.
echo Check the log file for details: %LOG_FILE%
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm run build
echo. echo.
echo Build failed. Check the error messages above.
pause pause
exit /b 1 exit /b 1
) )
echo [OK] Build succeeded. echo [OK] Build succeeded.
echo. echo.
:: ---- Step 4: Create default config if missing ---- :: ============================================================
:: 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.
echo Build completed but expected output is missing.
echo Check %LOG_FILE% for details.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
:: ============================================================
:: Optional: config.json hint
:: ============================================================
if not exist "config.json" ( if not exist "config.json" (
echo Creating default config.json... echo [INFO] config.json will be auto-generated on first launch.
echo Please edit config.json with your TeamSpeak server details before starting the bot.
) else ( ) else (
echo [OK] config.json already exists. echo [OK] config.json already exists.
) )
echo. echo.
:: ---- Done ---- :: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================ echo ============================================
echo Setup complete! echo Setup Complete!
echo ============================================ echo ============================================
echo. echo.
echo To start the bot, run: scripts\start.bat echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo 3. Follow the in-browser setup wizard.
echo.
echo Setup log saved to: %LOG_FILE%
echo. echo.
pause 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
+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();
});
});
+296 -199
View File
@@ -1,15 +1,18 @@
import { spawn, execSync, type ChildProcess } from "node:child_process"; import { spawn, execSync, type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { createRequire } from "node:module"; 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 { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Logger } from "../logger.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 require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static"); 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 { function isExecutable(binPath: string): boolean {
try { try {
accessSync(binPath, constants.X_OK); 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 { function ffmpegWorks(bin: string): boolean {
try { try {
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" }); 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 = (() => { const resolvedFfmpeg: string = (() => {
// 1. Prefer system-installed FFmpeg (always compatible with the runtime) if (ffmpegWorks("ffmpeg")) return "ffmpeg";
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// 2. Fall back to bundled ffmpeg-static binary
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
// it may return a Windows .exe path if node_modules was copied cross-platform.
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false; const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
const onWindows = process.platform === "win32"; const onWindows = process.platform === "win32";
if (ffmpegPath && (onWindows === isWinPath)) { if (ffmpegPath && (onWindows === isWinPath)) {
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath;
return ffmpegPath;
} }
}
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return "ffmpeg"; return "ffmpeg";
})(); })();
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
function getFfmpegCommand(): string { function getFfmpegCommand(): string {
return resolvedFfmpeg; 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 { export interface PlayerEvents {
frame: (opusFrame: Buffer) => void; frame: (opusFrame: Buffer) => void;
trackEnd: () => void; trackEnd: () => void;
@@ -93,14 +124,18 @@ export class AudioPlayer extends EventEmitter {
private nextFrameTime = 0; private nextFrameTime = 0;
private currentUrl = ""; private currentUrl = "";
private seekOffset = 0; private seekOffset = 0;
private framesPlayed = 0; // ground truth: number of 20ms frames sent private framesPlayed = 0;
private sessionId = 0; private sessionId = 0;
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo private static readonly BUFFER_HIGH_WATER = 640 * 1024;
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s private static readonly BUFFER_LOW_WATER = 256 * 1024;
private ffmpegPaused = false; private ffmpegPaused = false;
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade) private spawnFailed = false;
private consecutiveFailures = 0; private consecutiveFailures = 0;
private static readonly MAX_CONSECUTIVE_FAILURES = 3; 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;
constructor(logger: Logger) { constructor(logger: Logger) {
super(); super();
@@ -109,112 +144,255 @@ export class AudioPlayer extends EventEmitter {
} }
play(url: string, seekSeconds = 0): void { play(url: string, seekSeconds = 0): void {
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
this.stop(); this.stop();
this.sessionId++;
const playSessionId = this.sessionId; const currentSessionId = this.sessionId;
this.currentUrl = url; this.currentUrl = url;
this.seekOffset = seekSeconds; this.seekOffset = seekSeconds;
this.framesPlayed = 0; this.framesPlayed = 0;
this.healthyFrames = 0;
this.ffmpegPaused = false; this.ffmpegPaused = false;
this.spawnFailed = false; this.spawnFailed = false;
// Prevent rapid-fire spawn attempts when ffmpeg is broken
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) { if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error( this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
);
this.state = "idle"; this.state = "idle";
this.emit("error", new Error("ffmpeg unavailable after repeated failures")); this.emit("error", new Error("ffmpeg unavailable"));
return; return;
} }
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback"); if (shouldUsePowerShellDownload(url)) {
this.playViaPowerShellDownload(url, seekSeconds, currentSessionId);
const args: string[] = []; return;
// 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"
);
} }
args.push( const args = buildFfmpegArgs(url, seekSeconds);
"-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 ffmpegBin = getFfmpegCommand(); const ffmpegBin = getFfmpegCommand();
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] }); this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
// Prevent stream errors from crashing the process const currentPid = this.ffmpeg.pid;
this.ffmpeg.stdout!.on("error", (err) => { if (currentPid) {
this.logger.warn({ err }, "FFmpeg stdout error"); globalActivePids.add(currentPid);
}); this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned");
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");
} }
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
// 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 (
if (this.sessionId !== currentSessionId) {
return;
}
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]); 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) { if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause(); this.ffmpeg.stdout.pause();
this.ffmpegPaused = true; this.ffmpegPaused = true;
} }
}); });
this.ffmpeg.on("close", (code, signal) => { this.ffmpeg.on("exit", (code, signal) => {
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed"); if (currentPid) globalActivePids.delete(currentPid);
if (this.sessionId === playSessionId) { this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
this.ffmpeg = null; // Signal frame loop that no more data is coming
// 只有当前会话的进程结束才置空变量
if (this.sessionId === currentSessionId) {
this.ffmpeg = null;
} }
}); });
this.ffmpeg.on("error", (err) => { this.ffmpeg.on("error", (err) => {
this.logger.error({ err }, "FFmpeg error"); if (this.sessionId === currentSessionId) {
if (this.sessionId === playSessionId) {
this.spawnFailed = true; this.spawnFailed = true;
this.consecutiveFailures++; this.consecutiveFailures++;
this.emit("error", err); this.emit("error", err);
} }
}); });
// Log FFmpeg stderr at info level for debugging playback issues this.state = "playing";
this.ffmpeg.stderr!.on("data", (data: Buffer) => { this.startFrameLoop();
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");
} }
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);
}); });
this.state = "playing"; this.state = "playing";
this.startFrameLoop(); this.startFrameLoop();
} }
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.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);
}
});
}
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 { private startFrameLoop(): void {
if (this.frameLoopRunning) return; if (this.frameLoopRunning) return;
this.frameLoopRunning = true; this.frameLoopRunning = true;
@@ -224,50 +402,37 @@ export class AudioPlayer extends EventEmitter {
private scheduleNextFrame(): void { private scheduleNextFrame(): void {
if (!this.frameLoopRunning) return; if (!this.frameLoopRunning) return;
const loopSessionId = this.sessionId; const loopSessionId = this.sessionId;
this.nextFrameTime += FRAME_DURATION_MS; this.nextFrameTime += FRAME_DURATION_MS;
const now = performance.now(); const delay = Math.max(0, this.nextFrameTime - performance.now());
const delay = Math.max(0, this.nextFrameTime - now);
setTimeout(() => { setTimeout(() => {
// Discard callback from a stale play session // 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 (
if (loopSessionId !== this.sessionId) return; if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return;
if (!this.frameLoopRunning) return;
if (this.state === "playing") { if (this.state === "playing") this.sendNextFrame();
this.sendNextFrame(); else if (this.state === "paused") this.nextFrameTime = performance.now();
} else if (this.state === "paused") {
this.nextFrameTime = performance.now();
}
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) { if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false; this.frameLoopRunning = false;
if (this.state !== "idle") { if (this.state !== "idle") {
this.state = "idle"; this.state = "idle";
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade if (!this.spawnFailed) {
if (this.spawnFailed) { this.consecutiveFailures = 0;
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
} else {
this.consecutiveFailures = 0; // Reset on successful track completion
this.emit("trackEnd"); this.emit("trackEnd");
} }
} }
return; return;
} }
this.scheduleNextFrame(); this.scheduleNextFrame();
}, delay); }, delay);
} }
private sendNextFrame(): void { private sendNextFrame(): void {
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return; if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES); const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(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) { if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.resume(); this.ffmpeg.stdout.resume();
this.ffmpegPaused = false; this.ffmpegPaused = false;
@@ -278,101 +443,33 @@ export class AudioPlayer extends EventEmitter {
const opusFrame = this.encoder.encode(adjusted); const opusFrame = this.encoder.encode(adjusted);
this.emit("frame", opusFrame); this.emit("frame", opusFrame);
this.framesPlayed++; this.framesPlayed++;
this.healthyFrames++;
if (this.framesPlayed === 1) { if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted"); this.consecutiveFailures = 0;
} this.healthyFrames = 0;
// Log every ~10 seconds (500 frames * 20ms = 10s)
if (this.framesPlayed % 500 === 0) {
this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress");
} }
} catch (err) { } catch (err) {
this.logger.error({ err }, "Error encoding/sending audio frame");
this.emit("error", err as Error); this.emit("error", err as Error);
} }
} }
private applyVolume(pcm: Buffer): Buffer { private applyVolume(pcm: Buffer): Buffer {
if (this.volume === 100) return Buffer.from(pcm); 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); const out = Buffer.alloc(pcm.length);
for (let i = 0; i < pcm.length; i += 2) { for (let i = 0; i < pcm.length; i += 2) {
let sample = pcm.readInt16LE(i); let sample = Math.round(pcm.readInt16LE(i) * factor);
sample = Math.round(sample * factor); out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
out.writeInt16LE(sample, i);
} }
return out; return out;
} }
/** Actual elapsed time in seconds (ground truth from frame count) */ getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
getElapsed(): number { seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); }
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; 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; }
seek(seconds: number): void { setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
if (!this.currentUrl) return; getVolume(): number { return this.volume; }
// Reject NaN/Infinity/negative — the HTTP layer validates too, but a getState(): PlayerState { return this.state; }
// 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");
}
}
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;
}
} }
+318
View File
@@ -150,6 +150,101 @@ describe("PlayQueue", () => {
expect(next).not.toBeNull(); expect(next).not.toBeNull();
}); });
it("random mode with single song returns null on next", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("1"));
queue.play();
expect(queue.next()).toBeNull();
});
it("random mode plays each song exactly once then stops", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
const played = new Set<string>();
played.add(queue.current()!.id);
for (let i = 0; i < 3; i++) {
const song = queue.next();
if (!song) break;
played.add(song.id);
}
// All 3 songs should have been played
expect(played).toEqual(new Set(["A", "B", "C"]));
// next() after all played should return null
expect(queue.next()).toBeNull();
});
it("random mode: removing currently-playing song does not skip others", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.play(); // plays A (index 0)
const second = queue.next()!; // plays some song
// Remove the currently-playing song
const curIdx = queue.getCurrentIndex();
queue.remove(curIdx);
// Remaining songs (excluding A and the removed song) should all be reachable
const played = new Set<string>();
played.add("A"); // already played via play()
played.add(second.id); // played and then removed
let song = queue.next();
while (song) {
played.add(song.id);
song = queue.next();
}
// All 4 original songs should have been played or accounted for
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
});
it("random mode: prev does not cause duplicate plays", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play(); // plays A
queue.next(); // plays B or C
queue.prev(); // go back — this song is now marked as played
// Exhaust remaining songs
const ids: string[] = [];
let song = queue.next();
while (song) {
ids.push(song.id);
song = queue.next();
}
// No song ID should appear more than once across the entire session
expect(new Set(ids).size).toBe(ids.length);
});
it("random mode: adding song mid-playback includes the new song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A
queue.next(); // plays B
// Add a new song while all existing songs have been played
queue.add(makeSong("C"));
const song = queue.next();
expect(song).not.toBeNull();
expect(song!.id).toBe("C");
// After C, should stop
expect(queue.next()).toBeNull();
});
it("random mode: setMode preserves current song as played", () => {
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A in sequential mode
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
// next() should only return B, never A again
const song = queue.next();
expect(song?.id).toBe("B");
expect(queue.next()).toBeNull();
});
it("random-loop mode never returns null", () => { it("random-loop mode never returns null", () => {
queue.setMode(PlayMode.RandomLoop); queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("1")); queue.add(makeSong("1"));
@@ -166,4 +261,227 @@ describe("PlayQueue", () => {
queue.playAt(2); queue.playAt(2);
expect(queue.current()?.id).toBe("3"); 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");
});
});
}); });
+107 -13
View File
@@ -20,6 +20,17 @@ export class PlayQueue {
private songs: QueuedSong[] = []; private songs: QueuedSong[] = [];
private currentIndex = -1; private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential; private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
private history: 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 { add(song: QueuedSong): void {
this.songs.push(song); this.songs.push(song);
@@ -29,6 +40,34 @@ export class PlayQueue {
this.songs.push(...songs); 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 { remove(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null; if (index < 0 || index >= this.songs.length) return null;
const [removed] = this.songs.splice(index, 1); const [removed] = this.songs.splice(index, 1);
@@ -36,33 +75,51 @@ export class PlayQueue {
if (index < this.currentIndex) { if (index < this.currentIndex) {
this.currentIndex--; this.currentIndex--;
} else if (index === this.currentIndex) { } else if (index === this.currentIndex) {
// Move the pointer back by one so next() in sequential mode advances
// to the song that shifted into the removed slot. Without this, the
// shifted song is silently skipped because current() incorrectly
// returns it (even though the player is still on the removed song)
// and next() then increments past it. currentIndex may become -1,
// which is fine — it represents "no current song" and next() will
// pick index 0.
this.currentIndex--; this.currentIndex--;
} }
// Rebuild playedIndices to account for shifted indices
const newPlayed = new Set<number>();
for (const idx of this.playedIndices) {
if (idx === index) continue;
newPlayed.add(idx > index ? idx - 1 : idx);
}
this.playedIndices = newPlayed;
// Same shift logic for history — drop entries pointing at the
// removed song; shift entries > index down by 1.
this.history = this.history
.filter((idx) => idx !== index)
.map((idx) => (idx > index ? idx - 1 : idx));
return removed; return removed;
} }
clear(): void { clear(): void {
this.songs = []; this.songs = [];
this.currentIndex = -1; this.currentIndex = -1;
this.playedIndices.clear();
this.history = [];
} }
play(): QueuedSong | null { play(): QueuedSong | null {
if (this.songs.length === 0) return null; if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.history = [];
this.currentIndex = 0; this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0]; return this.songs[0];
} }
playAt(index: number): QueuedSong | null { playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return 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.currentIndex = index; this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index]; return this.songs[index];
} }
@@ -73,24 +130,31 @@ export class PlayQueue {
case PlayMode.Sequential: { case PlayMode.Sequential: {
const nextIndex = this.currentIndex + 1; const nextIndex = this.currentIndex + 1;
if (nextIndex >= this.songs.length) return null; if (nextIndex >= this.songs.length) return null;
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex; this.currentIndex = nextIndex;
return this.songs[nextIndex]; return this.songs[nextIndex];
} }
case PlayMode.Loop: { case PlayMode.Loop: {
this.pushHistory(this.currentIndex);
this.currentIndex = (this.currentIndex + 1) % this.songs.length; this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex]; return this.songs[this.currentIndex];
} }
case PlayMode.Random: { case PlayMode.Random: {
if (this.songs.length === 1) return this.songs[0]; const unplayed: number[] = [];
let nextIndex: number; for (let i = 0; i < this.songs.length; i++) {
do { if (!this.playedIndices.has(i)) unplayed.push(i);
nextIndex = Math.floor(Math.random() * this.songs.length); }
} while (nextIndex === this.currentIndex && this.songs.length > 1); if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex; this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex]; return this.songs[nextIndex];
} }
case PlayMode.RandomLoop: { case PlayMode.RandomLoop: {
if (this.songs.length === 1) { if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0; this.currentIndex = 0;
return this.songs[0]; return this.songs[0];
} }
@@ -98,6 +162,7 @@ export class PlayQueue {
do { do {
idx = Math.floor(Math.random() * this.songs.length); idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex); } while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx; this.currentIndex = idx;
return this.songs[idx]; return this.songs[idx];
} }
@@ -106,14 +171,33 @@ export class PlayQueue {
prev(): QueuedSong | null { prev(): QueuedSong | null {
if (this.songs.length === 0) return null; if (this.songs.length === 0) return null;
// 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.add(idx);
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; const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) { if (prevIndex < 0) {
// In Sequential mode, don't wrap around
if (this.mode === PlayMode.Sequential) return null; if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1; this.currentIndex = this.songs.length - 1;
} else { } else {
this.currentIndex = prevIndex; this.currentIndex = prevIndex;
} }
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex]; return this.songs[this.currentIndex];
} }
@@ -141,9 +225,19 @@ export class PlayQueue {
setMode(mode: PlayMode): void { setMode(mode: PlayMode): void {
this.mode = mode; this.mode = mode;
this.playedIndices.clear();
this.history = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
} }
getCurrentIndex(): number { getCurrentIndex(): number {
return this.currentIndex; 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([ export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help", "play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume", "playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
"artist",
]); ]);
export const ADMIN_COMMANDS = new Set([ export const ADMIN_COMMANDS = new Set([
Regular → Executable
+252 -13
View File
@@ -13,8 +13,9 @@ import {
type ParsedCommand, type ParsedCommand,
} from "./commands.js"; } from "./commands.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { BotDatabase } from "../data/database.js"; import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js"; import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
export interface BotInstanceOptions { export interface BotInstanceOptions {
id: string; id: string;
@@ -60,6 +61,10 @@ export class BotInstance extends EventEmitter {
private disconnectEmitted = false; private disconnectEmitted = false;
private voteSkipUsers = new Set<string>(); private voteSkipUsers = new Set<string>();
private isAdvancing = false; private isAdvancing = false;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
private profileManager: BotProfileManager;
private isFmMode = false;
constructor(options: BotInstanceOptions) { constructor(options: BotInstanceOptions) {
super(); super();
@@ -77,6 +82,14 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger); this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue(); this.queue = new PlayQueue();
const profileConfig = this.database.getProfileConfig(this.id);
this.profileManager = new BotProfileManager(
this.tsClient,
this.logger,
profileConfig,
options.tsOptions.nickname,
);
this.setupPlayerEvents(); this.setupPlayerEvents();
this.setupTsEvents(); this.setupTsEvents();
} }
@@ -121,6 +134,10 @@ export class BotInstance extends EventEmitter {
this.disconnectEmitted = true; this.disconnectEmitted = true;
this.emit("disconnected"); this.emit("disconnected");
}); });
this.tsClient.on("connected", () => {
this._startIdlePoller();
});
} }
async connect(): Promise<void> { async connect(): Promise<void> {
@@ -134,10 +151,12 @@ export class BotInstance extends EventEmitter {
throw new Error("Connect aborted by concurrent disconnect"); throw new Error("Connect aborted by concurrent disconnect");
} }
this.connected = true; this.connected = true;
this.profileManager.onConnect();
this.emit("connected"); this.emit("connected");
} }
disconnect(): void { disconnect(): void {
this._cancelIdleTimer();
this.player.stop(); this.player.stop();
this.connected = false; this.connected = false;
if (!this.disconnectEmitted) { if (!this.disconnectEmitted) {
@@ -147,6 +166,48 @@ export class BotInstance extends EventEmitter {
this.tsClient.disconnect(); 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> { private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand( const parsed = parseCommand(
msg.message, msg.message,
@@ -193,12 +254,15 @@ export class BotInstance extends EventEmitter {
const AUDIO_COMMANDS = new Set([ const AUDIO_COMMANDS = new Set([
"play", "play",
"add", "add",
"playnext",
"pn",
"next", "next",
"skip", "skip",
"prev", "prev",
"playlist", "playlist",
"album", "album",
"fm", "fm",
"artist",
]); ]);
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) { if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
throw new Error("Bot is not connected to TeamSpeak"); throw new Error("Bot is not connected to TeamSpeak");
@@ -208,6 +272,9 @@ export class BotInstance extends EventEmitter {
return this.cmdPlay(cmd); return this.cmdPlay(cmd);
case "add": case "add":
return this.cmdAdd(cmd); return this.cmdAdd(cmd);
case "playnext":
case "pn":
return this.cmdPlayNext(cmd);
case "pause": case "pause":
return this.cmdPause(); return this.cmdPause();
case "resume": case "resume":
@@ -238,6 +305,8 @@ export class BotInstance extends EventEmitter {
return this.cmdAlbum(cmd); return this.cmdAlbum(cmd);
case "fm": case "fm":
return this.cmdFm(); return this.cmdFm();
case "artist":
return this.cmdArtist(cmd);
case "vote": case "vote":
return this.cmdVote(msg); return this.cmdVote(msg);
case "lyrics": case "lyrics":
@@ -306,6 +375,10 @@ export class BotInstance extends EventEmitter {
platform: song.platform, platform: song.platform,
coverUrl: song.coverUrl, coverUrl: song.coverUrl,
}); });
// Update bot presence (fire-and-forget — never blocks playback)
this.profileManager.onSongChange(song).catch((err) => {
this.logger.warn({ err }, "Profile update failed after song change");
});
this.emit("stateChange"); this.emit("stateChange");
return true; return true;
} catch (err) { } catch (err) {
@@ -323,6 +396,7 @@ export class BotInstance extends EventEmitter {
const song = result.songs[0]; const song = result.songs[0];
this.queue.clear(); this.queue.clear();
this.isFmMode = false;
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
this.queue.play(); this.queue.play();
@@ -359,6 +433,39 @@ export class BotInstance extends EventEmitter {
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`; 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 { private cmdPause(): string {
this.player.pause(); this.player.pause();
this.emit("stateChange"); this.emit("stateChange");
@@ -374,6 +481,10 @@ export class BotInstance extends EventEmitter {
private cmdStop(): string { private cmdStop(): string {
this.player.stop(); this.player.stop();
this.queue.clear(); this.queue.clear();
this.isFmMode = false;
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
this.emit("stateChange"); this.emit("stateChange");
return "Stopped and queue cleared"; return "Stopped and queue cleared";
} }
@@ -387,13 +498,17 @@ export class BotInstance extends EventEmitter {
} }
private async cmdPrev(): Promise<string> { private async cmdPrev(): Promise<string> {
// 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(); const prev = this.queue.prev();
if (prev) { if (!prev) return "No previous song";
const ok = await this.resolveAndPlay(prev); const ok = await this.resolveAndPlay(prev);
if (!ok) return "Cannot play previous song"; if (ok) return `Now playing: ${prev.name} - ${prev.artist}`;
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 { private cmdVol(cmd: ParsedCommand): string {
@@ -424,6 +539,10 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string { private cmdClear(): string {
this.player.stop(); this.player.stop();
this.queue.clear(); this.queue.clear();
this.isFmMode = false;
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
this.emit("stateChange"); this.emit("stateChange");
return "Queue cleared"; return "Queue cleared";
} }
@@ -452,13 +571,48 @@ export class BotInstance extends EventEmitter {
} }
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> { 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); const provider = this.getProvider(cmd.flags);
// Determine if input is a numeric ID or a name search
const id = this.extractId(cmd.args); 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"; if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear(); this.queue.clear();
this.isFmMode = false;
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
} }
@@ -475,6 +629,7 @@ export class BotInstance extends EventEmitter {
if (songs.length === 0) return "Album is empty or not found"; if (songs.length === 0) return "Album is empty or not found";
this.queue.clear(); this.queue.clear();
this.isFmMode = false;
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
} }
@@ -496,12 +651,61 @@ export class BotInstance extends EventEmitter {
for (const song of songs) { for (const song of songs) {
this.queue.add({ ...song, platform: "netease" }); this.queue.add({ ...song, platform: "netease" });
} }
this.queue.setMode(PlayMode.Random);
this.isFmMode = true;
this.player.resetFailures();
const first = this.queue.play(); const first = this.queue.play();
if (first) await this.resolveAndPlay(first); if (first) await this.resolveAndPlay(first);
this.emit("stateChange"); this.emit("stateChange");
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`; 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> { private async cmdVote(msg?: TS3TextMessage): Promise<string> {
if (!msg) return "Vote can only be used in TeamSpeak"; if (!msg) return "Vote can only be used in TeamSpeak";
this.voteSkipUsers.add(msg.invokerUid); this.voteSkipUsers.add(msg.invokerUid);
@@ -553,15 +757,19 @@ export class BotInstance extends EventEmitter {
`${p}play -b <song> — Search from BiliBili`, `${p}play -b <song> — Search from BiliBili`,
`${p}play -y <song> — Search from YouTube (yt-dlp)`, `${p}play -y <song> — Search from YouTube (yt-dlp)`,
`${p}add <song> — Add to queue`, `${p}add <song> — Add to queue`,
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`, `${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`, `${p}next/prev — Next/previous`,
`${p}stop — Stop and clear queue`, `${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`, `${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`, `${p}queue — Show queue`,
`${p}mode <seq|loop|random|rloop> — Play mode`, `${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}album <id> — Load album`,
`${p}fm — Personal FM (NetEase)`, `${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}vote — Vote to skip`,
`${p}lyrics — Show lyrics`, `${p}lyrics — Show lyrics`,
`${p}now — Current song info`, `${p}now — Current song info`,
@@ -569,17 +777,25 @@ export class BotInstance extends EventEmitter {
].join("\n"); ].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; this.isAdvancing = true;
try { try {
this.voteSkipUsers.clear(); this.voteSkipUsers.clear();
const next = this.queue.next(); const next = this.queue.next();
let started = false;
if (next) { if (next) {
let started = await this.resolveAndPlay(next); started = await this.resolveAndPlay(next);
if (!started) { if (!started) {
// Skip to next if URL resolve fails (up to 3 retries) for (let i = 0; i < maxRetries && this.connected; i++) {
for (let i = 0; i < 3 && this.connected; i++) {
const retry = this.queue.next(); const retry = this.queue.next();
if (!retry) break; if (!retry) break;
if (await this.resolveAndPlay(retry)) { if (await this.resolveAndPlay(retry)) {
@@ -590,11 +806,30 @@ export class BotInstance extends EventEmitter {
} }
if (!started) { if (!started) {
this.player.stop(); 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 {
// 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 { } else {
this.player.stop(); this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} }
this.emit("stateChange"); this.emit("stateChange");
return started;
} finally { } finally {
this.isAdvancing = false; this.isAdvancing = false;
} }
@@ -639,6 +874,10 @@ export class BotInstance extends EventEmitter {
return this.connected; return this.connected;
} }
getProfileManager(): BotProfileManager {
return this.profileManager;
}
getIdentityExport(): string | undefined { getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport(); return this.tsClient.getIdentityExport();
} }
+450
View File
@@ -0,0 +1,450 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import axios from "axios";
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
import { HttpQueryError } from "../ts-protocol/http-query.js";
import type { ProfileConfig } from "../data/database.js";
import type { QueuedSong } from "../audio/queue.js";
import type { Logger } from "../logger.js";
const TS3_NICKNAME_MAX = 30;
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
const AVATAR_MAX_BYTES = 200 * 1024;
/** Timeout for file-transfer operations (upload / delete). */
const FILE_TRANSFER_TIMEOUT_MS = 6000;
/**
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
* away status, channel description, now-playing messages).
*
* Every update is permission-safe: if a feature fails due to insufficient
* server permissions, it silently disables itself until the next reconnect.
*/
export class BotProfileManager {
private tsClient: TS3Client;
private logger: Logger;
private config: ProfileConfig;
private defaultNickname: string;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
/**
* Monotonically increasing generation counter. Incremented on every
* onSongChange / onConnect call. Long-running operations (avatar
* download/upload) check this before committing their result — if
* the generation changed, a newer update has superseded them.
*/
private generation = 0;
constructor(
tsClient: TS3Client,
logger: Logger,
config: ProfileConfig,
defaultNickname: string,
) {
this.tsClient = tsClient;
this.logger = logger.child({ component: "profile" });
this.config = { ...config };
this.defaultNickname = defaultNickname;
}
// --- Public API ---
/**
* Called when a new song starts playing (song != null) or playback
* stops (song == null).
*
* Commands are serialized to avoid overwhelming the TS3 command queue.
* Nickname + away status are merged into a single `clientupdate` call.
*
* A generation counter guards against stale updates: if a newer
* onSongChange fires while the avatar is still downloading, the old
* update is discarded.
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
// could confuse the command matcher.
await this.updateAvatar(song?.coverUrl ?? null, gen);
if (this.generation !== gen) return; // superseded
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
await this.updateClientProperties(song);
// 3. Description (clientedit on TS3, httpQuery on TS6)
await this.updateDescription(song);
// 4. Channel description (fire-and-forget channeledit)
await this.updateChannelDescription(song);
// 5. Now-playing chat message
if (song) await this.sendNowPlayingMessage(song);
}
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
}
getConfig(): ProfileConfig {
return { ...this.config };
}
updateConfig(partial: Partial<ProfileConfig>): void {
Object.assign(this.config, partial);
}
// --- Internal update methods ---
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
try {
if (!coverUrl) {
await this.clearAvatar(gen);
return;
}
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
const thumbUrl = this.thumbnailUrl(coverUrl);
const imageBuffer = await this.downloadImage(thumbUrl);
// Check generation after the slow download — bail if superseded.
if (this.generation !== gen) return;
if (!imageBuffer || imageBuffer.length === 0) return;
if (imageBuffer.length > AVATAR_MAX_BYTES) {
this.logger.warn(
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
"Cover image still too large after resize — skipping avatar update",
);
return;
}
// Wrap the file-transfer sequence with a timeout — the TS3
// full-client file transfer can silently hang.
const start = Date.now();
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
this.logger.info(
{ bytes: imageBuffer.length, elapsedMs: Date.now() - start },
"Avatar updated",
);
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
/**
* Three-step upload. Each step is logged so the log can tell us whether
* a broken/loading avatar on the client is from:
* (a) init failing (no permission)
* (b) file transfer hanging on TCP 30033
* (c) client_flag_avatar not applying
* If (b) happens, the avatar MD5 would still be set in the past — leaving
* clients showing a placeholder. The flag is now only set after the TCP
* transfer resolves.
*/
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
const host = this.tsClient.getHost();
this.logger.debug({ bytes: imageBuffer.length, host }, "Avatar: init file transfer");
const info = await this.tsClient.fileTransferInitUpload(
0n, "/avatar", "", BigInt(imageBuffer.length), true,
);
this.logger.debug({ bytes: imageBuffer.length }, "Avatar: uploading file data");
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
const md5 = createHash("md5").update(imageBuffer).digest("hex");
this.logger.debug({ md5 }, "Avatar: setting client_flag_avatar");
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
}
private async clearAvatar(gen: number): Promise<void> {
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
try {
const text = song
? `${song.name} - ${song.artist} [${song.album}]`
: "";
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
// be misreported as success.
const result = await httpQuery.clientUpdate({ client_description: text });
this.logger.info({ status: result.status }, "Description updated");
} else {
// clientupdate rejects client_description (error 1538).
// Use clientedit on our own clid instead — this is what
// TS3AudioBot does via TSLib's ChangeDescription().
const clid = this.tsClient.getClientId();
if (clid <= 0) return;
// Use a 5s timeout — if clientedit hangs, don't block the
// remaining profile updates (channeledit, now-playing msg).
await this.withTimeout(
this.tsClient.execCommand(
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
),
5000,
);
this.logger.info("Description updated");
}
} catch (err) {
this.handleFeatureError("description", err);
}
}
/**
* Build and send a single `clientupdate` command that sets nickname
* and away status together, avoiding multiple round-trips that can
* cause command-queue timeouts on the TS3 protocol.
*
* Values are collected as raw strings/numbers. The TS6 HTTP path
* forwards them as JSON (the server expects real spaces, not `\s`);
* the TS3 wire path escapes them on the fly. Previously the code
* escaped upfront and then split the escaped string to build the
* JSON body, so TS6 received literal backslashes and silently
* rejected the update.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
const rawProps: Record<string, string | number> = {};
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
rawProps.client_nickname = this.defaultNickname;
} else {
const nickname = this.buildNickname(song);
if (nickname) {
rawProps.client_nickname = nickname;
}
}
}
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
rawProps.client_away = 0;
} else {
rawProps.client_away = 1;
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
}
}
if (Object.keys(rawProps).length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
const result = await httpQuery.clientUpdate(rawProps);
this.logger.info(
{ status: result.status, props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
} else {
// TS3 wire protocol: escape string values inline.
// sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
const parts = Object.entries(rawProps).map(([k, v]) =>
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
);
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
this.logger.info(
{ props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
}
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
this.handleFeatureError("awayStatus", err);
}
}
/**
* Build a nickname string that fits within TS3_NICKNAME_MAX.
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
* not characters.
*/
private buildNickname(song: QueuedSong): string | null {
const songInfo = `${song.name} - ${song.artist}`;
const prefix = "\u266A "; // ♪
const sep = " - ";
const suffix = `${sep}${this.defaultNickname}`;
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
if (overheadBytes >= TS3_NICKNAME_MAX) {
// Default nickname alone is too long with decoration — skip
return null;
}
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
return `${prefix}${truncated}${suffix}`;
}
/**
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
* Appends an ellipsis if truncation occurred, taking its byte cost
* into account. Never splits a multi-byte character.
*/
private truncateUtf8(str: string, maxBytes: number): string {
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
const ellipsis = "\u2026"; // …
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
const target = maxBytes - ellipsisBytes;
if (target <= 0) return ellipsis;
// Walk characters, accumulating byte length
let byteLen = 0;
let end = 0;
for (const ch of str) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (byteLen + chBytes > target) break;
byteLen += chBytes;
end += ch.length; // ch.length handles surrogate pairs
}
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
);
return;
}
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
`\u4E13\u8F91: ${song.album}`, // 专辑:
`\u5E73\u53F0: ${song.platform}`, // 平台:
];
const desc = lines.join("\\n");
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
}
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
try {
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
await this.tsClient.sendTextMessage(text);
} catch (err) {
this.handleFeatureError("nowPlayingMsg", err);
}
}
// --- Helpers ---
/**
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
* NetEase and QQ Music CDNs support URL-based image resizing.
* BiliBili and YouTube covers fall through to the size-check guard.
*/
private thumbnailUrl(url: string): string {
if (url.includes("music.126.net") || url.includes("netease")) {
return url.includes("?") ? url : `${url}?param=200y200`;
}
if (url.includes("qqmusic") || url.includes("qq.com")) {
return url.replace(/\/\d+$/, "/200");
}
if (url.includes("bilivideo") || url.includes("hdslb")) {
// BiliBili CDN supports @<w>w_<h>h suffix
return url.includes("@") ? url : `${url}@200w_200h`;
}
return url;
}
private async downloadImage(url: string): Promise<Buffer | null> {
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 8000,
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
});
return Buffer.from(resp.data);
} catch (err) {
this.logger.warn({ err, url }, "Failed to download cover image");
return null;
}
}
/** Race a promise against a timeout. */
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
),
]);
}
private handleFeatureError(
feature: keyof typeof this.permDenied,
err: unknown,
): void {
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
const status = err instanceof HttpQueryError ? err.status : undefined;
const body = err instanceof HttpQueryError ? err.body : undefined;
// Disable the feature for this session on unrecoverable errors:
// - permission / insufficient → server denies the action
// - invalid parameter → command not supported by this protocol
// - HTTP 401/403 → TS6 server rejects the API key/role
// - HTTP 400 → bad parameter; retrying on every song change is wasteful
const isUnrecoverable =
msg.includes("permission") ||
msg.includes("insufficient") ||
msg.includes("invalid parameter") ||
status === 400 ||
status === 401 ||
status === 403;
if (isUnrecoverable) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, status, body, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, status, body, err }, "Profile update failed");
}
}
}
Regular → Executable
+12
View File
@@ -13,6 +13,15 @@ export interface BotConfig {
adminGroups: number[]; adminGroups: number[];
autoReturnDelay: number; autoReturnDelay: number;
autoPauseOnEmpty: boolean; 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 { export function getDefaultConfig(): BotConfig {
@@ -28,6 +37,9 @@ export function getDefaultConfig(): BotConfig {
adminGroups: [], adminGroups: [],
autoReturnDelay: 300, autoReturnDelay: 300,
autoPauseOnEmpty: true, autoPauseOnEmpty: true,
idleTimeoutMinutes: 0,
publicUrl: "",
trustProxy: false,
}; };
} }
+1 -1
View File
@@ -68,7 +68,7 @@ describe("database", () => {
botDb.saveBotInstance(instance); botDb.saveBotInstance(instance);
const instances = botDb.getBotInstances(); const instances = botDb.getBotInstances();
expect(instances).toHaveLength(1); expect(instances).toHaveLength(1);
expect(instances[0]).toEqual(instance); expect(instances[0]).toMatchObject(instance);
expect(instances[0].autoStart).toBe(true); expect(instances[0].autoStart).toBe(true);
// Test upsert // Test upsert
+77
View File
@@ -33,6 +33,24 @@ export interface BotInstance {
identity?: string; identity?: string;
} }
export interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
avatarEnabled: true,
descriptionEnabled: true,
nicknameEnabled: true,
awayStatusEnabled: true,
channelDescEnabled: true,
nowPlayingMsgEnabled: true,
};
export interface BotDatabase { export interface BotDatabase {
db: Database.Database; db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void; addPlayHistory(entry: PlayHistoryEntry): void;
@@ -40,6 +58,8 @@ export interface BotDatabase {
saveBotInstance(instance: BotInstance): void; saveBotInstance(instance: BotInstance): void;
getBotInstances(): BotInstance[]; getBotInstances(): BotInstance[];
deleteBotInstance(id: string): boolean; deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
close(): void; close(): void;
} }
@@ -58,6 +78,20 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("serverPassword")) { if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
} }
// Profile feature flags
const profileCols = [
"profile_avatar_enabled",
"profile_description_enabled",
"profile_nickname_enabled",
"profile_away_enabled",
"profile_channel_desc_enabled",
"profile_now_playing_enabled",
];
for (const col of profileCols) {
if (!names.includes(col)) {
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
} }
function initTables(db: Database.Database): void { function initTables(db: Database.Database): void {
@@ -127,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`); const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
const selectProfileConfig = db.prepare(`
SELECT profile_avatar_enabled, profile_description_enabled,
profile_nickname_enabled, profile_away_enabled,
profile_channel_desc_enabled, profile_now_playing_enabled
FROM bot_instances WHERE id = ?
`);
const updateProfileConfig = db.prepare(`
UPDATE bot_instances SET
profile_avatar_enabled = @avatar,
profile_description_enabled = @description,
profile_nickname_enabled = @nickname,
profile_away_enabled = @away,
profile_channel_desc_enabled = @channelDesc,
profile_now_playing_enabled = @nowPlaying
WHERE id = @id
`);
return { return {
db, db,
@@ -165,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
return result.changes > 0; return result.changes > 0;
}, },
getProfileConfig(botId) {
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
return {
avatarEnabled: row.profile_avatar_enabled === 1,
descriptionEnabled: row.profile_description_enabled === 1,
nicknameEnabled: row.profile_nickname_enabled === 1,
awayStatusEnabled: row.profile_away_enabled === 1,
channelDescEnabled: row.profile_channel_desc_enabled === 1,
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
};
},
saveProfileConfig(botId, config) {
updateProfileConfig.run({
id: botId,
avatar: config.avatarEnabled ? 1 : 0,
description: config.descriptionEnabled ? 1 : 0,
nickname: config.nicknameEnabled ? 1 : 0,
away: config.awayStatusEnabled ? 1 : 0,
channelDesc: config.channelDescEnabled ? 1 : 0,
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
});
},
close() { close() {
db.close(); db.close();
}, },
Regular → Executable
+5 -1
View File
@@ -71,6 +71,7 @@ async function main() {
bilibiliProvider, bilibiliProvider,
database: db, database: db,
config, config,
configPath: CONFIG_PATH,
logger, logger,
cookieStore, cookieStore,
staticDir: STATIC_DIR, staticDir: STATIC_DIR,
@@ -78,7 +79,10 @@ async function main() {
await webServer.start(); await webServer.start();
logger.info({ webPort: config.webPort }, "TSMusicBot started"); 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 = () => { const shutdown = () => {
logger.info("Shutting down..."); logger.info("Shutting down...");
+8 -1
View File
@@ -76,7 +76,14 @@ export function createApiServerManager(
); );
} else { } else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any; 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") { if (koaApp && typeof koaApp.listen === "function") {
qqMusicServer = await new Promise<Server>((resolve, reject) => { qqMusicServer = await new Promise<Server>((resolve, reject) => {
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
+16
View File
@@ -3,6 +3,7 @@ import type {
MusicProvider, MusicProvider,
Song, Song,
Playlist, Playlist,
PlaylistDetail,
LyricLine, LyricLine,
SearchResult, SearchResult,
QrCodeResult, QrCodeResult,
@@ -319,6 +320,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[]> { async getUserPlaylists(): Promise<Playlist[]> {
// First get user ID from login status // First get user ID from login status
const statusRes = await this.api.get("/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"; platform: "netease" | "qq" | "bilibili" | "youtube";
} }
export interface PlaylistDetail {
id: string;
name: string;
description: string;
coverUrl: string;
songCount: number;
}
export interface Album { export interface Album {
id: string; id: string;
name: string; name: string;
@@ -77,4 +85,5 @@ export interface MusicProvider {
getPersonalFm?(): Promise<Song[]>; getPersonalFm?(): Promise<Song[]>;
getDailyRecommendSongs?(): Promise<Song[]>; getDailyRecommendSongs?(): Promise<Song[]>;
getUserPlaylists?(): Promise<Playlist[]>; getUserPlaylists?(): Promise<Playlist[]>;
getPlaylistDetail?(playlistId: string): Promise<PlaylistDetail | null>;
} }
+240 -16
View File
@@ -3,6 +3,7 @@ import type {
MusicProvider, MusicProvider,
Song, Song,
Playlist, Playlist,
PlaylistDetail,
LyricLine, LyricLine,
SearchResult, SearchResult,
QrCodeResult, QrCodeResult,
@@ -10,6 +11,30 @@ import type {
} from "./provider.js"; } from "./provider.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
const qqDirectApi = axios.create({
baseURL: "https://u.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/" },
});
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 { export class QQMusicProvider implements MusicProvider {
readonly platform = "qq" as const; readonly platform = "qq" as const;
private api: AxiosInstance; private api: AxiosInstance;
@@ -36,33 +61,102 @@ export class QQMusicProvider implements MusicProvider {
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20): Promise<SearchResult> {
const res = await this.api.get("/getSearchByKey", { const reqData = JSON.stringify({
params: { key: query, pageSize: limit, ...this.cookieParams }, req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: {
searchid: "1",
query,
num_per_page: Math.min(limit, 50),
},
},
}); });
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const list: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map( const songs: Song[] = list.map((s: any) => ({
(s: any) => ({ id: String(s.mid ?? s.id),
id: String(s.songmid ?? s.songid), name: s.title ?? s.name ?? "",
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "", album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0, duration: s.interval ?? 0,
coverUrl: s.albummid coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg` ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "", : "",
platform: "qq", platform: "qq",
}) }));
);
return { songs, playlists: [], albums: [] }; return { songs, playlists: [], albums: [] };
} }
async getSongUrl(songId: string, _quality?: string): Promise<string | null> { async getSongUrl(songId: string, quality?: string): Promise<string | null> {
try {
const res = await this.api.get("/getMusicPlay", { const res = await this.api.get("/getMusicPlay", {
params: { songmid: songId, ...this.cookieParams }, params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
}); });
const playUrl = res.data?.data?.playUrl?.[songId]; const playUrl = res.data?.data?.playUrl?.[songId];
return playUrl?.url || null; 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> { async getSongDetail(songId: string): Promise<Song | null> {
@@ -115,18 +209,35 @@ export class QQMusicProvider implements MusicProvider {
const cdlist = res.data?.response?.cdlist ?? []; const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return []; if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({ 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 ?? "", name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "), artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "", album: s.albumname ?? "",
duration: s.interval ?? 0, 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` ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "", : "",
platform: "qq", 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[]> { async getRecommendPlaylists(): Promise<Playlist[]> {
const res = await this.api.get("/getSongLists", { const res = await this.api.get("/getSongLists", {
params: { categoryId: 10000000, pageSize: 10, ...this.cookieParams }, params: { categoryId: 10000000, pageSize: 10, ...this.cookieParams },
@@ -255,4 +366,117 @@ export class QQMusicProvider implements MusicProvider {
return { loggedIn: false }; 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;
}
} }
+69
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { import {
Client as TS3FullClient, Client as TS3FullClient,
generateIdentity as genTS3Identity, generateIdentity as genTS3Identity,
@@ -7,9 +8,11 @@ import {
listChannels, listChannels,
listClients, listClients,
clientMove, clientMove,
fileTransferDeleteFile,
type Identity, type Identity,
type TextMessage, type TextMessage,
type ClientInfo, type ClientInfo,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client"; } from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import { import {
@@ -20,6 +23,19 @@ import { TS6HttpQuery } from "./http-query.js";
export { CODEC_OPUS_MUSIC } from "./voice.js"; export { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js"; export type { ServerProtocol } from "./protocol-detect.js";
export type { FileUploadInfo } from "@honeybbq/teamspeak-client";
/** Escape a string for use in TS3 ServerQuery-style commands. */
export function escapeTS3(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/ /g, "\\s")
.replace(/\//g, "\\/")
.replace(/\|/g, "\\p")
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r");
}
export interface TS3ClientOptions { export interface TS3ClientOptions {
host: string; host: string;
@@ -172,6 +188,9 @@ export class TS3Client extends EventEmitter {
}; };
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, { 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: { logger: {
debug: (msg) => this.logger.debug(msg), debug: (msg) => this.logger.debug(msg),
info: (msg) => this.logger.info(msg), info: (msg) => this.logger.info(msg),
@@ -278,6 +297,56 @@ export class TS3Client extends EventEmitter {
} }
} }
// --- Raw command & file transfer pass-through ---
async execCommand(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.execCommand(cmd);
}
/** Fire a command without waiting for the server's response. */
async sendCommandNoWait(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.sendCommandNoWait(cmd);
}
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
if (!this.client) throw new Error("Not connected");
return this.client.execCommandWithResponse(cmd);
}
async fileTransferInitUpload(
channelID: bigint,
path: string,
password: string,
size: bigint,
overwrite = true,
): Promise<FileUploadInfo> {
if (!this.client) throw new Error("Not connected");
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
}
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.uploadFileData(host, info, data);
}
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
if (!this.client) throw new Error("Not connected");
await fileTransferDeleteFile(this.client, channelID, paths);
}
/** The server host (needed for file transfer TCP connections). */
getHost(): string {
return this.options.host;
}
/** The current channel ID of this client. */
getChannelId(): bigint {
if (!this.client) return 0n;
return this.client.channelID();
}
private voiceFramesSent = 0; private voiceFramesSent = 0;
sendVoiceData(opusFrame: Buffer): void { sendVoiceData(opusFrame: Buffer): void {
+46 -2
View File
@@ -14,6 +14,38 @@ export interface HttpQueryResult {
body: unknown; 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. * 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( async clientUpdate(
properties: Record<string, string | number>, properties: Record<string, string | number>,
sid = 1, sid = 1,
): Promise<HttpQueryResult> { ): 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 */ /** Move a client to a channel */
Regular → Executable
+23
View File
@@ -1,11 +1,13 @@
import { Router } from "express"; import { Router } from "express";
import type { BotManager } from "../../bot/manager.js"; import type { BotManager } from "../../bot/manager.js";
import type { BotConfig } from "../../data/config.js"; import type { BotConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
export function createBotRouter( export function createBotRouter(
botManager: BotManager, botManager: BotManager,
config: BotConfig, config: BotConfig,
configPath: string,
logger: Logger logger: Logger
): Router { ): Router {
const router = Router(); const router = Router();
@@ -116,5 +118,26 @@ export function createBotRouter(
} }
}); });
// 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; return router;
} }
+7 -23
View File
@@ -168,32 +168,16 @@ export function createMusicRouter(
router.get("/playlist/:id/detail", async (req, res) => { router.get("/playlist/:id/detail", async (req, res) => {
try { try {
const provider = getProvider(req.query.platform as string); const provider = getProvider(req.query.platform as string);
// Use the playlist songs endpoint to get basic info, if (!provider.getPlaylistDetail) {
// but we also need detail info (name, cover, description). res.status(501).json({ error: "Not supported by this provider" });
// 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; return;
} }
} const detail = await provider.getPlaylistDetail(req.params.id);
if (!detail) {
res.status(404).json({ error: "Playlist not found" }); res.status(404).json({ error: "Playlist not found" });
return;
}
res.json({ playlist: detail });
} catch (err) { } catch (err) {
logger.error({ err }, "Get playlist detail failed"); logger.error({ err }, "Get playlist detail failed");
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
+136 -19
View File
@@ -249,9 +249,32 @@ export function createPlayerRouter(
return; 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(); const queue = bot.getQueueManager();
queue.clear(); queue.clear();
for (const song of songs) { for (const song of queueable) {
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
} }
@@ -265,11 +288,25 @@ export function createPlayerRouter(
first = queue.play(); first = queue.play();
} }
if (first) { // If the first picked song can't resolve (e.g., QQ song with no
await bot.resolveAndPlay(first); // 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) { } catch (err) {
logger.error({ err }, "Play playlist failed"); logger.error({ err }, "Play playlist failed");
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
@@ -277,35 +314,94 @@ export function createPlayerRouter(
}); });
// Play a single song by ID — resolves URL on demand // Play a single song by ID — resolves URL on demand
router.post("/:botId/play-by-id", async (req, res) => { router.post("/:botId/play-song", async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { songId, platform } = req.body; const { song } = req.body;
const provider = bot.getProviderFor( if (!song || !song.id || !song.platform) {
platform === "bilibili" || platform === "qq" || platform === "youtube" res.status(400).json({ error: "song object with id and platform is required" });
? platform
: "netease"
);
const song = await provider.getSongDetail(songId);
if (!song) {
res.json({ message: "Song not found" });
return; return;
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
queue.clear(); queue.clear();
queue.add({ ...song, platform: provider.platform }); queue.add(song);
queue.play(); queue.play();
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) { if (!ok) {
res.json({ message: `Cannot play: ${song.name}` }); res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
return; 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) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
} }
@@ -343,6 +439,27 @@ export function createPlayerRouter(
} }
}); });
// --- Profile config endpoints ---
router.get("/:botId/profile", (req, res) => {
const bot = (req as any).bot;
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
pm.updateConfig(req.body);
if (database) {
database.saveProfileConfig(bot.id, pm.getConfig());
}
res.json(pm.getConfig());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/history", (req, res) => { router.get("/:botId/history", (req, res) => {
if (!database) { if (!database) {
res.json({ history: [] }); res.json({ history: [] });
Regular → Executable
+12 -1
View File
@@ -22,6 +22,7 @@ export interface WebServerOptions {
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
configPath: string;
logger: Logger; logger: Logger;
cookieStore?: CookieStore; cookieStore?: CookieStore;
staticDir?: string; staticDir?: string;
@@ -37,11 +38,21 @@ export function createWebServer(options: WebServerOptions): WebServer {
const server = http.createServer(app); const server = http.createServer(app);
const logger = options.logger.child({ component: "web" }); const logger = options.logger.child({ component: "web" });
if (options.config.trustProxy) {
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
app.set("trust proxy", true);
}
app.use(express.json()); app.use(express.json());
app.get("/api/config/public-url", (_req, res) => {
const raw = (options.config.publicUrl ?? "").trim();
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
});
app.use( app.use(
"/api/bot", "/api/bot",
createBotRouter(options.botManager, options.config, logger) createBotRouter(options.botManager, options.config, options.configPath, logger)
); );
app.use( app.use(
"/api/music", "/api/music",
-57
View File
@@ -797,9 +797,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -821,9 +818,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -845,9 +839,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -869,9 +860,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -893,9 +881,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -917,9 +902,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1088,9 +1070,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1105,9 +1084,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1122,9 +1098,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1139,9 +1112,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1156,9 +1126,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1173,9 +1140,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1190,9 +1154,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1207,9 +1168,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1224,9 +1182,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1241,9 +1196,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1258,9 +1210,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1275,9 +1224,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1292,9 +1238,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
+177 -2
View File
@@ -5,32 +5,89 @@
<RouterView /> <RouterView />
</main> </main>
<Player /> <Player />
<Toast />
<!-- 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>
<button class="m-player-btn" @click.stop="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
<Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="m-player-btn" @click.stop="playerStore.next()">
<Icon icon="mdi:skip-next" />
</button>
</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> </div>
</template> </template>
<script setup lang="ts"> <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 { usePlayerStore } from './stores/player.js';
import { useWebSocket } from './composables/useWebSocket.js'; import { useWebSocket } from './composables/useWebSocket.js';
import Navbar from './components/Navbar.vue'; import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue'; import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue';
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const theme = computed(() => playerStore.theme); const theme = computed(() => playerStore.theme);
const route = useRoute();
const router = useRouter();
const { connect } = useWebSocket(); const { connect } = useWebSocket();
const currentSong = computed(() => playerStore.currentSong);
const mobileProgressPct = ref(0);
let syncTimer: ReturnType<typeof setInterval> | null = null; 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);
}
onMounted(() => { onMounted(() => {
playerStore.loadTheme(); playerStore.loadTheme();
connect(); connect();
playerStore.fetchBots(); playerStore.fetchBots();
// Periodically sync elapsed time from server (ground truth)
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000); syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress);
}); });
onUnmounted(() => { onUnmounted(() => {
if (syncTimer) clearInterval(syncTimer); if (syncTimer) clearInterval(syncTimer);
if (mobileRaf !== null) cancelAnimationFrame(mobileRaf);
}); });
</script> </script>
@@ -47,5 +104,123 @@ onUnmounted(() => {
@media (max-width: 1336px) { @media (max-width: 1336px) {
padding: 80px 5vw 80px; 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;
}
}
.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-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: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
opacity: 0.85;
flex-shrink: 0;
}
// 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> </style>
+369 -102
View File
@@ -5,6 +5,7 @@
<div class="nav-links"> <div class="nav-links">
<RouterLink to="/" class="nav-link" active-class="active">发现</RouterLink> <RouterLink to="/" class="nav-link" active-class="active">发现</RouterLink>
<RouterLink to="/search" 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> <RouterLink to="/history" class="nav-link" active-class="active">播放历史</RouterLink>
</div> </div>
@@ -19,38 +20,68 @@
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" /> <Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
</button> </button>
<div v-if="dropdownOpen" class="bot-dropdown"> <div v-if="dropdownOpen" class="bot-dropdown">
<div class="bot-dropdown-header">机器人</div>
<div <div
v-for="bot in store.bots" v-for="bot in store.bots"
:key="bot.id" :key="bot.id"
class="bot-dropdown-row" class="bot-card"
>
<button
class="bot-dropdown-item"
:class="{ active: bot.id === store.activeBotId }" :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-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-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.paused" class="bot-paused-badge">已暂停</span>
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span> <span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
<span v-else class="bot-offline-badge">离线</span> <span v-else class="bot-offline-badge">离线</span>
</button> </div>
<div class="bot-card-controls">
<button <button
class="bot-power-btn" v-if="bot.connected"
:class="{ online: bot.connected }" class="bot-ctrl-btn danger"
:title="bot.connected ? `停止 ${bot.name}` : `启动 ${bot.name}`"
:disabled="togglingBots[bot.id]" :disabled="togglingBots[bot.id]"
@click.stop="togglePower(bot)" @click.stop="togglePower(bot)"
> >
<Icon :icon="bot.connected ? 'mdi:power' : 'mdi:power-off'" /> <Icon icon="mdi:link-off" /> 断开
</button> </button>
<button class="bot-link-btn" :title="`复制 ${bot.name} 的专属链接`" @click.stop="copyBotLink(bot.id)"> <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" /> <Icon icon="mdi:link-variant" />
</button> </button>
</div> </div>
<div class="bot-dropdown-divider" /> </div>
<div class="bot-dropdown-hint">点击切换 · 🔗 复制专属链接</div>
</div> </div>
</div> </div>
@@ -59,10 +90,30 @@
</RouterLink> </RouterLink>
</div> </div>
</nav> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'; import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore } from '../stores/player.js';
@@ -71,17 +122,92 @@ const activeBot = computed(() => store.activeBot);
const dropdownOpen = ref(false); const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null); const selectorRef = ref<HTMLElement | null>(null);
const togglingBots = ref<Record<string, boolean>>({}); 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) { function selectBot(id: string) {
store.setActiveBotId(id); store.setActiveBotId(id);
dropdownOpen.value = false; dropdownOpen.value = false;
} }
function copyBotLink(id: string) { function resolveBaseUrl(): string {
const url = `${window.location.origin}/bot/${id}`; const base = publicBaseUrl.value;
navigator.clipboard.writeText(url).then(() => { 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; 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 }) { async function togglePower(bot: { id: string; connected: boolean; name: string }) {
@@ -108,6 +234,7 @@ function onClickOutside(e: MouseEvent) {
onMounted(() => { onMounted(() => {
document.addEventListener('click', onClickOutside); document.addEventListener('click', onClickOutside);
loadPublicBaseUrl();
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -131,6 +258,11 @@ onUnmounted(() => {
@media (max-width: 1336px) { @media (max-width: 1336px) {
padding: 0 5vw; padding: 0 5vw;
} }
@media (max-width: 768px) {
padding: 0 16px;
height: 52px;
}
} }
.logo { .logo {
@@ -138,11 +270,20 @@ onUnmounted(() => {
font-weight: 700; font-weight: 700;
color: var(--color-primary); color: var(--color-primary);
margin-right: 40px; margin-right: 40px;
@media (max-width: 768px) {
font-size: 17px;
margin-right: 0;
}
} }
.nav-links { .nav-links {
display: flex; display: flex;
gap: 24px; gap: 24px;
@media (max-width: 768px) {
display: none;
}
} }
.nav-link { .nav-link {
@@ -170,7 +311,7 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
&.online { &.online {
background: rgba(51, 94, 234, 0.15); background: var(--color-primary-15);
color: var(--color-primary); color: var(--color-primary);
opacity: 1; opacity: 1;
} }
@@ -199,12 +340,25 @@ onUnmounted(() => {
background: var(--bg-card); background: var(--bg-card);
border-color: var(--color-primary); 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 { .bot-state-mini {
font-size: 14px; font-size: 14px;
&.playing { color: #22c55e; } &.playing { color: var(--color-online); }
&.paused { color: #eab308; } &.paused { color: var(--color-paused); }
@media (max-width: 768px) {
display: none;
}
} }
.bot-chevron { .bot-chevron {
@@ -215,6 +369,10 @@ onUnmounted(() => {
&.rotated { &.rotated {
transform: rotate(180deg); transform: rotate(180deg);
} }
@media (max-width: 768px) {
display: none;
}
} }
.bot-dot { .bot-dot {
@@ -225,7 +383,12 @@ onUnmounted(() => {
flex-shrink: 0; flex-shrink: 0;
&.online { &.online {
background: #22c55e; background: var(--color-online);
}
@media (max-width: 768px) {
width: 8px;
height: 8px;
} }
} }
@@ -234,96 +397,83 @@ onUnmounted(() => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@media (max-width: 768px) {
max-width: 80px;
}
} }
.bot-dropdown { .bot-dropdown {
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
right: 0; right: 0;
min-width: 200px; min-width: 320px;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 4px; padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-dropdown);
z-index: 200; 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; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex: 1;
min-width: 0;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer; 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 { .bot-card-name {
display: flex;
align-items: center;
gap: 2px;
}
.bot-dropdown-name {
flex: 1; flex: 1;
font-size: 13px;
font-weight: 600;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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; flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 15px;
opacity: 0.4;
transition: opacity var(--transition-fast), background var(--transition-fast);
cursor: pointer;
&:hover {
opacity: 1;
background: var(--hover-bg);
}
}
.bot-power-btn {
flex-shrink: 0;
padding: 6px 8px;
border-radius: var(--radius-sm);
font-size: 16px;
opacity: 0.5;
color: var(--text-tertiary);
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
&:hover:not(:disabled) {
opacity: 1;
background: var(--hover-bg);
}
&:disabled {
opacity: 0.25;
cursor: wait;
}
&.online {
color: #22c55e;
opacity: 0.9;
}
} }
.bot-playing-badge, .bot-playing-badge,
@@ -331,25 +481,25 @@ onUnmounted(() => {
.bot-idle-badge, .bot-idle-badge,
.bot-offline-badge { .bot-offline-badge {
font-size: 11px; font-size: 11px;
padding: 1px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-weight: 500; font-weight: 500;
flex-shrink: 0; flex-shrink: 0;
} }
.bot-playing-badge { .bot-playing-badge {
background: rgba(34, 197, 94, 0.15); background: var(--color-online-15);
color: #22c55e; color: var(--color-online);
} }
.bot-paused-badge { .bot-paused-badge {
background: rgba(234, 179, 8, 0.15); background: var(--color-paused-15);
color: #eab308; color: var(--color-paused);
} }
.bot-idle-badge { .bot-idle-badge {
background: rgba(51, 94, 234, 0.12); background: var(--hover-bg);
color: var(--color-primary); color: var(--text-secondary);
} }
.bot-offline-badge { .bot-offline-badge {
@@ -357,17 +507,47 @@ onUnmounted(() => {
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.bot-dropdown-divider { .bot-card-controls {
height: 1px; display: flex;
background: var(--border-color); gap: 6px;
margin: 4px 0;
} }
.bot-dropdown-hint { .bot-ctrl-btn {
padding: 4px 12px 6px; 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; font-size: 11px;
color: var(--text-tertiary); font-weight: 500;
text-align: center; 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 { .settings-btn {
@@ -375,5 +555,92 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
&:hover { opacity: 1; } &: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);
}
}
} }
</style> </style>
+8 -4
View File
@@ -206,6 +206,10 @@ function cycleMode() {
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; z-index: 100;
@media (max-width: 768px) {
display: none;
}
} }
.player-bar { .player-bar {
@@ -326,12 +330,12 @@ function cycleMode() {
.bot-badge { .bot-badge {
display: inline-block; display: inline-block;
font-size: 10px; font-size: var(--fs-micro);
font-weight: 600; font-weight: var(--fw-semi);
padding: 0 5px; padding: 0 5px;
background: rgba(51, 94, 234, 0.15); background: var(--color-primary-15);
color: var(--color-primary); color: var(--color-primary);
border-radius: 3px; border-radius: var(--radius-xs);
line-height: 16px; line-height: 16px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
+26 -1
View File
@@ -3,6 +3,14 @@
<div class="queue-header"> <div class="queue-header">
<h3 class="queue-title">播放队列</h3> <h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span> <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')"> <button class="close-btn" @click="$emit('close')">
<Icon icon="mdi:close" /> <Icon icon="mdi:close" />
</button> </button>
@@ -70,6 +78,15 @@ async function removeSong(index: number) {
// Ignore // Ignore
} }
} }
async function clearAndStop() {
try {
await store.stop();
await store.fetchQueue();
} catch {
// Ignore
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -117,6 +134,14 @@ async function removeSong(index: number) {
&:hover { opacity: 1; } &: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 { .queue-empty {
padding: 40px 20px; padding: 40px 20px;
text-align: center; text-align: center;
@@ -146,7 +171,7 @@ async function removeSong(index: number) {
} }
&.active { &.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="播放"> <button class="action-btn" @click.stop="$emit('play')" title="播放">
<Icon icon="mdi:play" /> <Icon icon="mdi:play" />
</button> </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="添加到队列"> <button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
<Icon icon="mdi:playlist-plus" /> <Icon icon="mdi:playlist-plus" />
</button> </button>
@@ -28,15 +31,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import CoverArt from './CoverArt.vue'; import CoverArt from './CoverArt.vue';
import { Song } from '../stores/player.js';
defineProps<{ defineProps<{
song: { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }; song: Song;
index: number; index: number;
active?: boolean; active?: boolean;
}>(); }>();
defineEmits<{ defineEmits<{
play: []; play: [];
playNext: [];
add: []; add: [];
}>(); }>();
@@ -63,7 +68,7 @@ function formatDuration(seconds: number): string {
} }
&.active { &.active {
background: rgba(51, 94, 234, 0.1); background: var(--color-primary-10);
} }
} }
@@ -96,31 +101,31 @@ function formatDuration(seconds: number): string {
.platform-badge { .platform-badge {
flex-shrink: 0; flex-shrink: 0;
font-size: 10px; font-size: var(--fs-micro);
font-weight: 600; font-weight: var(--fw-semi);
padding: 1px 5px; padding: 1px 5px;
border-radius: 3px; border-radius: var(--radius-xs);
line-height: 1.4; line-height: 1.4;
} }
.badge-netease { .badge-netease {
background: rgba(232, 17, 35, 0.15); background: var(--brand-netease-15);
color: #e81123; color: var(--brand-netease);
} }
.badge-qq { .badge-qq {
background: rgba(18, 183, 106, 0.15); background: var(--brand-qq-15);
color: #12b76a; color: var(--brand-qq);
} }
.badge-bilibili { .badge-bilibili {
background: rgba(0, 161, 214, 0.15); background: var(--brand-bilibili-15);
color: #00a1d6; color: var(--brand-bilibili);
} }
.badge-youtube { .badge-youtube {
background: rgba(255, 0, 0, 0.12); background: var(--brand-youtube-12);
color: #ff0000; color: var(--brand-youtube);
} }
.song-artist { .song-artist {
@@ -151,6 +156,15 @@ function formatDuration(seconds: number): string {
transition: opacity var(--transition-fast); 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 { .action-btn {
font-size: 18px; font-size: 18px;
padding: 4px; 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>
+5
View File
@@ -13,6 +13,11 @@ const router = createRouter({
name: 'search', name: 'search',
component: () => import('../views/Search.vue'), component: () => import('../views/Search.vue'),
}, },
{
path: '/library',
name: 'library',
component: () => import('../views/Library.vue'),
},
{ {
path: '/playlist/:id', path: '/playlist/:id',
name: 'playlist', name: 'playlist',
+130 -23
View File
@@ -8,9 +8,11 @@ export interface Song {
album: string; album: string;
duration: number; duration: number;
coverUrl: string; coverUrl: string;
platform: 'netease' | 'qq'; platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
} }
export type Source = 'netease' | 'qq';
export interface BotStatus { export interface BotStatus {
id: string; id: string;
name: string; name: string;
@@ -54,12 +56,17 @@ export const usePlayerStore = defineStore('player', {
timings: {} as Record<string, TimingState>, timings: {} as Record<string, TimingState>,
theme: 'dark' as 'dark' | 'light', theme: 'dark' as 'dark' | 'light',
// Home page cache // Home page cache, split by source
recommendPlaylists: [] as PlaylistItem[], recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
dailySongs: [] as Song[], dailySongs: { netease: [] as Song[], qq: [] as Song[] },
userPlaylists: [] as PlaylistItem[], userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[], bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false },
lastFetchTime: 0, 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: { getters: {
@@ -91,6 +98,13 @@ export const usePlayerStore = defineStore('player', {
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration); if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, 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: { actions: {
@@ -259,6 +273,30 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction(); 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') { async addToQueue(query: string, platform = 'netease') {
if (!this.activeBotId) return; if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform }); await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
@@ -269,9 +307,17 @@ export const usePlayerStore = defineStore('player', {
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform }); 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') { async playPlaylist(playlistId: string, platform = 'netease') {
if (!this.activeBotId) return; 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._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction(); this._syncAfterAction();
}, },
@@ -338,31 +384,92 @@ export const usePlayerStore = defineStore('player', {
}, },
async fetchHomeData() { 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; return;
} }
const [playlistRes, dailyRes, userRes, biliRes] = await Promise.allSettled([ // 2. NetEase data: recommend playlists work anonymously; daily/user
axios.get('/api/music/recommend/playlists'), // playlists need login but Promise.allSettled isolates failures.
axios.get('/api/music/recommend/songs'), const neteasePromises = [
axios.get('/api/music/user/playlists'), axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
axios.get('/api/music/bilibili/popular?limit=12'), 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') { const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
this.recommendPlaylists = playlistRes.value.data.playlists;
} this.recommendPlaylists.netease =
if (dailyRes.status === 'fulfilled') { neRecPL.status === 'fulfilled' ? (neRecPL.value.data.playlists ?? []) : [];
this.dailySongs = dailyRes.value.data.songs; this.dailySongs.netease =
} neDaily.status === 'fulfilled' ? (neDaily.value.data.songs ?? []) : [];
if (userRes.status === 'fulfilled') { this.userPlaylists.netease =
this.userPlaylists = userRes.value.data.playlists; neUserPL.status === 'fulfilled' ? (neUserPL.value.data.playlists ?? []) : [];
} this.recommendPlaylists.qq =
if (biliRes.status === 'fulfilled') { qqRecPL.status === 'fulfilled' ? (qqRecPL.value.data.playlists ?? []) : [];
this.bilibiliPopular = biliRes.value.data.songs; 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 ?? [];
} }
// 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(); 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
}
}
+65 -2
View File
@@ -1,33 +1,95 @@
// YesPlayMusic-inspired design tokens // TSMusicBot Design System — canonical tokens
:root { :root {
// Fonts // Typography
--font-primary: 'Barlow', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; --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 // 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; --navbar-height: 56px;
--player-height: 56px; --player-height: 56px;
--page-gutter: 10vw;
// Radius // Radius
--radius-xs: 3px;
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 10px; --radius-md: 10px;
--radius-lg: 14px; --radius-lg: 14px;
--radius-xl: 20px; --radius-xl: 20px;
--radius-full: 9999px;
// Transitions // Transitions
--transition-fast: 0.2s ease; --transition-fast: 0.2s ease;
--transition-normal: 0.3s 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 // Accent
--color-primary: #335eea; --color-primary: #335eea;
--color-primary-bg: #eaeffd; --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'] { [data-theme='dark'] {
--bg-primary: #222222; --bg-primary: #222222;
--bg-secondary: #323232; --bg-secondary: #323232;
--bg-card: rgba(255, 255, 255, 0.04); --bg-card: rgba(255, 255, 255, 0.04);
--bg-navbar: rgba(34, 34, 34, 0.86); --bg-navbar: rgba(34, 34, 34, 0.86);
--bg-modal-scrim: rgba(0, 0, 0, 0.55);
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.58); --text-secondary: rgba(255, 255, 255, 0.58);
--text-tertiary: rgba(255, 255, 255, 0.38); --text-tertiary: rgba(255, 255, 255, 0.38);
@@ -40,6 +102,7 @@
--bg-secondary: #f5f5f7; --bg-secondary: #f5f5f7;
--bg-card: rgba(0, 0, 0, 0.02); --bg-card: rgba(0, 0, 0, 0.02);
--bg-navbar: rgba(255, 255, 255, 0.86); --bg-navbar: rgba(255, 255, 255, 0.86);
--bg-modal-scrim: rgba(0, 0, 0, 0.35);
--text-primary: #000000; --text-primary: #000000;
--text-secondary: rgba(0, 0, 0, 0.58); --text-secondary: rgba(0, 0, 0, 0.58);
--text-tertiary: rgba(0, 0, 0, 0.38); --text-tertiary: rgba(0, 0, 0, 0.38);
+6 -3
View File
@@ -19,8 +19,9 @@
:song="song" :song="song"
:index="i + 1" :index="i + 1"
:active="store.currentSong?.id === song.id" :active="store.currentSong?.id === song.id"
@play="store.play(song.name, song.platform)" @play="store.playSong(song)"
@add="store.addToQueue(song.name, song.platform)" @playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/> />
</div> </div>
</div> </div>
@@ -35,7 +36,9 @@ import SongCard from '../components/SongCard.vue';
const store = usePlayerStore(); 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); const loading = ref(true);
onMounted(async () => { onMounted(async () => {
+76 -17
View File
@@ -34,14 +34,17 @@
</section> </section>
<!-- 每日推荐 --> <!-- 每日推荐 -->
<section class="section" v-if="store.dailySongs.length > 0"> <section class="section" v-if="dailyAvailable.length > 0">
<h2 class="section-title">每日推荐</h2> <h2 class="section-title">
每日推荐
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
</h2>
<div class="daily-grid"> <div class="daily-grid">
<div <div
v-for="song in store.dailySongs.slice(0, 12)" v-for="song in (store.dailySongs[dailySourceSafe] ?? []).slice(0, 12)"
:key="song.id" :key="song.id"
class="daily-card hover-scale" 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" /> <CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div> <div class="daily-name">{{ song.name }}</div>
@@ -51,11 +54,14 @@
</section> </section>
<!-- 推荐歌单 --> <!-- 推荐歌单 -->
<section class="section" v-if="store.recommendPlaylists.length > 0"> <section class="section" v-if="recommendAvailable.length > 0">
<h2 class="section-title">推荐歌单</h2> <h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
v-for="playlist in store.recommendPlaylists" v-for="playlist in (store.recommendPlaylists[recommendSourceSafe] ?? [])"
:key="playlist.id" :key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`" :to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale" class="playlist-card hover-scale"
@@ -67,10 +73,11 @@
</section> </section>
<!-- 我的歌单 --> <!-- 我的歌单 -->
<section class="section" v-if="store.userPlaylists.length > 0"> <section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title"> <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> </h2>
<div class="playlist-grid"> <div class="playlist-grid">
<RouterLink <RouterLink
@@ -85,12 +92,12 @@
</RouterLink> </RouterLink>
</div> </div>
<button <button
v-if="store.userPlaylists.length > USER_PLAYLIST_LIMIT" v-if="currentUserPlaylists.length > USER_PLAYLIST_LIMIT"
class="expand-btn" class="expand-btn"
@click="userPlaylistsExpanded = !userPlaylistsExpanded" @click="userPlaylistsExpanded = !userPlaylistsExpanded"
> >
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" /> <Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${store.userPlaylists.length} 个歌单` }} {{ userPlaylistsExpanded ? '收起' : `展开全部 ${currentUserPlaylists.length} 个歌单` }}
</button> </button>
</section> </section>
@@ -105,7 +112,7 @@
v-for="song in store.bilibiliPopular.slice(0, 12)" v-for="song in store.bilibiliPopular.slice(0, 12)"
:key="song.id" :key="song.id"
class="daily-card hover-scale" 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" /> <CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div> <div class="daily-name">{{ song.name }}</div>
@@ -117,19 +124,60 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; 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 CoverArt from '../components/CoverArt.vue';
import SourceTabs from '../components/SourceTabs.vue';
const store = usePlayerStore(); const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20; const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false); 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(() => const visibleUserPlaylists = computed(() =>
userPlaylistsExpanded.value userPlaylistsExpanded.value
? store.userPlaylists ? currentUserPlaylists.value
: store.userPlaylists.slice(0, USER_PLAYLIST_LIMIT) : currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
); );
async function playFm() { async function playFm() {
@@ -179,6 +227,10 @@ onMounted(() => {
.section { .section {
margin-bottom: 36px; margin-bottom: 36px;
@media (max-width: 768px) {
margin-bottom: 28px;
}
} }
.section-title { .section-title {
@@ -188,6 +240,11 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@media (max-width: 768px) {
font-size: 18px;
margin-bottom: 12px;
}
} }
.section-count { .section-count {
@@ -223,7 +280,7 @@ onMounted(() => {
justify-content: center; justify-content: center;
width: 24px; width: 24px;
height: 24px; height: 24px;
background: #00a1d6; background: var(--brand-bilibili);
color: white; color: white;
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
@@ -317,6 +374,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 900px) { grid-template-columns: repeat(3, 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 { .daily-card {
@@ -348,6 +406,7 @@ onMounted(() => {
@media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (max-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 900px) { grid-template-columns: repeat(3, 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 { .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>
+32 -22
View File
@@ -31,8 +31,9 @@
:song="song" :song="song"
:index="i + 1" :index="i + 1"
:active="store.currentSong?.id === song.id" :active="store.currentSong?.id === song.id"
@play="store.playById(song.id, song.platform)" @play="store.playSong(song)"
@add="store.addToQueueById(song.id, song.platform)" @playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/> />
</div> </div>
</template> </template>
@@ -53,6 +54,8 @@ import SongCard from '../components/SongCard.vue';
const store = usePlayerStore(); const store = usePlayerStore();
const route = useRoute(); const route = useRoute();
import { Song } from '../stores/player.js';
interface PlaylistDetail { interface PlaylistDetail {
id: string; id: string;
name: string; name: string;
@@ -61,18 +64,8 @@ interface PlaylistDetail {
songCount: number; songCount: number;
} }
interface SongItem {
id: string;
name: string;
artist: string;
album: string;
duration: number;
coverUrl: string;
platform: string;
}
const playlist = ref<PlaylistDetail | null>(null); const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<SongItem[]>([]); const songs = ref<Song[]>([]);
const loading = ref(true); const loading = ref(true);
async function playAll() { async function playAll() {
@@ -85,20 +78,37 @@ onMounted(async () => {
const id = route.params.id as string; const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease'; const platform = (route.query.platform as string) || 'netease';
try { // allSettled, not Promise.all — if detail 404s but songs is fine
const [detailRes, songsRes] = await Promise.all([ // (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.
const [detailRes, songsRes] = await Promise.allSettled([
axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }), axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
axios.get(`/api/music/playlist/${id}`, { params: { platform } }), axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
]); ]);
playlist.value = detailRes.data.playlist;
songs.value = songsRes.data.songs; const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null;
} catch (err: any) { const songList = songsRes.status === 'fulfilled' ? (songsRes.value.data?.songs ?? []) : [];
console.error('Failed to load playlist:', err?.response?.status, err?.message);
if (detail) {
playlist.value = detail;
} else if (songList.length > 0) {
// Fall back to a stub built from the route + first song's cover.
playlist.value = {
id,
name: '歌单',
description: '',
coverUrl: songList[0]?.coverUrl ?? '',
songCount: songList.length,
};
} else {
playlist.value = null; playlist.value = null;
songs.value = []; if (detailRes.status === 'rejected') {
} finally { console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
loading.value = false;
} }
}
songs.value = songList;
loading.value = false;
}); });
</script> </script>
+6 -3
View File
@@ -27,8 +27,9 @@
:song="song" :song="song"
:index="i + 1" :index="i + 1"
:active="store.currentSong?.id === song.id" :active="store.currentSong?.id === song.id"
@play="store.playById(song.id, song.platform)" @play="store.playSong(song)"
@add="store.addToQueueById(song.id, song.platform)" @playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/> />
</div> </div>
@@ -50,7 +51,9 @@ const store = usePlayerStore();
const route = useRoute(); const route = useRoute();
const query = ref((route.query.q as string) || ''); 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 }>>([]); import { Song } from '../stores/player.js';
const results = ref<Song[]>([]);
const loading = ref(false); const loading = ref(false);
const searched = ref(false); const searched = ref(false);
Regular → Executable
+349 -9
View File
@@ -371,6 +371,78 @@
</div> </div>
</div> </div>
</section> </section>
<!-- Idle Timeout -->
<section class="settings-section">
<h2 class="section-title">行为设置</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
<div>
<div>闲置自动退出</div>
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
</div>
</div>
<div class="prefix-input-wrap">
<input
v-model.number="idleTimeout"
type="number"
min="0"
class="input input-sm"
style="max-width:80px"
placeholder="0"
/>
<span style="font-size:13px; opacity:0.7">分钟</span>
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
</div>
</div>
</section>
<!-- Bot Profile (TeamSpeak Behavior) -->
<section class="settings-section">
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
<div v-else class="profile-bot-list">
<div v-for="bot in store.bots" :key="bot.id" class="profile-bot">
<button
class="profile-bot-header"
:class="{ expanded: profileExpanded[bot.id] }"
@click="toggleProfileExpanded(bot.id)"
>
<Icon :icon="profileExpanded[bot.id] ? 'mdi:chevron-down' : 'mdi:chevron-right'" />
<span class="profile-bot-name">{{ bot.name }}</span>
</button>
<div v-if="profileExpanded[bot.id]" class="profile-toggles">
<div v-if="profileLoadError[bot.id]" class="profile-loading profile-error">
{{ profileLoadError[bot.id] }}
<button class="btn-link" @click="loadProfileConfig(bot.id)">重试</button>
</div>
<div v-else-if="!profileConfigs[bot.id]" class="profile-loading">加载中...</div>
<label
v-else
v-for="t in PROFILE_TOGGLES"
:key="t.key"
class="profile-toggle"
>
<div class="profile-toggle-text">
<div class="profile-toggle-label">
{{ t.label }}
<span v-if="t.warning" class="profile-warn-tag">⚠️ {{ t.warning }}</span>
</div>
<div class="profile-toggle-hint">{{ t.hint }}</div>
</div>
<input
type="checkbox"
class="profile-toggle-switch"
:checked="profileConfigs[bot.id][t.key]"
@change="updateProfile(bot.id, t.key, ($event.target as HTMLInputElement).checked)"
/>
</label>
</div>
</div>
</div>
</section>
</div> </div>
</template> </template>
@@ -661,10 +733,93 @@ async function savePrefix() {
// Prefix is saved client-side for now // Prefix is saved client-side for now
} }
// Idle timeout
const idleTimeout = ref(0);
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
} catch { /* ignore */ }
}
async function saveIdleTimeout() {
try {
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
} catch { /* ignore */ }
}
// --- Bot Profile config ---
interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
const PROFILE_TOGGLES: ReadonlyArray<{
key: keyof ProfileConfig;
label: string;
hint: string;
warning: string | null;
}> = [
{ key: 'avatarEnabled', label: '同步头像', hint: '使用专辑封面作为 bot 头像', warning: null },
{ key: 'descriptionEnabled', label: '同步个人描述', hint: '在 bot 简介里显示当前播放的歌曲', warning: null },
{ key: 'nicknameEnabled', label: '同步昵称', hint: 'bot 昵称跟着歌名变化', warning: null },
{ key: 'awayStatusEnabled', label: '走开状态', hint: '停止播放时把 bot 设为"走开"', warning: null },
{ key: 'channelDescEnabled', label: '更新频道描述', hint: '把"正在播放"信息写入频道描述', warning: '频道编辑提示音' },
{ key: 'nowPlayingMsgEnabled',label: '推送"正在播放"消息', hint: '切歌时在频道里发一条文字消息', warning: '新消息提示音' },
];
const profileConfigs = reactive<Record<string, ProfileConfig>>({});
const profileExpanded = reactive<Record<string, boolean>>({});
const profileLoadError = reactive<Record<string, string | null>>({});
async function loadProfileConfig(botId: string) {
if (profileConfigs[botId]) return;
profileLoadError[botId] = null;
try {
const res = await axios.get(`/api/player/${botId}/profile`);
// Defensive: a 200 response with non-object body (empty / proxy
// injection / etc.) would otherwise leave the row stuck on
// "加载中..." because profileConfigs[botId] would be falsy.
if (!res.data || typeof res.data !== 'object' || typeof res.data.avatarEnabled !== 'boolean') {
profileLoadError[botId] = '响应格式异常';
return;
}
profileConfigs[botId] = res.data;
} catch (err: any) {
profileLoadError[botId] = err?.response?.status === 404
? '机器人未加载'
: '加载失败,请重试';
}
}
function toggleProfileExpanded(botId: string) {
profileExpanded[botId] = !profileExpanded[botId];
if (profileExpanded[botId]) loadProfileConfig(botId);
}
async function updateProfile(botId: string, key: keyof ProfileConfig, value: boolean) {
const cfg = profileConfigs[botId];
if (!cfg) return;
const prev = cfg[key];
cfg[key] = value; // optimistic
try {
const res = await axios.put(`/api/player/${botId}/profile`, { [key]: value });
profileConfigs[botId] = res.data;
} catch {
cfg[key] = prev; // revert
}
}
onMounted(() => { onMounted(() => {
store.fetchBots(); // Refresh bot status on page visit store.fetchBots(); // Refresh bot status on page visit
checkAuthStatus(); checkAuthStatus();
loadQuality(); loadQuality();
loadIdleTimeout();
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -777,16 +932,16 @@ onUnmounted(() => {
background: var(--border-color); background: var(--border-color);
color: var(--text-tertiary); color: var(--text-tertiary);
&.online { &.online {
background: rgba(51, 94, 234, 0.15); background: var(--color-primary-15);
color: var(--color-primary); color: var(--color-primary);
} }
&.playing { &.playing {
background: rgba(76, 175, 80, 0.15); background: var(--color-online-15);
color: #4caf50; color: var(--color-online);
} }
&.paused { &.paused {
background: rgba(255, 152, 0, 0.15); background: var(--color-paused-15);
color: #ff9800; color: var(--color-paused);
} }
} }
@@ -810,7 +965,7 @@ onUnmounted(() => {
color: var(--color-primary); color: var(--color-primary);
&.bilibili-icon { &.bilibili-icon {
color: #00a1d6; color: var(--brand-bilibili);
} }
} }
@@ -822,7 +977,7 @@ onUnmounted(() => {
.account-status { .account-status {
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
&.logged { color: #4caf50; } &.logged { color: var(--color-online); }
} }
.login-methods { .login-methods {
@@ -845,7 +1000,7 @@ onUnmounted(() => {
&:hover { border-color: var(--color-primary); color: var(--color-primary); } &:hover { border-color: var(--color-primary); color: var(--color-primary); }
&.active { &.active {
background: rgba(51, 94, 234, 0.1); background: var(--color-primary-10);
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
} }
@@ -969,7 +1124,7 @@ onUnmounted(() => {
&:hover { border-color: var(--border-color); } &:hover { border-color: var(--border-color); }
&.active { &.active {
border-color: var(--color-primary); border-color: var(--color-primary);
background: rgba(51, 94, 234, 0.1); background: var(--color-primary-10);
} }
} }
@@ -1096,4 +1251,189 @@ onUnmounted(() => {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
// --- Bot Profile section ---
.profile-section-hint {
font-size: 13px;
color: var(--text-secondary);
margin: -8px 0 16px;
line-height: 1.5;
}
.empty-hint {
font-size: 13px;
color: var(--text-tertiary);
padding: 12px;
}
.profile-bot-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.profile-bot {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
}
.profile-bot-header {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: transparent;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
transition: background var(--transition-fast);
&:hover { background: var(--hover-bg); }
&.expanded { background: var(--hover-bg); }
}
.profile-bot-name {
flex: 1;
text-align: left;
}
.profile-toggles {
display: flex;
flex-direction: column;
padding: 0 16px 8px;
border-top: 1px solid var(--border-color);
}
.profile-loading {
padding: 16px 0;
font-size: 13px;
color: var(--text-tertiary);
text-align: center;
}
.profile-error {
color: var(--brand-netease); // re-uses red brand color for error state
.btn-link {
margin-left: 8px;
font-size: 13px;
color: var(--color-primary);
text-decoration: underline;
background: transparent;
cursor: pointer;
}
}
.profile-toggle {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
&:last-child { border-bottom: none; }
}
.profile-toggle-text {
flex: 1;
min-width: 0;
}
.profile-toggle-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
line-height: 1.3;
}
.profile-warn-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
background: var(--color-paused-15);
color: var(--color-paused);
border-radius: var(--radius-xs);
white-space: nowrap;
}
.profile-toggle-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
line-height: 1.4;
}
.profile-toggle-switch {
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
position: relative;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
margin: 0;
&::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-secondary);
transition: transform var(--transition-fast), background var(--transition-fast);
}
&:checked {
background: var(--color-primary);
border-color: var(--color-primary);
&::before {
transform: translateX(18px);
background: white;
}
}
}
@media (max-width: 768px) {
.profile-bot-header {
padding: 14px 12px;
font-size: 15px;
}
.profile-toggles {
padding: 0 12px 6px;
}
.profile-toggle {
gap: 12px;
padding: 14px 0;
}
.profile-toggle-switch {
width: 44px;
height: 24px;
&::before {
width: 18px;
height: 18px;
}
&:checked::before {
transform: translateX(20px);
}
}
}
</style> </style>