Files
Bose-SoundTouch/pkg/service/soundtouchweb/autoresume.go
T
Tobias Gesellchen df7b0fc7ae fix(soundtouchweb): auto-resume playback after an unsolicited SOURCE_DISCONNECTED (#622)
A speaker can drop its own active source mid-playback (errorUpdate 1041
SOURCE_DISCONNECTED -> now_playing INVALID_SOURCE) while the SoundTouch
WebSocket control channel stays healthy throughout. Nothing previously
noticed this: logNowPlayingError only logged the transition, leaving
the speaker silent until someone manually re-selected the source.

Add autoResumeState, tracking the last healthy ContentItem per device
connection. On a fresh transition into an error source (not a repeat
of one already seen), it re-issues that ContentItem via SelectContentItem
after a short backoff - the same call pressing the preset again makes.
No attempt cap: if the resume itself fails, the source stays in error
and nothing fires again until a genuine recovery is observed, which
already bounds retries for a station that's truly gone without capping
a station that legitimately (and repeatedly) recovers on its own.
2026-08-17 21:57:22 +02:00

99 lines
3.7 KiB
Go

package soundtouchweb
import (
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// autoResumeBackoff is the delay before re-issuing a dropped content item,
// giving a transient upstream hiccup a moment to clear before retrying.
const autoResumeBackoff = 2 * time.Second
// autoResumeState tracks what ConnectDeviceWebSocket needs to decide whether
// a now_playing transition should trigger an auto-resume. Split out from the
// WebSocket goroutine so the decision can be unit tested without a live
// connection.
//
// resumeAttempts only labels log lines — it is never used to cap retries.
// A resume is gated on wasError being false (see observe), which already
// means at most one attempt ever fires per drop: if the attempt fails and
// the source stays in error, every following event has wasError=true and
// nothing fires again until a genuine recovery is observed. A station that
// keeps recovering and re-dropping (the reported #622 pattern — a TuneIn
// stream disconnecting the speaker on a fixed cycle, indefinitely, while
// otherwise healthy) is exactly the case this should keep resuming forever.
type autoResumeState struct {
lastGoodContentItem *models.ContentItem
resumeAttempts int
}
// observe updates the state for a new now_playing event and reports whether
// the caller should fire an auto-resume for item, plus a label for the log
// line. prevSource is the source seen on the previous event.
//
// #622: some TuneIn stations disconnect the speaker's audio pipeline on
// their own (errorUpdate 1041 SOURCE_DISCONNECTED, observed ~5m35s into
// playback on one reporter's setup) even though the SoundTouch WebSocket
// control channel stays healthy throughout. The firmware does not recover
// on its own, so a fresh transition into an error source right after a
// healthy one — the speaker dropping a source it didn't choose to leave, as
// opposed to the user picking a new one — re-issues the last content item,
// exactly what pressing the physical preset button again does.
func (s *autoResumeState) observe(prevSource string, np *models.NowPlaying) (item *models.ContentItem, attempt int, shouldResume bool) {
wasError := isErrorSource(prevSource)
nowError := isErrorSource(np.Source)
if !nowError {
if np.ContentItem != nil {
s.lastGoodContentItem = np.ContentItem
}
return nil, 0, false
}
if wasError || s.lastGoodContentItem == nil {
return nil, 0, false
}
s.resumeAttempts++
return s.lastGoodContentItem, s.resumeAttempts, true
}
// autoResumePlayback re-selects item on conn's device after autoResumeBackoff.
// It runs in its own goroutine (never on the WebSocket read loop) so a slow
// or hanging /select call can't stall processing of further device events.
func autoResumePlayback(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int) {
autoResumePlaybackAfter(conn, deviceID, item, attempt, autoResumeBackoff)
}
// autoResumePlaybackAfter is autoResumePlayback with an injectable delay so
// tests don't have to wait out the real backoff.
func autoResumePlaybackAfter(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int, delay time.Duration) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
case <-conn.Done():
return
}
if conn.Client == nil {
return
}
if err := conn.Client.SelectContentItem(item); err != nil {
log.Printf("[play] device=%q auto-resume attempt %d failed: %v",
sanitizeLog(deviceID), attempt, err)
return
}
log.Printf("[play] device=%q auto-resume attempt %d re-selected source=%q location=%q",
sanitizeLog(deviceID), attempt, sanitizeLog(item.Source), sanitizeLog(item.Location))
}