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.
Capability flags (player.control/player.queue/bot.manage/platform.auth/quality) + per-member bot allow-list, layered under the existing member role; admin is super-user. Backend-enforced via requirePermission/requireBotAccess; existing members backfilled to full on upgrade, new members get a basic tier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
随机循环 (rloop) used true random-with-replacement, so some songs repeated constantly while others were starved (issue #70). Both random modes now draw from a shuffle bag: every song plays exactly once per cycle in random order. They differ only at cycle end — 随机 (random) stops, 随机循环 (rloop) reshuffles and continues, excluding the just-played song from the first pick of the new cycle to avoid a back-to-back repeat across the boundary. Songs added mid-cycle stay eligible within the current cycle.
随机's visible behavior is unchanged (it already avoided in-cycle repeats); the two branches now share one selection path. Adds shuffle-bag tests (per-cycle permutation, even distribution, no cross-boundary repeat, mid-cycle add).
Closes#70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>