From b33f597c6999b77c7218865d545a84db8fc68bd9 Mon Sep 17 00:00:00 2001 From: JRpersonal Date: Mon, 10 Aug 2026 00:23:29 +0200 Subject: [PATCH 1/6] fix(install): unlocking SSH on a stock speaker now survives the reboot The two config writes ran in the wrong order. envswitch commits the runtime layer as it stands when it runs, so the sys configuration write that followed it was discarded at the next boot. The fallback path we believed existed for chassis we cannot measure was never actually there, and bmxRegistryUrl and statsServerUrl, which have no envswitch form at all, could not be set by any route. Measured on a SoundTouch 20 (spotty, FW 27.0.6) with the box poisoned first so a pass could not be inherited from existing config: gesellix/Bose-SoundTouch#471. Confirmed live on a stock ST10 with no stick and SSH closed, which is the only state that runs this path: SSH opened 72 s after the reboot. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ --- desktop-app/telnet_bootstrap_marge.go | 20 +++++++++++++++++++- desktop-app/telnet_enable_ssh_test.go | 8 +++++++- 2 files changed, 26 insertions(+), 2 deletions(-) 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] { From 23d1f98964a6995d85405b7a4660a7c719c2e671 Mon Sep 17 00:00:00 2001 From: JRpersonal Date: Mon, 10 Aug 2026 00:23:37 +0200 Subject: [PATCH 2/6] fix(multiroom): a line of program code no longer shows up as text in the panel The call that fills in the stereo balance had been placed inside the markup string instead of after it, so the Multi-Room page printed it verbatim and the balance never loaded. It now runs once the markup exists, and only when a pair is actually present. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ --- desktop-app/frontend/src/views/multiroom.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop-app/frontend/src/views/multiroom.js b/desktop-app/frontend/src/views/multiroom.js index 3549b788..a852aa9b 100644 --- a/desktop-app/frontend/src/views/multiroom.js +++ b/desktop-app/frontend/src/views/multiroom.js @@ -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'); From 430ac004977a009de1651eb4892169a9c7095d23 Mon Sep 17 00:00:00 2001 From: JRpersonal Date: Mon, 10 Aug 2026 00:29:15 +0200 Subject: [PATCH 3/6] fix(diagnostics): the report now records which inputs and services a speaker has A diagnostic report described everything about a speaker except what it can play from. A CineMate 130 owner reported that the new "offer every input" feature still showed him Bluetooth alone (discussion #577), and his report could not say why, because nothing in it listed his inputs. The speaker's own source list is now part of the report: every input and service slot with its name, its status and whether it is a socket. Linked streaming accounts are replaced by a hash like the other personal data, while socket names such as AUX, TV or CBL-Sat stay readable, since those are the part that has to be readable for the report to be worth anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ --- desktop-app/logexport.go | 100 ++++++++++++++++++++++++++++++++-- desktop-app/logexport_test.go | 58 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) 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"} { From f6b93072b948fa89180bbc5637dc46772780c8ee Mon Sep 17 00:00:00 2001 From: JRpersonal Date: Mon, 10 Aug 2026 00:33:31 +0200 Subject: [PATCH 4/6] fix(multiroom): undoing a stereo pair now reaches both speakers, not just one Undo was only ever sent to the speaker recorded as the pair master, on the understanding that the master is the one that knows about the pair. That holds while a pair is healthy and stops holding the moment one half lets go. Measured on two SoundTouch 10s: the master reported no pair at all while the other speaker still held the entire pair, naming the master as the left channel. Every undo went to the master, was told there was nothing to undo, and the panel then said "current stereo pair" and "there is no stereo pair to undo" at the same time, with no way to clear it from the app. Sending it to the other speaker cleared both at once. Both speakers now get it, the master first. A speaker that is not in a pair answers that there is nothing to undo and is left alone, so the only case this changes is the one that was stuck. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ --- desktop-app/frontend/src/groups.js | 24 +++++++ desktop-app/frontend/src/groups.test.js | 31 +++++++++ desktop-app/frontend/src/views/multiroom.js | 71 +++++++++++++-------- 3 files changed, 99 insertions(+), 27 deletions(-) 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/views/multiroom.js b/desktop-app/frontend/src/views/multiroom.js index a852aa9b..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 = { @@ -392,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); } From 7b60019aec4d47c0c9a64f68c61d68d8b74f9213 Mon Sep 17 00:00:00 2001 From: JRpersonal Date: Mon, 10 Aug 2026 06:38:08 +0200 Subject: [PATCH 5/6] feat(music): play your network music library on the speaker itself Speakers can see the media servers on your network, a FRITZ!Box, a NAS, a Plex box, but they will not play from one until that server is added as a music source. STR can now add it, in the app and in the phone remote. Once added, the speaker browses and plays the library on its own, and the library also appears in the original Bose app. The speaker forgets the setting on every restart, roughly a minute into the boot, so STR remembers which servers you enabled and puts them back. That restore reads before it writes: nothing is written when nothing is missing, so it cannot keep a speaker out of deep standby. Adding is not instant. The speaker accepts it at once and then confirms the account before the library can be used, so both interfaces say it is on its way instead of reporting a source that is not there yet. Verified end to end on a Portable against a FRITZ!Box 6690: added, browsed down to a track, played it natively with no help from STR, and confirmed it comes back by itself after a reboot. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ --- cmd/agent/main.go | 28 +++ desktop-app/app_mediaservers.go | 115 ++++++++++ desktop-app/frontend/src/api.js | 3 + desktop-app/frontend/src/i18n/bundles/ar.json | 8 + desktop-app/frontend/src/i18n/bundles/de.json | 8 + desktop-app/frontend/src/i18n/bundles/en.json | 8 + desktop-app/frontend/src/i18n/bundles/es.json | 8 + desktop-app/frontend/src/i18n/bundles/fr.json | 8 + desktop-app/frontend/src/i18n/bundles/ja.json | 8 + desktop-app/frontend/src/i18n/bundles/lt.json | 8 + desktop-app/frontend/src/i18n/bundles/lv.json | 8 + desktop-app/frontend/src/i18n/bundles/nl.json | 8 + desktop-app/frontend/src/i18n/bundles/pl.json | 8 + desktop-app/frontend/src/i18n/bundles/tr.json | 8 + desktop-app/frontend/src/i18n/bundles/uk.json | 8 + desktop-app/frontend/src/views/settings.js | 89 ++++++++ desktop-app/frontend/wailsjs/go/main/App.d.ts | 6 + desktop-app/frontend/wailsjs/go/main/App.js | 12 + desktop-app/frontend/wailsjs/go/models.ts | 26 +++ internal/boxapi/boxapi.go | 59 +++-- internal/boxapi/mediaservers.go | 141 ++++++++++++ internal/mediaservers/mediaservers.go | 154 +++++++++++++ internal/mediaservers/mediaservers_test.go | 97 ++++++++ internal/webui/assets/index.html | 103 +++++++-- internal/webui/mediaservers.go | 214 ++++++++++++++++++ internal/webui/options.go | 8 + internal/webui/server.go | 11 +- 27 files changed, 1123 insertions(+), 39 deletions(-) create mode 100644 desktop-app/app_mediaservers.go create mode 100644 internal/boxapi/mediaservers.go create mode 100644 internal/mediaservers/mediaservers.go create mode 100644 internal/mediaservers/mediaservers_test.go create mode 100644 internal/webui/mediaservers.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 10c27c2c..0368089c 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,7 @@ func run() error { }), webui.WithWebhooks(webhooksStore), webui.WithZones(zonesStore), + webui.WithMediaServers(mediaServerStore), webui.WithMargeGroups(margeSrv.GroupSnapshot, margeSrv.SetCanonicalGroup, margeSrv.ClearGroup), webui.WithMargeForward(margeSrv.SetForward), webui.WithRecent(recentStore)) @@ -641,6 +653,22 @@ func run() error { // the current stream + the UPnP renderer. go webuiSrv.PeriodicZoneReconcile() + // Put the user's DLNA/UPnP music sources back after this restart. Once, not + // on a timer, and it reads before it writes: a write resets the speaker's + // standby countdown, and on the normal path nothing is missing so nothing is + // written. Delayed because the box needs its own boot before it can answer, + // and re-registering is pointless until its firmware is up. + go func() { + select { + case <-time.After(90 * time.Second): + case <-context.Background().Done(): + return + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + webuiSrv.ReapplyMediaServers(ctx) + }() + // 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/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/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/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/mediaservers/mediaservers.go b/internal/mediaservers/mediaservers.go new file mode 100644 index 00000000..da7652f3 --- /dev/null +++ b/internal/mediaservers/mediaservers.go @@ -0,0 +1,154 @@ +// Package mediaservers remembers which DLNA/UPnP media servers the user turned +// into native music sources on this box, so they are still there after a +// reboot. +// +// They are not otherwise. The speaker accepts the registration and keeps it +// across a restart just long enough to look convincing: measured on a Portable, +// the source was present for the first ~70 s after boot and then disappeared. +// The reason is that the box re-checks the account against its marge, STR's +// record of registered sources lives only in memory, and the agent restarted +// with the box, so the box was told the account has no sources and dropped it. +// +// Persisting the user's choice here lets the agent put it back once at startup. +// It stores the user's INTENT, not box state: the box remains the authority on +// what is currently registered, and the agent reconciles towards this list. +package mediaservers + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "sync" + + "github.com/JRpersonal/streborn/internal/atomicfile" +) + +// Server is one media server the user enabled as a music source. +type Server struct { + // ID is the UPnP UDN without the "uuid:" prefix, as the box reports it. + ID string `json:"id"` + // Name is the server's friendly name, kept so a re-registration after a + // reboot uses the same label the user saw, and so the box can be told the + // display name when the source is removed again. + Name string `json:"name,omitempty"` +} + +// Store is the persisted set of enabled servers. +type Store struct { + path string + mu sync.RWMutex + servers map[string]Server +} + +// New returns an empty in-memory store with no persistence path. +func New() *Store { return &Store{servers: map[string]Server{}} } + +// Load reads the store from path. A missing or empty file yields an empty store +// and no error: nothing enabled is the normal state, not a fault. +func Load(path string) (*Store, error) { + s := &Store{path: path, servers: map[string]Server{}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return s, fmt.Errorf("read media servers: %w", err) + } + if len(strings.TrimSpace(string(b))) == 0 { + return s, nil + } + var list []Server + if err := json.Unmarshal(b, &list); err != nil { + return s, fmt.Errorf("parse media servers: %w", err) + } + for _, srv := range list { + if id := strings.TrimSpace(srv.ID); id != "" { + srv.ID = id + s.servers[id] = srv + } + } + return s, nil +} + +// List returns the enabled servers, ordered by name so the UI is stable. +func (s *Store) List() []Server { + s.mu.RLock() + out := make([]Server, 0, len(s.servers)) + for _, srv := range s.servers { + out = append(out, srv) + } + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].ID < out[j].ID + }) + return out +} + +// Has reports whether a server id is enabled. +func (s *Store) Has(id string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.servers[strings.TrimSpace(id)] + return ok +} + +// Add enables a server and persists. Adding one that is already stored with the +// same name does not write: enabling is idempotent and a repeat must not cost a +// NAND write. +func (s *Store) Add(srv Server) error { + srv.ID = strings.TrimSpace(srv.ID) + if srv.ID == "" { + return fmt.Errorf("media server has no id") + } + s.mu.Lock() + if cur, ok := s.servers[srv.ID]; ok && cur.Name == srv.Name { + s.mu.Unlock() + return nil + } + s.servers[srv.ID] = srv + s.mu.Unlock() + return s.Save() +} + +// Remove disables a server and persists. Removing one that is not stored does +// not write, for the same reason Add is idempotent. +func (s *Store) Remove(id string) error { + id = strings.TrimSpace(id) + s.mu.Lock() + _, existed := s.servers[id] + delete(s.servers, id) + s.mu.Unlock() + if !existed { + return nil + } + return s.Save() +} + +// Save writes the current set atomically. +func (s *Store) Save() error { + s.mu.RLock() + defer s.mu.RUnlock() + if s.path == "" { + return fmt.Errorf("media server store has no path") + } + list := make([]Server, 0, len(s.servers)) + for _, srv := range s.servers { + list = append(list, srv) + } + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return fmt.Errorf("marshal media servers: %w", err) + } + // Durable write (fsync + rename): a plain write+rename can leave the file at + // 0 bytes after a speaker's standby power-cut. + if err := atomicfile.WriteFile(s.path, append(b, '\n'), 0o644); err != nil { + return fmt.Errorf("write media servers: %w", err) + } + return nil +} diff --git a/internal/mediaservers/mediaservers_test.go b/internal/mediaservers/mediaservers_test.go new file mode 100644 index 00000000..ed602aa9 --- /dev/null +++ b/internal/mediaservers/mediaservers_test.go @@ -0,0 +1,97 @@ +package mediaservers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingFileIsEmptyNotAnError(t *testing.T) { + s, err := Load(filepath.Join(t.TempDir(), "nope.json")) + if err != nil { + t.Fatalf("missing file must not error: %v", err) + } + if len(s.List()) != 0 { + t.Fatalf("want empty store, got %v", s.List()) + } +} + +func TestAddListRemoveRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "mediaservers.json") + s, _ := Load(path) + if err := s.Add(Server{ID: "fa095ecc-uuid", Name: "AVM FRITZ!Mediaserver"}); err != nil { + t.Fatalf("add: %v", err) + } + if err := s.Add(Server{ID: "00113251-uuid", Name: "Backupserver"}); err != nil { + t.Fatalf("add: %v", err) + } + if !s.Has("fa095ecc-uuid") { + t.Error("Has must find an added server") + } + + // Reload from disk: the whole point of the store. + again, err := Load(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + got := again.List() + if len(got) != 2 { + t.Fatalf("want 2 servers after reload, got %d: %v", len(got), got) + } + // Ordered by name, so "AVM ..." sorts before "Backupserver". + if got[0].Name != "AVM FRITZ!Mediaserver" || got[1].Name != "Backupserver" { + t.Errorf("List must be ordered by name, got %v", got) + } + + if err := again.Remove("fa095ecc-uuid"); err != nil { + t.Fatalf("remove: %v", err) + } + final, _ := Load(path) + if len(final.List()) != 1 || final.Has("fa095ecc-uuid") { + t.Errorf("removal did not persist: %v", final.List()) + } +} + +// A repeat enable/disable must not rewrite the file. NAND writes on these boxes +// are the scarce resource, and the UI can re-send the same state freely. +func TestIdempotentOpsDoNotRewrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "mediaservers.json") + s, _ := Load(path) + if err := s.Add(Server{ID: "a", Name: "A"}); err != nil { + t.Fatalf("add: %v", err) + } + st, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + // Remove the file: if a no-op write happened, it would come back. + if err := os.Remove(path); err != nil { + t.Fatalf("remove file: %v", err) + } + if err := s.Add(Server{ID: "a", Name: "A"}); err != nil { + t.Fatalf("re-add: %v", err) + } + if err := s.Remove("does-not-exist"); err != nil { + t.Fatalf("remove absent: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("an unchanged Add/Remove must not write the file again") + } + _ = st + + // A changed name IS a change and must persist. + if err := s.Add(Server{ID: "a", Name: "A renamed"}); err != nil { + t.Fatalf("rename: %v", err) + } + back, _ := Load(path) + if got := back.List(); len(got) != 1 || got[0].Name != "A renamed" { + t.Errorf("rename must persist, got %v", got) + } +} + +func TestAddRejectsEmptyID(t *testing.T) { + s := New() + if err := s.Add(Server{Name: "no id"}); err == nil { + t.Error("an entry without an id must be rejected") + } +} diff --git a/internal/webui/assets/index.html b/internal/webui/assets/index.html index 0443578a..6d7b95a4 100644 --- a/internal/webui/assets/index.html +++ b/internal/webui/assets/index.html @@ -432,6 +432,13 @@
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..8d1e95a7 --- /dev/null +++ b/internal/webui/mediaservers.go @@ -0,0 +1,214 @@ +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} + if err := c.RegisterMediaServer(ctx, srv); err != nil { + s.logger.Warn("media server: the speaker refused the registration", "err", err, "id", srv.ID) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + if s.mediaServers != nil { + if err := s.mediaServers.Add(mediaservers.Server{ID: srv.ID, Name: srv.FriendlyName}); err != nil { + // The box HAS the source; failing the whole call would tell the + // user it did not work. Say it will not survive a reboot instead. + s.logger.Warn("media server: registered on the speaker but could not be remembered", "err", err, "id", srv.ID) + } + } + s.logger.Info("media server: registered as a native music source", "id", srv.ID, "name", srv.FriendlyName) + // The source does not appear at once: the speaker confirms the account + // with marge first, which took minutes when measured. Say so rather than + // letting the UI report a source that is not there yet. + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "pending": true}) + + 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) + } + } + 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 +} + +// ReapplyMediaServers puts the user's enabled media servers back after a +// restart, once. +// +// It READS first and only writes for a server that is actually missing. That +// ordering is the point: a write to the box resets its standby countdown, and a +// speaker that is never allowed to reach deep standby is a bug we have shipped +// before (#472). On the normal path, where nothing is missing, this costs one +// read and no writes at all. +// +// Deliberately not on a timer. Once per agent start is enough, because the only +// thing that drops the registration is a restart. +func (s *Server) ReapplyMediaServers(ctx context.Context) { + if s.mediaServers == nil || s.boxHost == "" { + return + } + want := s.mediaServers.List() + if len(want) == 0 { + return + } + c := boxapi.New(s.boxHost) + have, err := c.RegisteredMediaServerAccounts(ctx) + if err != nil { + s.logger.Warn("media server: could not read the speaker's sources, leaving the registrations alone", "err", err) + return + } + for _, srv := range want { + m := boxapi.MediaServer{ID: srv.ID, FriendlyName: srv.Name} + if have[m.SourceAccount()] { + continue + } + if err := c.RegisterMediaServer(ctx, m); err != nil { + s.logger.Warn("media server: could not restore the music source after the restart", + "err", err, "id", srv.ID, "name", srv.Name) + continue + } + s.logger.Info("media server: music source restored after the restart", "id", srv.ID, "name", srv.Name) + } +} diff --git a/internal/webui/options.go b/internal/webui/options.go index 54d6a9e1..f77af3df 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,13 @@ 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 } +} + // 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..1a1b3ec3 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,13 @@ 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 + renderer *upnp.Renderer // sleep is the armed sleep timer, if any. See sleeptimer.go. sleep sleepState autoPair *autopair.Manager @@ -629,6 +635,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) From 374de201b5fa80d2b3f88b90f8f428b59246e44f Mon Sep 17 00:00:00 2001 From: JRpersonal Date: Mon, 10 Aug 2026 07:02:16 +0200 Subject: [PATCH 6/6] refactor(music): the speaker fetches its music library itself instead of being told The library was kept alive by writing to the speaker after every restart. It is now simply part of the account the speaker reads at boot, the same way its radio stations arrive, so it is there again without anybody sending it anything. That is how the original setup worked, and it is better for the speaker: the old way meant a write on every start, and writes are what reset the countdown into deep standby. There is now no write at all on that path. The speaker is still told directly at the moment somebody adds a library, and only then, so it can be used straight away rather than after the next restart. Adding one it already has is now recognised and skipped, instead of being reported as a failure. Measured on a Portable: the library was removed from the speaker completely, the agent restarted, and the speaker fetched it back on its own account read with no registration call anywhere in the log. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ --- cmd/agent/main.go | 29 +++++---- internal/marge/marge.go | 6 ++ internal/marge/responses.go | 93 +++++++++++++++++++++++++++- internal/marge/storedmusic_test.go | 91 +++++++++++++++++++++++++++ internal/webui/mediaservers.go | 98 ++++++++++++++++-------------- internal/webui/options.go | 13 ++++ internal/webui/server.go | 5 +- 7 files changed, 272 insertions(+), 63 deletions(-) create mode 100644 internal/marge/storedmusic_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 0368089c..5056d907 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -643,6 +643,13 @@ 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)) @@ -653,21 +660,13 @@ func run() error { // the current stream + the UPnP renderer. go webuiSrv.PeriodicZoneReconcile() - // Put the user's DLNA/UPnP music sources back after this restart. Once, not - // on a timer, and it reads before it writes: a write resets the speaker's - // standby countdown, and on the normal path nothing is missing so nothing is - // written. Delayed because the box needs its own boot before it can answer, - // and re-registering is pointless until its firmware is up. - go func() { - select { - case <-time.After(90 * time.Second): - case <-context.Background().Done(): - return - } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - webuiSrv.ReapplyMediaServers(ctx) - }() + // 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 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, `/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. // -// Deliberately not on a timer. Once per agent start is enough, because the only -// thing that drops the registration is a restart. -func (s *Server) ReapplyMediaServers(ctx context.Context) { - if s.mediaServers == nil || s.boxHost == "" { - return - } - want := s.mediaServers.List() - if len(want) == 0 { +// 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 } - c := boxapi.New(s.boxHost) - have, err := c.RegisteredMediaServerAccounts(ctx) - if err != nil { - s.logger.Warn("media server: could not read the speaker's sources, leaving the registrations alone", "err", err) - return - } - for _, srv := range want { + list := s.mediaServers.List() + out := make([]StoredMusicSource, 0, len(list)) + for _, srv := range list { m := boxapi.MediaServer{ID: srv.ID, FriendlyName: srv.Name} - if have[m.SourceAccount()] { - continue - } - if err := c.RegisterMediaServer(ctx, m); err != nil { - s.logger.Warn("media server: could not restore the music source after the restart", - "err", err, "id", srv.ID, "name", srv.Name) - continue - } - s.logger.Info("media server: music source restored after the restart", "id", srv.ID, "name", 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 f77af3df..4532bd59 100644 --- a/internal/webui/options.go +++ b/internal/webui/options.go @@ -94,6 +94,19 @@ 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 1a1b3ec3..e9c64f09 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -48,7 +48,10 @@ type Server struct { // not wired; the endpoints then still drive the box but nothing is restored // after a restart. mediaServers *mediaservers.Store - renderer *upnp.Renderer + // 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