- Settings → 行为设置: two new toggles (保存/加载播放清单, 单曲直接播放不清空队列)
that round-trip savedQueuesEnabled / playKeepsQueue and keep the nav gate in sync.
- New "已存队列" page (/saved-queues): save the current queue (with a 共享 option),
load (replace) / append / delete saved queues; renders a "feature disabled"
state on 403 so it degrades gracefully when the flag is off.
- Nav entry gated on the savedQueuesEnabled store flag (hidden for guests).
- useSavedQueues API composable + a pure, unit-tested list/ownership helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add three default-off capabilities that stop the play queue from being lost,
all gated behind admin/independent toggles so existing behavior is unchanged
until an operator opts in:
- Named save/load of queues (Feature 1): new saved_queues table (per-user +
reserved __shared__ owner, capped at 50 queues / 1000 songs, JSON song blob
that degrades to empty on corruption); /api/saved-queues router (list/save/
load/delete with ownership 404s, inert 403 when disabled); chat commands
!save / !load [-a] / !queues; BotInstance.loadSavedQueue (replace/append).
- Auto-restore live queue across restart (Feature 2): PlayQueue.snapshot/restore,
queue_state table (one row per bot), a debounced snapshot writer driven off
stateChange, and restore+resume on connect. Cancels the pending snapshot on
disconnect so a stale write can't wipe the row a restart must restore.
- playKeepsQueue (Feature 3): BotInstance.playSingleSong funnels chat !play and
the web /play-song route through one place; when enabled a single-song play
inserts-after-current and jumps instead of clearing the queue.
Config gains savedQueuesEnabled + playKeepsQueue (both default false, strict-
coerced on load like spotify.enabled); the settings API round-trips them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13-task TDD plan across 5 stages: config gates, playKeepsQueue seam,
named save/load (DB + API + chat + web), live-queue snapshot/restore,
and docs. Each task independently testable; all behaviors default off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design doc for issue #119: named per-user/shared save/load (chat + web),
auto-restore-and-resume of the live queue across restart (both behind an
admin-controlled savedQueuesEnabled flag, default off), and an independent
playKeepsQueue toggle so single-song !play inserts-and-plays instead of
clearing the queue. All toggles default off — no behavior change until opted in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revert the jellyfin-only default introduced by PR #123 so upgrading
users keep their online sources; Jellyfin becomes opt-in:
- default enabledProviders is now the online set (netease/qq/bilibili/
youtube/kugou); defaultPlatform() uses a fixed priority order
(netease -> qq -> kugou -> jellyfin -> bilibili -> youtube) instead
of jellyfin-first, so chat/REST/WebUI default to netease again
- Settings: Jellyfin card is always visible with a new enable toggle
(its enabled bit is enabledProviders membership); guards against
clobbering other providers before the list loads
- Setup wizard: saving the Jellyfin step auto-enables the source when
a server URL was entered
- Search/player store fallbacks flip from jellyfin to netease; !help
no longer hardcodes Jellyfin lines
- tests: update default-platform assertions, add coverage for the new
default set, legacy configs without enabledProviders, priority
order, and explicit jellyfin-only configs
- README: reframe Jellyfin as optional (badges, command table, quality
tiers, dedicated section, changelog), document the enabledProviders
default and the v1.10.0 jellyfin-only window fix, credit @ItsEricRao
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merges feature/play-history-requester (@Fa1nttt) into main.
The PR records the WebUI/TeamSpeak requester on queued songs and persists it
to play history (schema migration for requestedBy), rendering it as a badge in
SongCard (gray for 游客/guest).
Conflicts (frontend platform union) resolved to keep both 'spotify' (from #118)
and the new requestedBy/playedAt fields.
Integration fix: the Spotify playback branch in resolveAndPlay (added by #118,
which did not exist on the PR's base) also records play history — added
`requestedBy: song.requestedBy` there so Spotify tracks carry attribution too,
matching the non-Spotify path.
Verified on the merged tree: tsc --noEmit clean, full suite 1309/1309, web build clean.
Co-Authored-By: Fa1nttt <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflect the two merged PRs in the README:
- Multi-source bullet + changelog: Spotify (#112, experimental/opt-in) and
per-source search pagination "加载更多" (#115).
- !lyrics command now shows full lyrics chunked into multiple messages (#116).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
config.spotify is a single process-wide object shared by every BotInstance, so
config.spotify.deviceName was identical for all bots. On the Rust (librespot)
backend each bot spawned `librespot --name <deviceName>` with no per-bot
uniqueness, registering two Connect devices with the same name under the one
shared account. findDeviceByName()/waitForDevice() match by name, so bot A's
transfer()+play() could drive bot B's librespot (misroute) and a bot could
report ready on seeing the OTHER bot's same-named device (false readiness).
Fix: derive a per-bot-unique Connect identity from the shared base name.
- controller.ts: new exported pure helper perBotDeviceName(base, instanceId?)
(`${base}-${instanceId}` when an id is given, else base). Add optional
instanceId to SpotifyControllerOptions; buildBackend() computes the effective
name once and passes the SAME value to both the Rust and go backends so
--name, findDeviceByName, and waitForDevice all key on one identity.
- instance.ts: pass instanceId: this.id into buildController(); add instanceId
to the spotifyControllerFactory param type (test seam).
The user-configured config.spotify.deviceName base is left untouched; the suffix
applies only to the backend/Connect identity. Behavior-preserving for callers
that pass no instanceId (base name used unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rustPresent/goPresent and getBackendInfo used existsSync(findX()), which for a
bare PATH command name resolves against cwd, not $PATH — so a scoop/choco/cargo/
apt install was invisible and Spotify was gated off. Add a sync PATH-aware
resolveExecutable() + isLibrespotPresent()/isGoLibrespotPresent() in binary.ts
and route controller.ts and web/server.ts through them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>