Commit Graph
100 Commits
Author SHA1 Message Date
saopig1andClaude Fable 5 750ad9b1cc feat: make Jellyfin an optional source instead of the default
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>
2026-07-14 00:54:34 +08:00
b9767a7471 Merge PR #121: show requester names in play history
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>
2026-07-04 23:17:16 +08:00
saopig1andClaude Opus 4.8 47b29358ae docs(readme): add Spotify badge to the badge bar, marked 可选 like YouTube
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:08:16 +08:00
saopig1andClaude Opus 4.8 9f5e27a277 docs(readme): document Spotify source, search pagination, and full !lyrics
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>
2026-07-04 15:18:34 +08:00
saopig1 486c3a0a69 Merge PR #120: full !lyrics output (#116) + web search pagination (#115)
# Conflicts:
#	src/bot/instance.test.ts
2026-07-04 15:12:26 +08:00
saopig1 8359c56dc7 Merge PR #118: optional Spotify audio source (hybrid go-librespot/Rust librespot) (#112) 2026-07-04 15:08:50 +08:00
saopig1andClaude Opus 4.8 a97e72ef30 feat(search): per-source load-more pagination in Search.vue (#115 frontend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:42:07 +08:00
saopig1andClaude Opus 4.8 a1cc0b8574 feat(search): server-side offset pagination through providers + /search route (#115 backend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:36:54 +08:00
saopig1andClaude Opus 4.8 81b8953d52 fix(lyrics): send full lyrics chunked under TeamSpeak message cap (#116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:26:51 +08:00
saopig1andClaude Opus 4.8 1a3ccd0e38 fix(spotify): device-scope Rust Connect control + ignore foreign-device poll state (multi-bot) [corner-case R4-4]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:50:28 +08:00
saopig1andClaude Opus 4.8 ec0a027a1e fix(spotify): round seek position to integer ms + one-poll seek grace before near-end skip [corner-case R4-1,R4-6]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:40:21 +08:00
saopig1andClaude Opus 4.8 19306002e3 fix(spotify): go-librespot recover on sidecar death + WS-reconnect status re-sync + stable-connection backoff [corner-case R4-2,R4-3,R4-5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:08:29 +08:00
saopig1andClaude Opus 4.8 8e89a078a7 fix(spotify): atomic OAuth token store write so a crash during rotating refresh can't corrupt/lose it [corner-case R3-5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:31:00 +08:00
saopig1andClaude Opus 4.8 4cd269d852 fix(player): don't run stall/EOF end-detection while paused [corner-case R3-4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:25:23 +08:00
saopig1andClaude Opus 4.8 49a47f5e2b fix(spotify): reconcile sidecar/player state on remove-current, skip-while-paused, and queue-exhaust [corner-case R3-2,R3-3,R3-6]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:17:59 +08:00
saopig1andClaude Opus 4.8 952bd26d2d fix(spotify): confirm transient null-item over two polls before ending Rust track [corner-case R3-1]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:08:28 +08:00
saopig1andClaude Opus 4.8 7e58e8a611 fix: bound album-tracks pagination + treat non-object config as corrupt (backup, not crash) [corner-case R2 minors]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:44:33 +08:00
saopig1andClaude Opus 4.8 2e05d276bf fix(spotify): per-bot Connect device name to avoid multi-bot device collision/misroute [corner-case R2-5]
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>
2026-07-03 12:32:02 +08:00
saopig1andClaude Opus 4.8 9a32f3c602 fix(spotify): refresh Web API search provider creds on Settings save (no restart) [corner-case R2-4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:25:45 +08:00
saopig1andClaude Opus 4.8 2413a9a3a1 fix(spotify): paginate playlist/album tracks (bounded) + null-filter search tracks [corner-case R2-3,R2-6,R2-7]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:20:47 +08:00
saopig1andClaude Opus 4.8 03690952cb fix(config): atomic saveConfig + never overwrite a real config on transient/corrupt read [corner-case R2-1,R2-2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:03:01 +08:00
saopig1andClaude Opus 4.8 c8227faf31 fix(spotify): never skip a self-paused Rust track (gate near-end + null-state on !paused) [corner-case residual]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:37:55 +08:00
saopig1andClaude Opus 4.8 beda8d626c docs(spotify): document per-bot port-collision limitation + correct misleading comment [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:24:06 +08:00
saopig1andClaude Opus 4.8 08fe350e02 fix(spotify): guard non-numeric 429 Retry-After (no immediate retry) [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:22:34 +08:00
saopig1andClaude Opus 4.8 6dc99d88e2 fix(spotify): don't skip paused Rust track; handle ffmpeg stdin EPIPE; guard sub-window end-detection [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:18:47 +08:00
saopig1andClaude Opus 4.8 11c0948330 docs(spotify): document known limitations (token CLI arg, gapless elapsed) [whole-branch d1,d2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:55:34 +08:00
saopig1andClaude Opus 4.8 0914cfeb2f test(spotify): guard 429 retry bound, disclaimer copy, deviceName blank-ignore, catalog mappers [whole-branch I5,m2,m3,m4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:54:02 +08:00
saopig1andClaude Opus 4.8 d796dd48ff fix(spotify): apply UI-entered Client ID to live SpotifyOAuth without restart [whole-branch I2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:49:33 +08:00
saopig1andClaude Opus 4.8 399cf0cf41 fix(spotify): PATH-aware binary presence detection (bin/ or PATH) [whole-branch I3,m1]
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>
2026-07-03 01:45:48 +08:00
saopig1andClaude Opus 4.8 b4c3cc0539 fix(spotify): tear down in-flight backend on stop + degrade-to-skip on persistent play failure [whole-branch I1,I4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:37:49 +08:00
saopig1andClaude Opus 4.8 dc763ec689 fix(spotify): resume re-attached PCM stream so mixed-queue Spotify tracks aren't silent [whole-branch C1]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:27:55 +08:00
saopig1andClaude Opus 4.8 b9fe770ab1 feat(spotify): retry/backoff on Connect commands (device-latency/flakiness watchdog) [S4.6]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:09:00 +08:00
saopig1andClaude Opus 4.8 657c198a37 docs(spotify): README Spotify source section — setup, binaries, warnings, license [S4.5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:02:52 +08:00
saopig1andClaude Opus 4.8 488734c0db feat(spotify): Connect-Spotify settings card (config + OAuth login + status) [S4.4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:57:30 +08:00
saopig1 6e0a0f7392 fix(spotify): collapse concurrent OAuth refresh + TTL/cap PKCE verifiers [S4.3] 2026-07-03 00:50:40 +08:00
saopig1andClaude Opus 4.8 6a72833c2a feat(spotify): report resolved backend + binaryAvailable on /status; share backend resolver [S4.2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:45:55 +08:00
saopig1 b672d76634 feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1] 2026-07-03 00:41:29 +08:00
saopig1 eb3523f374 docs(spotify): stage 4 plan — config UI, OAuth hardening, Connect watchdog, docs (#112) 2026-07-03 00:38:11 +08:00
saopig1 6bde51c991 chore(spotify): stage 3 verification pass 2026-07-03 00:17:20 +08:00
saopig1andClaude Opus 4.8 8998b9623f feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6)
Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike
seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth
in index.ts (clientId/redirectUri from config; store via the already-exported
createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer
AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes
playback (C3.1). Reuses the existing file token store (no token-store.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:09:12 +08:00
saopig1andClaude Opus 4.8 322335dfc5 feat(spotify): backend selection + Rust librespot wiring in SpotifyController
Add chooseBackend() honoring config.spotify.backend (go-librespot|librespot|
auto) against platform + binary availability (auto: linux+go binary -> go;
else librespot present -> Rust; else null). Controller now owns a shared
SpotifyOAuth + SpotifyConnectApi passed to the Rust backend; isAvailable() =
enabled && a backend is selectable; the Rust path additionally gates
ensureStarted() on oauth.isAuthorized(). go-librespot path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:50:03 +08:00
saopig1andClaude Opus 4.8 efe47c5bbc fix(spotify): arm RustLibrespot track-end detection only after playTrack (no spurious startup advance)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:43:27 +08:00
saopig1andClaude Opus 4.8 35bdd2a168 feat(spotify): add RustLibrespotBackend (stdout-pipe -> ffmpeg, Connect-API control)
Implements the Stage-2 SpotifyAudioBackend over Rust librespot: spawns
librespot with --backend pipe (no --device => s16le/44100/2 on stdout, no
--passthrough), pipes stdout -> ffmpeg (44100->48000 s16le), waits for the
Connect device to register before emitting "ready", and controls playback
(transfer/play/pause/resume/seek) plus track-end/position/metadata via a
polled SpotifyConnectApi. child_process/connect/oauth/ffmpeg injected for
fully mocked, no-network unit tests (Windows-targeted; not e2e without Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:34:58 +08:00
saopig1andClaude Opus 4.8 540bf8c032 feat(spotify): add SpotifyConnectApi Web API Connect control client
Wraps an injected axios instance with a live user Bearer token from
getToken(): getDevices/findDeviceByName, transfer/play/pause/resume/seek,
and getPlaybackState (null on 204). Read-only calls degrade gracefully;
mutating calls no-op when unauthorized. Fully unit-tested with a mocked
AxiosInstance (no network) — Windows-targeted, not e2e-testable (no Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:22:54 +08:00
saopig1andClaude Opus 4.8 333f7606e4 feat(spotify): add SpotifyOAuth Authorization Code + PKCE control-token flow
Stage 3 Task 2. PKCE (S256) authorize URL, code exchange, and refresh with
rotated-refresh-token persistence + invalid_grant store-clear. axios/http and
token store injected for fully mocked, network-free unit tests.

Corrections C3.2 (require the operator's own client_id; no librespot public
client / :5588 default; buildAuthorizeUrl throws + isAuthorized false without
it) and C3.7 (delete the state->verifier map entry in a finally on every
terminal path) applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:13:05 +08:00
saopig1andClaude Opus 4.8 8c84090b63 feat(spotify): add Rust librespot binary resolver (Stage 3 Task 1)
Append isRustLibrespotSupported/pickLibrespotPath/findLibrespot/
checkLibrespotAvailable/resetLibrespotBinaryCache to binary.ts, mirroring
the go-librespot resolver. Supported on all platforms (pipe->stdout),
resolves librespot.exe on win32, caches only positive --version probes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:04:16 +08:00
saopig1andClaude Opus 4.8 3277736f5d docs(spotify): stage 3 plan — Rust librespot Windows backend (#112)
Drafted from a locked contract + research map, adversarially verified (3 critics).
REQUIRED CORRECTIONS: single shared OAuth threaded to web+controller; auth via the
user's own Developer app + web callback (drop ToS-gray librespot-client + :5588);
resolved-backend status; poll hasPlayed-gating + 204 track-end; Connect error
guarding; verifier-map cleanup. Cross-platform, gated, not e2e-testable (Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:00:08 +08:00
saopig1andClaude Opus 4.8 8bd0aae7c3 fix(spotify): loopback-bind sidecar API, recover on sidecar death, per-bot go-librespot ports
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:24:51 +08:00
saopig1andClaude Opus 4.8 9c796ec05c fix(spotify): correct Spotify seek units (s→ms) + gate re-attach on player external state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:00:56 +08:00
saopig1andClaude Opus 4.8 b5b3585e77 feat(spotify): orchestrate go-librespot backend from BotInstance (Stage 2 Task 7)
Construct one SpotifyController per bot (config.spotify + per-bot work/config
dirs under DATA_DIR, threaded via BotManager + index). resolveAndPlay now
routes spotify: sentinels through controller.ensureStarted/playTrack +
player.playPcmStream (falling back to the Stage-1 message when unavailable),
fences/pauses the sidecar on source transitions, advances via controller
"trackEnded", and delegates pause/resume/stop transport. Correction C4:
no re-attach on a spotify->spotify handoff (playPcmStream once across tracks,
no player.stop() — playPcmStream fences the prior ffmpeg internally); occupancy
auto-pause/resume + updateAutoPause + a new BotInstance.seek() (web seek route)
also delegate to the sidecar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:44:39 +08:00
saopig1andClaude Opus 4.8 179e7c248a fix(spotify): tear down errored backend in SpotifyController (no leak/cross-talk)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:30:13 +08:00
saopig1andClaude Opus 4.8 3a9504500b feat(spotify): SpotifyController backend lifecycle, gating, and event re-emission
Per-bot orchestrator: isAvailable() gates on config.enabled + platform +
binary presence; ensureStarted() starts the backend once (idempotent, retries
on failure); playTrack/pause/resume/seek/stop delegate; getPcmStream() proxies
the backend PCM; re-emits backend trackEnded/metadata. backendFactory injected
for tests (fake backend, no real binary/network).

Correction C3: the controller does not re-emit a raw "error" event (Node's
EventEmitter throws on an unhandled "error"); it logs the backend error and
marks itself not-ready so the next ensureStarted() relaunches. getPcmStream()
returns the backend's single persistent stream (no per-attach PassThrough).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:22:12 +08:00
saopig1andClaude Opus 4.8 6debe23034 feat(audio): add external-PCM mode (playPcmStream) for Spotify sidecar
Adds AudioPlayer.playPcmStream(readable, {onExternalEnd}) that feeds a
long-lived external 48kHz/s16le/stereo Readable into the existing pcmBuffer +
20ms frame loop + Opus encoder without spawning a per-URL ffmpeg. Reuses the
same high/low-water backpressure (pausing/resuming the Readable), suppresses the
underrun trackEnd drain/stall branches while external (emitting a silence frame
to keep the 20ms timeline), tears down externalMode in stop() by DETACHING the
shared readable (remove our data/end/error listeners + pause, never destroy the
sidecar stream) and clearing onExternalEnd, and makes seek() a local no-op in
external mode. The url play() path and all exported pure functions are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:14:15 +08:00
saopig1andClaude Opus 4.8 641da086e5 fix(spotify): GoLibrespotBackend start() cleanup on failure + unhandled-error guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:04:42 +08:00
saopig1andClaude Opus 4.8 9d55bea240 feat(spotify): GoLibrespotBackend sidecar (FIFO + ffmpeg PCM + REST/WS)
Implements SpotifyAudioBackend over a go-librespot sidecar: start() mkfifos
the pipe, spawns the FIFO->48k s16le ffmpeg reader BEFORE go-librespot, writes
config.yml, polls the REST /  until ready, then connects the WS event stream.
Maps not_playing/stopped -> trackEnded and metadata -> SpotifyNowPlaying;
play/pause/resume/seek delegate to the REST client. All child_process/fs/REST/WS
seams are injectable so the lifecycle is fully unit-tested without a real binary.

Correction C1: ffmpeg is resolved via getFfmpegCommand() (now exported from
src/audio/player.ts) so the bundled ffmpeg-static fallback is honored in Docker;
the ffmpeg command is overridable via deps.ffmpegCommand for tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:58:13 +08:00
saopig1andClaude Opus 4.8 f2b14b5f00 feat(spotify): add go-librespot REST client + WS event client (Stage 2)
GoLibrespotRestClient wraps axios (injectable via deps.http) for
/player/play|pause|resume|stop|seek, GET /status, GET / ping; ping/getStatus
swallow errors to false/null, mutating ops reject. GoLibrespotEventClient
(EventEmitter) parses {type,data} /events frames and re-emits type with data,
reconnects on close with capped backoff, stop() tears down. TDD with a mock
AxiosInstance and a fake WebSocket (no real binary/network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:47:44 +08:00
saopig1andClaude Opus 4.8 470a62129a feat(spotify): add SpotifyAudioBackend interface + go-librespot config.yml renderer
- backend.ts: type-only SpotifyAudioBackend contract + track/metadata DTOs
- go-librespot-config.ts: renderConfigYml() hand-built config (pipe/s16le,
  server enabled, interactive OAuth), no yaml dependency
- tests assert exact keys/values and round-trip via a tiny structural parser

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:40:15 +08:00
saopig1andClaude Opus 4.8 32718f0118 feat(spotify): add go-librespot binary resolver + Linux support gate
Mirror youtube.ts findYtDlp/checkYtDlpAvailable (bin/ then PATH,
cache-positive-only availability, reset test hook) and add
isGoLibrespotSupported() Linux gate for the Stage 2 audio backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:33:42 +08:00
saopig1andClaude Opus 4.8 978e6ee7f2 docs(spotify): stage 2 plan — go-librespot audio backend (#112)
Drafted from a locked interface contract + integration map, then adversarially
verified (3 critics). Includes REQUIRED CORRECTIONS fixing the gapless-handoff
blocker (no stream re-attach on spotify->spotify), detach-not-destroy teardown,
ffmpeg-static resolution, occupancy/seek transport delegation, and unhandled-error
guarding. Linux/Docker-only + gated; not e2e-testable without Premium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:30:45 +08:00
saopig1andClaude Opus 4.8 77d71fe418 docs(spotify): note intentional Spotify omission from unified /search/all (stage 1)
Spotify tracks are metadata-only until the librespot audio backend lands, so
they are surfaced only from the dedicated Spotify tab, not the default
all-sources search. Conscious decision from the whole-branch review (#112).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:32:08 +08:00
saopig1andClaude Opus 4.8 50bdaae26a feat(spotify): frontend plumbing (source tab, badge, auth status)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:22:45 +08:00
saopig1andClaude Opus 4.8 e1171dbcfe feat(spotify): expose provider through web music/auth routers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:19:22 +08:00
saopig1andClaude Opus 4.8 9a9f68c446 feat(spotify): wire provider through manager/instance; skip playback sentinel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:13:22 +08:00
saopig1andClaude Opus 4.8 848b3931b8 feat(spotify): config block (disabled by default) + sanitize
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:04:31 +08:00
saopig1 828fd21e29 feat(spotify): SpotifyProvider (search/browse; playback sentinel) 2026-07-01 23:59:41 +08:00
saopig1andClaude Opus 4.8 6c16e2d966 feat(spotify): Web API client + catalog mappers, add spotify platform
Adds src/music/spotify/webapi.ts (client-credentials token, catalog
mappers, 429 retry) + tests, and threads the new "spotify" platform id
through the type unions in provider.ts and database.ts. Also widens the
downstream QueuedSong.platform union (audio/queue.ts) and the
getProviderFor parameter (bot/instance.ts) so tsc --noEmit stays clean;
these two are the necessary call-site fixes for the new union member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:52:22 +08:00
saopig1andClaude Opus 4.8 b352d71528 docs(spotify): stage 1 implementation plan (metadata + provider + wiring) (#112)
Bite-sized TDD plan for the first shippable increment: Spotify becomes a
searchable/browsable source (Web API), with playback cleanly reporting
"not playable yet". Adversarially verified against the codebase (3 critics)
and fixed: getProviderFor signature, pre-existing config.test.ts imports,
all three BotInstance sites, enabled-gate safety, 429 handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:04:35 +08:00
saopig1andClaude Opus 4.8 92bdfcb294 docs(spotify): design spec for optional hybrid librespot audio source (#112)
Adds the approved design for a new optional `spotify` MusicProvider that
streams real Spotify audio via a librespot-family sidecar behind one
SpotifyAudioBackend interface (go-librespot on Linux/Docker, Rust librespot
on Windows), with metadata via the Spotify Web API. Disabled by default,
opt-in, Premium-only, ToS-risk warned. Includes verified research appendix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:26:07 +08:00
saopig1andClaude Opus 4.8 f6e42811a5 chore(deps): update NeteaseCloudMusicApi (pinned) + safe patch/minor bumps
Dependency audit follow-up to the QQ pin:
- NeteaseCloudMusicApi ^4.30.0 → ~4.32.0. Same rationale as @sansenjian/
  qq-music-api: it's an embedded API server loaded via dynamic import, so a loose
  `^` could drift into a breaking minor and silently kill the netease server
  (ECONNREFUSED). Tighten to ~4.32.x. Verified at runtime: server starts on 3001
  and /banner returns 200.
- Lockfile bumps within existing ^ ranges (no API-server risk, so ranges kept):
  better-sqlite3 12.8.0→12.11.1, koa 3.2.0→3.2.1, ws 8.20.0→8.21.0,
  typescript 6.0.2→6.0.3, ts3-nodejs-library 3.5.1→3.5.3, vitest 4.1.4→4.1.9,
  tsx 4.21.0→4.22.4.
- Deliberately NOT bumped (major / needs a migration): bcryptjs 2→3 (password
  hashing), @types/node 25→26, @types/supertest 6→7.

tsc clean; full suite 913 passing under vitest 4.1.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:16:25 +08:00
saopig1andClaude Opus 4.8 08260182a9 fix(qq): pin @sansenjian/qq-music-api to ~2.4.0 + guard startup failures
A loose `^2.2.10` range let npm pull a newer build of the QQ Music API. The
library went ESM in 2.3.x: an ESM-only 2.3.0/2.3.1 throws ERR_REQUIRE_ESM on
load, so the embedded server never binds port 3200 and every QQ request
(including /getQQLoginQr) fails downstream with ECONNREFUSED — the QR code never
appears and cookies look broken.

- Pin to `~2.4.0` (verified: loads via the bot's native ESM import, exposes the
  Koa app, and every endpoint qq.ts calls returns the exact shapes it parses —
  QR, recommend, lyric, play, playlist detail). Blocks the broken 2.3.0/2.3.1
  and any future 2.5 migration. NOTE: 2.4.x requires Node >=20.17 (or >=22.9).
- Add describeQqApiStartupError(): on startup failure, log an actionable error
  (ERR_REQUIRE_ESM → version-pin hint; engine mismatch → Node-upgrade hint)
  instead of a generic warning, so this is obvious from the logs next time.
- README: troubleshooting entry for "QQ 二维码不弹 / 登录失败 / cookie 无法使用"
  and the version/Node note in the dependency table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:05:47 +08:00
saopig1andClaude Opus 4.8 af50d69b00 docs: document Kugou (酷狗音乐) source + login features in README
Kugou shipped in #110 but the README still listed only netease/qq/bilibili/
youtube. Add Kugou throughout:
- tagline, badge, 多平台音源, QR 登录, 歌单管理 (酷狗私人电台 !fm -k + the
  login-gated daily/recommend/user playlists)
- quick-start account login, WebUI page table (FM sources, 三→四平台 search,
  multi-platform login), architecture tree (kugou.ts), dependency table,
  milestones, and a credit to the MIT MakcRe/KuGouMusicApi reference
- command table: !play -k / !search [-k] / !artist -k / !fm -k (the flags that
  actually route to Kugou; not !playlist -k — Kugou search returns no playlists)

Also fix the in-bot `!search` usage string to include -k so it matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:36:00 +08:00
saopig1andClaude Opus 4.8 5ae5168b6b fix(web): keep the volume slider draggable under the per-frame progress loop (#111)
The 60fps requestAnimationFrame progress clock (#107) re-renders the player
every ~16ms, and Vue re-applied `el.value = storeVolume` on a range input each
time — snapping the thumb back to the stale store value mid-drag (un-draggable
on desktop, janky on mobile).

Extract the decoupling into a useDecoupledSlider composable used by both the
desktop (Player.vue) and mobile (App.vue) sliders: a local display ref tracks
the native drag via @input (so the bound value always matches the element), the
store is committed only on @change (release), and an onRelease safety-net
(pointerup/pointercancel/blur) clears the dragging guard even when the browser
skips `change` (value released at its start point). External/store changes still
flow into the display except while dragging. Adds a regression test for the
no-snap-back invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:12:05 +08:00
saopig1andClaude Opus 4.8 4cb1da29d4 fix(web): render login QR codes dark-on-light so scanners can read them
The QR images used theme-aware colours, so in the default dark theme they were
rendered light-on-dark (inverted). Many in-app scanners — notably the Kugou
music app — cannot decode an inverted QR, so the code looked fine on screen but
silently failed to scan. Force standard dark-on-light regardless of theme; the
white quiet-zone frames it cleanly in dark mode anyway. Affects all platforms'
QR login (netease/qq/bilibili/kugou).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:11:53 +08:00
saopig1andClaude Opus 4.8 f9caea6c79 feat(kugou): add login-gated discovery (daily/recommend/user playlists, FM) + covers
Implements the NetEase-parity login features for the Kugou provider now that
QR login works:
- getDailyRecommendSongs (每日推荐), getUserPlaylists (我的歌单), and a real
  getRecommendPlaylists (推荐歌单, was a stub) ported from the reference API.
- mapKugouSong now extracts cover art per endpoint (sizable_cover / cover /
  trans_param.union_cover, resolving the {size} template) — Kugou songs had no
  artwork before.
- Fix the playlist-song shape (combined "歌手 - 歌名" in `name`, mixsongid as the
  audio id) so opened playlists show real titles instead of 未知歌曲.
- New defensive playlist mappers keyed on global_collection_id (the only id
  getPlaylistSongs can open); dedup user playlists in case the list endpoint
  ignores pagination; firstStr() so an empty-string field can't mask a real one.

Frontend wires Kugou as a third home-discovery source (Source type, store
caches/auth, availableSources, fetchHomeData, Home FM card + source tabs,
SourceTabs label, persisted-tab whitelist). SourceTabs now highlights the
fallback-corrected source so the active tab shows when a logged-out source was
persisted (newly possible with 3 sources).

Adds kugou.test.ts coverage for the new mappers, the cover/empty-string and
playlist-shape handling, and id openability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:11:45 +08:00
saopig1andClaude Opus 4.8 a12c419dd2 feat(music): add Kugou (酷狗音乐) as a music source (#69)
Adds a self-contained Kugou provider (bilibili-style: direct API calls, no
embedded API server, no new npm dependency) plus full backend + WebUI wiring.

Provider (src/music/kugou.ts): search, song-url (with device registration),
lyrics (KRC decode), song detail, playlist, album, personal FM, QR login +
cookie persistence, and quality. Request signing / crypto / KRC decoding are
ported from the MIT-licensed MakcRe/KuGouMusicApi using Node's built-in
crypto and zlib (no third-party crypto packages).

Wiring: the "kugou" platform is threaded through the provider contract, queue,
play-history, bot instance/manager dispatch (getProviderFor + the -k command
flag), index/server composition, the music/player/auth routers (unified
/search/all, /quality, the platform coercions, QR login), the cookie store,
and the WebUI (search source tab + badge, SongCard badge, brand token, and a
Kugou QR/cookie login card in Settings).

Verified live during development: search, lyrics, and album playback resolve
correctly. NOT verifiable in CI (Kugou anti-bot blocks the build host's IP):
play-URL resolution, QR login, and VIP audio — these are built faithfully to
the reference and need end-to-end testing on a non-flagged IP / a Kugou
account. See the header comment in kugou.ts.

Includes src/music/kugou.test.ts (mappers + KRC→LRC). An adversarial review
pass fixed: pagination truncating on filtered counts, an ms/seconds duration
heuristic, dfid soft-fail caching, the /v5/url random-dfid fallback, the FM
body identity, and an unguarded nickname decode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:55:41 +08:00
saopig1andClaude Opus 4.8 45f5d236c1 fix(web): tick player time every frame and keep lyrics in sync (#107)
`store.elapsed` is a Pinia getter (a cached Vue computed) that interpolates
with `Date.now()`. Because `Date.now()` is not a reactive dependency, the
computed only re-ran on WebSocket pushes / the 3s server poll, so the bottom
progress bar jumped ~3s at a time and lyric highlighting lagged ~half a line —
even though the consumers read it from a 60fps requestAnimationFrame loop.

Add a pure `interpolateElapsed()` helper and a non-cached `liveElapsed()` store
action. The per-frame consumers now call `liveElapsed()` so the value advances
every frame instead of returning a frozen cache:
- web/src/components/Player.vue  (desktop progress bar, rAF)
- web/src/App.vue                (mobile progress bar, rAF)
- web/src/views/Lyrics.vue       (lyric highlight, 500ms interval)

pause() now freezes at the live value rather than a possibly-stale cached one.
The `elapsed` getter is refactored onto the same helper (behaviour unchanged).

Adds web/src/stores/elapsed.test.ts covering the time-advancing interpolation,
paused freeze, no-anchor, and duration-clamp cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:17:53 +08:00
saopig1andClaude Opus 4.8 e849db2286 fix(local-audio): reference-aware cleanup, upload quota, stricter validation
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>
2026-06-30 15:58:22 +08:00
saopig1 9c861f487d docs: add playCollection to the guest-permission table (#103) 2026-06-29 12:16:17 +08:00
saopig1 70c0273ae7 fix(guest): add playCollection permission so guests can Play All playlist/album (#103)
- 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)
2026-06-29 12:12:47 +08:00
saopig1 31d3830791 test(ts-protocol): smoke-test getClientServerGroups query string + client_servergroups parse 2026-06-28 23:40:30 +08:00
saopig1andClaude Opus 4.8 8e5e9c810e fix(bot): resolve sender server groups live + server-wide for the admin-command gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:51:06 +08:00
saopig1 e104093614 fix: sanitize adminGroups on config load + final-review cleanups 2026-06-26 21:12:56 +08:00
saopig1 b387d6581e docs: TS chat-command permission implementation plan 2026-06-26 20:57:39 +08:00
saopig1 17ab477af6 docs: correct stale adminGroups references now that the feature ships 2026-06-26 20:53:56 +08:00
saopig1 10e29476f4 docs: document TeamSpeak chat-command permission control 2026-06-26 20:51:08 +08:00
saopig1 215e328f17 feat(web): admin-only command-permission (adminGroups) settings section 2026-06-26 20:47:34 +08:00
saopig1 3346286ffd feat(api): read/write adminGroups in bot settings endpoints 2026-06-26 20:44:29 +08:00
saopig1 72ffd44f68 feat(bot): gate admin chat commands on adminGroups with fallback + deny reply 2026-06-26 20:40:01 +08:00
saopig1 b090a8ec21 feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper 2026-06-26 20:35:31 +08:00
saopig1 f98ce47c52 feat(commands): add canRunCommand gate helper + admin-set source of truth 2026-06-26 20:32:45 +08:00
saopig1andClaude Opus 4.8 0c7f7e128b docs: TS chat-command permission control design spec
Binary admin gate keyed on TS server groups (config.adminGroups),
opt-in/backward-compatible (empty = no enforcement), gated in the
chat handler (executeCommand stays agnostic so WebUI is unaffected),
with adminGroups editable from the WebUI settings. Completes the
unused adminGroups/ADMIN_COMMANDS scaffold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:04:58 +08:00
saopig1andClaude Opus 4.8 3b2b2185a5 docs: surface guest mode in feature list + reflect shipped behavior
- Add a 游客模式 bullet to the top-level 功能特性 list.
- Note guests share one short-lived anonymous identity, and that
  disabling/narrowing takes effect live (incl. open WebSockets).
- Expand the always-denied list to include favorites, change-password,
  and the operator's personal platform-account data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:24:25 +08:00
saopig1andClaude Opus 4.8 a1a70dea5d fix(guest): serialize concurrent queue-mutation playback per bot
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>
2026-06-25 16:10:38 +08:00
saopig1andClaude Opus 4.8 43c0175334 fix(guest): tear down guest WS on guest-mode config change
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>
2026-06-25 16:05:14 +08:00
saopig1andClaude Opus 4.8 c1d73b6ba8 fix(guest): cap guest session TTL on touch
The sliding-refresh branch in validateAndTouch hardcoded SESSION_TTL_MS
(7d) for all roles, so a guest session created with GUEST_SESSION_TTL_MS
(1d) was wrongly bumped to 7d on the first touch after the touch
interval. Derive the touch TTL from row.role instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:02:44 +08:00
saopig1andClaude Opus 4.8 66230e6b43 fix(guest): normalize guestMode config on load + strict-boolean authorize gate
loadConfig now sanitizes guestMode the same way the write path does: bots is
coerced to "all" | string[] (numbers/objects/missing fall back to the default
"all"), and permissions are rebuilt from defaults with each known flag
strict-coerced to a boolean so a hand-edited/legacy/corrupt config.json can no
longer crash the gate or leak garbage index keys. The authorize guest gate now
uses === true instead of a truthy check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:59:42 +08:00
saopig1andClaude Opus 4.8 952f1fbad3 fix(guest): deny operator personal-data reads to guests
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>
2026-06-25 15:54:29 +08:00
saopig1andClaude Opus 4.8 365352cdd3 fix(guest): guard reserved __guest__ principal in user mgmt
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>
2026-06-25 15:54:24 +08:00
saopig1andClaude Opus 4.8 45414b3baa feat(guest): prune deleted bot from guest scope on removeBot
When a bot is deleted, prune its id from config.guestMode.bots (when an
array) and persist, mirroring the existing permissions.pruneBot(id)
member-access pruning. Thread CONFIG_PATH into BotManager so removeBot
can save the updated config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:46 +08:00
saopig1andClaude Opus 4.8 f142c514cd fix(guest): deny favorites + auth-status reads to guests; UI polish
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>
2026-06-25 12:44:07 +08:00