Documentation
¶
Overview ¶
Package spotify runs go-librespot as a persistent Spotify Connect receiver on the speaker and exposes its audio so the box can play it over UPnP, the audio plane of the Spotify-preset feature (#78, P1).
Why go-librespot (devgianlu) and not librespot-org:
- Hardware preset buttons 1..6 must recall a saved Spotify playlist autonomously, with no phone app present. That needs the box to be able to say "play URI X" by itself. librespot-org has no local control API, so its only autonomous path is the Spotify Web API with a refreshable OAuth token stored on the box (a security surface we do not want). go-librespot ships a local HTTP API: POST /player/play {uri} plays a URI using its own cached credential, no token plane. See Play below.
- GPL-3.0 is fine here: go-librespot runs as a separate sidecar process (exec + localhost HTTP). STR merely aggregates it; the agent stays Apache-2.0. The binary is built, attested, audited and credited separately.
Audio shape:
- go-librespot runs with the STR Ogg-passthrough patch (.github/patches/go-librespot-passthrough.patch) and audio_output_pipe_passthrough. We point audio_output_pipe at /dev/stdout so it writes the raw Ogg/Vorbis to its stdout (it logs to stderr); the manager drains that and ServeOgg streams it to the box, which decodes the Ogg natively over UPnP. This roughly halves CPU on the weak A8 vs streaming decoded PCM (validated live).
Credentials: zeroconf with persist_credentials. The user taps the device once in the Spotify app (the natural "connect to a speaker" flow); go-librespot persists the reusable credential under configDir and auto-logs-in on every later start, so API-driven recall works with no controller attached.
Single consumer by design: one box plays one Spotify stream at a time. When no HTTP client is attached the audio is discarded so go-librespot never blocks on a full pipe.
Index ¶
- Variables
- type Manager
- func (m *Manager) AccountNames() map[string]string
- func (m *Manager) AccountNamesSorted() []string
- func (m *Manager) ArmRecallCut()
- func (m *Manager) AudioKeyRefused() bool
- func (m *Manager) Bitrate() int
- func (m *Manager) CanRecall(ctx context.Context) bool
- func (m *Manager) ChainSnapshot() map[string]any
- func (m *Manager) ClearGroupedRefusal()
- func (m *Manager) CredentialSnapshot() map[string]any
- func (m *Manager) CurrentUsername(ctx context.Context) string
- func (m *Manager) DeviceName() string
- func (m *Manager) EnginePID() int
- func (m *Manager) ExpectReattach(window time.Duration)
- func (m *Manager) ExportCredential() ([]byte, error)
- func (m *Manager) GroupedRefusal() bool
- func (m *Manager) ImportCredential(ctx context.Context, data []byte) error
- func (m *Manager) LastBoundaryStaleKB() int64
- func (m *Manager) LastBoxLag() boxLagMeasurement
- func (m *Manager) LastSkipBoundary() time.Time
- func (m *Manager) LoggedIn() bool
- func (m *Manager) Next(ctx context.Context) error
- func (m *Manager) NoteBoxVolume(pct int)
- func (m *Manager) NoteGroupedRefusal()
- func (m *Manager) NoteSkip()
- func (m *Manager) Pause(ctx context.Context) error
- func (m *Manager) Play(ctx context.Context, uri string, opts PlayOptions) error
- func (m *Manager) PlayAccount(ctx context.Context, uri, account string, opts PlayOptions) error
- func (m *Manager) PlayingContext() string
- func (m *Manager) PlaylistMeta(ctx context.Context, uri string) (cover, title string)
- func (m *Manager) PremiumRequired() bool
- func (m *Manager) Prev(ctx context.Context) error
- func (m *Manager) Ready() bool
- func (m *Manager) ReloadBinary() bool
- func (m *Manager) RepeatingContext(ctx context.Context) bool
- func (m *Manager) Resume(ctx context.Context) error
- func (m *Manager) ResumeSnapshot() ResumeSnapshot
- func (m *Manager) Run(ctx context.Context)
- func (m *Manager) SeamsTotal() int
- func (m *Manager) ServeInfo(w http.ResponseWriter, r *http.Request)
- func (m *Manager) ServeOgg(w http.ResponseWriter, r *http.Request)
- func (m *Manager) ServeQuality(w http.ResponseWriter, r *http.Request)
- func (m *Manager) SessionActive(ctx context.Context) bool
- func (m *Manager) SetBitrate(kbps int) (applied bool, err error)
- func (m *Manager) SetBoxPositionFn(fn func(context.Context) (time.Duration, bool))
- func (m *Manager) SetConnectIntentHooks(onPause func(event string), onPlay func())
- func (m *Manager) SetGroupSlaveIPsFn(fn func() []string)
- func (m *Manager) SetOnActivate(f func(context.Context))
- func (m *Manager) SetOnSinkDetach(fn func(attachedMs int64))
- func (m *Manager) SetOnTrack(fn func(track, artist string))
- func (m *Manager) SetRecalling()
- func (m *Manager) SetStereoFn(fn StereoFn)
- func (m *Manager) SetVolume(ctx context.Context, pct int) error
- func (m *Manager) SetZeroconfHost(label string)
- func (m *Manager) ShufflingContext(ctx context.Context) bool
- func (m *Manager) StereoStatusNow() StereoStatus
- func (m *Manager) StopEngine() bool
- func (m *Manager) StreamStalled() bool
- func (m *Manager) Streaming() bool
- func (m *Manager) SuppressActivate(d time.Duration)
- func (m *Manager) SwitchAccount(ctx context.Context, username string) (bool, error)
- func (m *Manager) SwitchedAway(ctx context.Context)
- func (m *Manager) UserStopped(ctx context.Context, reason string)
- func (m *Manager) VolumeSnapshot() map[string]any
- type PlayOptions
- type ResumePoint
- type ResumeRecall
- type ResumeSnapshot
- type StereoFn
- type StereoRole
- type StereoStatus
Constants ¶
This section is empty.
Variables ¶
var ErrNoSpotifySession = errors.New("spotify: no live device session for recall")
ErrNoSpotifySession is returned by PlayAccount when the speaker holds no live Spotify session and none could be re-established (no persisted credential, or the credential no longer authenticates, e.g. another controller took the account's single live session). The caller maps it to an actionable "tap this speaker in Spotify once" hint rather than letting the box buffer into nothing.
Functions ¶
This section is empty.
Types ¶
type Manager ¶
type Manager struct {
// MemAvailKB reads the box's MemAvailable in KB, negative when unknown.
// Wired by the agent from /proc/meminfo before Run; nil in tests. The
// chain cutter (oggchain.go) reads it a couple of times a minute while a
// long track streams, to seam the track before the memory guard fires.
MemAvailKB func() int64
// EngineRSSKB reads the resident set (KB) of the go-librespot process with
// that pid, negative when unknown. Wired by the agent; nil in tests. It
// rides on every seam line: an engine RSS climbing with the audio means
// the engine holds the memory, flat means the firmware does.
EngineRSSKB func(pid int) int64
// contains filtered or unexported fields
}
Manager supervises one go-librespot process and brokers its PCM output (as a live WAV stream) to at most one HTTP consumer (the speaker), plus drives playback through go-librespot's local HTTP API.
func New ¶
New returns a Manager. binPath is the go-librespot binary, configDir the config + credential directory (config.yml is written there on Run; the persisted zeroconf credential lives there after the first Spotify-app tap). box is the Bose REST client: the manager reads the speaker's friendly name from it (so the Spotify Connect device and its local mDNS advert carry the speaker's own name, not a hardcoded one) and bridges Spotify volume changes onto the box. fallbackName is used only until the box answers /info.
func (*Manager) AccountNames ¶
AccountNames returns a copy of what is known, id to display name, for the apps to draw with. Empty on a speaker that has never had an answer from Spotify, and the apps then print nothing at all rather than an id.
func (*Manager) AccountNamesSorted ¶
AccountNamesSorted is AccountNames with a stable order, for tests and for a log line that has to be the same twice.
func (*Manager) ArmRecallCut ¶
func (m *Manager) ArmRecallCut()
ArmRecallCut arms the boundary cut for a preset recall, so the box never re-buffers the OLD track's audio while the new context loads. The window is 30s, longer than NoteSkip's 15s: the recall preamble may include an account restart (~8s) plus the play POST before the new context's BOS can arrive. Extends only, never shrinks, so a re-arm mid-recall cannot cut the window short. Every recall path that arms this and then aborts before /player/play MUST disarm (clearSkipCut): an armed cut with no BOS coming drops live audio until the window expires.
func (*Manager) AudioKeyRefused ¶
AudioKeyRefused reports whether Spotify has just refused the audio key for a run of tracks, which is the signature of an account this engine is not allowed to stream: a free account, or one in the cohort Spotify moved to its newer DRM, where every request of this kind is refused regardless of Premium.
Deliberately short-lived. The user is told while the silence is fresh and the message goes away by itself, because the cause is on Spotify's side and there is nothing on the speaker that clearing it would fix. The window is generous enough that switching to the app and back still shows it.
func (*Manager) Bitrate ¶
Bitrate returns the bitrate measured from the live stream (kbit/s), or the configured nominal when nothing has streamed yet.
func (*Manager) CanRecall ¶
CanRecall reports whether a Spotify preset recall can proceed: either go-librespot holds a LIVE session right now (SessionActive, e.g. the user just streamed to the box from their phone) OR a reusable credential is persisted on disk (LoggedIn, so ensureSession can restart go-librespot and re-auth from it).
Recall must gate on this, NOT on LoggedIn alone. A box with a live-but-never- persisted zeroconf session (go-librespot authenticated the phone but wrote no credential to state.json) reports LoggedIn()==false yet plays Spotify fine; gating on LoggedIn alone refused recall on exactly such a box (Patrick, ST10 rhino, 2026-06-24: streamed Spotify, go-librespot running, box flipped to source=SPOTIFY, yet the recall bailed "speaker not logged into Spotify"). Only when BOTH are false is the recall genuinely impossible, so the "tap this speaker in Spotify once" hint is correct. PlayAccount->ensureSession then handles the live vs cold-restart decision from here.
The on-disk half asks hasUsableCredential, not LoggedIn: a login Spotify has already refused is on disk but cannot start anything, and counting it made a doomed recall report "playing" (credreject.go).
func (*Manager) ChainSnapshot ¶
ChainSnapshot is the spotify_chain debug section: the cutter's published state plus the live pid and memory reading. The agent adds the engine RSS.
func (*Manager) ClearGroupedRefusal ¶
func (m *Manager) ClearGroupedRefusal()
ClearGroupedRefusal forgets the refusal, because the speaker has just been driven successfully and is therefore no longer a group member.
func (*Manager) CredentialSnapshot ¶ added in v1.0.4
CredentialSnapshot is the spotify_auth debug section: whether the engine has a saved login, and whether (and how) Spotify refusing it was handled. It deliberately carries no account name or credential bytes, only state.
func (*Manager) CurrentUsername ¶
CurrentUsername is the exported form; the preset-save path stamps it onto a new Spotify preset so a later recall can switch back to that account.
func (*Manager) DeviceName ¶
DeviceName returns the name currently advertised to Spotify - the box's friendly name plus the STR marker (see advertisedName), matching what the Spotify app shows so the STR UI and the Connect picker agree. m.name itself stays the bare friendly name so watchDeviceName's change detection is not tripped every cycle by the suffix.
func (*Manager) EnginePID ¶
EnginePID returns the pid of the supervised go-librespot process, 0 when none has been started. The process may already have exited: callers read /proc and tolerate an absent entry.
func (*Manager) ExpectReattach ¶
ExpectReattach marks the NEXT box re-attach inside window as deliberate, caused by STR's own re-push, so ServeOgg's storm damping does not count it. The soft-skip re-push legitimately re-attaches as often as every 8 s, well inside the 20 s storm window; counting those grew the activate backoff and held the auto-repoint off for up to a minute after two ordinary skips. One-shot: a genuine INVALID_SOURCE storm re-attaches repeatedly and only the announced one is excused.
func (*Manager) ExportCredential ¶
ExportCredential returns the active go-librespot credential (credentials.json) so it can be copied to another speaker. The blob is a reusable Spotify Connect credential for whatever account last logged in here; copying it to another box lets that box log into the SAME Spotify account without the user picking it in Spotify again. Returns an error when no credential is stored yet (the box was never logged in). LAN-only, same trust model as the rest of the agent API.
func (*Manager) GroupedRefusal ¶
GroupedRefusal reports whether the speaker has just refused to be driven because it is part of a group.
func (*Manager) ImportCredential ¶
ImportCredential writes a credential blob exported from another speaker into this box's go-librespot config and restarts go-librespot so it logs in as that account. This is the receiving half of "log in once, sync to all speakers": the user logs into Spotify on one box, and STR copies that credential to the others so recall works everywhere without tapping each box in Spotify.
func (*Manager) LastBoundaryStaleKB ¶
LastBoundaryStaleKB reports the stale (pre-boundary) KB the current sink was fed before the last cut boundary landed. It gates the recall re-push: ~0 means the armed cut kept the box's buffer clean, so no re-push (and no audible stream flap) is needed.
func (*Manager) LastBoxLag ¶
func (m *Manager) LastBoxLag() boxLagMeasurement
LastBoxLag returns the most recent measurement; the zero value when none has been taken.
func (*Manager) LastSkipBoundary ¶
LastSkipBoundary returns when a user skip last reached its track boundary in the forwarded stream (zero time when none has yet).
func (*Manager) LoggedIn ¶
LoggedIn reports whether this speaker has ever completed a Spotify Connect login, i.e. a reusable credential is persisted on disk. Recall needs this: without a credential go-librespot cannot start playback on its own, so the preset does nothing (#45 Pierre: the saved preset had account="" and go-librespot was not running). It is a filesystem check (not a :3678 query), so it stays true while go-librespot is mid-restart, distinguishing "never logged in" (actionable: log the speaker into Spotify first) from "logged in but momentarily down" (recovers on its own).
Current go-librespot persists the zeroconf credential into configDir/state.json (under .credentials.username); credentials.json is only its read-only LEGACY fallback for installs predating that merged-state layout, so a box logged in via a current binary has state.json and NO credentials.json. Checking only credentials.json reported every fresh install as not-logged-in and silently blocked all Spotify recall, even with an active session (a user diagnostic, 2026-06-23: go-librespot loaded its persisted credential and authenticated, yet LoggedIn() was false). So check state.json too. go-librespot also writes a bare state.json (device_id/last_volume only) before any login, so require a non-empty persisted username there rather than mere file presence.
func (*Manager) Next ¶
Next and Prev skip tracks. Wired to the SoundTouch remote's next/prev keys: the box cannot skip a UPnP source itself (it emits QPLAY_SKIP_*_FAILED), so STR catches that and skips here instead. The new track reaches the box after its buffer drains.
func (*Manager) NoteBoxVolume ¶ added in v1.0.13
NoteBoxVolume is the box -> Spotify direction: the agent calls it for every gabbo volumeUpdated frame with the speaker's new level (negative when the frame carried none; the level is then read from the box). It never blocks: the value is handed to a worker that waits out the key burst and pushes the latest level to the engine. A level that is the echo of STR mirroring the Spotify app onto the box is dropped here, so it never bounces back.
func (*Manager) NoteGroupedRefusal ¶
func (m *Manager) NoteGroupedRefusal()
NoteGroupedRefusal records that the speaker refused transport control because it is a member of a group. Called by the agent when its auto-switch fails with that fault.
func (*Manager) NoteSkip ¶
func (m *Manager) NoteSkip()
NoteSkip arms the skip-cut window: until the next track boundary (or the window's expiry) the forward loop drops the old track's audio instead of playing it out. Called by the webui skip worker right before /player/next|prev. Sized above the slowest observed engine track load (11 s, Portable 2026-08-01) so the boundary always lands inside it.
func (*Manager) Play ¶
Play asks go-librespot to start playing a Spotify context (playlist/album/ track) on this device, using its own cached credential. This is the autonomous-recall path: the agent calls it on a Spotify preset press, with no app involved.
The recall calls Play FIRST, then points the box at /spotify/stream.ogg. With the sink-gated drain (see runOnce) go-librespot blocks on its full output pipe until the box attaches, so the pipe holds the track's start incl. the Ogg headers; the box therefore receives the stream from the beginning and can decode it.
Shuffle is driven by opts, NOT forced on. The previous code force-enabled shuffle on EVERY recall, skipped to a random track, and never turned shuffle off again, so every preset press landed on a random song AND the remote's next key jumped around a still-shuffled queue (Patrick + Jens, 2026-06-25). Now: a default (non-shuffle) recall resumes the context where the user left off and keeps shuffle off; a shuffle preset starts on a fresh random track.
func (*Manager) PlayAccount ¶
PlayAccount switches to the preset's account (if needed) then plays the URI with the given options (shuffle vs resume). This is the recall entry point used by both the hardware-button and the desktop/API paths, so both honour the preset's shuffle flag and the per-context resume point identically.
func (*Manager) PlayingContext ¶
PlayingContext returns the Spotify context URI (playlist/album) go-librespot is currently playing, or "" if none. The preset-save path uses it to decide when the live account is authoritative: saving a preset for the content that is playing right now should stamp the account that is actually playing it, even over a stale account carried in from an earlier save.
func (*Manager) PlaylistMeta ¶
PlaylistMeta returns a stable cover image URL and the human title for a Spotify context URI (playlist, album, ...) via Spotify's public oEmbed endpoint, which needs no token. A saved preset uses the cover as its tile logo (#24) and the title as its name, so the box display and the tile show e.g. "Jens Chill" instead of a bare "Spotify". Returns "","" on any failure. Best-effort, called off the play path (on preset save).
func (*Manager) PremiumRequired ¶
PremiumRequired reports whether the current Spotify account cannot do the autonomous on-demand playback a preset recall needs, i.e. it is a free/open account rather than Premium (#45). Non-blocking: it uses the cached product type and the go-librespot free-account log signal, kicking a background product refresh when the type is not yet known. Conservative: returns true only on a POSITIVE non-Premium signal, never on "unknown", so a Premium user is never wrongly blocked.
func (*Manager) Ready ¶
Ready reports whether go-librespot can run: the binary exists. The device advertises over zeroconf even before the first tap, so we start it whenever the binary is present; playback control just returns an error until a credential is cached.
func (*Manager) ReloadBinary ¶
ReloadBinary restarts the supervised go-librespot so it re-execs from its binary path, activating a freshly OTA-delivered engine WITHOUT a box reboot. The OTA write (webui.handleAgentSidecar) overwrites the binary at m.binPath and then calls this; the supervise loop cancels the current run and relaunches from the same path, so the relaunch runs the new bytes. This closes the gap where an engine UPDATE on an already-running agent needed a full box reboot to take effect, the manual restart Pierre and Daniel had to do after an update (#240). A first-time delivery to a box that had no engine running is already picked up live by waitForBinary; this method covers the already-running case.
The only side effect is a brief (~3 s) audio gap while the process relaunches; the box's stream buffer covers most of it. It is the same trade-off as an account switch, which already restarts the engine live and is unnoticeable in practice. Returns true when a live restart was triggered, false when go-librespot is not currently running (the supervise loop / waitForBinary then starts the new binary on its own) or no binary is present.
func (*Manager) RepeatingContext ¶
RepeatingContext reports whether the live go-librespot session currently loops its context, straight from GET /status. The preset-save path stamps it onto a Spotify preset saved from the running playback, so a playlist the user listens to on repeat recalls on repeat instead of making him set it by hand in the Spotify app every evening (Patrick, 2026-09-09 and again on the 15th).
Only repeat_context counts. repeat_track loops the single song, which is not what a playlist preset means, and the recall clears it for that reason. False on any error: a failed read must never invent a repeat preset.
func (*Manager) ResumeSnapshot ¶ added in v1.0.8
func (m *Manager) ResumeSnapshot() ResumeSnapshot
ResumeSnapshot returns the per-context resume points for the diagnostic file (debug section spotify_resume).
func (*Manager) Run ¶
Run supervises go-librespot until ctx is cancelled, restarting it with a short backoff if it exits. It returns immediately (idles) when not Ready, so callers can start it unconditionally.
func (*Manager) SeamsTotal ¶
SeamsTotal reports how many chain seams the current engine run has opened.
func (*Manager) ServeInfo ¶
func (m *Manager) ServeInfo(w http.ResponseWriter, r *http.Request)
ServeInfo answers GET /spotify/info with the live state the UI needs: whether Spotify is available, the measured bitrate, and the advertised device name.
func (*Manager) ServeQuality ¶
func (m *Manager) ServeQuality(w http.ResponseWriter, r *http.Request)
ServeQuality answers GET and POST /spotify/quality.
func (*Manager) SessionActive ¶
SessionActive reports whether go-librespot currently holds a live, authenticated device session (an active Connect device that can accept /player/play), as opposed to merely having a persisted credential on disk (LoggedIn). go-librespot auto-loads the persisted zeroconf credential at process start and re-auths on its own, but a cold start, a dropped AP connection (the "did not receive last pong ack" case), or a takeover can leave it momentarily logged out even though credentials.json exists. Recall checks this, not just LoggedIn.
func (*Manager) SetBitrate ¶
SetBitrate persists the preference, rewrites config.yml and restarts the engine when idle. Returns applied=false when a stream is live: the config is already written, and the box's next detach from the Ogg stream performs the restart (applyPendingBitrateAfterDetach; watchDeviceName would have been the natural place, but it is deliberately disabled, see engine.go).
func (*Manager) SetBoxPositionFn ¶
SetBoxPositionFn wires in the box's own playback clock (UPnP RelPosition's RelTime). The reader must report ok=false when the box gave no USABLE clock, not merely when the query failed: see upnp.RelPosition. Without it no measurement is taken at all, which is what keeps the agent's own tests and any non-UPnP consumer free of it.
func (*Manager) SetConnectIntentHooks ¶
SetConnectIntentHooks wires deliberate playback intent from the Spotify app into the box-side latches. onPause fires on paused/stopped/inactive events outside the own-command window; onPlay fires on active/playing. nil-safe.
func (*Manager) SetGroupSlaveIPsFn ¶
SetOnActivate wires the callback that points the box at the Spotify stream when the user starts playback from the Spotify app while the box is on another source (#14). SetGroupSlaveIPsFn wires a provider for the LAN IPs of the multiroom followers this box leads, so a Spotify Connect volume change is mirrored to the whole group, not just the master.
func (*Manager) SetOnActivate ¶
func (*Manager) SetOnSinkDetach ¶
SetOnSinkDetach registers the hook fired when the box drops a Spotify stream it had been playing. The twin of streamproxy's SetOnDisconnect, which has given internet radio an automatic recovery for a long time while the Spotify audio path had none.
func (*Manager) SetOnTrack ¶
SetOnTrack registers the recently-played hook (webui.NoteRecentSpotifyTrack).
func (*Manager) SetRecalling ¶
func (m *Manager) SetRecalling()
SetRecalling marks a preset recall as in progress for the next few seconds, so ServeOgg does not resume the old (mid-position) track when the box attaches; Play drives the new track from its start instead. Called at the very start of a recall, before the box attaches.
func (*Manager) SetStereoFn ¶
SetStereoFn installs the reporter. Called once at startup.
func (*Manager) SetVolume ¶
SetVolume tells go-librespot the volume as a percent (0..100) so the Spotify app's slider shows the speaker's real level. With volume_steps 100 the API value is the percent directly. The engine echoes it as a "volume" event, which the event loop recognises as ours and does not write back.
func (*Manager) SetZeroconfHost ¶
SetZeroconfHost gives the engine the bare mDNS label this speaker answers for. The caller must only supply it once its own responder is live: an engine pointed at a name nobody answers is worse off than one pointed at the chassis codename, because at least the codename is sometimes still in a router's cache.
Takes effect at the next engine start. Nothing here restarts it: the engine is started once at boot, and forcing an extra restart to apply a name would cost audio for a cosmetic-looking reason.
func (*Manager) ShufflingContext ¶
ShufflingContext reports whether the live go-librespot session is currently playing its context shuffled, straight from GET /status. The preset-save path stamps this onto a Spotify preset saved from the running playback, so a playlist the user listens to shuffled recalls shuffled (a long-press save used to always produce a resume preset that replayed the identical order every press, live ST30 2026-08-19). False on any error: a failed read must never invent a shuffle preset.
func (*Manager) StereoStatusNow ¶
func (m *Manager) StereoStatusNow() StereoStatus
StereoStatusNow reports the pair state and what this speaker is advertising because of it.
func (*Manager) StopEngine ¶
StopEngine stops the supervised go-librespot and waits (briefly) for the process to exit, so the kernel releases its executable inode and the NAND blocks it pinned are actually freed. This is the piece the #119 "drop the regenerable engine to fit a tight agent update" reclaim was missing: an os.Remove of a RUNNING binary only drops the directory entry, the blocks stay pinned until the holder exits, so dropping the engine freed nothing while it ran (which is the normal state during an OTA) and the update still failed with "no space left". The OTA write calls this before unlinking the engine; the engine is re-delivered after the reboot by EnsureSpotifyEngine, when free space is high again. Safe to call when nothing is running (returns false). The supervise loop relaunches go-librespot after a 3 s backoff and the caller removes the binary right after this returns, so it does not re-pin the inode.
func (*Manager) StreamStalled ¶
forward writes p to the box sink; on write error it drops the sink. StreamStalled reports whether a box is attached to the Ogg stream but has not been handed a single audio page for longer than stallAfter. That is the silent-stream failure the recall verify used to count as success: the box shows the playlist name and a spinner while nothing decodes, until the Bose transport gives up ~30 s later with AUDIO_ERROR_BAD_URL (field 2026-07-27). False when no box is attached at all.
func (*Manager) Streaming ¶
Streaming reports whether a box is currently attached to the Ogg stream (i.e. Spotify is actively playing to the speaker). The memory guard uses this to avoid rebooting the box mid-playback.
func (*Manager) SuppressActivate ¶
SuppressActivate holds maybeActivate/repointBox off for d, so STR does not auto-repoint the box at the Spotify stream during a window where a different, deliberate recovery is in flight. The hardware-skip recovery uses it: when the box runs its own failed native skip it tears the UPnP source down while go-librespot is still playing, which would otherwise trip maybeActivate into re-pointing the box at the slot-less stream and racing the clean slot recall (box then attaches mid-restart and wedges on a 3102 decoder error). Extends the window rather than shrinking it, so it never cuts an existing suppression short.
func (*Manager) SwitchAccount ¶
SwitchAccount makes go-librespot log in as username if it is not already. It returns (false, nil) with no restart when username is empty, already active, or has no stored credential (the recall then plays with the current account; public playlists still work). Otherwise it swaps the credential and restarts go-librespot, waiting until it re-auths as the target.
func (*Manager) SwitchedAway ¶
SwitchedAway is called when the user deliberately points the box at a non-Spotify source (a radio preset, an ad-hoc station). It suppresses the #14 auto-attach for a window so the still-connected go-librespot session does not yank the box back to Spotify, and pauses go-librespot so the playlist does not keep advancing silently in the background. Starting Spotify again from the app or recalling a Spotify preset un-pauses it. No-op when Spotify is not running.
func (*Manager) UserStopped ¶ added in v1.0.4
UserStopped records that the user stopped or paused the speaker through STR (the app's Stop or Pause, standby). It ends any recall window so the drain pauses the engine as it would outside one, and pauses the engine directly when it was producing. Best effort and quiet: an engine that is not running needs no pause, and a failed pause is covered by the latch.
func (*Manager) VolumeSnapshot ¶ added in v1.0.13
VolumeSnapshot is the spotify_volume debug section: what the bridge last knew on each side, plus the engine's live Connect volume (one GET on the loopback, only when a bundle or the debug page asks).
type PlayOptions ¶
type PlayOptions struct {
// Shuffle starts the context on a random track with shuffle enabled, the
// behaviour a preset saved with "shuffle" wants. When false (the default),
// recall RESUMES the context on the last track that played from it (the
// Spotify Connect "continue where you left off" behaviour) with shuffle OFF,
// so the speaker's remote next/prev walk the playlist in order.
Shuffle bool
// Repeat loops the context. Like Shuffle it is applied EXPLICITLY on every
// recall, including when false: the engine keeps repeat per session, so a
// recall that stays silent inherits whatever the previous one left, and a
// preset saved without repeat would keep looping because an earlier one did.
Repeat bool
// contains filtered or unexported fields
}
PlayOptions tunes a Spotify context recall.
type ResumePoint ¶ added in v1.0.8
type ResumePoint struct {
ContextURI string `json:"contextUri"`
TrackURI string `json:"trackUri"`
// NotedAt is when this agent run last changed the entry (RFC 3339). Empty
// for an entry loaded from NAND and not changed since the agent started.
NotedAt string `json:"notedAt,omitempty"`
}
ResumePoint is one entry of the spotify_resume debug section.
type ResumeRecall ¶ added in v1.0.8
type ResumeRecall struct {
ContextURI string `json:"contextUri"`
SkipToURI string `json:"skipToUri"`
Shuffle bool `json:"shuffle"`
At string `json:"at"`
}
ResumeRecall is the last preset recall's use of the store.
type ResumeSnapshot ¶ added in v1.0.8
type ResumeSnapshot struct {
Count int `json:"count"`
// Points is ordered most-recently touched first.
Points []ResumePoint `json:"points"`
LastRecall *ResumeRecall `json:"lastRecall,omitempty"`
}
ResumeSnapshot is the spotify_resume section of /api/debug/state. It holds URIs and times only, no account names: a legacy spotify:user:<id>:... URI has its account part replaced before it leaves the agent. The store keeps no playback position; a recall resumes on the remembered track from its start.
type StereoFn ¶
type StereoFn func() (role StereoRole, pairName string)
StereoFn reports this speaker's place in a stereo pair and the pair's display name. Supplied by the agent, which owns the zone store and the marge group record; nil means "no idea", which is treated as no pair.
type StereoRole ¶
type StereoRole int
StereoRole is what this speaker is inside a stereo pair.
const ( // StereoNone: not in a stereo pair. A multiroom zone is not a stereo pair // and is deliberately not covered: its members stay individually pickable, // which is what a multiroom group is for. StereoNone StereoRole = iota // StereoMaster: in a pair and leading it. StereoMaster // StereoFollower: in a pair and following. StereoFollower )
type StereoStatus ¶
type StereoStatus struct {
Role string `json:"role"`
PairName string `json:"pairName,omitempty"`
Advertise string `json:"advertise"`
Suspended bool `json:"suspended"`
}
StereoStatus is what the pair state looks like from outside, for /spotify/info and the diagnostic bundle.
It exists because the first hardware run of this change could not be judged: the status endpoint reported the box name whatever the engine was actually advertising, and the only other evidence was a log line that is written on a CHANGE and therefore absent on a pair that was already formed before the agent started. Two speakers, no way to tell a working fix from a no-op.