diff --git a/cmd/agent/main.go b/cmd/agent/main.go
index 10c27c2c..5056d907 100644
--- a/cmd/agent/main.go
+++ b/cmd/agent/main.go
@@ -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"
@@ -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")
@@ -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))
@@ -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"
diff --git a/desktop-app/app_mediaservers.go b/desktop-app/app_mediaservers.go
new file mode 100644
index 00000000..a4c2f4a8
--- /dev/null
+++ b/desktop-app/app_mediaservers.go
@@ -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
+}
diff --git a/desktop-app/frontend/src/api.js b/desktop-app/frontend/src/api.js
index 23929486..73dc76c1 100644
--- a/desktop-app/frontend/src/api.js
+++ b/desktop-app/frontend/src/api.js
@@ -66,6 +66,9 @@ export {
SuggestBoxLanguage,
ListWiFiProfiles,
TryWiFiPassword,
+ ListBoxMediaServers,
+ EnableBoxMediaServer,
+ DisableBoxMediaServer,
CurrentWiFi,
CheckAppUpdate,
ResolveStationLogo,
diff --git a/desktop-app/frontend/src/groups.js b/desktop-app/frontend/src/groups.js
index 31da968b..7d1c149c 100644
--- a/desktop-app/frontend/src/groups.js
+++ b/desktop-app/frontend/src/groups.js
@@ -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
diff --git a/desktop-app/frontend/src/groups.test.js b/desktop-app/frontend/src/groups.test.js
index 2d42ec03..f787d192 100644
--- a/desktop-app/frontend/src/groups.test.js
+++ b/desktop-app/frontend/src/groups.test.js
@@ -20,6 +20,7 @@ import {
stereoPairOf,
pairMemberBoxes,
inStereoPair,
+ stereoUndoTargets,
} from './groups.js';
// Placeholder LAN (192.0.2.0/24, RFC 5737) and deviceIDs only.
@@ -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' };
diff --git a/desktop-app/frontend/src/i18n/bundles/ar.json b/desktop-app/frontend/src/i18n/bundles/ar.json
index 64eb2e37..a25334ad 100644
--- a/desktop-app/frontend/src/i18n/bundles/ar.json
+++ b/desktop-app/frontend/src/i18n/bundles/ar.json
@@ -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": "تُستخدم كدولة افتراضية للبحث عن الراديو ومرشّح اللغة. تسري فورًا وتُخزّن على الذاكرة.",
diff --git a/desktop-app/frontend/src/i18n/bundles/de.json b/desktop-app/frontend/src/i18n/bundles/de.json
index 7fbd24a8..19f2212e 100644
--- a/desktop-app/frontend/src/i18n/bundles/de.json
+++ b/desktop-app/frontend/src/i18n/bundles/de.json
@@ -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.",
diff --git a/desktop-app/frontend/src/i18n/bundles/en.json b/desktop-app/frontend/src/i18n/bundles/en.json
index 104a0c02..0ae9bd1a 100644
--- a/desktop-app/frontend/src/i18n/bundles/en.json
+++ b/desktop-app/frontend/src/i18n/bundles/en.json
@@ -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.",
diff --git a/desktop-app/frontend/src/i18n/bundles/es.json b/desktop-app/frontend/src/i18n/bundles/es.json
index b840b8c6..fe855045 100644
--- a/desktop-app/frontend/src/i18n/bundles/es.json
+++ b/desktop-app/frontend/src/i18n/bundles/es.json
@@ -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.",
diff --git a/desktop-app/frontend/src/i18n/bundles/fr.json b/desktop-app/frontend/src/i18n/bundles/fr.json
index 787dbde5..41489a04 100644
--- a/desktop-app/frontend/src/i18n/bundles/fr.json
+++ b/desktop-app/frontend/src/i18n/bundles/fr.json
@@ -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é.",
diff --git a/desktop-app/frontend/src/i18n/bundles/ja.json b/desktop-app/frontend/src/i18n/bundles/ja.json
index 864e7e1b..1650d09e 100644
--- a/desktop-app/frontend/src/i18n/bundles/ja.json
+++ b/desktop-app/frontend/src/i18n/bundles/ja.json
@@ -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": "ラジオ検索と言語フィルターの既定の国として使用されます。すぐに反映され、メモリに保存されます。",
diff --git a/desktop-app/frontend/src/i18n/bundles/lt.json b/desktop-app/frontend/src/i18n/bundles/lt.json
index 01c25b17..578b960d 100644
--- a/desktop-app/frontend/src/i18n/bundles/lt.json
+++ b/desktop-app/frontend/src/i18n/bundles/lt.json
@@ -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.",
diff --git a/desktop-app/frontend/src/i18n/bundles/lv.json b/desktop-app/frontend/src/i18n/bundles/lv.json
index b300ea38..bc5104e0 100644
--- a/desktop-app/frontend/src/i18n/bundles/lv.json
+++ b/desktop-app/frontend/src/i18n/bundles/lv.json
@@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "pieejams",
"settingsView.spotifyHint": "Spotify Connect nevar ieslēgt bez Bose mākoņa. Integrācija caur Spotify Web API ir plānos.",
"settingsView.airplayHint": "AirPlay 2 ir aparatūrā, bet to ieslēdz tikai oriģinālā Bose iestatīšana ar Bose kontu. Ja skaļrunis nekad nav bijis savienots ar Bose kontu, tas paliek neaktīvs.",
+ "settingsView.musicLibHeading": "Mūzikas bibliotēka",
+ "settingsView.musicLibHelp": "Atskaņojiet mūziku no servera jūsu tīklā. Skaļrunis to atskaņo pats, un bibliotēka parādās arī Bose lietotnē.",
+ "settingsView.musicLibNone": "Tīklā nav atrasts multivides serveris.",
+ "settingsView.musicLibAdd": "Pievienot",
+ "settingsView.musicLibRemove": "Noņemt",
+ "settingsView.musicLibWait": "Pievienots. Skaļrunim vajag dažas minūtes, līdz bibliotēka parādās.",
+ "settingsView.musicLibActive": "skaļrunī",
+ "settingsView.musicLibPending": "tiek iestatīts",
"settingsView.regionHeading": "Reģions",
"settingsView.regionCurrent": "Pašreizējais",
"settingsView.regionHelp": "Tiek izmantots kā noklusējuma valsts radio meklēšanai un valodas filtram. Darbojas uzreiz un tiek saglabāts zibatmiņā.",
diff --git a/desktop-app/frontend/src/i18n/bundles/nl.json b/desktop-app/frontend/src/i18n/bundles/nl.json
index 692eb5bb..3562e3ca 100644
--- a/desktop-app/frontend/src/i18n/bundles/nl.json
+++ b/desktop-app/frontend/src/i18n/bundles/nl.json
@@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "beschikbaar",
"settingsView.spotifyHint": "Spotify Connect kan niet worden ingeschakeld zonder de Bose-cloud. Een integratie via de Spotify Web API staat op de roadmap.",
"settingsView.airplayHint": "AirPlay 2 is in de hardware aanwezig maar wordt alleen ingeschakeld door de originele Bose-setup met een Bose-account. Als de speaker nooit met een Bose-account is gekoppeld, blijft het inactief.",
+ "settingsView.musicLibHeading": "Muziekbibliotheek",
+ "settingsView.musicLibHelp": "Speel muziek van een server in je netwerk. De speaker speelt die zelf af, en hij verschijnt ook in de Bose app.",
+ "settingsView.musicLibNone": "Geen mediaserver in je netwerk gevonden.",
+ "settingsView.musicLibAdd": "Toevoegen",
+ "settingsView.musicLibRemove": "Verwijderen",
+ "settingsView.musicLibWait": "Toegevoegd. De speaker heeft een paar minuten nodig voordat de bibliotheek verschijnt.",
+ "settingsView.musicLibActive": "op de speaker",
+ "settingsView.musicLibPending": "wordt ingesteld",
"settingsView.regionHeading": "Regio",
"settingsView.regionCurrent": "Huidig",
"settingsView.regionHelp": "Wordt gebruikt als standaardland voor het radiozoeken en het taalfilter. Werkt meteen en wordt op de stick opgeslagen.",
diff --git a/desktop-app/frontend/src/i18n/bundles/pl.json b/desktop-app/frontend/src/i18n/bundles/pl.json
index 140c240c..c3ce8512 100644
--- a/desktop-app/frontend/src/i18n/bundles/pl.json
+++ b/desktop-app/frontend/src/i18n/bundles/pl.json
@@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "dostępne",
"settingsView.spotifyHint": "Spotify Connect nie można włączyć bez chmury Bose. Integracja przez Spotify Web API jest na mapie drogowej.",
"settingsView.airplayHint": "AirPlay 2 jest obecne w sprzęcie, ale włącza je tylko oryginalna konfiguracja Bose z kontem Bose. Jeśli głośnik nigdy nie był sparowany z kontem Bose, pozostaje nieaktywne.",
+ "settingsView.musicLibHeading": "Biblioteka muzyki",
+ "settingsView.musicLibHelp": "Odtwarzaj muzykę z serwera w swojej sieci. Głośnik odtwarza ją sam, a biblioteka pojawia się też w aplikacji Bose.",
+ "settingsView.musicLibNone": "Nie znaleziono serwera multimediów w sieci.",
+ "settingsView.musicLibAdd": "Dodaj",
+ "settingsView.musicLibRemove": "Usuń",
+ "settingsView.musicLibWait": "Dodano. Głośnik potrzebuje kilku minut, zanim biblioteka się pojawi.",
+ "settingsView.musicLibActive": "na głośniku",
+ "settingsView.musicLibPending": "konfigurowanie",
"settingsView.regionHeading": "Region",
"settingsView.regionCurrent": "Bieżący",
"settingsView.regionHelp": "Używany jako domyślny kraj w wyszukiwaniu radia i filtrze języka. Działa natychmiast i jest zapisywany na pendrive.",
diff --git a/desktop-app/frontend/src/i18n/bundles/tr.json b/desktop-app/frontend/src/i18n/bundles/tr.json
index 090495bd..f75f51c5 100644
--- a/desktop-app/frontend/src/i18n/bundles/tr.json
+++ b/desktop-app/frontend/src/i18n/bundles/tr.json
@@ -741,6 +741,14 @@
"settingsView.sourceAvailable": "kullanılabilir",
"settingsView.spotifyHint": "Spotify Connect, Bose bulutu olmadan açılamaz. Spotify Web API üzerinden entegrasyon planlanmaktadır.",
"settingsView.airplayHint": "AirPlay 2 donanımdadır, ancak yalnızca bir Bose hesabıyla orijinal Bose kurulumu onu açar. Hoparlör hiç bir Bose hesabıyla eşleştirilmediyse, etkin değil kalır.",
+ "settingsView.musicLibHeading": "Müzik kitaplığı",
+ "settingsView.musicLibHelp": "Ağınızdaki bir sunucudan müzik çalın. Hoparlör bunu kendi başına çalar ve kitaplık Bose uygulamasında da görünür.",
+ "settingsView.musicLibNone": "Ağınızda medya sunucusu bulunamadı.",
+ "settingsView.musicLibAdd": "Ekle",
+ "settingsView.musicLibRemove": "Kaldır",
+ "settingsView.musicLibWait": "Eklendi. Kitaplığın görünmesi için hoparlörün birkaç dakikaya ihtiyacı var.",
+ "settingsView.musicLibActive": "hoparlörde",
+ "settingsView.musicLibPending": "kuruluyor",
"settingsView.regionHeading": "Bölge",
"settingsView.regionCurrent": "Mevcut",
"settingsView.regionHelp": "Radyo araması ve dil filtresi için varsayılan ülke olarak kullanılır. Hemen çalışır ve bellekte saklanır.",
diff --git a/desktop-app/frontend/src/i18n/bundles/uk.json b/desktop-app/frontend/src/i18n/bundles/uk.json
index d708c689..2220dd77 100644
--- a/desktop-app/frontend/src/i18n/bundles/uk.json
+++ b/desktop-app/frontend/src/i18n/bundles/uk.json
@@ -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": "Використовується як країна за замовчуванням для пошуку радіо та фільтра мови. Застосовується одразу й зберігається на накопичувачі.",
diff --git a/desktop-app/frontend/src/views/multiroom.js b/desktop-app/frontend/src/views/multiroom.js
index 3549b788..ef2bd1c4 100644
--- a/desktop-app/frontend/src/views/multiroom.js
+++ b/desktop-app/frontend/src/views/multiroom.js
@@ -11,7 +11,7 @@ import { t } from '../i18n/index.js';
import { FormZone, DissolveZone, DissolveStereoPair, WakeBox, BrowserOpenURL } from '../api.js';
// Group membership + the shared zoneLive poll live in groups.js: ONE
// implementation for this tab, the music-tab frames and the group chips.
-import { masterOf as zoneMasterOf, fetchZoneLive, stereoPairOf, pairMemberBoxes } from '../groups.js';
+import { masterOf as zoneMasterOf, fetchZoneLive, stereoPairOf, pairMemberBoxes, stereoUndoTargets } from '../groups.js';
// Injected main.js helpers (see initMultiroomView).
let deps = {
@@ -179,7 +179,6 @@ export function renderMultiroom(fetchLive) {
- if (livePair) fillPairBalance(livePair, strBoxes).catch(() => {});
${escapeHtml(t('multiroom.stereoHeading'))} ${escapeHtml(t('common.alpha'))}
${escapeHtml(t('multiroom.stereoNote'))}
${canPair ? '' : `
${escapeHtml(t('multiroom.stereoNeedTwo'))}
`}
@@ -195,6 +194,9 @@ export function renderMultiroom(fetchLive) {
${state.stereoMsg || ''}
`;
+ // Read-only, filled after the markup exists, and only when a pair does.
+ if (livePair) fillPairBalance(livePair, strBoxes).catch(() => {});
+
const issueLink = $('multiroomIssueLink');
if (issueLink) issueLink.onclick = (e) => { e.preventDefault(); try { BrowserOpenURL('https://github.com/JRpersonal/streborn/issues/70'); } catch {} };
const email = $('multiroomEmail');
@@ -390,41 +392,58 @@ async function doFormZone(strBoxes) {
// three SoundTouch 10s pressed undo twice; both calls went to a speaker that
// was not paired, both returned "nothing to dissolve", the app reported
// success, and the pair was still there in the Bose app (field, 2026-08-04).
+// EVERY member of the pair gets the undo, master first, because the pair does
+// not reliably live where we expected. The rule used to be "ask the master, only
+// its firmware reports the pair", and that held while a pair was healthy. It
+// does not hold once one half has let go: measured 2026-08-10 on two SoundTouch
+// 10s, the MASTER answered /getGroup with an empty group while the right-hand
+// speaker still held the whole document naming the master as LEFT. Every undo
+// went to the master, was told there was nothing to undo, and the app then said
+// both "current stereo pair: ..." and "there is no stereo pair to undo" in the
+// same panel. Sending it to the other half cleared both speakers at once.
+//
+// So neither half can be assumed to be the one holding it. Asking both is
+// harmless (a speaker not in a pair answers "nothing to undo" and is left
+// alone) and it is the only way a one-sided leftover can be cleared at all.
async function doDissolveStereo(pairCands) {
- // The MASTER first, and not just as a preference: on real hardware only the
- // master's firmware reports the pair at all. Asked about its group, the
- // right-hand speaker answers that it is in none, so a dissolve sent there
- // returns "nothing to undo" while the pair is very much alive (live on two
- // SoundTouch 10s, 2026-08-04).
const pair = stereoPairOf(state.zoneLive);
- const live = pairMemberBoxes(pair, state.boxes || []).map(x => x.box).filter(Boolean);
- const masterUp = String((pair && pair.master) || '').toUpperCase();
- const master = live.find(b => String(b.deviceID || '').toUpperCase() === masterUp)
- || live[0]
- || pairCands.find(b => b.deviceID === ($('stereoLeft') || {}).value);
- if (!master) {
+ const targets = stereoUndoTargets(pair, state.boxes || []);
+ if (!targets.length) {
+ const guess = pairCands.find(b => b.deviceID === ($('stereoLeft') || {}).value);
+ if (guess) targets.push(guess);
+ }
+ if (!targets.length) {
state.stereoMsg = `
`;
- try {
- // The stereo-intent endpoint: it also dissolves a firmware pair the agent
- // has no persisted record of (agent reinstalled, pair formed elsewhere),
- // which the plain dissolve deliberately leaves alone.
- await DissolveStereoPair(master.host, master.port);
+ let dissolved = false;
+ let failure = null;
+ for (const box of targets) {
+ try {
+ // The stereo-intent endpoint: it also dissolves a firmware pair the agent
+ // has no persisted record of (agent reinstalled, pair formed elsewhere),
+ // which the plain dissolve deliberately leaves alone.
+ await DissolveStereoPair(box.host, box.port);
+ dissolved = true;
+ } catch (e) {
+ // "This speaker is not in a pair" is not an error the user should read as
+ // a failure, and it must not read as success either (which is what it used
+ // to do, because the agent answers 200 for it). With more than one target
+ // it is also the EXPECTED answer from the half that already let go, so it
+ // never stops the sweep.
+ if (!String((e && e.message) || e || '').includes('stereo-not-paired')) failure = e;
+ }
+ }
+ if (dissolved) {
state.stereoMsg = `
${escapeHtml(t('multiroom.stereoDissolved'))}
`;
showToast(t('multiroom.stereoDissolved'));
- } catch (e) {
- // "This speaker is not in a pair" is not an error the user should read as
- // a failure, and it must not read as success either (which is what it used
- // to do, because the agent answers 200 for it).
- if (String((e && e.message) || e || '').includes('stereo-not-paired')) {
- state.stereoMsg = `
`;
+ showToast(t('multiroom.stereoNothingToUndo'));
}
renderMultiroom(true);
}
diff --git a/desktop-app/frontend/src/views/settings.js b/desktop-app/frontend/src/views/settings.js
index 36634eb4..adacbfa0 100644
--- a/desktop-app/frontend/src/views/settings.js
+++ b/desktop-app/frontend/src/views/settings.js
@@ -69,6 +69,9 @@ import {
SetBoxBass,
ListWiFiProfiles,
TryWiFiPassword,
+ ListBoxMediaServers,
+ EnableBoxMediaServer,
+ DisableBoxMediaServer,
} from '../api.js';
// isMacOS is re-derived locally (the same pure check main.js uses) so the WLAN
@@ -502,6 +505,80 @@ function groupSettingsSections() {
body.appendChild(frag);
}
+// fillMusicLib lists the media servers this speaker can see and lets the user
+// turn one into a source the SPEAKER plays by itself.
+//
+// The speaker finds DLNA/UPnP servers on the network on its own, but it will not
+// play from one until that server is registered as a music account. Once it is,
+// the speaker browses and plays it natively and the library also appears in the
+// original Bose app. Verified against a FRITZ!Box and a Synology NAS.
+//
+// Enabling is NOT instant. The speaker accepts the registration at once and then
+// confirms the account with STR before the source becomes usable, which took
+// minutes on real hardware. So the row shows what the user asked for
+// (`enabled`), with a separate note for "on the speaker" vs "being set up",
+// rather than a state that would read as failure for the first few minutes.
+async function fillMusicLib(box) {
+ const list = $('musicLibList');
+ const section = $('musicLibSection');
+ if (!list) return;
+ if (!box || !box.host || box.kind === 'stock') {
+ if (section) section.style.display = 'none';
+ return;
+ }
+ let servers = [];
+ try {
+ servers = (await ListBoxMediaServers(box.host, box.port)) || [];
+ } catch {
+ // An older agent has no such endpoint. Nothing useful to say, so say
+ // nothing and leave the section out.
+ if (section) section.style.display = 'none';
+ return;
+ }
+ if (section) section.style.display = '';
+ if (!servers.length) {
+ list.innerHTML = `
@@ -902,6 +986,11 @@ function renderBoxSettings(s, box) {
`;
+ // Music library. Filled AFTER the markup exists, never from inside the
+ // template literal: a call placed in there renders as literal text on the
+ // page instead of running.
+ fillMusicLib(box).catch(() => {});
+
// Phone control: build this speaker's web-remote URL from its reachable
// host:port (probeSTR records the right port: 8888 direct or 17008 redirect)
// and render a locally generated QR (no external service). Grouped into the
diff --git a/desktop-app/frontend/wailsjs/go/main/App.d.ts b/desktop-app/frontend/wailsjs/go/main/App.d.ts
index c2c93dbd..ecb0f27d 100644
--- a/desktop-app/frontend/wailsjs/go/main/App.d.ts
+++ b/desktop-app/frontend/wailsjs/go/main/App.d.ts
@@ -49,6 +49,8 @@ export function DeletePreset(arg1:string,arg2:number,arg3:number):Promise;
export function DeleteRecentCard(arg1:string,arg2:number,arg3:string,arg4:string):Promise;
+export function DisableBoxMediaServer(arg1:string,arg2:number,arg3:string,arg4:string):Promise;
+
export function DiscoverBoxes(arg1:number):Promise>;
export function DissolveStereoPair(arg1:string,arg2:number):Promise;
@@ -59,6 +61,8 @@ export function DownloadUpdate(arg1:string):Promise;
export function EjectDrive(arg1:string):Promise;
+export function EnableBoxMediaServer(arg1:string,arg2:number,arg3:string,arg4:string):Promise;
+
export function EnsureSpotifyEngine(arg1:string,arg2:number):Promise;
export function ExportDiagnosticLogs(arg1:main.LogExportRequest):Promise;
@@ -97,6 +101,8 @@ export function InstallSTROnBox(arg1:string,arg2:string):Promise;
+export function ListBoxMediaServers(arg1:string,arg2:number):Promise>;
+
export function ListDrives():Promise>;
export function ListMediaServers(arg1:number):Promise>;
diff --git a/desktop-app/frontend/wailsjs/go/main/App.js b/desktop-app/frontend/wailsjs/go/main/App.js
index 7dbbb0cd..737b6482 100644
--- a/desktop-app/frontend/wailsjs/go/main/App.js
+++ b/desktop-app/frontend/wailsjs/go/main/App.js
@@ -90,6 +90,10 @@ export function DeleteRecentCard(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['DeleteRecentCard'](arg1, arg2, arg3, arg4);
}
+export function DisableBoxMediaServer(arg1, arg2, arg3, arg4) {
+ return window['go']['main']['App']['DisableBoxMediaServer'](arg1, arg2, arg3, arg4);
+}
+
export function DiscoverBoxes(arg1) {
return window['go']['main']['App']['DiscoverBoxes'](arg1);
}
@@ -110,6 +114,10 @@ export function EjectDrive(arg1) {
return window['go']['main']['App']['EjectDrive'](arg1);
}
+export function EnableBoxMediaServer(arg1, arg2, arg3, arg4) {
+ return window['go']['main']['App']['EnableBoxMediaServer'](arg1, arg2, arg3, arg4);
+}
+
export function EnsureSpotifyEngine(arg1, arg2) {
return window['go']['main']['App']['EnsureSpotifyEngine'](arg1, arg2);
}
@@ -186,6 +194,10 @@ export function IsBoseStick(arg1) {
return window['go']['main']['App']['IsBoseStick'](arg1);
}
+export function ListBoxMediaServers(arg1, arg2) {
+ return window['go']['main']['App']['ListBoxMediaServers'](arg1, arg2);
+}
+
export function ListDrives() {
return window['go']['main']['App']['ListDrives']();
}
diff --git a/desktop-app/frontend/wailsjs/go/models.ts b/desktop-app/frontend/wailsjs/go/models.ts
index 5678c53f..094dcaee 100644
--- a/desktop-app/frontend/wailsjs/go/models.ts
+++ b/desktop-app/frontend/wailsjs/go/models.ts
@@ -78,6 +78,32 @@ export namespace main {
this.portVerified = source["portVerified"];
}
}
+ export class BoxMediaServer {
+ id: string;
+ ip: string;
+ manufacturer: string;
+ modelName: string;
+ friendlyName: string;
+ registered: boolean;
+ enabled: boolean;
+ status: string;
+
+ static createFrom(source: any = {}) {
+ return new BoxMediaServer(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.ip = source["ip"];
+ this.manufacturer = source["manufacturer"];
+ this.modelName = source["modelName"];
+ this.friendlyName = source["friendlyName"];
+ this.registered = source["registered"];
+ this.enabled = source["enabled"];
+ this.status = source["status"];
+ }
+ }
export class BoxPresetInfo {
slot: number;
source: string;
diff --git a/desktop-app/logexport.go b/desktop-app/logexport.go
index 7547b434..a10d9984 100644
--- a/desktop-app/logexport.go
+++ b/desktop-app/logexport.go
@@ -131,7 +131,7 @@ Boxes asked: %d
Contents:
README.txt this file
app.log desktop app log (rolling, up to 2 MB)
- box-.json per-box snapshot (Bose /info + STR /api/status + /api/agent/version + /api/box/zone)
+ box-.json per-box snapshot (Bose /info + /sources + STR /api/status + /api/agent/version + /api/box/zone)
stick-/setup.log FAT32 setup.log if an STR stick is plugged into this PC
stick-/_meta.json drive metadata (path, label, free space)
manifest.json summary
@@ -139,8 +139,9 @@ Contents:
Privacy:
When Anonymized=true (default), LAN IPs are masked to 192.0.2.x,
MAC addresses / device IDs / serial numbers / friendly names are
- hashed (first 8 chars of SHA256), and SSID-looking strings in the
- app log are scrubbed. Even so, please skim the files before
+ hashed (first 8 chars of SHA256), linked streaming accounts appear
+ as ACCT# instead of the account name, and SSID-looking strings
+ in the app log are scrubbed. Even so, please skim the files before
attaching to a public issue.
`, time.Now().UTC().Format(time.RFC3339), runtime.GOOS, runtime.GOARCH, appVersion, req.Anonymize, len(req.BoxHosts), boxSummary.String())
if err := writeZipEntry(zw, "README.txt", []byte(readme)); err != nil {
@@ -346,8 +347,22 @@ type boxIndexEntry struct {
}
type boxSnapshot struct {
- Host string `json:"host"`
- BoseInfo string `json:"boseInfoXml"`
+ Host string `json:"host"`
+ BoseInfo string `json:"boseInfoXml"`
+ // BoseSources is the firmware's own /sources list: every input and service
+ // slot the box has, with the source name, the account, the READY/UNAVAILABLE
+ // status and the isLocal flag. It is the only record of what a box can
+ // actually switch to, and its absence has already cost us a diagnosis: a
+ // CineMate 130 owner reported that the "offer every input" feature still
+ // showed him Bluetooth alone (discussion #577), and his bundle could not say
+ // why, because nothing in it described his inputs. What a soundbar reports
+ // for its HDMI sockets is exactly the evidence the filter in
+ // internal/webui/assets/index.html (isPhysicalInput) was written without.
+ //
+ // It also settles source questions in general: /sources status is a
+ // connection indicator, not a capability, so seeing the real list stops us
+ // reading UNAVAILABLE as "this box cannot do that".
+ BoseSources string `json:"boseSourcesXml,omitempty"`
STRStatus string `json:"strStatusJson"`
STRAgentVer map[string]any `json:"strAgentVersion"`
// STRZone is the box's live multiroom zone (GET /api/box/zone via the
@@ -451,6 +466,11 @@ func captureBoxSnapshot(host string) boxSnapshot {
s.Reachable8091 = portOpen(host, 8091, 1200)
if s.Reachable8090 {
s.BoseInfo = httpGetText(fmt.Sprintf("http://%s:8090/info", host), 4096)
+ // 16 KB, not the 4 KB /info gets: a speaker's list is ~1.5 KB, but a
+ // soundbar adds a sourceItem per socket and every linked service, and
+ // truncating this one costs the entries at the end of the list, which is
+ // where the firmware puts the ones we have never seen.
+ s.BoseSources = httpGetText(fmt.Sprintf("http://%s:8090/sources", host), 16*1024)
}
if s.Reachable8888 {
base := fmt.Sprintf("http://%s:%d", host, strPort)
@@ -653,6 +673,9 @@ func sanitizeLog(b []byte) []byte {
func anonymizeSnapshot(s boxSnapshot) boxSnapshot {
s.Host = maskIP(s.Host)
s.BoseInfo = anonymizeBoseInfoXML(s.BoseInfo)
+ // /sources names the linked streaming accounts, so it needs its own pass:
+ // the shared one would leave a Deezer id and its nickname in the clear.
+ s.BoseSources = anonymizeBoseSourcesXML(s.BoseSources)
s.STRStatus = anonymizeText(s.STRStatus)
// The zone JSON carries member IPs and device IDs.
s.STRZone = anonymizeText(s.STRZone)
@@ -759,6 +782,73 @@ func anonymizeBoseInfoXML(xml string) string {
return out
}
+var sourceItemRegex = regexp.MustCompile(`]*(?:/>|>[^<]*)`)
+var sourceAccountAttrRegex = regexp.MustCompile(`sourceAccount="([^"]*)"`)
+var sourceItemTextRegex = regexp.MustCompile(`>([^<>]+)`)
+var allDigitsRegex = regexp.MustCompile(`^[0-9]{6,}$`)
+
+// looksLikeAccountIdentity decides whether a /sources value identifies a person
+// rather than a socket. Getting this wrong in either direction has a cost, so
+// the rule is written around what real boxes report:
+//
+// - Names ending in "UserName" are firmware placeholders for an unlinked slot
+// (QPlay1UserName, SpotifyConnectUserName, StoredMusicUserName,
+// AirPlay2DefaultUserName). They name nobody and must survive, because the
+// input filter keys on exactly this suffix.
+// - A linked service reports the real account: a Deezer numeric id, a Spotify
+// user id, or an address. Those are hashed.
+// - A physical socket's account is its own short label (AUX, AUX1, TV,
+// CBL-Sat). Those survive, and they are the reason to capture /sources at
+// all: hashing them would leave the bundle unable to answer which inputs a
+// soundbar has.
+func looksLikeAccountIdentity(v string) bool {
+ v = strings.TrimSpace(v)
+ if v == "" || strings.HasSuffix(v, "UserName") {
+ return false
+ }
+ if strings.Contains(v, "@") || allDigitsRegex.MatchString(v) {
+ return true
+ }
+ // Opaque service ids are long and unbroken; socket labels are short.
+ return len(v) >= 16 && !strings.ContainsAny(v, " \t")
+}
+
+// anonymizeBoseSourcesXML hashes the account identities in /sources and leaves
+// the structure intact. The deviceID attribute, IPs and MACs are handled by the
+// shared scrubPII pass; what is left is sourceAccount and the display name, and
+// on a linked service the display name is the account nickname that belongs to
+// the same person as the account id. So when the account is judged personal,
+// its display name goes with it.
+func anonymizeBoseSourcesXML(xml string) string {
+ if xml == "" {
+ return ""
+ }
+ return sourceItemRegex.ReplaceAllStringFunc(scrubPII(xml), func(item string) string {
+ acct := ""
+ if m := sourceAccountAttrRegex.FindStringSubmatch(item); m != nil {
+ acct = m[1]
+ }
+ // The display name alone rarely looks personal ("DeezerUser" is a
+ // nickname that no pattern catches), so the account decides for both.
+ if !looksLikeAccountIdentity(acct) && !looksLikeAccountIdentity(displayNameOf(item)) {
+ return item
+ }
+ item = sourceAccountAttrRegex.ReplaceAllString(item,
+ `sourceAccount="ACCT#`+hashShort(acct)+`"`)
+ return sourceItemTextRegex.ReplaceAllStringFunc(item, func(m string) string {
+ v := sourceItemTextRegex.FindStringSubmatch(m)[1]
+ return `>ACCT#` + hashShort(v) + ``
+ })
+ })
+}
+
+func displayNameOf(item string) string {
+ if m := sourceItemTextRegex.FindStringSubmatch(item); m != nil {
+ return m[1]
+ }
+ return ""
+}
+
func maskIP(ip string) string {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
diff --git a/desktop-app/logexport_test.go b/desktop-app/logexport_test.go
index 0ccaf8b2..ee29a672 100644
--- a/desktop-app/logexport_test.go
+++ b/desktop-app/logexport_test.go
@@ -87,6 +87,64 @@ func TestAnonymizeBoseInfoXML(t *testing.T) {
}
}
+// TestAnonymizeBoseSourcesXML pins both halves of the /sources pass, because
+// both can fail silently: over-scrubbing leaves the bundle unable to say which
+// inputs a box has (the reason the field exists), and under-scrubbing publishes
+// somebody's streaming account on a GitHub issue.
+func TestAnonymizeBoseSourcesXML(t *testing.T) {
+ // Verbatim shapes from a SoundTouch 30 and a SoundTouch 10, plus the linked
+ // Deezer and soundbar-socket entries we do not have hardware for.
+ xml := `` +
+ `AUX IN` +
+ `CBL-Sat` +
+ `` +
+ `QPlay1UserName` +
+ `SpotifyConnectUserName` +
+ `DeezerUser` +
+ `listener@example.com` +
+ ``
+ got := anonymizeBoseSourcesXML(xml)
+
+ // The account id, the nickname that belongs to it, the address, and the
+ // device id must all be gone.
+ for _, leaked := range []string{`1456373802`, `DeezerUser`, `listener@example.com`, `000C8A96488D`} {
+ if strings.Contains(got, leaked) {
+ t.Errorf("anonymized /sources still contains %q:\n%s", leaked, got)
+ }
+ }
+ // Everything that describes the box rather than its owner must survive.
+ for _, kept := range []string{
+ `source="AUX"`, `sourceAccount="AUX"`, `>AUX IN<`,
+ `source="PRODUCT"`, `sourceAccount="TV"`, `>CBL-Sat<`,
+ `source="BLUETOOTH"`, `isLocal="true"`, `status="UNAVAILABLE"`,
+ `sourceAccount="QPlay1UserName"`, `sourceAccount="SpotifyConnectUserName"`,
+ } {
+ if !strings.Contains(got, kept) {
+ t.Errorf("anonymized /sources dropped %q:\n%s", kept, got)
+ }
+ }
+ if anonymizeBoseSourcesXML("") != "" {
+ t.Error("empty input must stay empty")
+ }
+}
+
+func TestLooksLikeAccountIdentity(t *testing.T) {
+ personal := []string{"1456373802", "user@example.com", "31abcdefghijklmnop"}
+ notPersonal := []string{"", "AUX", "AUX1", "TV", "CBL-Sat", "BD-DVD", "HDMI 1",
+ "QPlay1UserName", "SpotifyConnectUserName", "AirPlay2DefaultUserName",
+ "StoredMusicUserName"}
+ for _, v := range personal {
+ if !looksLikeAccountIdentity(v) {
+ t.Errorf("%q should be treated as an account identity", v)
+ }
+ }
+ for _, v := range notPersonal {
+ if looksLikeAccountIdentity(v) {
+ t.Errorf("%q must not be treated as an account identity", v)
+ }
+ }
+}
+
func TestAnonymizeText(t *testing.T) {
got := anonymizeText("box 192.168.0.5 mac de:ad:be:ef:00:11 ssid=Cafe")
for _, leaked := range []string{"192.168.0.5", "de:ad:be:ef:00:11", "Cafe"} {
diff --git a/desktop-app/telnet_bootstrap_marge.go b/desktop-app/telnet_bootstrap_marge.go
index 2977d882..6625ead0 100644
--- a/desktop-app/telnet_bootstrap_marge.go
+++ b/desktop-app/telnet_bootstrap_marge.go
@@ -160,9 +160,27 @@ func localIPForBox(host string) (string, error) {
func buildBootstrapEnableSSHCommands(base string) []string {
inj := base + remoteServicesInjection
upd := base + "update"
+ // ORDER MATTERS: sys configuration FIRST, envswitch LAST.
+ //
+ // envswitch commits the runtime layer as it stands when it runs, so a
+ // sys configuration write issued AFTER it does not survive the reboot. We
+ // had it the other way round, which meant the second write was decorative:
+ // the fallback we believed we had for chassis we cannot measure was never
+ // actually there.
+ //
+ // Measured by @bitranox on a SoundTouch 20 (variant spotty, FW 27.0.6) and
+ // reported in gesellix/Bose-SoundTouch#471. The method is why this is
+ // trusted: an already-migrated box was POISONED first, all four URLs set to
+ // an unreachable address and confirmed to survive a reboot of their own, so
+ // a pass could not be inherited from existing config. With this order all
+ // four URLs survived; with envswitch first they did not.
+ //
+ // It also matters beyond the fallback: bmxRegistryUrl and statsServerUrl
+ // have no envswitch form at all, so sys configuration is the only way to
+ // set them, and on the old order it never persisted.
return []string{
- `envswitch boseurls set "` + inj + `" "` + upd + `"`,
`sys configuration margeServerUrl "` + inj + `"`,
+ `envswitch boseurls set "` + inj + `" "` + upd + `"`,
}
}
diff --git a/desktop-app/telnet_enable_ssh_test.go b/desktop-app/telnet_enable_ssh_test.go
index 529688d0..e692dfd7 100644
--- a/desktop-app/telnet_enable_ssh_test.go
+++ b/desktop-app/telnet_enable_ssh_test.go
@@ -78,9 +78,15 @@ func TestBuildBootstrapEnableSSHCommands(t *testing.T) {
t.Fatalf("want 2 commands, got %d: %v", len(cmds), cmds)
}
inj := base + remoteServicesInjection
+ // ORDER IS PART OF THE CONTRACT: sys configuration first, envswitch last.
+ // envswitch commits the runtime layer as it stands when it runs, so a
+ // sys configuration write after it does not survive the reboot
+ // (measured on a SoundTouch 20 with a poisoned box,
+ // gesellix/Bose-SoundTouch#471). The old order made the second write
+ // decorative.
want := []string{
- `envswitch boseurls set "` + inj + `" "` + base + `update"`,
`sys configuration margeServerUrl "` + inj + `"`,
+ `envswitch boseurls set "` + inj + `" "` + base + `update"`,
}
for i := range want {
if cmds[i] != want[i] {
diff --git a/internal/boxapi/boxapi.go b/internal/boxapi/boxapi.go
index 5c5d6aef..25dc8c83 100644
--- a/internal/boxapi/boxapi.go
+++ b/internal/boxapi/boxapi.go
@@ -172,34 +172,47 @@ func (c *Client) LoadSettings(ctx context.Context) (Settings, error) {
}
// Sources
- {
- var raw struct {
- Items []struct {
- Source string `xml:"source,attr"`
- SourceAccount string `xml:"sourceAccount,attr"`
- Status string `xml:"status,attr"`
- IsLocal string `xml:"isLocal,attr"`
- Multiroom string `xml:"multiroomallowed,attr"`
- Name string `xml:",chardata"`
- } `xml:"sourceItem"`
- }
- if err := get("/sources", &raw); err == nil {
- for _, it := range raw.Items {
- s.Sources = append(s.Sources, Source{
- Source: it.Source,
- SourceAccount: it.SourceAccount,
- Status: it.Status,
- IsLocal: strings.EqualFold(it.IsLocal, "true"),
- Multiroom: strings.EqualFold(it.Multiroom, "true"),
- DisplayName: strings.TrimSpace(it.Name),
- })
- }
- }
+ if srcs, err := c.GetSources(ctx); err == nil {
+ s.Sources = srcs
}
return s, nil
}
+// GetSources reads /sources: every input and service slot the box has.
+//
+// Remember that `status` is a CONNECTION indicator, not a capability. A
+// SoundTouch 10's Bluetooth reports UNAVAILABLE simply because nothing is
+// paired to it, and UPNP reports UNAVAILABLE even while it is the actively
+// playing source. Never diagnose a source as unusable from this field alone.
+func (c *Client) GetSources(ctx context.Context) ([]Source, error) {
+ var raw struct {
+ Items []struct {
+ Source string `xml:"source,attr"`
+ SourceAccount string `xml:"sourceAccount,attr"`
+ Status string `xml:"status,attr"`
+ IsLocal string `xml:"isLocal,attr"`
+ Multiroom string `xml:"multiroomallowed,attr"`
+ Name string `xml:",chardata"`
+ } `xml:"sourceItem"`
+ }
+ if err := c.getXML(ctx, "/sources", &raw); err != nil {
+ return nil, err
+ }
+ out := make([]Source, 0, len(raw.Items))
+ for _, it := range raw.Items {
+ out = append(out, Source{
+ Source: it.Source,
+ SourceAccount: it.SourceAccount,
+ Status: it.Status,
+ IsLocal: strings.EqualFold(it.IsLocal, "true"),
+ Multiroom: strings.EqualFold(it.Multiroom, "true"),
+ DisplayName: strings.TrimSpace(it.Name),
+ })
+ }
+ return out, nil
+}
+
// GetInfo reads /info and returns the static box description incl.
// margeAccountUUID (empty = not paired / after factory reset),
// moduleType/variant (taigan/scm/...) and the LAN IP from the
diff --git a/internal/boxapi/mediaservers.go b/internal/boxapi/mediaservers.go
new file mode 100644
index 00000000..b7413ecd
--- /dev/null
+++ b/internal/boxapi/mediaservers.go
@@ -0,0 +1,141 @@
+package boxapi
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+// UPnP media servers, and registering one as a native music source.
+//
+// The speaker discovers DLNA/UPnP media servers on the LAN by itself and lists
+// them at /listMediaServers, but it will not play from one until that server is
+// registered as a STORED_MUSIC account. Once it is, the source turns READY and
+// the box browses and plays the server NATIVELY: no stream proxy, no UPnP push
+// from STR, and the server appears in the original Bose app as well.
+//
+// Measured end to end on a Portable against a FRITZ!Box 6690 (2026-08-10).
+// Everything below is the shape the firmware actually accepts; the reference
+// for it is thlucas1/bosesoundtouchapi, since none of this is in Bose's public
+// API document.
+
+// MediaServer is one DLNA/UPnP server the speaker has discovered.
+type MediaServer struct {
+ // ID is the server's UPnP UDN WITHOUT the "uuid:" prefix, exactly as the
+ // box reports it. It is what the source account is built from, and it must
+ // match case-sensitively.
+ ID string `json:"id"`
+ IP string `json:"ip"`
+ Manufacturer string `json:"manufacturer"`
+ ModelName string `json:"modelName"`
+ FriendlyName string `json:"friendlyName"`
+ // Registered is filled in by callers that also read /sources; the box's own
+ // media-server list says nothing about whether a server is usable yet.
+ Registered bool `json:"registered"`
+}
+
+// SourceAccount is the sourceAccount value that identifies this server as a
+// music source. The trailing "/0" selects the server's first (and, on every
+// server measured, only) account.
+func (m MediaServer) SourceAccount() string {
+ if strings.TrimSpace(m.ID) == "" {
+ return ""
+ }
+ return m.ID + "/0"
+}
+
+// ListMediaServers reads /listMediaServers: every DLNA/UPnP media server the
+// speaker can currently see. Discovery is the firmware's own, so this works on
+// a box that has never had a music source registered.
+func (c *Client) ListMediaServers(ctx context.Context) ([]MediaServer, error) {
+ var raw struct {
+ Servers []struct {
+ ID string `xml:"id,attr"`
+ IP string `xml:"ip,attr"`
+ Manufacturer string `xml:"manufacturer,attr"`
+ ModelName string `xml:"model_name,attr"`
+ FriendlyName string `xml:"friendly_name,attr"`
+ } `xml:"media_server"`
+ }
+ if err := c.getXML(ctx, "/listMediaServers", &raw); err != nil {
+ return nil, err
+ }
+ out := make([]MediaServer, 0, len(raw.Servers))
+ for _, s := range raw.Servers {
+ if strings.TrimSpace(s.ID) == "" {
+ continue
+ }
+ out = append(out, MediaServer{
+ ID: s.ID, IP: s.IP, Manufacturer: s.Manufacturer,
+ ModelName: s.ModelName, FriendlyName: s.FriendlyName,
+ })
+ }
+ return out, nil
+}
+
+// musicServiceAccountBody builds the document both the set and
+// the remove endpoint take.
+func musicServiceAccountBody(source, displayName, account string) string {
+ return `` +
+ xmlEscape(account) + ``
+}
+
+// RegisterMediaServer registers a media server as a native STORED_MUSIC source.
+//
+// POST, not PUT: the firmware answers 405 to a PUT here even though this reads
+// like a write of a single setting.
+//
+// The box answers 200 immediately, but the source does NOT appear at once. The
+// speaker then calls out to its marge (STR) with an addSource callback, STR
+// answers it and serves the account's source list, and only then does /sources
+// gain the entry as READY. Measured live, that round trip took minutes rather
+// than seconds, so a caller must not treat "not READY yet" as failure.
+func (c *Client) RegisterMediaServer(ctx context.Context, m MediaServer) error {
+ acct := m.SourceAccount()
+ if acct == "" {
+ return fmt.Errorf("media server has no id")
+ }
+ name := strings.TrimSpace(m.FriendlyName)
+ if name == "" {
+ name = "Media server"
+ }
+ return c.postXML(ctx, "/setMusicServiceAccount",
+ musicServiceAccountBody("STORED_MUSIC", name, acct))
+}
+
+// UnregisterMediaServer removes the STORED_MUSIC account again. The display
+// name must match the one the source was registered under, which is why callers
+// pass the source's own display name back rather than a fresh guess.
+func (c *Client) UnregisterMediaServer(ctx context.Context, m MediaServer) error {
+ acct := m.SourceAccount()
+ if acct == "" {
+ return fmt.Errorf("media server has no id")
+ }
+ name := strings.TrimSpace(m.FriendlyName)
+ if name == "" {
+ name = "Media server"
+ }
+ return c.postXML(ctx, "/removeMusicServiceAccount",
+ musicServiceAccountBody("STORED_MUSIC", name, acct))
+}
+
+// RegisteredMediaServerAccounts returns the sourceAccount of every STORED_MUSIC
+// entry currently in /sources, whatever its status.
+//
+// Status is deliberately NOT filtered on: `status` in /sources is a connection
+// indicator rather than a capability, and treating UNAVAILABLE as "not
+// registered" would make a caller re-register a source that is already there.
+func (c *Client) RegisteredMediaServerAccounts(ctx context.Context) (map[string]bool, error) {
+ srcs, err := c.GetSources(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]bool{}
+ for _, s := range srcs {
+ if strings.EqualFold(s.Source, "STORED_MUSIC") && s.SourceAccount != "" {
+ out[s.SourceAccount] = true
+ }
+ }
+ return out, nil
+}
diff --git a/internal/marge/marge.go b/internal/marge/marge.go
index d7497329..fd542df9 100644
--- a/internal/marge/marge.go
+++ b/internal/marge/marge.go
@@ -89,6 +89,12 @@ type Server struct {
// addSource callback this run (see respondAddSource).
registered []registeredSource
+ // storedMusic holds the DLNA/UPnP media servers the user enabled, published
+ // into every account response so the box picks them up on its own poll
+ // instead of being pushed to. Seeded at startup from the agent's persisted
+ // store; see SetStoredMusicSources.
+ storedMusic []registeredSource
+
// forward relays the box's cloud traffic to a developer machine when set
// (see forward.go). Empty = answer locally. Never persisted.
forward string
diff --git a/internal/marge/responses.go b/internal/marge/responses.go
index ca76ffa2..1efe0594 100644
--- a/internal/marge/responses.go
+++ b/internal/marge/responses.go
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"os"
+ "strconv"
"strings"
"text/template"
"time"
@@ -306,7 +307,7 @@ func (s *Server) respondAccountFull(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`
global
- ` + staticRadioSourceXML() + `
+ ` + staticRadioSourceXML() + s.storedMusicXML() + `
`))
}
@@ -438,7 +439,7 @@ func (s *Server) respondMargeAccountFull(w http.ResponseWriter, _ *http.Request)
`global` +
`en` +
`` +
- `` + staticRadioSourceXML() + s.reflectedSourcesXML() + `` +
+ `` + staticRadioSourceXML() + s.storedMusicXML() + s.reflectedSourcesXML() + `` +
``))
}
@@ -617,7 +618,7 @@ func (s *Server) respondAccountSources(w http.ResponseWriter, _ *http.Request) {
for _, r := range regs {
b.WriteString(renderAccountSource(format, r))
}
- inner := staticRadioSourceXML() + b.String()
+ inner := staticRadioSourceXML() + s.storedMusicXML() + b.String()
var body string
switch format {
case "wrap":
@@ -713,3 +714,89 @@ func staticRadioSourceXML() string {
`` +
``
}
+
+// storedMusicSourcesXML renders the user's DLNA/UPnP media servers as account
+// sources, so the box PICKS THEM UP ITSELF instead of being told about them.
+//
+// This is the same channel radio arrives on. The box polls
+// GET /streaming/account//full at boot (measured in the marge request log:
+// two /full reads within a second of the account handshake, and no /sources read
+// at all unless an addSource just happened), and it keeps whatever that document
+// advertises. Sitting in that document is therefore all a source needs; a push
+// to /setMusicServiceAccount only matters for making a NEW server usable within
+// the current session, before the next poll.
+//
+// The element set and order are copied from staticRadioSourceXML deliberately.
+// A source rendered into /full that omits an element the firmware expects is the
+// one documented way to make the whole account document fail rather than just
+// that entry, so the safe move is to differ from the known-good source in
+// nothing but the three values that have to change: the numeric provider id 7,
+// the sourcename STORED_MUSIC, and the username, which is the media server's
+// UPnP id with "/0" appended.
+//
+// ids start at 10 so they cannot collide with the radio source's fixed id 3.
+func storedMusicSourcesXML(list []registeredSource) string {
+ if len(list) == 0 {
+ return ""
+ }
+ const ts = "2020-01-01T00:00:00.000+00:00"
+ var b strings.Builder
+ for i, r := range list {
+ name := r.Name
+ if strings.TrimSpace(name) == "" {
+ name = "Music library"
+ }
+ b.WriteString(`` +
+ `` + ts + `` +
+ `` +
+ `` + xmlEscapeText(name) + `` +
+ `7` +
+ `STORED_MUSIC` +
+ `` +
+ `` + ts + `` +
+ `` + xmlEscapeText(r.Username) + `` +
+ ``)
+ }
+ return b.String()
+}
+
+// storedMusicXML is the current media-server source block for the account
+// responses, under the read lock.
+func (s *Server) storedMusicXML() string {
+ s.mu.RLock()
+ list := make([]registeredSource, len(s.storedMusic))
+ copy(list, s.storedMusic)
+ s.mu.RUnlock()
+ return storedMusicSourcesXML(list)
+}
+
+// SetStoredMusicSources publishes the user's enabled media servers so every
+// account response advertises them. The agent calls this at startup from the
+// persisted store, and again whenever the user enables or removes one.
+//
+// Replaces the whole set: the store is the authority on what the user wants.
+func (s *Server) SetStoredMusicSources(servers []StoredMusicSource) {
+ list := make([]registeredSource, 0, len(servers))
+ for _, srv := range servers {
+ if strings.TrimSpace(srv.Account) == "" {
+ continue
+ }
+ list = append(list, registeredSource{
+ Username: srv.Account, ProviderID: "7",
+ Name: srv.Name, SourceName: "STORED_MUSIC",
+ })
+ }
+ s.mu.Lock()
+ s.storedMusic = list
+ s.mu.Unlock()
+ s.logger.Info("media server sources published to the account", slog.String("comp", "marge"),
+ slog.Int("count", len(list)))
+}
+
+// StoredMusicSource is one media server the account advertises. Account is the
+// media server's UPnP id with "/0" appended, exactly as the box reports the id
+// in /listMediaServers.
+type StoredMusicSource struct {
+ Account string
+ Name string
+}
diff --git a/internal/marge/storedmusic_test.go b/internal/marge/storedmusic_test.go
new file mode 100644
index 00000000..63cc3253
--- /dev/null
+++ b/internal/marge/storedmusic_test.go
@@ -0,0 +1,91 @@
+package marge
+
+import (
+ "io"
+ "log/slog"
+ "strings"
+ "testing"
+)
+
+func testStoredMusicLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
+// The media-server source must differ from the known-good radio source in
+// exactly three values and nothing else. A source rendered into the account
+// document that omits an element the firmware expects is the documented way to
+// break the WHOLE document rather than just that entry, so the element set and
+// order are part of the contract, not a style choice.
+func TestStoredMusicSourceMatchesRadioSourceShape(t *testing.T) {
+ got := storedMusicSourcesXML([]registeredSource{
+ {Username: "fa095ecc-uuid/0", Name: "AVM FRITZ!Mediaserver"},
+ })
+
+ elements := func(s string) []string {
+ var out []string
+ for _, part := range strings.Split(s, "<") {
+ if i := strings.IndexAny(part, " >/"); i > 0 {
+ out = append(out, part[:i])
+ }
+ }
+ return out
+ }
+ wantShape := elements(staticRadioSourceXML())
+ gotShape := elements(got)
+ if len(wantShape) != len(gotShape) {
+ t.Fatalf("element count differs from the radio source:\n radio: %v\n music: %v", wantShape, gotShape)
+ }
+ for i := range wantShape {
+ if wantShape[i] != gotShape[i] {
+ t.Errorf("element %d differs: radio %q, music %q", i, wantShape[i], gotShape[i])
+ }
+ }
+
+ for _, want := range []string{
+ `7`,
+ `STORED_MUSIC`,
+ `fa095ecc-uuid/0`,
+ `AVM FRITZ!Mediaserver`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %s in:\n%s", want, got)
+ }
+ }
+ // id 3 belongs to the radio source and must not be reused.
+ if strings.Contains(got, `Tap a speaker to control it.
+
+
Music library
+
Play from a media server on your network, on the speaker itself.
+
+
+
+
@@ -510,18 +517,18 @@
// enormous, and a separate block is reviewable and merges into T identically.
// {n} is substituted at use time.
var I18N2 = {
- en:{tPlay:"Play",tFind:"Find",tSpk:"Speakers",tMore:"More",scOne:"This speaker",scGroup:"Group",scPair:"Pair",grp:"Group",grpSum:"{n} speakers playing together",grpFollow:"This speaker follows {name}. Dissolve the group there.",ungroup:"Dissolve group",joinTitle:"Play together",joinAria:"Add {{name}} to the group",joining:"{{name}} is joining. This takes a few seconds.",joinFail:"That speaker could not be added.",leaveAria:"Remove {{name}} from the group",pairSum:"Two speakers playing as one stereo pair",unpair:"Undo stereo pair",find:"Find a station",findSum:"Search the worldwide station directory and play it here.",searchBtn:"Search",qph:"Station name",searching:"Searching…",noRes:"Nothing found. Try a shorter word.",netFail:"Could not reach the station directory. Check that this phone has internet.",saveKey:"Save",pickSlot:"Which key should hold it?",saved:"Saved to key {n}",saveFail:"Could not save that station.",diag:"Report a problem",diagSum:"Saves a file describing this speaker's state. Send it with your report and I can see what happened.",diagBtn:"Save diagnostic file",diagOk:"File saved. Attach it to your report.",diagFail:"Could not read the speaker's state.",peersHint:"Tap a speaker to control it.",coach:"Stations, speakers and settings are in the bar below.",back:"Back",sleep:"Sleep timer",sleepSum:"Switches the speaker off by itself.",sleepGroup:"Whole group",sleepOff:"Cancel",sleepIn:"Off in {m} min",sleepInS:"Off in less than a minute",sleepFail:"Could not set the timer on this speaker."},
- de:{tPlay:"Spielen",tFind:"Suchen",tSpk:"Boxen",tMore:"Mehr",scOne:"Diese Box",scGroup:"Gruppe",scPair:"Paar",grp:"Gruppe",grpSum:"{n} Lautsprecher spielen zusammen",grpFollow:"Diese Box folgt {name}. Auflösen geht dort.",ungroup:"Gruppe auflösen",joinTitle:"Zusammen abspielen",joinAria:"{{name}} zur Gruppe hinzufügen",joining:"{{name}} kommt dazu. Das dauert ein paar Sekunden.",joinFail:"Der Lautsprecher ließ sich nicht hinzufügen.",leaveAria:"{{name}} aus der Gruppe entfernen",pairSum:"Zwei Lautsprecher spielen als ein Stereopaar",unpair:"Stereopaar auflösen",find:"Sender suchen",findSum:"Durchsuche das weltweite Senderverzeichnis und spiel den Sender hier ab.",searchBtn:"Suchen",qph:"Sendername",searching:"Suche…",noRes:"Nichts gefunden. Probier ein kürzeres Wort.",netFail:"Das Senderverzeichnis ist nicht erreichbar. Prüf, ob dieses Handy Internet hat.",saveKey:"Merken",pickSlot:"Auf welche Taste?",saved:"Auf Taste {n} gelegt",saveFail:"Der Sender ließ sich nicht speichern.",diag:"Problem melden",diagSum:"Speichert eine Datei mit dem Zustand dieses Lautsprechers. Schick sie mit deiner Meldung, dann sehe ich, was passiert ist.",diagBtn:"Diagnosedatei speichern",diagOk:"Datei gespeichert. Häng sie an deine Meldung.",diagFail:"Der Zustand des Lautsprechers ließ sich nicht lesen.",peersHint:"Tippe einen Lautsprecher an, um ihn zu steuern.",coach:"Sender, Lautsprecher und Einstellungen findest du unten in der Leiste.",back:"Zurück",sleep:"Einschlaf-Timer",sleepSum:"Schaltet den Lautsprecher von selbst aus.",sleepGroup:"Ganze Gruppe",sleepOff:"Abbrechen",sleepIn:"Aus in {m} Min",sleepInS:"Aus in weniger als einer Minute",sleepFail:"Der Timer ließ sich auf diesem Lautsprecher nicht stellen."},
- nl:{tPlay:"Spelen",tFind:"Zoeken",tSpk:"Speakers",tMore:"Meer",scOne:"Deze speaker",scGroup:"Groep",scPair:"Paar",grp:"Groep",grpSum:"{n} speakers spelen samen",grpFollow:"Deze speaker volgt {name}. Daar kun je de groep opheffen.",ungroup:"Groep opheffen",joinTitle:"Samen afspelen",joinAria:"{{name}} aan de groep toevoegen",joining:"{{name}} komt erbij. Dat duurt een paar seconden.",joinFail:"Die speaker kon niet worden toegevoegd.",leaveAria:"{{name}} uit de groep halen",pairSum:"Twee speakers spelen als één stereopaar",unpair:"Stereopaar opheffen",find:"Zender zoeken",findSum:"Doorzoek de wereldwijde zenderlijst en speel hier af.",searchBtn:"Zoeken",qph:"Zendernaam",searching:"Zoeken…",noRes:"Niets gevonden. Probeer een korter woord.",netFail:"De zenderlijst is niet bereikbaar. Controleer of deze telefoon internet heeft.",saveKey:"Bewaren",pickSlot:"Op welke toets?",saved:"Op toets {n} gezet",saveFail:"De zender kon niet worden bewaard.",diag:"Probleem melden",diagSum:"Bewaart een bestand met de toestand van deze speaker. Stuur het mee, dan zie ik wat er gebeurde.",diagBtn:"Diagnosebestand bewaren",diagOk:"Bestand bewaard. Voeg het toe aan je melding.",diagFail:"De toestand van de speaker kon niet gelezen worden.",peersHint:"Tik op een speaker om die te bedienen.",coach:"Zenders, speakers en instellingen staan in de balk hieronder.",back:"Terug",sleep:"Slaaptimer",sleepSum:"Schakelt de speaker vanzelf uit.",sleepGroup:"Hele groep",sleepOff:"Annuleren",sleepIn:"Uit over {m} min",sleepInS:"Uit over minder dan een minuut",sleepFail:"De timer kon niet worden ingesteld op deze speaker."},
- fr:{tPlay:"Lecture",tFind:"Chercher",tSpk:"Enceintes",tMore:"Plus",scOne:"Cette enceinte",scGroup:"Groupe",scPair:"Paire",grp:"Groupe",grpSum:"{n} enceintes jouent ensemble",grpFollow:"Cette enceinte suit {name}. Dissolvez le groupe là-bas.",ungroup:"Dissoudre le groupe",joinTitle:"Écouter ensemble",joinAria:"Ajouter {{name}} au groupe",joining:"{{name}} rejoint le groupe. Cela prend quelques secondes.",joinFail:"Cette enceinte n'a pas pu être ajoutée.",leaveAria:"Retirer {{name}} du groupe",pairSum:"Deux enceintes forment une paire stéréo",unpair:"Défaire la paire stéréo",find:"Chercher une station",findSum:"Cherchez dans l'annuaire mondial des stations et écoutez ici.",searchBtn:"Chercher",qph:"Nom de la station",searching:"Recherche…",noRes:"Rien trouvé. Essayez un mot plus court.",netFail:"L'annuaire des stations est injoignable. Vérifiez qu'internet fonctionne sur ce téléphone.",saveKey:"Garder",pickSlot:"Sur quelle touche ?",saved:"Placée sur la touche {n}",saveFail:"Impossible d'enregistrer cette station.",diag:"Signaler un problème",diagSum:"Enregistre un fichier décrivant l'état de cette enceinte. Joignez-le à votre message et je verrai ce qui s'est passé.",diagBtn:"Enregistrer le fichier de diagnostic",diagOk:"Fichier enregistré. Joignez-le à votre message.",diagFail:"Impossible de lire l'état de l'enceinte.",peersHint:"Touchez une enceinte pour la commander.",coach:"Stations, enceintes et réglages se trouvent dans la barre du bas.",back:"Retour",sleep:"Minuterie",sleepSum:"Éteint l’enceinte toute seule.",sleepGroup:"Tout le groupe",sleepOff:"Annuler",sleepIn:"Extinction dans {m} min",sleepInS:"Extinction dans moins d’une minute",sleepFail:"Impossible de régler la minuterie sur cette enceinte."},
- es:{tPlay:"Reproducir",tFind:"Buscar",tSpk:"Altavoces",tMore:"Más",scOne:"Este altavoz",scGroup:"Grupo",scPair:"Pareja",grp:"Grupo",grpSum:"{n} altavoces suenan juntos",grpFollow:"Este altavoz sigue a {name}. Deshaz el grupo allí.",ungroup:"Deshacer el grupo",joinTitle:"Sonar juntos",joinAria:"Añadir {{name}} al grupo",joining:"{{name}} se está uniendo. Tarda unos segundos.",joinFail:"No se pudo añadir ese altavoz.",leaveAria:"Quitar {{name}} del grupo",pairSum:"Dos altavoces suenan como una pareja estéreo",unpair:"Deshacer la pareja estéreo",find:"Buscar emisora",findSum:"Busca en el directorio mundial de emisoras y escúchala aquí.",searchBtn:"Buscar",qph:"Nombre de la emisora",searching:"Buscando…",noRes:"No se encontró nada. Prueba con una palabra más corta.",netFail:"No se puede acceder al directorio de emisoras. Comprueba que este teléfono tenga internet.",saveKey:"Guardar",pickSlot:"¿En qué tecla?",saved:"Guardada en la tecla {n}",saveFail:"No se pudo guardar esa emisora.",diag:"Informar de un problema",diagSum:"Guarda un archivo con el estado de este altavoz. Envíalo con tu aviso y podré ver qué pasó.",diagBtn:"Guardar archivo de diagnóstico",diagOk:"Archivo guardado. Adjúntalo a tu aviso.",diagFail:"No se pudo leer el estado del altavoz.",peersHint:"Toca un altavoz para controlarlo.",coach:"Emisoras, altavoces y ajustes están en la barra de abajo.",back:"Atrás",sleep:"Temporizador",sleepSum:"Apaga el altavoz por sí solo.",sleepGroup:"Todo el grupo",sleepOff:"Cancelar",sleepIn:"Se apaga en {m} min",sleepInS:"Se apaga en menos de un minuto",sleepFail:"No se pudo ajustar el temporizador en este altavoz."},
- pl:{tPlay:"Odtwarzaj",tFind:"Szukaj",tSpk:"Głośniki",tMore:"Więcej",scOne:"Ten głośnik",scGroup:"Grupa",scPair:"Para",grp:"Grupa",grpSum:"{n} głośniki grają razem",grpFollow:"Ten głośnik podąża za {name}. Rozwiąż grupę tam.",ungroup:"Rozwiąż grupę",joinTitle:"Graj razem",joinAria:"Dodaj {{name}} do grupy",joining:"{{name}} dołącza. To potrwa kilka sekund.",joinFail:"Nie udało się dodać tego głośnika.",leaveAria:"Usuń {{name}} z grupy",pairSum:"Dwa głośniki grają jako para stereo",unpair:"Rozłącz parę stereo",find:"Znajdź stację",findSum:"Przeszukaj światowy katalog stacji i odtwórz tutaj.",searchBtn:"Szukaj",qph:"Nazwa stacji",searching:"Szukam…",noRes:"Nic nie znaleziono. Spróbuj krótszego słowa.",netFail:"Katalog stacji jest nieosiągalny. Sprawdź, czy telefon ma internet.",saveKey:"Zapisz",pickSlot:"Na którym przycisku?",saved:"Zapisano na przycisku {n}",saveFail:"Nie udało się zapisać tej stacji.",diag:"Zgłoś problem",diagSum:"Zapisuje plik ze stanem tego głośnika. Dołącz go do zgłoszenia, a zobaczę, co się stało.",diagBtn:"Zapisz plik diagnostyczny",diagOk:"Plik zapisany. Dołącz go do zgłoszenia.",diagFail:"Nie udało się odczytać stanu głośnika.",peersHint:"Dotknij głośnika, aby nim sterować.",coach:"Stacje, głośniki i ustawienia są na pasku poniżej.",back:"Wróć",sleep:"Wyłącznik czasowy",sleepSum:"Sam wyłączy głośnik.",sleepGroup:"Cała grupa",sleepOff:"Anuluj",sleepIn:"Wyłączy za {m} min",sleepInS:"Wyłączy się za mniej niż minutę",sleepFail:"Nie udało się ustawić wyłącznika na tym głośniku."},
- tr:{tPlay:"Çal",tFind:"Bul",tSpk:"Hoparlörler",tMore:"Daha fazla",scOne:"Bu hoparlör",scGroup:"Grup",scPair:"Çift",grp:"Grup",grpSum:"{n} hoparlör birlikte çalıyor",grpFollow:"Bu hoparlör {name} cihazını izliyor. Grubu orada dağıtın.",ungroup:"Grubu dağıt",joinTitle:"Birlikte çal",joinAria:"{{name}} hoparlörünü gruba ekle",joining:"{{name}} katılıyor. Bu birkaç saniye sürer.",joinFail:"Bu hoparlör eklenemedi.",leaveAria:"{{name}} hoparlörünü gruptan çıkar",pairSum:"İki hoparlör tek bir stereo çift olarak çalıyor",unpair:"Stereo çifti ayır",find:"İstasyon bul",findSum:"Dünya çapındaki istasyon dizininde ara ve burada çal.",searchBtn:"Ara",qph:"İstasyon adı",searching:"Aranıyor…",noRes:"Bir şey bulunamadı. Daha kısa bir kelime deneyin.",netFail:"İstasyon dizinine ulaşılamıyor. Bu telefonun internete bağlı olduğunu kontrol edin.",saveKey:"Kaydet",pickSlot:"Hangi tuşa?",saved:"{n} numaralı tuşa kaydedildi",saveFail:"Bu istasyon kaydedilemedi.",diag:"Sorun bildir",diagSum:"Bu hoparlörün durumunu anlatan bir dosya kaydeder. Bildiriminizle gönderin, ne olduğunu görebileyim.",diagBtn:"Tanılama dosyasını kaydet",diagOk:"Dosya kaydedildi. Bildiriminize ekleyin.",diagFail:"Hoparlörün durumu okunamadı.",peersHint:"Kontrol etmek için bir hoparlöre dokunun.",coach:"İstasyonlar, hoparlörler ve ayarlar aşağıdaki çubukta.",back:"Geri",sleep:"Uyku zamanlayıcı",sleepSum:"Hoparlörü kendiliğinden kapatır.",sleepGroup:"Tüm grup",sleepOff:"İptal",sleepIn:"{m} dk sonra kapanır",sleepInS:"Bir dakikadan az içinde kapanır",sleepFail:"Bu hoparlörde zamanlayıcı ayarlanamadı."},
- ar:{tPlay:"تشغيل",tFind:"بحث",tSpk:"السمّاعات",tMore:"المزيد",scOne:"هذه السمّاعة",scGroup:"المجموعة",scPair:"الزوج",grp:"المجموعة",grpSum:"{n} سمّاعات تعمل معاً",grpFollow:"هذه السمّاعة تتبع {name}. فُكّ المجموعة من هناك.",ungroup:"فَكّ المجموعة",joinTitle:"تشغيل معاً",joinAria:"أضف {{name}} إلى المجموعة",joining:"{{name}} تنضم الآن. يستغرق ذلك بضع ثوانٍ.",joinFail:"تعذّرت إضافة هذه السمّاعة.",leaveAria:"أزل {{name}} من المجموعة",pairSum:"سمّاعتان تعملان كزوج ستيريو واحد",unpair:"فَكّ الزوج الستيريو",find:"ابحث عن محطة",findSum:"ابحث في دليل المحطات العالمي وشغّلها هنا.",searchBtn:"بحث",qph:"اسم المحطة",searching:"جارٍ البحث…",noRes:"لم يُعثر على شيء. جرّب كلمة أقصر.",netFail:"تعذّر الوصول إلى دليل المحطات. تأكّد من اتصال هذا الهاتف بالإنترنت.",saveKey:"حفظ",pickSlot:"على أي زر؟",saved:"حُفظت على الزر {n}",saveFail:"تعذّر حفظ هذه المحطة.",diag:"الإبلاغ عن مشكلة",diagSum:"يحفظ ملفاً يصف حالة هذه السمّاعة. أرسله مع بلاغك لأرى ما حدث.",diagBtn:"حفظ ملف التشخيص",diagOk:"تم حفظ الملف. أرفقه ببلاغك.",diagFail:"تعذّرت قراءة حالة السمّاعة.",peersHint:"انقر على سمّاعة للتحكم بها.",coach:"المحطات والسمّاعات والإعدادات في الشريط بالأسفل.",back:"رجوع",sleep:"مؤقّت النوم",sleepSum:"يُطفئ السمّاعة تلقائياً.",sleepGroup:"المجموعة كاملة",sleepOff:"إلغاء",sleepIn:"الإطفاء بعد {m} دقيقة",sleepInS:"الإطفاء خلال أقل من دقيقة",sleepFail:"تعذّر ضبط المؤقّت على هذه السمّاعة."},
- ja:{tPlay:"再生",tFind:"さがす",tSpk:"スピーカー",tMore:"その他",scOne:"このスピーカー",scGroup:"グループ",scPair:"ペア",grp:"グループ",grpSum:"{n} 台がいっしょに再生中",grpFollow:"このスピーカーは {name} に従っています。解除はそちらで行えます。",ungroup:"グループを解除",joinTitle:"いっしょに再生",joinAria:"{{name}} をグループに追加",joining:"{{name}} が参加しています。数秒かかります。",joinFail:"このスピーカーを追加できませんでした。",leaveAria:"{{name}} をグループから外す",pairSum:"2台がステレオペアとして再生中",unpair:"ステレオペアを解除",find:"放送局をさがす",findSum:"世界の放送局リストから探して、ここで再生します。",searchBtn:"検索",qph:"放送局名",searching:"検索中…",noRes:"見つかりませんでした。短い言葉でお試しください。",netFail:"放送局リストに接続できません。この端末がインターネットにつながっているか確認してください。",saveKey:"登録",pickSlot:"どのボタンに登録しますか?",saved:"ボタン {n} に登録しました",saveFail:"この放送局を登録できませんでした。",diag:"問題を報告",diagSum:"このスピーカーの状態を書いたファイルを保存します。報告に添えていただければ、何が起きたか分かります。",diagBtn:"診断ファイルを保存",diagOk:"保存しました。報告に添付してください。",diagFail:"スピーカーの状態を読み取れませんでした。",peersHint:"スピーカーをタップすると操作できます。",coach:"放送局・スピーカー・設定は下のバーにあります。",back:"もどる",sleep:"スリープタイマー",sleepSum:"スピーカーをひとりでに切ります。",sleepGroup:"グループ全体",sleepOff:"取り消す",sleepIn:"あと {m} 分で切れます",sleepInS:"まもなく切れます",sleepFail:"このスピーカーにタイマーを設定できませんでした。"},
- lt:{tPlay:"Groti",tFind:"Ieškoti",tSpk:"Kolonėlės",tMore:"Daugiau",scOne:"Ši kolonėlė",scGroup:"Grupė",scPair:"Pora",grp:"Grupė",grpSum:"{n} kolonėlės groja kartu",grpFollow:"Ši kolonėlė seka {name}. Grupę išardykite ten.",ungroup:"Išardyti grupę",joinTitle:"Groti kartu",joinAria:"Pridėti {{name}} į grupę",joining:"{{name}} prisijungia. Tai užtrunka kelias sekundes.",joinFail:"Nepavyko pridėti šios kolonėlės.",leaveAria:"Pašalinti {{name}} iš grupės",pairSum:"Dvi kolonėlės groja kaip viena stereo pora",unpair:"Išardyti stereo porą",find:"Rasti stotį",findSum:"Ieškokite pasauliniame stočių kataloge ir klausykite čia.",searchBtn:"Ieškoti",qph:"Stoties pavadinimas",searching:"Ieškoma…",noRes:"Nieko nerasta. Pabandykite trumpesnį žodį.",netFail:"Stočių katalogas nepasiekiamas. Patikrinkite, ar telefonas turi internetą.",saveKey:"Įrašyti",pickSlot:"Į kurį mygtuką?",saved:"Įrašyta į mygtuką {n}",saveFail:"Nepavyko įrašyti šios stoties.",diag:"Pranešti apie problemą",diagSum:"Įrašo failą su šios kolonėlės būsena. Atsiųskite jį kartu su pranešimu ir pamatysiu, kas nutiko.",diagBtn:"Įrašyti diagnostikos failą",diagOk:"Failas įrašytas. Pridėkite jį prie pranešimo.",diagFail:"Nepavyko nuskaityti kolonėlės būsenos.",peersHint:"Bakstelėkite kolonėlę, kad ją valdytumėte.",coach:"Stotys, kolonėlės ir nustatymai yra juostoje apačioje.",back:"Atgal",sleep:"Miego laikmatis",sleepSum:"Pats išjungs kolonėlę.",sleepGroup:"Visa grupė",sleepOff:"Atšaukti",sleepIn:"Išsijungs po {m} min",sleepInS:"Išsijungs greičiau nei per minutę",sleepFail:"Nepavyko nustatyti laikmačio šioje kolonėlėje."},
- lv:{tPlay:"Atskaņot",tFind:"Meklēt",tSpk:"Skaļruņi",tMore:"Vairāk",scOne:"Šis skaļrunis",scGroup:"Grupa",scPair:"Pāris",grp:"Grupa",grpSum:"{n} skaļruņi atskaņo kopā",grpFollow:"Šis skaļrunis seko {name}. Grupu izjauc tur.",ungroup:"Izjaukt grupu",joinTitle:"Atskaņot kopā",joinAria:"Pievienot {{name}} grupai",joining:"{{name}} pievienojas. Tas aizņem dažas sekundes.",joinFail:"Šo skaļruni neizdevās pievienot.",leaveAria:"Noņemt {{name}} no grupas",pairSum:"Divi skaļruņi atskaņo kā viens stereo pāris",unpair:"Izjaukt stereo pāri",find:"Meklēt staciju",findSum:"Meklē pasaules staciju katalogā un klausies šeit.",searchBtn:"Meklēt",qph:"Stacijas nosaukums",searching:"Meklē…",noRes:"Nekas netika atrasts. Pamēģini īsāku vārdu.",netFail:"Staciju katalogs nav sasniedzams. Pārbaudi, vai šim tālrunim ir internets.",saveKey:"Saglabāt",pickSlot:"Uz kuru taustiņu?",saved:"Saglabāts uz taustiņa {n}",saveFail:"Šo staciju neizdevās saglabāt.",diag:"Ziņot par problēmu",diagSum:"Saglabā failu ar šī skaļruņa stāvokli. Nosūti to kopā ar ziņojumu, un es redzēšu, kas notika.",diagBtn:"Saglabāt diagnostikas failu",diagOk:"Fails saglabāts. Pievieno to ziņojumam.",diagFail:"Neizdevās nolasīt skaļruņa stāvokli.",peersHint:"Pieskaries skaļrunim, lai to vadītu.",coach:"Stacijas, skaļruņi un iestatījumi ir joslā apakšā.",back:"Atpakaļ",sleep:"Miega taimeris",sleepSum:"Pats izslēgs skaļruni.",sleepGroup:"Visa grupa",sleepOff:"Atcelt",sleepIn:"Izslēgsies pēc {m} min",sleepInS:"Izslēgsies mazāk nekā minūtē",sleepFail:"Neizdevās iestatīt taimeri šim skaļrunim."},
- uk:{tPlay:"Грати",tFind:"Пошук",tSpk:"Колонки",tMore:"Більше",scOne:"Ця колонка",scGroup:"Група",scPair:"Пара",grp:"Група",grpSum:"{n} колонки грають разом",grpFollow:"Ця колонка слідує за {name}. Розпустіть групу там.",ungroup:"Розпустити групу",joinTitle:"Грати разом",joinAria:"Додати {{name}} до групи",joining:"{{name}} приєднується. Це триває кілька секунд.",joinFail:"Не вдалося додати цю колонку.",leaveAria:"Прибрати {{name}} з групи",pairSum:"Дві колонки грають як одна стереопара",unpair:"Роз'єднати стереопару",find:"Знайти станцію",findSum:"Шукайте у світовому каталозі станцій і слухайте тут.",searchBtn:"Пошук",qph:"Назва станції",searching:"Пошук…",noRes:"Нічого не знайдено. Спробуйте коротше слово.",netFail:"Каталог станцій недоступний. Перевірте, чи має цей телефон інтернет.",saveKey:"Зберегти",pickSlot:"На яку кнопку?",saved:"Збережено на кнопку {n}",saveFail:"Не вдалося зберегти цю станцію.",diag:"Повідомити про проблему",diagSum:"Зберігає файл зі станом цієї колонки. Надішліть його разом із повідомленням, і я побачу, що сталося.",diagBtn:"Зберегти файл діагностики",diagOk:"Файл збережено. Додайте його до повідомлення.",diagFail:"Не вдалося прочитати стан колонки.",peersHint:"Торкніться колонки, щоб керувати нею.",coach:"Станції, колонки та налаштування — на панелі внизу.",back:"Назад",sleep:"Таймер сну",sleepSum:"Сам вимкне колонку.",sleepGroup:"Уся група",sleepOff:"Скасувати",sleepIn:"Вимкнеться за {m} хв",sleepInS:"Вимкнеться менш ніж за хвилину",sleepFail:"Не вдалося встановити таймер на цій колонці."}
+ en:{tPlay:"Play",tFind:"Find",tSpk:"Speakers",tMore:"More",scOne:"This speaker",scGroup:"Group",scPair:"Pair",grp:"Group",grpSum:"{n} speakers playing together",grpFollow:"This speaker follows {name}. Dissolve the group there.",ungroup:"Dissolve group",joinTitle:"Play together",joinAria:"Add {{name}} to the group",joining:"{{name}} is joining. This takes a few seconds.",joinFail:"That speaker could not be added.",leaveAria:"Remove {{name}} from the group",pairSum:"Two speakers playing as one stereo pair",unpair:"Undo stereo pair",find:"Find a station",findSum:"Search the worldwide station directory and play it here.",searchBtn:"Search",qph:"Station name",searching:"Searching…",noRes:"Nothing found. Try a shorter word.",netFail:"Could not reach the station directory. Check that this phone has internet.",saveKey:"Save",pickSlot:"Which key should hold it?",saved:"Saved to key {n}",saveFail:"Could not save that station.",diag:"Report a problem",diagSum:"Saves a file describing this speaker's state. Send it with your report and I can see what happened.",diagBtn:"Save diagnostic file",diagOk:"File saved. Attach it to your report.",diagFail:"Could not read the speaker's state.",peersHint:"Tap a speaker to control it.",coach:"Stations, speakers and settings are in the bar below.",back:"Back",sleep:"Sleep timer",sleepSum:"Switches the speaker off by itself.",sleepGroup:"Whole group",sleepOff:"Cancel",sleepIn:"Off in {m} min",sleepInS:"Off in less than a minute",sleepFail:"Could not set the timer on this speaker.",musicLib:"Music library",musicLibSum:"Play from a media server on your network, on the speaker itself.",musicLibNone:"No media server found on your network.",musicLibWait:"Added. The speaker needs a few minutes before it appears.",musicLibAdd:"Add",musicLibRemove:"Remove",musicLibFailed:"The speaker refused that. Try again in a moment."},
+ de:{tPlay:"Spielen",tFind:"Suchen",tSpk:"Boxen",tMore:"Mehr",scOne:"Diese Box",scGroup:"Gruppe",scPair:"Paar",grp:"Gruppe",grpSum:"{n} Lautsprecher spielen zusammen",grpFollow:"Diese Box folgt {name}. Auflösen geht dort.",ungroup:"Gruppe auflösen",joinTitle:"Zusammen abspielen",joinAria:"{{name}} zur Gruppe hinzufügen",joining:"{{name}} kommt dazu. Das dauert ein paar Sekunden.",joinFail:"Der Lautsprecher ließ sich nicht hinzufügen.",leaveAria:"{{name}} aus der Gruppe entfernen",pairSum:"Zwei Lautsprecher spielen als ein Stereopaar",unpair:"Stereopaar auflösen",find:"Sender suchen",findSum:"Durchsuche das weltweite Senderverzeichnis und spiel den Sender hier ab.",searchBtn:"Suchen",qph:"Sendername",searching:"Suche…",noRes:"Nichts gefunden. Probier ein kürzeres Wort.",netFail:"Das Senderverzeichnis ist nicht erreichbar. Prüf, ob dieses Handy Internet hat.",saveKey:"Merken",pickSlot:"Auf welche Taste?",saved:"Auf Taste {n} gelegt",saveFail:"Der Sender ließ sich nicht speichern.",diag:"Problem melden",diagSum:"Speichert eine Datei mit dem Zustand dieses Lautsprechers. Schick sie mit deiner Meldung, dann sehe ich, was passiert ist.",diagBtn:"Diagnosedatei speichern",diagOk:"Datei gespeichert. Häng sie an deine Meldung.",diagFail:"Der Zustand des Lautsprechers ließ sich nicht lesen.",peersHint:"Tippe einen Lautsprecher an, um ihn zu steuern.",coach:"Sender, Lautsprecher und Einstellungen findest du unten in der Leiste.",back:"Zurück",sleep:"Einschlaf-Timer",sleepSum:"Schaltet den Lautsprecher von selbst aus.",sleepGroup:"Ganze Gruppe",sleepOff:"Abbrechen",sleepIn:"Aus in {m} Min",sleepInS:"Aus in weniger als einer Minute",sleepFail:"Der Timer ließ sich auf diesem Lautsprecher nicht stellen.",musicLib:"Musiksammlung",musicLibSum:"Spiele von einem Medienserver in deinem Netzwerk, direkt auf der Box.",musicLibNone:"Kein Medienserver im Netzwerk gefunden.",musicLibWait:"Hinzugefügt. Die Box braucht ein paar Minuten, bis er erscheint.",musicLibAdd:"Hinzufügen",musicLibRemove:"Entfernen",musicLibFailed:"Die Box hat das abgelehnt. Versuche es gleich noch einmal."},
+ nl:{tPlay:"Spelen",tFind:"Zoeken",tSpk:"Speakers",tMore:"Meer",scOne:"Deze speaker",scGroup:"Groep",scPair:"Paar",grp:"Groep",grpSum:"{n} speakers spelen samen",grpFollow:"Deze speaker volgt {name}. Daar kun je de groep opheffen.",ungroup:"Groep opheffen",joinTitle:"Samen afspelen",joinAria:"{{name}} aan de groep toevoegen",joining:"{{name}} komt erbij. Dat duurt een paar seconden.",joinFail:"Die speaker kon niet worden toegevoegd.",leaveAria:"{{name}} uit de groep halen",pairSum:"Twee speakers spelen als één stereopaar",unpair:"Stereopaar opheffen",find:"Zender zoeken",findSum:"Doorzoek de wereldwijde zenderlijst en speel hier af.",searchBtn:"Zoeken",qph:"Zendernaam",searching:"Zoeken…",noRes:"Niets gevonden. Probeer een korter woord.",netFail:"De zenderlijst is niet bereikbaar. Controleer of deze telefoon internet heeft.",saveKey:"Bewaren",pickSlot:"Op welke toets?",saved:"Op toets {n} gezet",saveFail:"De zender kon niet worden bewaard.",diag:"Probleem melden",diagSum:"Bewaart een bestand met de toestand van deze speaker. Stuur het mee, dan zie ik wat er gebeurde.",diagBtn:"Diagnosebestand bewaren",diagOk:"Bestand bewaard. Voeg het toe aan je melding.",diagFail:"De toestand van de speaker kon niet gelezen worden.",peersHint:"Tik op een speaker om die te bedienen.",coach:"Zenders, speakers en instellingen staan in de balk hieronder.",back:"Terug",sleep:"Slaaptimer",sleepSum:"Schakelt de speaker vanzelf uit.",sleepGroup:"Hele groep",sleepOff:"Annuleren",sleepIn:"Uit over {m} min",sleepInS:"Uit over minder dan een minuut",sleepFail:"De timer kon niet worden ingesteld op deze speaker.",musicLib:"Muziekbibliotheek",musicLibSum:"Speel vanaf een mediaserver in je netwerk, op de speaker zelf.",musicLibNone:"Geen mediaserver in je netwerk gevonden.",musicLibWait:"Toegevoegd. De speaker heeft een paar minuten nodig.",musicLibAdd:"Toevoegen",musicLibRemove:"Verwijderen",musicLibFailed:"De speaker weigerde dat. Probeer het zo nog eens."},
+ fr:{tPlay:"Lecture",tFind:"Chercher",tSpk:"Enceintes",tMore:"Plus",scOne:"Cette enceinte",scGroup:"Groupe",scPair:"Paire",grp:"Groupe",grpSum:"{n} enceintes jouent ensemble",grpFollow:"Cette enceinte suit {name}. Dissolvez le groupe là-bas.",ungroup:"Dissoudre le groupe",joinTitle:"Écouter ensemble",joinAria:"Ajouter {{name}} au groupe",joining:"{{name}} rejoint le groupe. Cela prend quelques secondes.",joinFail:"Cette enceinte n'a pas pu être ajoutée.",leaveAria:"Retirer {{name}} du groupe",pairSum:"Deux enceintes forment une paire stéréo",unpair:"Défaire la paire stéréo",find:"Chercher une station",findSum:"Cherchez dans l'annuaire mondial des stations et écoutez ici.",searchBtn:"Chercher",qph:"Nom de la station",searching:"Recherche…",noRes:"Rien trouvé. Essayez un mot plus court.",netFail:"L'annuaire des stations est injoignable. Vérifiez qu'internet fonctionne sur ce téléphone.",saveKey:"Garder",pickSlot:"Sur quelle touche ?",saved:"Placée sur la touche {n}",saveFail:"Impossible d'enregistrer cette station.",diag:"Signaler un problème",diagSum:"Enregistre un fichier décrivant l'état de cette enceinte. Joignez-le à votre message et je verrai ce qui s'est passé.",diagBtn:"Enregistrer le fichier de diagnostic",diagOk:"Fichier enregistré. Joignez-le à votre message.",diagFail:"Impossible de lire l'état de l'enceinte.",peersHint:"Touchez une enceinte pour la commander.",coach:"Stations, enceintes et réglages se trouvent dans la barre du bas.",back:"Retour",sleep:"Minuterie",sleepSum:"Éteint l’enceinte toute seule.",sleepGroup:"Tout le groupe",sleepOff:"Annuler",sleepIn:"Extinction dans {m} min",sleepInS:"Extinction dans moins d’une minute",sleepFail:"Impossible de régler la minuterie sur cette enceinte.",musicLib:"Bibliothèque musicale",musicLibSum:"Écoutez un serveur multimédia de votre réseau, sur l'enceinte même.",musicLibNone:"Aucun serveur multimédia trouvé sur votre réseau.",musicLibWait:"Ajouté. L'enceinte a besoin de quelques minutes.",musicLibAdd:"Ajouter",musicLibRemove:"Retirer",musicLibFailed:"L'enceinte a refusé. Réessayez dans un instant."},
+ es:{tPlay:"Reproducir",tFind:"Buscar",tSpk:"Altavoces",tMore:"Más",scOne:"Este altavoz",scGroup:"Grupo",scPair:"Pareja",grp:"Grupo",grpSum:"{n} altavoces suenan juntos",grpFollow:"Este altavoz sigue a {name}. Deshaz el grupo allí.",ungroup:"Deshacer el grupo",joinTitle:"Sonar juntos",joinAria:"Añadir {{name}} al grupo",joining:"{{name}} se está uniendo. Tarda unos segundos.",joinFail:"No se pudo añadir ese altavoz.",leaveAria:"Quitar {{name}} del grupo",pairSum:"Dos altavoces suenan como una pareja estéreo",unpair:"Deshacer la pareja estéreo",find:"Buscar emisora",findSum:"Busca en el directorio mundial de emisoras y escúchala aquí.",searchBtn:"Buscar",qph:"Nombre de la emisora",searching:"Buscando…",noRes:"No se encontró nada. Prueba con una palabra más corta.",netFail:"No se puede acceder al directorio de emisoras. Comprueba que este teléfono tenga internet.",saveKey:"Guardar",pickSlot:"¿En qué tecla?",saved:"Guardada en la tecla {n}",saveFail:"No se pudo guardar esa emisora.",diag:"Informar de un problema",diagSum:"Guarda un archivo con el estado de este altavoz. Envíalo con tu aviso y podré ver qué pasó.",diagBtn:"Guardar archivo de diagnóstico",diagOk:"Archivo guardado. Adjúntalo a tu aviso.",diagFail:"No se pudo leer el estado del altavoz.",peersHint:"Toca un altavoz para controlarlo.",coach:"Emisoras, altavoces y ajustes están en la barra de abajo.",back:"Atrás",sleep:"Temporizador",sleepSum:"Apaga el altavoz por sí solo.",sleepGroup:"Todo el grupo",sleepOff:"Cancelar",sleepIn:"Se apaga en {m} min",sleepInS:"Se apaga en menos de un minuto",sleepFail:"No se pudo ajustar el temporizador en este altavoz.",musicLib:"Biblioteca de música",musicLibSum:"Reproduce desde un servidor multimedia de tu red, en el propio altavoz.",musicLibNone:"No se encontró ningún servidor multimedia en tu red.",musicLibWait:"Añadido. El altavoz necesita unos minutos.",musicLibAdd:"Añadir",musicLibRemove:"Quitar",musicLibFailed:"El altavoz lo rechazó. Inténtalo de nuevo en un momento."},
+ pl:{tPlay:"Odtwarzaj",tFind:"Szukaj",tSpk:"Głośniki",tMore:"Więcej",scOne:"Ten głośnik",scGroup:"Grupa",scPair:"Para",grp:"Grupa",grpSum:"{n} głośniki grają razem",grpFollow:"Ten głośnik podąża za {name}. Rozwiąż grupę tam.",ungroup:"Rozwiąż grupę",joinTitle:"Graj razem",joinAria:"Dodaj {{name}} do grupy",joining:"{{name}} dołącza. To potrwa kilka sekund.",joinFail:"Nie udało się dodać tego głośnika.",leaveAria:"Usuń {{name}} z grupy",pairSum:"Dwa głośniki grają jako para stereo",unpair:"Rozłącz parę stereo",find:"Znajdź stację",findSum:"Przeszukaj światowy katalog stacji i odtwórz tutaj.",searchBtn:"Szukaj",qph:"Nazwa stacji",searching:"Szukam…",noRes:"Nic nie znaleziono. Spróbuj krótszego słowa.",netFail:"Katalog stacji jest nieosiągalny. Sprawdź, czy telefon ma internet.",saveKey:"Zapisz",pickSlot:"Na którym przycisku?",saved:"Zapisano na przycisku {n}",saveFail:"Nie udało się zapisać tej stacji.",diag:"Zgłoś problem",diagSum:"Zapisuje plik ze stanem tego głośnika. Dołącz go do zgłoszenia, a zobaczę, co się stało.",diagBtn:"Zapisz plik diagnostyczny",diagOk:"Plik zapisany. Dołącz go do zgłoszenia.",diagFail:"Nie udało się odczytać stanu głośnika.",peersHint:"Dotknij głośnika, aby nim sterować.",coach:"Stacje, głośniki i ustawienia są na pasku poniżej.",back:"Wróć",sleep:"Wyłącznik czasowy",sleepSum:"Sam wyłączy głośnik.",sleepGroup:"Cała grupa",sleepOff:"Anuluj",sleepIn:"Wyłączy za {m} min",sleepInS:"Wyłączy się za mniej niż minutę",sleepFail:"Nie udało się ustawić wyłącznika na tym głośniku.",musicLib:"Biblioteka muzyki",musicLibSum:"Odtwarzaj z serwera multimediów w sieci, bezpośrednio na głośniku.",musicLibNone:"Nie znaleziono serwera multimediów w sieci.",musicLibWait:"Dodano. Głośnik potrzebuje kilku minut.",musicLibAdd:"Dodaj",musicLibRemove:"Usuń",musicLibFailed:"Głośnik to odrzucił. Spróbuj ponownie za chwilę."},
+ tr:{tPlay:"Çal",tFind:"Bul",tSpk:"Hoparlörler",tMore:"Daha fazla",scOne:"Bu hoparlör",scGroup:"Grup",scPair:"Çift",grp:"Grup",grpSum:"{n} hoparlör birlikte çalıyor",grpFollow:"Bu hoparlör {name} cihazını izliyor. Grubu orada dağıtın.",ungroup:"Grubu dağıt",joinTitle:"Birlikte çal",joinAria:"{{name}} hoparlörünü gruba ekle",joining:"{{name}} katılıyor. Bu birkaç saniye sürer.",joinFail:"Bu hoparlör eklenemedi.",leaveAria:"{{name}} hoparlörünü gruptan çıkar",pairSum:"İki hoparlör tek bir stereo çift olarak çalıyor",unpair:"Stereo çifti ayır",find:"İstasyon bul",findSum:"Dünya çapındaki istasyon dizininde ara ve burada çal.",searchBtn:"Ara",qph:"İstasyon adı",searching:"Aranıyor…",noRes:"Bir şey bulunamadı. Daha kısa bir kelime deneyin.",netFail:"İstasyon dizinine ulaşılamıyor. Bu telefonun internete bağlı olduğunu kontrol edin.",saveKey:"Kaydet",pickSlot:"Hangi tuşa?",saved:"{n} numaralı tuşa kaydedildi",saveFail:"Bu istasyon kaydedilemedi.",diag:"Sorun bildir",diagSum:"Bu hoparlörün durumunu anlatan bir dosya kaydeder. Bildiriminizle gönderin, ne olduğunu görebileyim.",diagBtn:"Tanılama dosyasını kaydet",diagOk:"Dosya kaydedildi. Bildiriminize ekleyin.",diagFail:"Hoparlörün durumu okunamadı.",peersHint:"Kontrol etmek için bir hoparlöre dokunun.",coach:"İstasyonlar, hoparlörler ve ayarlar aşağıdaki çubukta.",back:"Geri",sleep:"Uyku zamanlayıcı",sleepSum:"Hoparlörü kendiliğinden kapatır.",sleepGroup:"Tüm grup",sleepOff:"İptal",sleepIn:"{m} dk sonra kapanır",sleepInS:"Bir dakikadan az içinde kapanır",sleepFail:"Bu hoparlörde zamanlayıcı ayarlanamadı.",musicLib:"Müzik kitaplığı",musicLibSum:"Ağınızdaki bir medya sunucusundan doğrudan hoparlörde çalın.",musicLibNone:"Ağınızda medya sunucusu bulunamadı.",musicLibWait:"Eklendi. Hoparlörün birkaç dakikaya ihtiyacı var.",musicLibAdd:"Ekle",musicLibRemove:"Kaldır",musicLibFailed:"Hoparlör bunu reddetti. Birazdan tekrar deneyin."},
+ ar:{tPlay:"تشغيل",tFind:"بحث",tSpk:"السمّاعات",tMore:"المزيد",scOne:"هذه السمّاعة",scGroup:"المجموعة",scPair:"الزوج",grp:"المجموعة",grpSum:"{n} سمّاعات تعمل معاً",grpFollow:"هذه السمّاعة تتبع {name}. فُكّ المجموعة من هناك.",ungroup:"فَكّ المجموعة",joinTitle:"تشغيل معاً",joinAria:"أضف {{name}} إلى المجموعة",joining:"{{name}} تنضم الآن. يستغرق ذلك بضع ثوانٍ.",joinFail:"تعذّرت إضافة هذه السمّاعة.",leaveAria:"أزل {{name}} من المجموعة",pairSum:"سمّاعتان تعملان كزوج ستيريو واحد",unpair:"فَكّ الزوج الستيريو",find:"ابحث عن محطة",findSum:"ابحث في دليل المحطات العالمي وشغّلها هنا.",searchBtn:"بحث",qph:"اسم المحطة",searching:"جارٍ البحث…",noRes:"لم يُعثر على شيء. جرّب كلمة أقصر.",netFail:"تعذّر الوصول إلى دليل المحطات. تأكّد من اتصال هذا الهاتف بالإنترنت.",saveKey:"حفظ",pickSlot:"على أي زر؟",saved:"حُفظت على الزر {n}",saveFail:"تعذّر حفظ هذه المحطة.",diag:"الإبلاغ عن مشكلة",diagSum:"يحفظ ملفاً يصف حالة هذه السمّاعة. أرسله مع بلاغك لأرى ما حدث.",diagBtn:"حفظ ملف التشخيص",diagOk:"تم حفظ الملف. أرفقه ببلاغك.",diagFail:"تعذّرت قراءة حالة السمّاعة.",peersHint:"انقر على سمّاعة للتحكم بها.",coach:"المحطات والسمّاعات والإعدادات في الشريط بالأسفل.",back:"رجوع",sleep:"مؤقّت النوم",sleepSum:"يُطفئ السمّاعة تلقائياً.",sleepGroup:"المجموعة كاملة",sleepOff:"إلغاء",sleepIn:"الإطفاء بعد {m} دقيقة",sleepInS:"الإطفاء خلال أقل من دقيقة",sleepFail:"تعذّر ضبط المؤقّت على هذه السمّاعة.",musicLib:"مكتبة الموسيقى",musicLibSum:"شغّل من خادم وسائط في شبكتك، على السمّاعة نفسها.",musicLibNone:"لم يتم العثور على خادم وسائط في شبكتك.",musicLibWait:"تمت الإضافة. تحتاج السمّاعة بضع دقائق.",musicLibAdd:"إضافة",musicLibRemove:"إزالة",musicLibFailed:"رفضت السمّاعة ذلك. حاول مرة أخرى بعد قليل."},
+ ja:{tPlay:"再生",tFind:"さがす",tSpk:"スピーカー",tMore:"その他",scOne:"このスピーカー",scGroup:"グループ",scPair:"ペア",grp:"グループ",grpSum:"{n} 台がいっしょに再生中",grpFollow:"このスピーカーは {name} に従っています。解除はそちらで行えます。",ungroup:"グループを解除",joinTitle:"いっしょに再生",joinAria:"{{name}} をグループに追加",joining:"{{name}} が参加しています。数秒かかります。",joinFail:"このスピーカーを追加できませんでした。",leaveAria:"{{name}} をグループから外す",pairSum:"2台がステレオペアとして再生中",unpair:"ステレオペアを解除",find:"放送局をさがす",findSum:"世界の放送局リストから探して、ここで再生します。",searchBtn:"検索",qph:"放送局名",searching:"検索中…",noRes:"見つかりませんでした。短い言葉でお試しください。",netFail:"放送局リストに接続できません。この端末がインターネットにつながっているか確認してください。",saveKey:"登録",pickSlot:"どのボタンに登録しますか?",saved:"ボタン {n} に登録しました",saveFail:"この放送局を登録できませんでした。",diag:"問題を報告",diagSum:"このスピーカーの状態を書いたファイルを保存します。報告に添えていただければ、何が起きたか分かります。",diagBtn:"診断ファイルを保存",diagOk:"保存しました。報告に添付してください。",diagFail:"スピーカーの状態を読み取れませんでした。",peersHint:"スピーカーをタップすると操作できます。",coach:"放送局・スピーカー・設定は下のバーにあります。",back:"もどる",sleep:"スリープタイマー",sleepSum:"スピーカーをひとりでに切ります。",sleepGroup:"グループ全体",sleepOff:"取り消す",sleepIn:"あと {m} 分で切れます",sleepInS:"まもなく切れます",sleepFail:"このスピーカーにタイマーを設定できませんでした。",musicLib:"音楽ライブラリ",musicLibSum:"ネットワーク上のメディアサーバーからスピーカーで直接再生します。",musicLibNone:"ネットワークにメディアサーバーが見つかりません。",musicLibWait:"追加しました。表示されるまで数分かかります。",musicLibAdd:"追加",musicLibRemove:"削除",musicLibFailed:"スピーカーに拒否されました。少ししてからもう一度お試しください。"},
+ lt:{tPlay:"Groti",tFind:"Ieškoti",tSpk:"Kolonėlės",tMore:"Daugiau",scOne:"Ši kolonėlė",scGroup:"Grupė",scPair:"Pora",grp:"Grupė",grpSum:"{n} kolonėlės groja kartu",grpFollow:"Ši kolonėlė seka {name}. Grupę išardykite ten.",ungroup:"Išardyti grupę",joinTitle:"Groti kartu",joinAria:"Pridėti {{name}} į grupę",joining:"{{name}} prisijungia. Tai užtrunka kelias sekundes.",joinFail:"Nepavyko pridėti šios kolonėlės.",leaveAria:"Pašalinti {{name}} iš grupės",pairSum:"Dvi kolonėlės groja kaip viena stereo pora",unpair:"Išardyti stereo porą",find:"Rasti stotį",findSum:"Ieškokite pasauliniame stočių kataloge ir klausykite čia.",searchBtn:"Ieškoti",qph:"Stoties pavadinimas",searching:"Ieškoma…",noRes:"Nieko nerasta. Pabandykite trumpesnį žodį.",netFail:"Stočių katalogas nepasiekiamas. Patikrinkite, ar telefonas turi internetą.",saveKey:"Įrašyti",pickSlot:"Į kurį mygtuką?",saved:"Įrašyta į mygtuką {n}",saveFail:"Nepavyko įrašyti šios stoties.",diag:"Pranešti apie problemą",diagSum:"Įrašo failą su šios kolonėlės būsena. Atsiųskite jį kartu su pranešimu ir pamatysiu, kas nutiko.",diagBtn:"Įrašyti diagnostikos failą",diagOk:"Failas įrašytas. Pridėkite jį prie pranešimo.",diagFail:"Nepavyko nuskaityti kolonėlės būsenos.",peersHint:"Bakstelėkite kolonėlę, kad ją valdytumėte.",coach:"Stotys, kolonėlės ir nustatymai yra juostoje apačioje.",back:"Atgal",sleep:"Miego laikmatis",sleepSum:"Pats išjungs kolonėlę.",sleepGroup:"Visa grupė",sleepOff:"Atšaukti",sleepIn:"Išsijungs po {m} min",sleepInS:"Išsijungs greičiau nei per minutę",sleepFail:"Nepavyko nustatyti laikmačio šioje kolonėlėje.",musicLib:"Muzikos biblioteka",musicLibSum:"Grokite iš medijos serverio jūsų tinkle tiesiai kolonėlėje.",musicLibNone:"Tinkle medijos serverių nerasta.",musicLibWait:"Pridėta. Kolonėlei reikia kelių minučių.",musicLibAdd:"Pridėti",musicLibRemove:"Šalinti",musicLibFailed:"Kolonėlė tai atmetė. Pabandykite dar kartą po akimirkos."},
+ lv:{tPlay:"Atskaņot",tFind:"Meklēt",tSpk:"Skaļruņi",tMore:"Vairāk",scOne:"Šis skaļrunis",scGroup:"Grupa",scPair:"Pāris",grp:"Grupa",grpSum:"{n} skaļruņi atskaņo kopā",grpFollow:"Šis skaļrunis seko {name}. Grupu izjauc tur.",ungroup:"Izjaukt grupu",joinTitle:"Atskaņot kopā",joinAria:"Pievienot {{name}} grupai",joining:"{{name}} pievienojas. Tas aizņem dažas sekundes.",joinFail:"Šo skaļruni neizdevās pievienot.",leaveAria:"Noņemt {{name}} no grupas",pairSum:"Divi skaļruņi atskaņo kā viens stereo pāris",unpair:"Izjaukt stereo pāri",find:"Meklēt staciju",findSum:"Meklē pasaules staciju katalogā un klausies šeit.",searchBtn:"Meklēt",qph:"Stacijas nosaukums",searching:"Meklē…",noRes:"Nekas netika atrasts. Pamēģini īsāku vārdu.",netFail:"Staciju katalogs nav sasniedzams. Pārbaudi, vai šim tālrunim ir internets.",saveKey:"Saglabāt",pickSlot:"Uz kuru taustiņu?",saved:"Saglabāts uz taustiņa {n}",saveFail:"Šo staciju neizdevās saglabāt.",diag:"Ziņot par problēmu",diagSum:"Saglabā failu ar šī skaļruņa stāvokli. Nosūti to kopā ar ziņojumu, un es redzēšu, kas notika.",diagBtn:"Saglabāt diagnostikas failu",diagOk:"Fails saglabāts. Pievieno to ziņojumam.",diagFail:"Neizdevās nolasīt skaļruņa stāvokli.",peersHint:"Pieskaries skaļrunim, lai to vadītu.",coach:"Stacijas, skaļruņi un iestatījumi ir joslā apakšā.",back:"Atpakaļ",sleep:"Miega taimeris",sleepSum:"Pats izslēgs skaļruni.",sleepGroup:"Visa grupa",sleepOff:"Atcelt",sleepIn:"Izslēgsies pēc {m} min",sleepInS:"Izslēgsies mazāk nekā minūtē",sleepFail:"Neizdevās iestatīt taimeri šim skaļrunim.",musicLib:"Mūzikas bibliotēka",musicLibSum:"Atskaņojiet no multivides servera tīklā tieši skaļrunī.",musicLibNone:"Tīklā nav atrasts multivides serveris.",musicLibWait:"Pievienots. Skaļrunim vajag dažas minūtes.",musicLibAdd:"Pievienot",musicLibRemove:"Noņemt",musicLibFailed:"Skaļrunis to noraidīja. Mēģiniet vēlreiz pēc brīža."},
+ uk:{tPlay:"Грати",tFind:"Пошук",tSpk:"Колонки",tMore:"Більше",scOne:"Ця колонка",scGroup:"Група",scPair:"Пара",grp:"Група",grpSum:"{n} колонки грають разом",grpFollow:"Ця колонка слідує за {name}. Розпустіть групу там.",ungroup:"Розпустити групу",joinTitle:"Грати разом",joinAria:"Додати {{name}} до групи",joining:"{{name}} приєднується. Це триває кілька секунд.",joinFail:"Не вдалося додати цю колонку.",leaveAria:"Прибрати {{name}} з групи",pairSum:"Дві колонки грають як одна стереопара",unpair:"Роз'єднати стереопару",find:"Знайти станцію",findSum:"Шукайте у світовому каталозі станцій і слухайте тут.",searchBtn:"Пошук",qph:"Назва станції",searching:"Пошук…",noRes:"Нічого не знайдено. Спробуйте коротше слово.",netFail:"Каталог станцій недоступний. Перевірте, чи має цей телефон інтернет.",saveKey:"Зберегти",pickSlot:"На яку кнопку?",saved:"Збережено на кнопку {n}",saveFail:"Не вдалося зберегти цю станцію.",diag:"Повідомити про проблему",diagSum:"Зберігає файл зі станом цієї колонки. Надішліть його разом із повідомленням, і я побачу, що сталося.",diagBtn:"Зберегти файл діагностики",diagOk:"Файл збережено. Додайте його до повідомлення.",diagFail:"Не вдалося прочитати стан колонки.",peersHint:"Торкніться колонки, щоб керувати нею.",coach:"Станції, колонки та налаштування — на панелі внизу.",back:"Назад",sleep:"Таймер сну",sleepSum:"Сам вимкне колонку.",sleepGroup:"Уся група",sleepOff:"Скасувати",sleepIn:"Вимкнеться за {m} хв",sleepInS:"Вимкнеться менш ніж за хвилину",sleepFail:"Не вдалося встановити таймер на цій колонці.",musicLib:"Музична бібліотека",musicLibSum:"Відтворюйте з медіасервера у вашій мережі просто на колонці.",musicLibNone:"Медіасервер у мережі не знайдено.",musicLibWait:"Додано. Колонці потрібно кілька хвилин.",musicLibAdd:"Додати",musicLibRemove:"Вилучити",musicLibFailed:"Колонка це відхилила. Спробуйте ще раз за мить."}
};
// Pick the best matching locale from the phone and build T (chosen strings with
// English fall-through per key). Runs immediately so the dynamic helpers below
@@ -589,6 +596,7 @@
set('lblGroup', T.grp); set('lblUngroup', T.ungroup);
set('lblFind', T.find); set('lblFindSum', T.findSum); set('lblSearch', T.searchBtn);
set('lblPeersHint', T.peersHint);
+ set('lblMusicLib', T.musicLib); set('lblMusicLibSum', T.musicLibSum);
set('lblDiag', T.diag); set('lblDiagSum', T.diagSum); set('lblDiagBtn', T.diagBtn);
set('lblSleep', T.sleep); set('lblSleepGroup', T.sleepGroup); set('lblSleepOff', T.sleepOff);
var q = document.getElementById('q');
@@ -877,6 +885,73 @@
card.className = inputs.length > 4 ? 'row c3' : 'row c2';
}
+// Music library: turning a media server on the network into a source the
+// SPEAKER plays by itself.
+//
+// The speaker finds DLNA/UPnP servers on its own but will not play from one
+// until that server is registered as a music account. Once it is, the speaker
+// browses and plays it natively and the server also appears in the original
+// Bose app. Verified against a FRITZ!Box and a Synology NAS on 2026-08-10.
+//
+// The card stays hidden unless the speaker actually sees a server, because on a
+// network without one there is nothing to say and an empty card just raises
+// questions.
+var musicLibBusy = false;
+
+async function loadMusicLib() {
+ var card = document.getElementById('musicLibCard');
+ var list = document.getElementById('musicLibList');
+ if (!card || !list) return;
+ var data = await api('/api/box/mediaservers');
+ var servers = (data && data.servers) || [];
+ if (!servers.length) {
+ card.style.display = 'none';
+ return;
+ }
+ card.style.display = '';
+ list.innerHTML = '';
+ servers.forEach(function(s) {
+ var row = document.createElement('div');
+ row.className = 'row';
+ row.style.cssText = 'align-items:center;justify-content:space-between;gap:10px;margin-top:8px';
+ var name = document.createElement('div');
+ name.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
+ name.textContent = s.friendlyName || s.modelName || s.id;
+ var b = document.createElement('button');
+ b.className = 'btn';
+ b.type = 'button';
+ b.style.flex = 'none';
+ b.textContent = s.enabled ? T.musicLibRemove : T.musicLibAdd;
+ b.addEventListener('click', function(){ toggleMusicLib(s, b); });
+ row.appendChild(name);
+ row.appendChild(b);
+ list.appendChild(row);
+ });
+}
+
+async function toggleMusicLib(s, btn) {
+ if (musicLibBusy) return;
+ musicLibBusy = true;
+ var msg = document.getElementById('musicLibMsg');
+ btn.disabled = true;
+ if (msg) msg.textContent = T.loading;
+ var ok;
+ if (s.enabled) {
+ ok = await api('/api/box/mediaservers?id=' + encodeURIComponent(s.id) +
+ '&name=' + encodeURIComponent(s.friendlyName || ''), 'DELETE');
+ if (msg) msg.textContent = ok ? '' : T.musicLibFailed;
+ } else {
+ ok = await api('/api/box/mediaservers', 'POST', {id: s.id, name: s.friendlyName || ''});
+ // Honest about the wait: the speaker confirms the account with STR before
+ // the source shows up, and that took minutes when measured. Saying nothing
+ // here reads as "it did not work".
+ if (msg) msg.textContent = ok ? T.musicLibWait : T.musicLibFailed;
+ }
+ musicLibBusy = false;
+ btn.disabled = false;
+ loadMusicLib();
+}
+
// inputLabel prefers what the speaker calls the socket, because that is what
// its owner named it: a CineMate reports "CBL-Sat" and it should say CBL-Sat.
// Bluetooth and AUX stay untranslated deliberately, like the other brand names
@@ -1276,7 +1351,7 @@
// refreshAll re-fetches every panel at once (used by pull-to-refresh and on
// regaining foreground), with a short minimum so the spinner does not flash.
function refreshAll(){
- var work = Promise.all([loadSettings(), loadPresets(), refreshStatus(), refreshWedge(), loadPeers(), loadVersion(), loadZone(), loadSleep()]).catch(function(){});
+ var work = Promise.all([loadSettings(), loadPresets(), refreshStatus(), refreshWedge(), loadPeers(), loadVersion(), loadZone(), loadSleep(), loadMusicLib()]).catch(function(){});
var minSpin = new Promise(function(r){ setTimeout(r, 500); });
return Promise.all([work, minSpin]);
}
@@ -1865,7 +1940,7 @@
});
})();
-applyStaticI18n(); syncTabbarSpace(); loadSettings(); loadPresets(); refreshStatus(); refreshWedge(); loadPeers(); loadVersion(); loadZone(); loadSleep();
+applyStaticI18n(); syncTabbarSpace(); loadSettings(); loadPresets(); refreshStatus(); refreshWedge(); loadPeers(); loadVersion(); loadZone(); loadSleep(); loadMusicLib();
setInterval(function(){ refreshStatus(); refreshWedge(); }, 5000);
// The group is polled far more slowly than the transport: it changes when
// somebody forms or dissolves one, not second by second, and every poll costs
diff --git a/internal/webui/mediaservers.go b/internal/webui/mediaservers.go
new file mode 100644
index 00000000..5a044be4
--- /dev/null
+++ b/internal/webui/mediaservers.go
@@ -0,0 +1,224 @@
+package webui
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/JRpersonal/streborn/internal/boxapi"
+ "github.com/JRpersonal/streborn/internal/mediaservers"
+)
+
+// Native music sources from a DLNA/UPnP media server (NAS, FRITZ!Box, Plex).
+//
+// The speaker finds media servers on the LAN by itself but will not play from
+// one until it is registered as a STORED_MUSIC account. Once registered it
+// browses and plays the server natively, and the server also shows up in the
+// original Bose app. That is the whole feature: STR turns the registration on
+// and keeps it on, and then gets out of the way.
+//
+// Everything here is a thin shell around boxapi. What STR adds is memory: the
+// registration does NOT survive a reboot on its own (see the mediaservers
+// package), so the user's choice is persisted and reapplied at startup.
+
+// mediaServerView is one server as the UI sees it: what the box discovered,
+// plus whether it is on right now and whether STR will put it back after a
+// reboot.
+type mediaServerView struct {
+ boxapi.MediaServer
+ // Enabled is the user's stored intent. It can differ from Registered for a
+ // short while after a reboot or a fresh enable, because the box takes its
+ // time confirming the account with marge.
+ Enabled bool `json:"enabled"`
+ // Status is the raw /sources status when the source exists, purely
+ // informational. It is a connection indicator, not a capability.
+ Status string `json:"status,omitempty"`
+}
+
+// handleMediaServers is GET (list), POST (enable) and DELETE (disable).
+func (s *Server) handleMediaServers(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodGet, http.MethodPost, http.MethodDelete) {
+ return
+ }
+ if s.boxHost == "" {
+ http.Error(w, "box host not configured", http.StatusServiceUnavailable)
+ return
+ }
+ c := boxapi.New(s.boxHost)
+
+ switch r.Method {
+ case http.MethodGet:
+ // 12 s: the firmware answers /listMediaServers from its own discovery
+ // cache, but a box that just woke can take a while to produce it.
+ ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
+ defer cancel()
+ out, err := s.mediaServerViews(ctx, c)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"servers": out})
+
+ case http.MethodPost:
+ var req struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ }
+ if !decodeJSONRequest(w, r, 4<<10, &req) {
+ return
+ }
+ if strings.TrimSpace(req.ID) == "" {
+ http.Error(w, "id required", http.StatusBadRequest)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ srv := boxapi.MediaServer{ID: strings.TrimSpace(req.ID), FriendlyName: req.Name}
+ // Store and publish FIRST. This is the durable half: once the server is
+ // in the account document, the speaker picks it up on its own poll and
+ // keeps it through every reboot. It cannot fail on the box, so doing it
+ // first means a refused push below still leaves the user with a setting
+ // that works, just not until the speaker next reads its account.
+ if s.mediaServers != nil {
+ if err := s.mediaServers.Add(mediaservers.Server{ID: srv.ID, Name: srv.FriendlyName}); err != nil {
+ s.logger.Warn("media server: could not remember the server", "err", err, "id", srv.ID)
+ }
+ }
+ s.publishMediaServers()
+
+ // Then make it usable NOW rather than at the next boot. Skipped when the
+ // speaker already has the account, which is the normal state after a
+ // restart: pushing it again answers 500 / 1024 and would report a
+ // perfectly healthy source as a failure.
+ pending := false
+ if have, herr := c.RegisteredMediaServerAccounts(ctx); herr == nil && have[srv.SourceAccount()] {
+ s.logger.Info("media server: already known to the speaker, nothing to push", "id", srv.ID)
+ } else if err := c.RegisterMediaServer(ctx, srv); err != nil {
+ // Not an error the user needs to see as failure: the setting is
+ // stored, so the library turns up after the speaker's next restart.
+ s.logger.Warn("media server: the speaker refused the immediate registration, it will appear after a restart",
+ "err", err, "id", srv.ID)
+ pending = true
+ } else {
+ // The speaker accepted it but the source is not usable yet: it
+ // confirms the account with marge first, which took minutes when
+ // measured. Never report this as ready.
+ pending = true
+ }
+ s.logger.Info("media server: enabled as a native music source", "id", srv.ID, "name", srv.FriendlyName)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "pending": pending})
+
+ case http.MethodDelete:
+ id := strings.TrimSpace(r.URL.Query().Get("id"))
+ if id == "" {
+ http.Error(w, "id required", http.StatusBadRequest)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ name := r.URL.Query().Get("name")
+ if name == "" && s.mediaServers != nil {
+ for _, srv := range s.mediaServers.List() {
+ if srv.ID == id {
+ name = srv.Name
+ break
+ }
+ }
+ }
+ // Forget it FIRST. If the box call fails we must not be left with a
+ // stored intent that puts the source back at the next boot.
+ if s.mediaServers != nil {
+ if err := s.mediaServers.Remove(id); err != nil {
+ s.logger.Warn("media server: could not forget the server", "err", err, "id", id)
+ }
+ // Stop advertising it before telling the box to drop it, or its next
+ // account poll would put it straight back.
+ s.publishMediaServers()
+ }
+ if err := c.UnregisterMediaServer(ctx, boxapi.MediaServer{ID: id, FriendlyName: name}); err != nil {
+ s.logger.Warn("media server: the speaker refused the removal", "err", err, "id", id)
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ s.logger.Info("media server: removed as a music source", "id", id)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ }
+}
+
+// mediaServerViews merges what the box discovered, what is registered right
+// now, and what STR was told to keep.
+//
+// A server the user enabled but that is not answering right now still has to
+// appear, or the only control for turning it off would vanish with it.
+func (s *Server) mediaServerViews(ctx context.Context, c *boxapi.Client) ([]mediaServerView, error) {
+ found, err := c.ListMediaServers(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // Best-effort: a box that will not answer /sources still gets a usable list,
+ // it just cannot mark which entries are live yet.
+ status := map[string]string{}
+ if srcs, serr := c.GetSources(ctx); serr == nil {
+ for _, src := range srcs {
+ if strings.EqualFold(src.Source, "STORED_MUSIC") && src.SourceAccount != "" {
+ status[src.SourceAccount] = src.Status
+ }
+ }
+ }
+
+ out := make([]mediaServerView, 0, len(found))
+ seen := map[string]bool{}
+ for _, m := range found {
+ seen[m.ID] = true
+ st, registered := status[m.SourceAccount()]
+ m.Registered = registered
+ out = append(out, mediaServerView{
+ MediaServer: m,
+ Enabled: s.mediaServers != nil && s.mediaServers.Has(m.ID),
+ Status: st,
+ })
+ }
+ if s.mediaServers != nil {
+ for _, srv := range s.mediaServers.List() {
+ if seen[srv.ID] {
+ continue
+ }
+ m := boxapi.MediaServer{ID: srv.ID, FriendlyName: srv.Name}
+ st, registered := status[m.SourceAccount()]
+ m.Registered = registered
+ out = append(out, mediaServerView{MediaServer: m, Enabled: true, Status: st})
+ }
+ }
+ return out, nil
+}
+
+// publishMediaServers hands the current set to the marge account responses, so
+// the box PICKS THE SERVERS UP ITSELF on its next account poll.
+//
+// This is the whole persistence mechanism, and it is a pull, not a push. The box
+// reads GET /streaming/account//full at boot and keeps whatever sources that
+// document advertises; radio has always arrived that way. So a media server that
+// sits in the account document is simply there after every reboot, with no write
+// to the speaker at all, which also means nothing here can touch its standby
+// countdown.
+//
+// The push to /setMusicServiceAccount is kept for the moment the user enables a
+// server, and only for that: it makes the new source usable within the current
+// session instead of at the next boot.
+func (s *Server) publishMediaServers() {
+ if s.mediaServers == nil || s.publishStoredMusic == nil {
+ return
+ }
+ list := s.mediaServers.List()
+ out := make([]StoredMusicSource, 0, len(list))
+ for _, srv := range list {
+ m := boxapi.MediaServer{ID: srv.ID, FriendlyName: srv.Name}
+ out = append(out, StoredMusicSource{Account: m.SourceAccount(), Name: srv.Name})
+ }
+ s.publishStoredMusic(out)
+}
+
+// PublishMediaServers is publishMediaServers for cmd/agent to call once at
+// startup, after the marge bridge is wired.
+func (s *Server) PublishMediaServers() { s.publishMediaServers() }
diff --git a/internal/webui/options.go b/internal/webui/options.go
index 54d6a9e1..4532bd59 100644
--- a/internal/webui/options.go
+++ b/internal/webui/options.go
@@ -10,6 +10,7 @@ import (
"github.com/JRpersonal/streborn/internal/autopair"
"github.com/JRpersonal/streborn/internal/boxcli"
+ "github.com/JRpersonal/streborn/internal/mediaservers"
"github.com/JRpersonal/streborn/internal/presets"
"github.com/JRpersonal/streborn/internal/recent"
"github.com/JRpersonal/streborn/internal/streamproxy"
@@ -86,6 +87,26 @@ func WithZones(z *zones.Store) Option {
return func(s *Server) { s.zones = z }
}
+// WithMediaServers wires the store of DLNA/UPnP media servers the user enabled
+// as native music sources, so they are restored after a reboot (the speaker
+// itself drops them a minute or so into every boot).
+func WithMediaServers(m *mediaservers.Store) Option {
+ return func(s *Server) { s.mediaServers = m }
+}
+
+// StoredMusicSource is one media server the marge account advertises. Account
+// is the server's UPnP id with "/0" appended.
+type StoredMusicSource struct {
+ Account string
+ Name string
+}
+
+// WithStoredMusicPublisher wires the marge bridge that publishes the enabled
+// media servers into the account document the box polls.
+func WithStoredMusicPublisher(f func([]StoredMusicSource)) Option {
+ return func(s *Server) { s.publishStoredMusic = f }
+}
+
// WithBoxHost sets the Bose box IP/hostname for UPnP calls.
func WithBoxHost(host string) Option {
return func(s *Server) {
diff --git a/internal/webui/server.go b/internal/webui/server.go
index e9c3bb35..e9c64f09 100644
--- a/internal/webui/server.go
+++ b/internal/webui/server.go
@@ -15,6 +15,7 @@ import (
"github.com/JRpersonal/streborn/internal/autopair"
"github.com/JRpersonal/streborn/internal/boxcli"
+ "github.com/JRpersonal/streborn/internal/mediaservers"
"github.com/JRpersonal/streborn/internal/netutil"
"github.com/JRpersonal/streborn/internal/presets"
"github.com/JRpersonal/streborn/internal/recent"
@@ -41,8 +42,16 @@ type Server struct {
// zones persists this box's multiroom membership so a zone auto-reforms
// after reboot/standby (#70). nil when not wired; zone write endpoints
// then still drive the box but do not persist.
- zones *zones.Store
- renderer *upnp.Renderer
+ zones *zones.Store
+ // mediaServers remembers which DLNA/UPnP media servers the user turned into
+ // native music sources, because the speaker forgets them on reboot. nil when
+ // not wired; the endpoints then still drive the box but nothing is restored
+ // after a restart.
+ mediaServers *mediaservers.Store
+ // publishStoredMusic hands the enabled media servers to the marge account
+ // responses so the box picks them up on its own poll. nil when not wired.
+ publishStoredMusic func([]StoredMusicSource)
+ renderer *upnp.Renderer
// sleep is the armed sleep timer, if any. See sleeptimer.go.
sleep sleepState
autoPair *autopair.Manager
@@ -629,6 +638,7 @@ func (s *Server) Run(ctx context.Context) error {
mux.HandleFunc("/api/box/airplay-opt", s.handleBoxAirplayOpt)
mux.HandleFunc("/api/box/resume-on-power-on", s.handleResumeOnPowerOn)
mux.HandleFunc("/api/box/display-track", s.handleDisplayTrack)
+ mux.HandleFunc("/api/box/mediaservers", s.handleMediaServers)
mux.HandleFunc("/api/box/presets", s.handleBoxPresets)
mux.HandleFunc("/api/box/presets/recall", s.handleBoxPresetRecall)
mux.HandleFunc("/api/box/snapshot", s.handleBoxSnapshot)