!play/!add/!playnext only ever searched with limit 1, so a same-name song could
never be reached from chat (e.g. 'Die For You' always returned the most popular
match, not The Weeknd's). Add three disambiguation paths via a shared resolver:
- !search <name> — list the top matches (numbered, with id), remembered per bot
- !play #N / !add #N — play/queue the Nth result of the last !search
- !play id:<id> and pasted NetEase/QQ/BiliBili song URLs — play an exact song
Pure parsing (parseSongRef / parseSelectionIndex) is unit-tested; plain-text
search keeps the historical top-hit behavior. WebUI search (20 results) already
allowed picking same-name songs and is unchanged.
Fixes#90
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the README to reflect everything merged recently:
- feature list: fine-grained permissions (capabilities + per-bot allow-list),
local favorites, dedicated-link scope, channel-empty auto-pause, QQ radar FM
- first-run + WebUI page table + !fm command (-q) + architecture tree (new modules)
- config section: config.json now lives in data/config.json (+ migration note),
complete the example with idleTimeoutMinutes/publicUrl/trustProxy
- FAQ: fine-grained member permissions, favorites, dedicated link, auto-pause
- changelog: new entry for the feature batch + bug fixes #84/#86/#89
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyVolume() mapped 0-100 with a two-piece, discontinuous curve: gain =
(vol/100)*0.2 for vol<100 (so the whole 0-99 range only spanned 0..0.198, making
80->99 feel flat) then a raw passthrough at vol===100 (a ~5x jump to full
loudness). That produced the reported dead zone + sudden ear-blast at 100.
Replace it with a single continuous, strictly-monotonic curve
volumeToFactor(v) = 0.2*x + 0.8*x^8 (x = v/100): 0 at 0, exactly 1.0 at 100, no
flat region and no discontinuity, so the slider feels proportional and full
loudness is still reserved at 100. Extracted as an exported pure function and
unit-tested (boundaries, strict monotonicity, dead-zone removal, no jump at 100).
Note: per the maintainer's note on #84 the >80% suppression was intentional
ear-protection; this change makes the upper range (above ~75%) audibly louder
than before in exchange for a proportional slider — applied per maintainer
decision.
Fixes#84
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues caused a long BiliBili stream to stop partway (~16 min) and never resume:
1. FFmpeg lacked -reconnect_at_eof. B站 CDN sessions can close the connection
mid-file (premature EOF); without this flag FFmpeg treats that EOF as
end-of-input and stops. Added it (HTTP only) so FFmpeg re-issues a Range
request and finishes the stream.
2. The frame loop only ended a live-but-silent FFmpeg when within 5s of the song
end (isNearEnd). Far from the end, emptyFrameAttempts grew unbounded, no
trackEnd was emitted, and audio went permanently silent ('无法继续播放').
Added a far-from-end stall watchdog (MAX_STALL_ATTEMPTS ~= 60s) via a pure,
tested shouldEndOnStall() helper, so a genuinely dead stream advances instead
of hanging — while a transient underrun on a healthy stream is left alone.
Tests: assert -reconnect_at_eof 1 is present (before -i) for HTTP and absent for
local files; shouldEndOnStall covers near-end fast end, far-from-end no-false-skip,
and far-from-end eventual recovery.
Fixes#89
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CONFIG_PATH resolved to ROOT_DIR/config.json (/app/config.json in Docker), but only
DATA_DIR (/app/data) is the mounted volume — every other artifact (DB, cookies, logs,
avatars) already lives under DATA_DIR. So on first run the default config was written
into the ephemeral image layer (never appearing in the volume), and a manually-placed
data/config.json was ignored because the bot read/wrote the root path.
- Move CONFIG_PATH to DATA_DIR/config.json so it lands in the volume and manual edits
take effect.
- Add migrateLegacyConfig(): one-time move of an existing root-level config.json into
the data dir, so existing local installs keep their settings (no silent reset).
- Tests for first-run persistence + the three migration cases.
- README directory tree updated to data/config.json.
Fixes#86
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
The checkbox @change was wired to saveIdleTimeout, which POSTed BOTH idleTimeoutMinutes
and autoPauseOnEmpty: toggling silently committed an unsaved idle edit, and an empty/
non-numeric idle field made the combined POST 400 (errors swallowed), leaving the
checkbox flipped but not persisted. Give the toggle its own saveAutoPause() sending only
the boolean; 保存 now sends only idleTimeoutMinutes.
removeBotStatus (botRemoved WS frame or admin deleting the scoped bot) left
scopedBotId dangling: isScoped stayed true, displayedBots went empty, and activeBot
silently fell back to bots[0], locking the UI onto a phantom bot. Clear the scope
when the scoped bot disappears.