Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/JRpersonal/streborn/internal/dnsboot"
"github.com/JRpersonal/streborn/internal/hosts"
"github.com/JRpersonal/streborn/internal/marge"
"github.com/JRpersonal/streborn/internal/mediaservers"
"github.com/JRpersonal/streborn/internal/presets"
"github.com/JRpersonal/streborn/internal/recent"
"github.com/JRpersonal/streborn/internal/shepherd"
Expand Down Expand Up @@ -303,6 +304,16 @@ func run() error {
logger.Warn("zones config load failed, continuing standalone", "err", zErr)
}

// DLNA/UPnP media servers the user turned into native music sources. The
// speaker drops the registration about a minute into every boot (it re-checks
// the account against marge, whose record of it was in memory and went away
// with the restart), so STR remembers the choice and puts it back. A load
// error is non-fatal: start with nothing enabled.
mediaServerStore, msErr := mediaservers.Load("/mnt/nv/streborn/mediaservers.json")
if msErr != nil {
logger.Warn("media server config load failed, starting with none enabled", "err", msErr)
}

// Recently-played ring (#135), persisted on NAND (debounced; see the recent
// package). A load error is non-fatal: start with an empty history.
recentStore, rErr := recent.Load("/mnt/nv/streborn/recent.json")
Expand Down Expand Up @@ -631,6 +642,14 @@ func run() error {
}),
webui.WithWebhooks(webhooksStore),
webui.WithZones(zonesStore),
webui.WithMediaServers(mediaServerStore),
webui.WithStoredMusicPublisher(func(list []webui.StoredMusicSource) {
out := make([]marge.StoredMusicSource, 0, len(list))
for _, m := range list {
out = append(out, marge.StoredMusicSource{Account: m.Account, Name: m.Name})
}
margeSrv.SetStoredMusicSources(out)
}),
webui.WithMargeGroups(margeSrv.GroupSnapshot, margeSrv.SetCanonicalGroup, margeSrv.ClearGroup),
webui.WithMargeForward(margeSrv.SetForward),
webui.WithRecent(recentStore))
Expand All @@ -641,6 +660,14 @@ func run() error {
// the current stream + the UPnP renderer.
go webuiSrv.PeriodicZoneReconcile()

// Publish the user's DLNA/UPnP music sources into the marge account, which
// the box polls for itself at boot and keeps whatever it finds there, exactly
// the way radio arrives. That is the entire persistence mechanism: no write
// to the speaker, so nothing here can disturb its standby countdown. Done
// synchronously and early, because the box's account poll comes seconds
// after its own boot.
webuiSrv.PublishMediaServers()

// Auto-leave the out-of-box SETUP source. A box that installed STR over the
// network but never finished Bose's app-driven onboarding keeps the SETUP
// source active: the display shows "follow the SoundTouch app instructions"
Expand Down
115 changes: 115 additions & 0 deletions desktop-app/app_mediaservers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

// Music library: DLNA/UPnP media servers as a native source on the speaker.
//
// The speaker discovers media servers on the LAN by itself but will not play
// from one until that server is registered as a music account. Once it is, the
// speaker browses and plays the server on its own, and it shows up in the
// original Bose app too. STR only turns the registration on and keeps it on;
// the agent holds the memory of it, because the speaker forgets it on reboot.
//
// This is deliberately thin. All three calls are the agent's endpoint with the
// port self-healing every other box call gets.

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)

// BoxMediaServer is one media server as the speaker sees it. Distinct from the
// Library tab's LibraryServer, which is what the DESKTOP discovered for its own
// browsing: this one is about what the SPEAKER plays by itself.
type BoxMediaServer struct {
ID string `json:"id"`
IP string `json:"ip"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"modelName"`
FriendlyName string `json:"friendlyName"`
// Registered is what the speaker reports RIGHT NOW; Enabled is what the user
// asked for. They differ for a while after enabling, and after a reboot,
// because the speaker confirms the account with STR before the source
// appears. The UI shows Enabled and explains the wait.
Registered bool `json:"registered"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
}

// mediaServerCallTimeout is generous on purpose: /listMediaServers comes out of
// the firmware's own discovery cache, and a speaker that just woke can take
// several seconds to produce it.
const mediaServerCallTimeout = 25 * time.Second

// ListBoxMediaServers returns the media servers this speaker can see, each marked
// with whether it is enabled as a music source.
func (a *App) ListBoxMediaServers(host string, port int) ([]BoxMediaServer, error) {
resp, err := a.boxDoTimeout(host, port, http.MethodGet, "/api/box/mediaservers", "", "", mediaServerCallTimeout)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, readHTTPError(resp)
}
var out struct {
Servers []BoxMediaServer `json:"servers"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Servers, nil
}

// EnableBoxMediaServer registers a media server as a music source on the speaker.
//
// It returns once the speaker has ACCEPTED the registration, which is not the
// same as the source being usable: the speaker then confirms the account with
// STR's marge, and measured on real hardware that took minutes. Callers must
// present this as "on its way", never as "ready now".
func (a *App) EnableBoxMediaServer(host string, port int, id, name string) error {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("no media server selected")
}
body, err := json.Marshal(map[string]string{"id": id, "name": name})
if err != nil {
return err
}
resp, err := a.boxDoTimeout(host, port, http.MethodPost, "/api/box/mediaservers",
"application/json", string(body), mediaServerCallTimeout)
if err != nil {
a.logger.Info("media server: enable failed", "host", host, "id", id, "err", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
herr := readHTTPError(resp)
a.logger.Info("media server: the speaker refused the registration", "host", host, "id", id, "err", herr)
return herr
}
a.logger.Info("media server: enabled as a music source", "host", host, "id", id, "name", name)
return nil
}

// DisableBoxMediaServer removes the media server as a music source again.
func (a *App) DisableBoxMediaServer(host string, port int, id, name string) error {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("no media server selected")
}
path := "/api/box/mediaservers?id=" + url.QueryEscape(id) + "&name=" + url.QueryEscape(name)
resp, err := a.boxDoTimeout(host, port, http.MethodDelete, path, "", "", mediaServerCallTimeout)
if err != nil {
a.logger.Info("media server: disable failed", "host", host, "id", id, "err", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
herr := readHTTPError(resp)
a.logger.Info("media server: the speaker refused the removal", "host", host, "id", id, "err", herr)
return herr
}
a.logger.Info("media server: removed as a music source", "host", host, "id", id)
return nil
}
3 changes: 3 additions & 0 deletions desktop-app/frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export {
SuggestBoxLanguage,
ListWiFiProfiles,
TryWiFiPassword,
ListBoxMediaServers,
EnableBoxMediaServer,
DisableBoxMediaServer,
CurrentWiFi,
CheckAppUpdate,
ResolveStationLogo,
Expand Down
24 changes: 24 additions & 0 deletions desktop-app/frontend/src/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,30 @@ export function inStereoPair(box, pair) {
(box.host && (m && m.ip) === box.host));
}

// stereoUndoTargets lists the speakers an "undo pair" has to be sent to, master
// first.
//
// It returns BOTH halves rather than the master alone. "Only the master's
// firmware reports the pair" was true of a healthy pair and is not true of a
// broken one: measured 2026-08-10 on two SoundTouch 10s, the master answered
// /getGroup with an empty group while the other half still held the whole
// document naming the master as LEFT. An undo aimed at the master was told
// there was nothing to undo, so the leftover could not be cleared from the app
// at all, and the panel contradicted itself, naming the pair and denying it in
// the same breath.
//
// Master first because on a healthy pair it is the half that owns the pair, and
// asking it first keeps the common case a single call's worth of work. Asking
// the other half after costs nothing: a speaker that is not in a pair answers
// "nothing to undo" and is left untouched.
export function stereoUndoTargets(pair, boxes) {
const live = pairMemberBoxes(pair, boxes).map(x => x.box)
.filter(b => b && b.kind !== 'stock');
const masterUp = String((pair && pair.master) || '').toUpperCase();
const isMaster = (b) => masterUp && String(b.deviceID || '').toUpperCase() === masterUp;
return [...live.filter(isMaster), ...live.filter(b => !isMaster(b))];
}

// balanceSourceBox picks which speaker to ask for a stereo pair's balance.
//
// Only the MASTER of a pair reports a balance; ask the other half and it says
Expand Down
31 changes: 31 additions & 0 deletions desktop-app/frontend/src/groups.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
stereoPairOf,
pairMemberBoxes,
inStereoPair,
stereoUndoTargets,
} from './groups.js';

// Placeholder LAN (192.0.2.0/24, RFC 5737) and deviceIDs only.
Expand All @@ -42,6 +43,36 @@ function liveMap() {
};
}

describe('stereoUndoTargets', () => {
const pair = {
id: 'str-grp-1', master: master.deviceID,
members: [
{ deviceID: master.deviceID, ip: master.host, role: 'LEFT' },
{ deviceID: boxA.deviceID, ip: boxA.host, role: 'RIGHT' },
],
};
it('returns both halves, master first', () => {
expect(stereoUndoTargets(pair, [boxA, boxB, master])).toEqual([master, boxA]);
});
it('still returns the other half when the master is not discovered', () => {
// The leftover case: the half that answers is not the recorded master.
expect(stereoUndoTargets(pair, [boxA, boxB])).toEqual([boxA]);
});
it('skips stock speakers, which have no agent to ask', () => {
const stockPair = {
master: stock.deviceID,
members: [
{ deviceID: stock.deviceID, ip: stock.host, role: 'LEFT' },
{ deviceID: boxA.deviceID, ip: boxA.host, role: 'RIGHT' },
],
};
expect(stereoUndoTargets(stockPair, [stock, boxA])).toEqual([boxA]);
});
it('is empty without a pair', () => {
expect(stereoUndoTargets(null, [master, boxA])).toEqual([]);
});
});

describe('zoneBoxes', () => {
it('keeps only STR boxes with host and deviceID', () => {
const noID = { host: '192.0.2.4', port: 8888, kind: 'str' };
Expand Down
8 changes: 8 additions & 0 deletions desktop-app/frontend/src/i18n/bundles/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "متاح",
"settingsView.spotifyHint": "لا يمكن تفعيل Spotify Connect دون سحابة Bose. يوجد تكامل مع Spotify Web API ضمن خارطة الطريق.",
"settingsView.airplayHint": "AirPlay 2 موجود في العتاد لكنه يُفعّل فقط عبر إعداد Bose الأصلي بحساب Bose. إذا لم تُقرن السمّاعة قطّ بحساب Bose، يبقى غير نشط.",
"settingsView.musicLibHeading": "مكتبة الموسيقى",
"settingsView.musicLibHelp": "شغّل الموسيقى من خادم في شبكتك. تشغّلها السمّاعة بنفسها، وتظهر أيضاً في تطبيق Bose.",
"settingsView.musicLibNone": "لم يتم العثور على خادم وسائط في شبكتك.",
"settingsView.musicLibAdd": "إضافة",
"settingsView.musicLibRemove": "إزالة",
"settingsView.musicLibWait": "تمت الإضافة. تحتاج السمّاعة بضع دقائق قبل ظهور المكتبة.",
"settingsView.musicLibActive": "على السمّاعة",
"settingsView.musicLibPending": "قيد الإعداد",
"settingsView.regionHeading": "المنطقة",
"settingsView.regionCurrent": "الحالية",
"settingsView.regionHelp": "تُستخدم كدولة افتراضية للبحث عن الراديو ومرشّح اللغة. تسري فورًا وتُخزّن على الذاكرة.",
Expand Down
8 changes: 8 additions & 0 deletions desktop-app/frontend/src/i18n/bundles/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,14 @@
"settingsView.sourceAvailable": "verfügbar",
"settingsView.spotifyHint": "Spotify Connect ohne Bose-Cloud aktuell nicht aktivierbar. Implementierung via Spotify Web API folgt.",
"settingsView.airplayHint": "AirPlay 2 ist hardwareseitig da, wird aber erst durch Bose-Setup mit Cloud-Account aktiviert. Wenn der Lautsprecher vorher nie mit einem Bose-Konto verbunden war, bleibt es inaktiv.",
"settingsView.musicLibHeading": "Musiksammlung",
"settingsView.musicLibHelp": "Spiele Musik von einem Server in deinem Netzwerk. Die Box spielt sie selbst ab, und sie taucht auch in der Bose App auf.",
"settingsView.musicLibNone": "Kein Medienserver im Netzwerk gefunden.",
"settingsView.musicLibAdd": "Hinzufügen",
"settingsView.musicLibRemove": "Entfernen",
"settingsView.musicLibWait": "Hinzugefügt. Die Box braucht ein paar Minuten, bis die Sammlung erscheint.",
"settingsView.musicLibActive": "auf der Box",
"settingsView.musicLibPending": "wird eingerichtet",
"settingsView.regionHeading": "Region",
"settingsView.regionCurrent": "Aktuell",
"settingsView.regionHelp": "Wird für das Default-Land der Radio-Suche und den Sprach-Filter benutzt. Änderung greift sofort und wird auf dem Stick gespeichert.",
Expand Down
8 changes: 8 additions & 0 deletions desktop-app/frontend/src/i18n/bundles/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,14 @@
"settingsView.sourceAvailable": "available",
"settingsView.spotifyHint": "Spotify Connect cannot be enabled without the Bose cloud. A Spotify Web API integration is on the roadmap.",
"settingsView.airplayHint": "AirPlay 2 is present in hardware but only enabled by the original Bose setup with a Bose account. If the speaker was never paired with a Bose account, it stays inactive.",
"settingsView.musicLibHeading": "Music library",
"settingsView.musicLibHelp": "Play music from a server on your network. The speaker plays it by itself, and it also shows up in the Bose app.",
"settingsView.musicLibNone": "No media server found on your network.",
"settingsView.musicLibAdd": "Add",
"settingsView.musicLibRemove": "Remove",
"settingsView.musicLibWait": "Added. The speaker needs a few minutes before the library appears.",
"settingsView.musicLibActive": "on the speaker",
"settingsView.musicLibPending": "being set up",
"settingsView.regionHeading": "Region",
"settingsView.regionCurrent": "Current",
"settingsView.regionHelp": "Used as the default country for the radio search and language filter. Takes effect immediately and is stored on the stick.",
Expand Down
8 changes: 8 additions & 0 deletions desktop-app/frontend/src/i18n/bundles/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "disponible",
"settingsView.spotifyHint": "Spotify Connect no puede activarse sin la nube de Bose. Una integración con la API web de Spotify está en la hoja de ruta.",
"settingsView.airplayHint": "AirPlay 2 está presente en el hardware pero solo lo activa la configuración Bose original con una cuenta Bose. Si el altavoz nunca se emparejó con una cuenta Bose, permanece inactivo.",
"settingsView.musicLibHeading": "Biblioteca de música",
"settingsView.musicLibHelp": "Reproduce música desde un servidor de tu red. El altavoz la reproduce por sí mismo y también aparece en la app de Bose.",
"settingsView.musicLibNone": "No se encontró ningún servidor multimedia en tu red.",
"settingsView.musicLibAdd": "Añadir",
"settingsView.musicLibRemove": "Quitar",
"settingsView.musicLibWait": "Añadido. El altavoz necesita unos minutos antes de que aparezca la biblioteca.",
"settingsView.musicLibActive": "en el altavoz",
"settingsView.musicLibPending": "configurándose",
"settingsView.regionHeading": "Región",
"settingsView.regionCurrent": "Actual",
"settingsView.regionHelp": "Se usa como país predeterminado para la búsqueda de radio y el filtro de idioma. Surte efecto de inmediato y se almacena en la memoria.",
Expand Down
8 changes: 8 additions & 0 deletions desktop-app/frontend/src/i18n/bundles/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "disponible",
"settingsView.spotifyHint": "Spotify Connect ne peut pas être activé sans le cloud Bose. Une intégration de l'API Web Spotify est prévue dans la feuille de route.",
"settingsView.airplayHint": "AirPlay 2 est présent matériellement mais n'est activé que par la configuration Bose d'origine avec un compte Bose. Si l'enceinte n'a jamais été associée à un compte Bose, il reste inactif.",
"settingsView.musicLibHeading": "Bibliothèque musicale",
"settingsView.musicLibHelp": "Écoutez la musique d'un serveur de votre réseau. L'enceinte la lit elle-même, et elle apparaît aussi dans l'application Bose.",
"settingsView.musicLibNone": "Aucun serveur multimédia trouvé sur votre réseau.",
"settingsView.musicLibAdd": "Ajouter",
"settingsView.musicLibRemove": "Retirer",
"settingsView.musicLibWait": "Ajouté. L'enceinte a besoin de quelques minutes avant que la bibliothèque apparaisse.",
"settingsView.musicLibActive": "sur l'enceinte",
"settingsView.musicLibPending": "en cours de configuration",
"settingsView.regionHeading": "Région",
"settingsView.regionCurrent": "Actuelle",
"settingsView.regionHelp": "Utilisée comme pays par défaut pour la recherche radio et le filtre de langue. Prend effet immédiatement et est stockée sur la clé.",
Expand Down
8 changes: 8 additions & 0 deletions desktop-app/frontend/src/i18n/bundles/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "利用可能",
"settingsView.spotifyHint": "Boseクラウドなしでは Spotify Connect を有効にできません。Spotify Web API連携はロードマップにあります。",
"settingsView.airplayHint": "AirPlay 2 はハードウェアに搭載されていますが、Boseアカウントによる純正セットアップでのみ有効になります。Boseアカウントと一度もペアリングしていない場合は無効のままです。",
"settingsView.musicLibHeading": "音楽ライブラリ",
"settingsView.musicLibHelp": "ネットワーク上のサーバーから音楽を再生します。スピーカーが自分で再生し、Bose アプリにも表示されます。",
"settingsView.musicLibNone": "ネットワークにメディアサーバーが見つかりません。",
"settingsView.musicLibAdd": "追加",
"settingsView.musicLibRemove": "削除",
"settingsView.musicLibWait": "追加しました。ライブラリが表示されるまで数分かかります。",
"settingsView.musicLibActive": "スピーカーで利用可能",
"settingsView.musicLibPending": "設定中",
"settingsView.regionHeading": "地域",
"settingsView.regionCurrent": "現在",
"settingsView.regionHelp": "ラジオ検索と言語フィルターの既定の国として使用されます。すぐに反映され、メモリに保存されます。",
Expand Down
8 changes: 8 additions & 0 deletions desktop-app/frontend/src/i18n/bundles/lt.json
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "prieinama",
"settingsView.spotifyHint": "Spotify Connect negalima įjungti be Bose debesies. Integracija per Spotify Web API yra planuose.",
"settingsView.airplayHint": "AirPlay 2 yra aparatinėje įrangoje, bet jį įjungia tik originali Bose sąranka su Bose paskyra. Jei garsiakalbis niekada nebuvo susietas su Bose paskyra, jis lieka neaktyvus.",
"settingsView.musicLibHeading": "Muzikos biblioteka",
"settingsView.musicLibHelp": "Grokite muziką iš serverio jūsų tinkle. Kolonėlė ją groja pati, o biblioteka matoma ir Bose programėlėje.",
"settingsView.musicLibNone": "Tinkle medijos serverių nerasta.",
"settingsView.musicLibAdd": "Pridėti",
"settingsView.musicLibRemove": "Šalinti",
"settingsView.musicLibWait": "Pridėta. Kolonėlei reikia kelių minučių, kol biblioteka pasirodys.",
"settingsView.musicLibActive": "kolonėlėje",
"settingsView.musicLibPending": "nustatoma",
"settingsView.regionHeading": "Regionas",
"settingsView.regionCurrent": "Dabartinis",
"settingsView.regionHelp": "Naudojamas kaip numatytoji šalis radijo paieškai ir kalbos filtrui. Veikia iškart ir išsaugomas atmintinėje.",
Expand Down
Loading