The embedded QQ Music API sidecar could bind a different port than the one
the client base URL (getQQMusicBaseUrl) targets. The upstream
@sansenjian/qq-music-api package derives its default port from
process.env.PORT (falling back to 3200) and, in some historical versions,
auto-started that server as an import side effect. When an old build listened
on 3300 while the client requested 3200 (issue #122), fetching the QQ login QR
failed with ECONNREFUSED on 127.0.0.1:3200, so the QR never showed and login /
cookie persistence silently broke.
Align process.env.PORT with the configured qqMusicApiPort for the duration of
the import (restoring the previous value afterwards so nothing else in the
process is affected), reuse an already-listening instance instead of racing a
second listen, and log the port actually bound (read from the socket) so any
mismatch is visible in the logs.
- src/music/api-server.ts: PORT alignment + reuse-on-auto-start + bound-port log
- src/music/api-server.test.ts: regression coverage that the sidecar follows
qqMusicPort (not an injected PORT) and restores PORT afterwards
- README.md: QQ login FAQ clarifies the sidecar and client share qqMusicApiPort
and points stale-latest-image users (who saw 3300) at re-pulling the image
Co-Authored-By: Claude Fable 5 <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>
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>
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>
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>
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>
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>
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>
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>
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>
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>