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 = `
${escapeHtml(t('multiroom.stereoNothingToUndo'))}
`; renderMultiroom(false); return; } $('stereoResult').innerHTML = `
${escapeHtml(t('common.loading'))}
`; - 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 = `
${escapeHtml(t('multiroom.stereoNothingToUndo'))}
`; - showToast(t('multiroom.stereoNothingToUndo')); - } else { - state.stereoMsg = `
${escapeHtml(t('multiroom.formFailed', { err: String(e) }))}
`; - } + } else if (failure) { + state.stereoMsg = `
${escapeHtml(t('multiroom.formFailed', { err: String(failure) }))}
`; + } else { + state.stereoMsg = `
${escapeHtml(t('multiroom.stereoNothingToUndo'))}
`; + 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 = `
${escapeHtml(t('settingsView.musicLibNone'))}
`; + return; + } + list.innerHTML = servers.map((srv, i) => { + const name = srv.friendlyName || srv.modelName || srv.id; + const where = srv.manufacturer ? `${srv.manufacturer}${srv.ip ? ' · ' + srv.ip : ''}` : (srv.ip || ''); + const stateLabel = srv.enabled + ? (srv.registered ? t('settingsView.musicLibActive') : t('settingsView.musicLibPending')) + : ''; + return `
+
+
${escapeHtml(name)}
+ ${escapeHtml(where)}${stateLabel ? ' · ' + escapeHtml(stateLabel) : ''} +
+ +
`; + }).join(''); + + list.querySelectorAll('[data-mlidx]').forEach(btn => { + btn.onclick = async () => { + const srv = servers[Number(btn.getAttribute('data-mlidx'))]; + if (!srv) return; + const msg = $('musicLibMsg'); + btn.disabled = true; + try { + if (srv.enabled) { + await DisableBoxMediaServer(box.host, box.port, srv.id, srv.friendlyName || ''); + if (msg) msg.innerHTML = ''; + } else { + await EnableBoxMediaServer(box.host, box.port, srv.id, srv.friendlyName || ''); + if (msg) msg.innerHTML = `
${escapeHtml(t('settingsView.musicLibWait'))}
`; + } + } catch (e) { + if (msg) msg.innerHTML = `
${escapeHtml(String(e))}
`; + } + btn.disabled = false; + fillMusicLib(box).catch(() => {}); + }; + }); +} + function renderBoxSettings(s, box) { const info = s.info || {}; const vol = s.volume || {}; @@ -841,6 +918,13 @@ function renderBoxSettings(s, box) { ${sources.some(x => x.source === 'AIRPLAY' && x.status !== 'READY') ? `${escapeHtml(t('settingsView.airplayHint'))}` : ''} +
+

${escapeHtml(t('settingsView.musicLibHeading'))}

+
${escapeHtml(t('common.loading'))}
+
+ ${escapeHtml(t('settingsView.musicLibHelp'))} +
+

${escapeHtml(t('settingsView.regionHeading'))}

${escapeHtml(t('settingsView.regionCurrent'))}${escapeHtml(t('common.loading'))}
@@ -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. + + @@ -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)