Compare commits

..
1 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 5 21043d542a feat(release): add real per-platform download links to release notes
Release notes previously pointed at the flat, alphabetical Assets
list, forcing readers to hunt for their platform's soundtouch-service
or soundtouch-cli build. Generate direct per-platform links (with
inline checksum links, one row per OS/arch) from the deterministic
asset naming convention, and wire it into both release paths: the
auto-generated notes (create_release) and the hand-authored notes a
maintainer publishes via the GitHub web UI (update_release, which now
replaces the Downloads footer line in place). The footer-replace logic
always goes through the same strip-then-append path so re-running the
job for the same tag stays byte-for-byte idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:52:56 +02:00
10 changed files with 176 additions and 386 deletions
+62 -2
View File
@@ -346,6 +346,14 @@ jobs:
TAG_NAME="${{ needs.validate.outputs.tag }}"
VERSION="${TAG_NAME#v}"
# Real per-platform links for the two most-used tools, generated
# from the deterministic `<binary>-<tag>-<os>-<arch>[.exe]` asset
# naming convention (see scripts/release/quick-downloads.sh),
# instead of requiring a scroll through the flat, alphabetical
# Assets list. Inline checksum link per row (à la Helm's release
# notes) instead of sending people to the combined checksums file.
QUICK_DOWNLOADS="$(scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}")"
# Short, accurate header. GitHub's auto-generated "What's Changed"
# + "Full Changelog" are appended after this (generate_release_notes).
cat > release_notes.md << EOF
@@ -353,13 +361,15 @@ jobs:
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
$QUICK_DOWNLOADS
## What's included
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
- **soundtouch-service** (see above)
- **soundtouch-cli** (see above)
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
@@ -414,12 +424,62 @@ jobs:
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
- name: Upgrade the Downloads footer with direct per-platform links
# This is the path real releases take: a maintainer hand-writes
# "Noteworthy" notes and publishes via the GitHub web UI, which
# fires this job, not create_release (workflow_dispatch only).
# _/releases/_TEMPLATE.md's convention is a trailing footer line:
# ---
# 📦 **Downloads / installation:** <downloads page URL>
# Drop that line (if present) and append the quick-downloads
# block in its place. Always goes through the same append path
# (strip block + strip footer + append), whether or not a
# footer line is still there, so re-runs stay byte-for-byte
# idempotent instead of drifting on the 2nd run.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ needs.validate.outputs.tag }}"
scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}" > quick_downloads.md
gh release view "$TAG_NAME" --json body -q .body > existing_body.md
python3 - << 'PYEOF'
import re
with open("existing_body.md") as f:
body = f.read()
with open("quick_downloads.md") as f:
block = f.read().rstrip("\n")
# Drop a block this automation inserted on a previous run.
body = re.sub(r"\n*<!-- quick-downloads:start -->.*?<!-- quick-downloads:end -->\n*", "\n", body, flags=re.DOTALL)
# Drop the hand-authored footer line (first run only) so both
# cases converge on the same append below and re-runs stay
# byte-for-byte idempotent.
footer = re.compile(r"^📦 \*\*Downloads / installation:\*\*.*\n?", re.MULTILINE)
body = footer.sub("", body, count=1)
body = body.rstrip("\n") + "\n\n" + block + "\n"
with open("combined_notes.md", "w") as f:
f.write(body)
PYEOF
gh release edit "$TAG_NAME" --notes-file combined_notes.md
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
-11
View File
@@ -1471,17 +1471,6 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
return err
}
// Opt-in (#622): hand-edit settings.json's auto_resume_on_source_disconnect
// to enable. Read fresh per drop so toggling it applies without a restart.
webApp.AutoResumeOnSourceDisconnect = func() bool {
settings, err := ds.GetSettings()
if err != nil {
return false
}
return settings.AutoResumeOnSourceDisconnect
}
// Keep the UI registry live as the service discovers or devices are added.
server.SetDevicesChangedHook(func() {
webApp.SeedExtraDevices()
+11 -6
View File
@@ -17,12 +17,17 @@ then **which build** matches your computer.
AfterTouch is a small set of separate programs. Most people run one or
two of them.
| Tool | What it does | You want this if… |
|----------------------|-----------------------------------------------------------------------------------------------|----------------------------------------------------------|
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
| Tool | What it does | You want this if… |
|----------------------|------------------------------------------------------------------------------------------------|---------------------------------------------------------|
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
Most people only need **`soundtouch-service`** and **`soundtouch-cli`** — the
release notes on each [GitHub release](https://github.com/gesellix/Bose-SoundTouch/releases/latest)
link those two directly, one row per platform, so you don't have to hunt
through the flat Assets list below.
> Running a migration from the command line (for example the telnet
> re-migration in the
-13
View File
@@ -2651,19 +2651,6 @@ type Settings struct {
// individual format tokens.
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
// AutoResumeOnSourceDisconnect, when true, re-issues a device's last
// playing content item if now_playing drops into an error source right
// after a healthy one, instead of leaving the speaker silent until a
// user manually re-selects it. See #622: some TuneIn streams disconnect
// the speaker's own audio pipeline (errorUpdate 1041
// SOURCE_DISCONNECTED) on their own, mid-playback, with the SoundTouch
// WebSocket control channel staying healthy throughout; the observed
// fix is exactly what pressing the preset again does. Opt-in (default
// false): this automatically re-triggers content selection without a
// user action, which not every operator wants. Hand-edit settings.json
// to enable — no admin UI control yet, matching TuneInStreamFormats.
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
// DefaultLanding selects what the root path "/" serves to a browser:
// "chooser" (or empty) — the neutral landing page that links to the
// player and the admin/setup console;
+44 -46
View File
@@ -673,29 +673,28 @@ func (s *Server) addSystemFiles(tw *tar.Writer) {
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
// struct can be marshalled into the archive without exposing credentials.
type diagSettings struct {
ServerURL string `json:"server_url"`
HTTPSServerURL string `json:"https_server_url,omitempty"`
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
SpotifyClientID string `json:"spotify_client_id,omitempty"`
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
ServerURL string `json:"server_url"`
HTTPSServerURL string `json:"https_server_url,omitempty"`
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
SpotifyClientID string `json:"spotify_client_id,omitempty"`
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
}
// addSettingsJSON serialises the service settings into the archive as
@@ -774,29 +773,28 @@ func (s *Server) addSettingsJSON(tw *tar.Writer) {
_, effectiveHTTPSURL := s.GetSettings()
ds := diagSettings{
ServerURL: st.ServerURL,
HTTPSServerURL: effectiveHTTPSURL,
HTTPSServerURLOverride: st.HTTPServerURL,
RedactLogs: st.RedactLogs,
LogBodies: st.LogBodies,
RecordInteractions: st.RecordInteractions,
DiscoveryInterval: st.DiscoveryInterval,
DiscoveryEnabled: st.DiscoveryEnabled,
DNSEnabled: st.DNSEnabled,
DNSUpstream: st.DNSUpstream,
DNSBindAddr: st.DNSBindAddr,
InternalPaths: st.InternalPaths,
Shortcuts: st.Shortcuts,
SpotifyClientID: st.SpotifyClientID,
SpotifyClientSecret: redact(st.SpotifyClientSecret),
SpotifyRedirectURI: st.SpotifyRedirectURI,
AmazonClientID: st.AmazonClientID,
AmazonClientSecret: redact(st.AmazonClientSecret),
AmazonRedirectURI: st.AmazonRedirectURI,
TrustForwardedHeaders: st.TrustForwardedHeaders,
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
TuneInStreamFormats: st.TuneInStreamFormats,
AutoResumeOnSourceDisconnect: st.AutoResumeOnSourceDisconnect,
ServerURL: st.ServerURL,
HTTPSServerURL: effectiveHTTPSURL,
HTTPSServerURLOverride: st.HTTPServerURL,
RedactLogs: st.RedactLogs,
LogBodies: st.LogBodies,
RecordInteractions: st.RecordInteractions,
DiscoveryInterval: st.DiscoveryInterval,
DiscoveryEnabled: st.DiscoveryEnabled,
DNSEnabled: st.DNSEnabled,
DNSUpstream: st.DNSUpstream,
DNSBindAddr: st.DNSBindAddr,
InternalPaths: st.InternalPaths,
Shortcuts: st.Shortcuts,
SpotifyClientID: st.SpotifyClientID,
SpotifyClientSecret: redact(st.SpotifyClientSecret),
SpotifyRedirectURI: st.SpotifyRedirectURI,
AmazonClientID: st.AmazonClientID,
AmazonClientSecret: redact(st.AmazonClientSecret),
AmazonRedirectURI: st.AmazonRedirectURI,
TrustForwardedHeaders: st.TrustForwardedHeaders,
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
TuneInStreamFormats: st.TuneInStreamFormats,
}
data, err := json.MarshalIndent(ds, "", " ")
-98
View File
@@ -1,98 +0,0 @@
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))
}
@@ -1,190 +0,0 @@
package soundtouchweb
import (
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
func tuneInNowPlaying(source string) *models.NowPlaying {
return &models.NowPlaying{
Source: source,
ContentItem: &models.ContentItem{
Source: "TUNEIN",
Type: "stationurl",
Location: "/v1/playback/station/s119025",
ItemName: "Arabella Lovesongs",
},
}
}
func TestAutoResumeState_HealthyRemembersContentItemAndDoesNotResume(t *testing.T) {
s := &autoResumeState{}
item, attempt, shouldResume := s.observe("", tuneInNowPlaying("TUNEIN"))
if shouldResume {
t.Fatalf("shouldResume = true on a healthy source, want false")
}
if item != nil || attempt != 0 {
t.Errorf("item/attempt = %v/%d, want nil/0", item, attempt)
}
if s.lastGoodContentItem == nil {
t.Fatal("lastGoodContentItem was not recorded from a healthy now_playing")
}
}
func TestAutoResumeState_FreshErrorAfterHealthyTriggersResume(t *testing.T) {
s := &autoResumeState{}
// Prime with a healthy TUNEIN event, matching the WS handler calling
// observe once per event with the source seen on the previous call.
s.observe("", tuneInNowPlaying("TUNEIN"))
item, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatal("shouldResume = false on a fresh error transition, want true")
}
if attempt != 1 {
t.Errorf("attempt = %d, want 1", attempt)
}
if item == nil || item.Location != "/v1/playback/station/s119025" {
t.Errorf("item = %+v, want the last healthy ContentItem", item)
}
}
func TestAutoResumeState_DoesNotResumeWithoutAPriorGoodContentItem(t *testing.T) {
s := &autoResumeState{}
// No healthy event was ever observed, so there's nothing to restore.
_, _, shouldResume := s.observe("", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatal("shouldResume = true with no prior good ContentItem, want false")
}
}
func TestAutoResumeState_DoesNotResumeOnRepeatedErrorEvents(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE")) // first resume, attempt 1
// A second consecutive error event (wasError=true this time) must not
// fire another resume — one attempt per drop, not per event.
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatal("shouldResume = true on a repeated error event, want false")
}
}
func TestAutoResumeState_KeepsResumingIndefinitelyAcrossRepeatedDrops(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
// The reported #622 pattern: the same station drops and (once resumed)
// recovers repeatedly, indefinitely, on a fixed cycle. Each fresh drop
// after a genuine recovery must keep resuming — there is no cap.
const cycles = 20
for i := 1; i <= cycles; i++ {
_, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatalf("cycle %d: shouldResume = false, want true", i)
}
if attempt != i {
t.Errorf("cycle %d: attempt label = %d, want %d", i, attempt, i)
}
s.observe("INVALID_SOURCE", tuneInNowPlaying("TUNEIN")) // the resume worked
}
}
func TestAutoResumeState_StopsRetryingAfterAFailedResume(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
_, _, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatal("shouldResume = false on the first drop, want true")
}
// The resume attempt itself failed (or the station is genuinely gone):
// the speaker keeps reporting the same error source on further events.
// wasError is now true, so nothing should fire again without a genuine
// recovery in between — this is what keeps a truly dead station from
// being retried forever.
for i := 0; i < 5; i++ {
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatalf("iteration %d: shouldResume = true on a persisting error, want false", i)
}
}
}
func TestAutoResumePlaybackAfter_ReselectsContentItem(t *testing.T) {
speaker, captured := setupSpeakerMock(t, nil)
defer speaker.Close()
c := client.NewClient(&client.Config{Host: speaker.URL})
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025", ItemName: "Arabella Lovesongs"}
done := make(chan struct{})
go func() {
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, 0)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("autoResumePlaybackAfter did not return in time")
}
body, ok := captured["/select"]
if !ok {
t.Fatalf("no /select request captured; requests: %v", captured)
}
if !strings.Contains(body, `source="TUNEIN"`) || !strings.Contains(body, "/v1/playback/station/s119025") {
t.Errorf("/select body = %q, want it to carry the TUNEIN content item", body)
}
}
func TestAutoResumePlaybackAfter_StopsWhenConnectionClosed(t *testing.T) {
speaker, captured := setupSpeakerMock(t, nil)
defer speaker.Close()
c := client.NewClient(&client.Config{Host: speaker.URL})
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
conn.Close()
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025"}
done := make(chan struct{})
go func() {
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, time.Hour)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("autoResumePlaybackAfter did not return promptly after conn.Close()")
}
if _, ok := captured["/select"]; ok {
t.Error("/select was called after the connection was closed, want no request")
}
}
-9
View File
@@ -78,15 +78,6 @@ type WebApp struct {
// removal only prunes the in-memory registry).
RemoveDeviceHook func(deviceID string) error
// AutoResumeOnSourceDisconnect, when set and returning true, makes
// ConnectDeviceWebSocket re-issue a device's last playing content item
// after an unsolicited drop into an error source (#622). Opt-in: the
// embedded build wires it to Settings.AutoResumeOnSourceDisconnect
// (settings.json, default false); standalone soundtouch-player leaves it
// nil, which disables the behaviour. Read once per drop rather than
// cached, so toggling the setting takes effect without a restart.
AutoResumeOnSourceDisconnect func() bool
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
-11
View File
@@ -166,12 +166,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
// error source is logged once per transition into it, not on every event.
var prevSource string
// resumeState survives both the speaker's own WebSocket reconnects and
// this loop's outer reconnects (declared once, outside the loop) so an
// auto-resume can fire regardless of which layer last re-established
// the connection.
resumeState := &autoResumeState{}
for {
// Stop if the device was removed from the registry (conn.Close()).
select {
@@ -195,11 +189,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
logNowPlayingError(deviceID, np.Source, np.SourceAccount)
}
if item, attempt, shouldResume := resumeState.observe(prevSource, np); shouldResume &&
app.AutoResumeOnSourceDisconnect != nil && app.AutoResumeOnSourceDisconnect() {
go autoResumePlayback(conn, deviceID, item, attempt)
}
prevSource = np.Source
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Emits a "Quick downloads" markdown section with real, direct download
# links for soundtouch-service and soundtouch-cli, one row per platform.
# Asset URLs are deterministic (<binary>-<tag>-<os>-<arch>[.exe]), so this
# needs no GitHub API call to build them.
#
# Usage: quick-downloads.sh <tag-name> <owner/repo>
# Output goes to stdout, wrapped in <!-- quick-downloads:start/end -->
# markers so callers can find-and-replace a previously inserted block.
set -euo pipefail
TAG_NAME="$1"
REPOSITORY="$2"
BASE_URL="https://github.com/${REPOSITORY}/releases/download/${TAG_NAME}"
# suffix|human label, same order as docs/content/docs/downloads/_index.md
PLATFORMS=(
"linux-arm64|Raspberry Pi (64-bit) / ARM64 Linux"
"linux-armv7|Raspberry Pi (32-bit) / ARMv7"
"linux-amd64|Linux (64-bit PC)"
"darwin-arm64|macOS (Apple Silicon)"
"darwin-amd64|macOS (Intel)"
"windows-amd64.exe|Windows (64-bit)"
"freebsd-amd64|FreeBSD (64-bit)"
)
build_table() {
local BINARY_NAME=$1
echo "| Platform | Download | Checksum |"
echo "|---|---|---|"
for ENTRY in "${PLATFORMS[@]}"; do
local SUFFIX="${ENTRY%%|*}"
local LABEL="${ENTRY##*|}"
local FILENAME="${BINARY_NAME}-${TAG_NAME}-${SUFFIX}"
echo "| ${LABEL} | [${FILENAME}](${BASE_URL}/${FILENAME}) | [sha256](${BASE_URL}/${FILENAME}.sha256) |"
done
}
SERVICE_TABLE="$(build_table soundtouch-service)"
CLI_TABLE="$(build_table soundtouch-cli)"
cat << EOF
<!-- quick-downloads:start -->
## Quick downloads
Most people only need one of these two:
**soundtouch-service** — the local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
$SERVICE_TABLE
**soundtouch-cli** — command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
$CLI_TABLE
Everything else (soundtouch-player, soundtouch-backup, other platforms, Docker, install scripts): [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/).
<!-- quick-downloads:end -->
EOF