Uploaded local files were deleted whenever a track left the current slot,
with no check on whether the file was still needed — causing data loss in
several flows. Replace with reference-aware cleanup: a file is deleted only
once it has been played AND is no longer referenced by ANY bot's queue
(BotManager.getReferencedLocalSongIds wired into the provider via
setInUseResolver), with the sweep run AFTER each queue mutation.
Fixes:
- play-song replay no longer deletes the file it is about to play
- loop / repeat-all / prev no longer destroy uploads mid-cycle
- a shared upload queued on multiple bots is not deleted while still in use
- !play / play-playlist / play-album clean the whole replaced queue, and an
empty/failed playlist/album load keeps the previous queue + files intact
- bound disk use with an upload quota (evict oldest UNREFERENCED files)
- validate uploads by extension against the audio whitelist (never trust the
client Content-Type); the stored extension is always a known audio type
Deletion now unlinks the file FIRST and drops the record only on success,
with a bounded non-blocking retry for briefly-locked files (Windows/ffmpeg),
so a failed unlink never orphans a file or diverges index.json. The quota
never evicts the just-uploaded file, and long filenames keep their extension.
Adds src/music/local.test.ts covering the cleanup lifecycle, quota eviction,
and upload validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New guest flag playCollection (default OFF), gates play-playlist/play-album
- Keeps playNow's non-destructive semantics intact (Play All clears the queue)
- Admin-toggleable in Settings → 游客模式; default-off, backward-compatible
- Frontend: gate the 播放全部 button on the flag + surface 403 as a toast
instead of failing silently (the silent-failure half of the issue)
The queue-mutating playback routes (play-now-song, play-next-song,
add-song, play-at) read queue position synchronously, mutate the queue,
then await resolveAndPlay() which suspends at an async URL fetch before
player.play(). With no serialization, two concurrent requests (normal in
login-less guest mode) interleave: the audible song (decided by URL-fetch
latency) can disagree with queue.currentIndex (decided by sync-block
ordering), corrupting "now playing" and causing skipped/duplicate songs.
Add a per-bot async serializer (BotInstance.runExclusive) and wrap the
critical region of all four routes in it. Single-request behavior and
every response shape / validation 400 are preserved; only the critical
region moved inside runExclusive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An open guest WebSocket stamped isGuest/botScope once at upgrade and
never rechecked them, so it kept streaming bot state after an admin
disabled guest mode or narrowed guestMode.bots. setupWebSocket now
returns { cleanup, refreshGuestPolicy }; POST /api/bot/settings invokes
refreshGuestPolicy after saving a guestMode change, force-closing guest
sockets when disabled and live re-scoping them otherwise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /recommend/songs, /personal/fm, and /user/playlists read the
operator's own logged-in music account; gate them with requireNotGuest
so login-less guests cannot see the operator's recommendations, FM, or
playlists. Generic search/browse stays open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The by-id user-management handlers use findById, which has no role
filter, so supplying the synthetic GUEST_USER_ID let an admin delete,
re-role, reset-password, and read/write permissions on the shared guest
principal (privilege-escalation / DoS / credential-login holes).
- web/api/users.ts: 404-guard every :id handler against GUEST_USER_ID
(DELETE, reset-password, role, GET/PUT permissions).
- data/users.ts: defense-in-depth — setRoleIfNotLastAdmin and
deleteUserIfNotLastAdmin return "not_found" for any role=guest row.
- web/api/session.ts: wrap POST /guest createSession in try/catch so a
missing guest row yields 503 instead of an unhandled 500.
- Tests: data-layer guest-protection + users-router 404 by-id guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidated fix wave from the final whole-branch review of guest mode.
- FIX 1 (critical): gate /api/favorites mount with requireNotGuest — the
router keys off req.user.id (shared __guest__ principal), so guests could
read/write a shared favorites bucket. Added focused guest-deny tests.
- FIX 2: gate GET /api/auth/status and /api/auth/qrcode/status with
requireNotGuest so config reads no longer leak to guests.
- FIX 3: requireAuthInline in createSessionRouter now rejects guest sessions
with 401 once guest mode is disabled (mirrors createRequireAuth), so /me
stops returning guest data after an admin disables the feature.
- FIX 4: Login guest button now sits BELOW the card (auth-page flex-direction
column + guest-btn width 360px) instead of beside it.
- FIX 5: mobile mini-player transport buttons in App.vue are now per-button
gated for guests (prev/play/next/mode/volume), mirroring Player.vue.
- FIX 6: refreshed stale "gated on player.control" seek comments in Player.vue
and relabeled the now-stale quality-GET test.
npm test: 354/354 pass. npm run build: tsc + vue-tsc + vite all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
requirePermission('player.control') so the new control endpoint honors #80's
permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
/settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
two-arg signature.
#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
- addFavorite/removeFavorite now wrap axios in try/catch: a 409 (already favorited,
common on a stale heart) or 404 resyncs instead of throwing an unhandled promise
rejection; other errors surface a toast.
- fetchHomeData refreshes favorites BEFORE the TTL cache-return (was appended after
the early return, so warm-cache loads never refreshed); removed the now-redundant
trailing call. App.vue onMounted also hydrates favorites so deep-links to Search/
Playlist show correct hearts.
- favorites API: GET /check rejects non-string (array) query params with 400 instead
of a 500; POST defaults req.body to {} so a missing JSON body yields the intended 400.
- startFm() now refuses with 'Bot is not connected to TeamSpeak' before mutating the
queue, so POST /api/player/:id/fm can no longer wipe the queue and flip the bot into
FM mode while disconnected (the !fm chat command already had this guard).
- The /fm route's success detection also treats 'not connected' as a failure so the
toast type is correct.
- QQMusicProvider.setCookie() resets radarPage to 1 so a re-login with a different
account no longer inherits the previous account's radar pagination cursor.
Adds an admin/member role to WebUI auth. /api/users and /api/audit
are now gated by a requireAdmin middleware; all other authenticated
endpoints accept both roles. Schema migration defaults all existing
users to admin to preserve access. POST /api/users defaults new users
to member; first-run setup always creates an admin. Adds PATCH
/api/users/:id/role with last-admin demotion and deletion guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>