spotify

package
v1.0.16 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 29 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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

func New(binPath, configDir, fallbackName string, box *boxapi.Client, logger *slog.Logger) *Manager

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

func (m *Manager) AccountNames() map[string]string

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

func (m *Manager) AccountNamesSorted() []string

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

func (m *Manager) AudioKeyRefused() bool

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

func (m *Manager) Bitrate() int

Bitrate returns the bitrate measured from the live stream (kbit/s), or the configured nominal when nothing has streamed yet.

func (*Manager) CanRecall

func (m *Manager) CanRecall(ctx context.Context) bool

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

func (m *Manager) ChainSnapshot() map[string]any

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

func (m *Manager) CredentialSnapshot() map[string]any

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

func (m *Manager) CurrentUsername(ctx context.Context) string

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

func (m *Manager) DeviceName() string

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

func (m *Manager) EnginePID() int

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

func (m *Manager) ExpectReattach(window time.Duration)

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

func (m *Manager) ExportCredential() ([]byte, error)

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

func (m *Manager) GroupedRefusal() bool

GroupedRefusal reports whether the speaker has just refused to be driven because it is part of a group.

func (*Manager) ImportCredential

func (m *Manager) ImportCredential(ctx context.Context, data []byte) error

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

func (m *Manager) LastBoundaryStaleKB() int64

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

func (m *Manager) LastSkipBoundary() time.Time

LastSkipBoundary returns when a user skip last reached its track boundary in the forwarded stream (zero time when none has yet).

func (*Manager) LoggedIn

func (m *Manager) LoggedIn() bool

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

func (m *Manager) Next(ctx context.Context) error

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

func (m *Manager) NoteBoxVolume(pct int)

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) Pause

func (m *Manager) Pause(ctx context.Context) error

Pause and Resume mirror the obvious controls.

func (*Manager) Play

func (m *Manager) Play(ctx context.Context, uri string, opts PlayOptions) error

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

func (m *Manager) PlayAccount(ctx context.Context, uri, account string, opts PlayOptions) error

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

func (m *Manager) PlayingContext() string

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

func (m *Manager) PlaylistMeta(ctx context.Context, uri string) (cover, title string)

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

func (m *Manager) PremiumRequired() bool

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) Prev

func (m *Manager) Prev(ctx context.Context) error

func (*Manager) Ready

func (m *Manager) Ready() bool

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

func (m *Manager) ReloadBinary() bool

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

func (m *Manager) RepeatingContext(ctx context.Context) bool

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) Resume

func (m *Manager) Resume(ctx context.Context) error

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

func (m *Manager) Run(ctx context.Context)

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

func (m *Manager) SeamsTotal() int

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) ServeOgg

func (m *Manager) ServeOgg(w http.ResponseWriter, r *http.Request)

func (*Manager) ServeQuality

func (m *Manager) ServeQuality(w http.ResponseWriter, r *http.Request)

ServeQuality answers GET and POST /spotify/quality.

func (*Manager) SessionActive

func (m *Manager) SessionActive(ctx context.Context) bool

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

func (m *Manager) SetBitrate(kbps int) (applied bool, err error)

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

func (m *Manager) SetBoxPositionFn(fn func(context.Context) (time.Duration, bool))

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

func (m *Manager) SetConnectIntentHooks(onPause func(event string), onPlay func())

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

func (m *Manager) SetGroupSlaveIPsFn(fn func() []string)

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 (m *Manager) SetOnActivate(f func(context.Context))

func (*Manager) SetOnSinkDetach

func (m *Manager) SetOnSinkDetach(fn func(attachedMs int64))

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

func (m *Manager) SetOnTrack(fn func(track, artist string))

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

func (m *Manager) SetStereoFn(fn StereoFn)

SetStereoFn installs the reporter. Called once at startup.

func (*Manager) SetVolume

func (m *Manager) SetVolume(ctx context.Context, pct int) error

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

func (m *Manager) SetZeroconfHost(label string)

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

func (m *Manager) ShufflingContext(ctx context.Context) bool

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

func (m *Manager) StopEngine() bool

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

func (m *Manager) StreamStalled() bool

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

func (m *Manager) Streaming() bool

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

func (m *Manager) SuppressActivate(d time.Duration)

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

func (m *Manager) SwitchAccount(ctx context.Context, username string) (bool, error)

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

func (m *Manager) SwitchedAway(ctx context.Context)

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

func (m *Manager) UserStopped(ctx context.Context, reason string)

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

func (m *Manager) VolumeSnapshot() map[string]any

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL