Files
Bose-SoundTouch/pkg/service/soundtouchweb/handler_library.go
T
Tobias GesellchenandClaude Sonnet 5 f899dbaa89 test(marge): guard source XML shape; feat(library): merge speaker-side media server discovery
Comparing against JRpersonal/streborn#587 surfaced two gaps: no test
pinned that a newly added source type renders the same element shape
as a known-good default (the firmware rejects the whole account
document if one source entry omits an expected element), and our DLNA
discovery only swept SSDP from the service host, missing servers only
visible from a paired speaker's own LAN segment.

Adds TestSourceXMLShapeConsistencyAcrossTypes in pkg/service/marge,
and has HandleDiscoverLibraryServers merge results from each paired
speaker's own /listMediaServers alongside the existing SSDP sweep,
deduped by UDN, with unreachable speakers skipped silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 22:27:13 +02:00

547 lines
16 KiB
Go

package soundtouchweb
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
)
// libraryServer is the JSON DTO for a DLNA media server. The registered and
// ready fields reflect state on the specific speaker that was queried;
// HandleDiscoverLibraryServers leaves them false because it performs a
// LAN-wide sweep with no device context.
type libraryServer struct {
UDN string `json:"udn"`
Name string `json:"name"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
CDSControlURL string `json:"cdsControlURL"`
Registered bool `json:"registered"`
Ready bool `json:"ready"`
}
// libraryEntry is the JSON DTO for a single item returned by the speaker's
// /navigate endpoint. IsDir is derived from the item type so the frontend
// can render directory entries differently without an extra string comparison.
type libraryEntry struct {
Name string `json:"name"`
Type string `json:"type"`
Location string `json:"location"`
SourceAccount string `json:"sourceAccount"`
Playable bool `json:"playable"`
IsDir bool `json:"isDir"`
}
// libraryPage wraps a slice of libraryEntry as the Data payload.
type libraryPage struct {
Entries []libraryEntry `json:"entries"`
TotalItems int `json:"totalItems"`
}
// normalizeUDN strips the "uuid:" prefix that UPnP device descriptions include
// in the UDN field (e.g. "uuid:fa095ecc-e13e-40e7-8e6c-e0286d5bc000") so the
// result matches the bare UUID that a SoundTouch speaker uses as the
// STORED_MUSIC sourceAccount before the "/0" suffix is appended.
func normalizeUDN(s string) string {
return strings.TrimPrefix(s, "uuid:")
}
// HandleDiscoverLibraryServers performs a LAN-wide SSDP sweep for DLNA media
// servers, plus a query to every paired speaker's own /listMediaServers, and
// returns the merged set as a JSON array. An optional ?timeout= query
// parameter (in seconds, integer) overrides the default 5-second SSDP budget.
// This handler is global (not device-scoped) and lives under
// /api/control/providers/library/servers.
//
// The two sources see different networks: our SSDP sweep runs from the
// AfterTouch service host, while each speaker's /listMediaServers reflects
// what that speaker sees on its own LAN segment. They can disagree when the
// service isn't co-located with the speaker (different subnet/VLAN), so a
// server invisible to one path may still be visible via the other.
func (app *WebApp) HandleDiscoverLibraryServers(w http.ResponseWriter, r *http.Request) {
timeout := 5 * time.Second
if raw := r.URL.Query().Get("timeout"); raw != "" {
if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
timeout = time.Duration(secs) * time.Second
}
}
servers, err := discovery.DiscoverMediaServers(r.Context(), timeout)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
byUDN := make(map[string]libraryServer, len(servers))
order := make([]string, 0, len(servers))
for _, s := range servers {
udn := normalizeUDN(s.UDN)
byUDN[udn] = libraryServer{
UDN: udn,
Name: s.FriendlyName,
Manufacturer: s.Manufacturer,
Model: s.ModelName,
CDSControlURL: s.CDSControlURL,
}
order = append(order, udn)
}
deviceFound := app.discoverDeviceMediaServers()
for i := range deviceFound {
found := &deviceFound[i]
udn := normalizeUDN(found.ID)
if _, exists := byUDN[udn]; exists {
// Already found via SSDP, which carries CDSControlURL; keep that
// entry since registration itself only needs the UDN and name.
continue
}
byUDN[udn] = libraryServer{
UDN: udn,
Name: found.FriendlyName,
Manufacturer: found.Manufacturer,
Model: found.ModelName,
}
order = append(order, udn)
}
out := make([]libraryServer, 0, len(order))
for _, udn := range order {
out = append(out, byUDN[udn])
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: out}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// discoverDeviceMediaServers queries every paired speaker's own
// /listMediaServers endpoint concurrently and returns the union of what they
// report, deduplicated by UDN. A speaker that is offline, times out, or runs
// firmware without the endpoint is skipped silently: this is a best-effort
// second discovery path, and one unreachable speaker must not fail or delay
// the overall response.
func (app *WebApp) discoverDeviceMediaServers() []models.MediaServerInfo {
devices := app.DeviceSnapshot()
type result struct {
servers []models.MediaServerInfo
}
results := make(chan result, len(devices))
var wg sync.WaitGroup
for _, entry := range devices {
deviceClient := entry.Device.Client
if deviceClient == nil {
continue
}
wg.Add(1)
go func(c *client.Client) {
defer wg.Done()
resp, err := c.ListMediaServers()
if err != nil || resp == nil {
results <- result{}
return
}
results <- result{servers: resp.MediaServers}
}(deviceClient)
}
go func() {
wg.Wait()
close(results)
}()
seen := make(map[string]bool)
out := make([]models.MediaServerInfo, 0, len(devices))
for r := range results {
for i := range r.servers {
s := &r.servers[i]
udn := normalizeUDN(s.ID)
if udn == "" || seen[udn] {
continue
}
seen[udn] = true
out = append(out, *s)
}
}
return out
}
// HandleDeviceLibraryServers returns the STORED_MUSIC sources currently
// registered on a specific speaker. Each source corresponds to one DLNA
// server that has been paired with that device.
func (app *WebApp) HandleDeviceLibraryServers(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
sources, err := device.Client.GetSources()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
out := make([]libraryServer, 0)
for _, si := range sources.SourceItem {
if si.Source != "STORED_MUSIC" {
continue
}
udn := strings.TrimSuffix(si.SourceAccount, "/0")
out = append(out, libraryServer{
UDN: udn,
Name: si.DisplayName,
Registered: true,
Ready: si.Status == "READY",
})
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: out}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAddLibraryServer registers a DLNA media server on a specific speaker
// using the speaker's setMusicServiceAccount endpoint. The request body must
// contain {udn, name}. The account sent to the speaker is "<udn>/0" as
// required by the STORED_MUSIC protocol. Error code 1024 from the speaker
// means the account is already registered and is treated as success.
//
// After a successful registration the handler fires a best-effort
// sourcesUpdated notification so the speaker re-fetches its account list and
// registers the new source without requiring a power-cycle. The notification
// outcome is reflected in the response field "refreshed" but never fails the
// request.
func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
var req struct {
UDN string `json:"udn"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.UDN == "" {
app.sendError(w, "udn is required", http.StatusBadRequest)
return
}
account := normalizeUDN(req.UDN) + "/0"
if err := device.Client.AddStoredMusicAccount(account, req.Name); err != nil {
// Error code 1024 means the account is already registered on the speaker.
// Treat it as success so callers can be idempotent.
if !strings.Contains(err.Error(), "1024") {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
}
// Resolve the Bose device ID for the sourcesUpdated nudge. Prefer the
// cached DeviceInfo (no extra round-trip); fall back to a live /info
// fetch only if the cached value is absent or empty.
boseDeviceID := ""
if device.DeviceInfo != nil && device.DeviceInfo.DeviceID != "" {
boseDeviceID = device.DeviceInfo.DeviceID
} else {
if info, infoErr := device.Client.GetDeviceInfo(); infoErr == nil && info != nil {
boseDeviceID = info.DeviceID
}
}
// Send the sourcesUpdated nudge best-effort: the registration already
// succeeded, so an error here must never fail the request.
refreshed := false
if boseDeviceID != "" {
if nudgeErr := device.Client.NotifySourcesUpdated(boseDeviceID); nudgeErr == nil {
refreshed = true
}
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{"account": account, "refreshed": refreshed},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleRemoveLibraryServer unregisters a DLNA media server from a specific
// speaker. The {account} URL parameter is the full account string (e.g.
// "uuid:1234.../0") and must be URL-encoded by the caller.
func (app *WebApp) HandleRemoveLibraryServer(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
rawAccount := chi.URLParam(r, "account")
account, err := url.PathUnescape(rawAccount)
if err != nil {
account = rawAccount
}
if account == "" {
app.sendError(w, "account is required", http.StatusBadRequest)
return
}
if err := device.Client.RemoveStoredMusicAccount(account, ""); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleLibraryBrowse browses STORED_MUSIC content via the speaker's own
// /navigate endpoint, which returns speaker-native location tokens. These
// tokens are what /select requires when playing a track; raw DLNA ContentID
// values are not accepted by the speaker.
//
// Query parameters:
// - account (required) the sourceAccount string, e.g. "uuid:.../0"
// - location (optional) location token from a previous browse; empty means root
// - type (optional) type hint for the container item, defaults to "dir"
// - start (optional) 1-based start index, defaults to 1
// - count (optional) number of items to return, defaults to 200
func (app *WebApp) HandleLibraryBrowse(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
account := r.URL.Query().Get("account")
if account == "" {
app.sendError(w, "account is required", http.StatusBadRequest)
return
}
location := r.URL.Query().Get("location")
itemType := r.URL.Query().Get("type")
start := 1
if raw := r.URL.Query().Get("start"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v >= 1 {
start = v
}
}
count := 200
if raw := r.URL.Query().Get("count"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v >= 1 {
count = v
}
}
var (
resp *models.NavigateResponse
navErr error
)
if location == "" {
resp, navErr = device.Client.Navigate("STORED_MUSIC", account, start, count)
} else {
if itemType == "" {
itemType = "dir"
}
container := &models.ContentItem{
Source: "STORED_MUSIC",
SourceAccount: account,
Location: location,
Type: itemType,
}
resp, navErr = device.Client.NavigateContainer("STORED_MUSIC", account, start, count, container)
}
if navErr != nil {
app.sendError(w, navErr.Error(), http.StatusInternalServerError)
return
}
entries := make([]libraryEntry, 0, len(resp.Items))
for _, item := range resp.Items {
loc := ""
if item.ContentItem != nil {
loc = item.ContentItem.Location
}
entries = append(entries, libraryEntry{
Name: item.GetDisplayName(),
Type: item.Type,
Location: loc,
SourceAccount: account,
Playable: item.Playable == 1,
IsDir: item.Type == "dir",
})
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: libraryPage{
Entries: entries,
TotalItems: resp.TotalItems,
},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandlePlayLibrary plays a STORED_MUSIC track on a specific speaker using
// the speaker's /select endpoint. The request body must contain:
// - account (required) sourceAccount, e.g. "uuid:.../0"
// - location (required) the speaker-native location token from /navigate
// - type (optional) content type, defaults to "track"
// - name (optional) display name logged with the playback request
func (app *WebApp) HandlePlayLibrary(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
var req struct {
Account string `json:"account"`
Location string `json:"location"`
Type string `json:"type"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Account == "" {
app.sendError(w, "account is required", http.StatusBadRequest)
return
}
if req.Location == "" {
app.sendError(w, "location is required", http.StatusBadRequest)
return
}
itemType := req.Type
if itemType == "" {
itemType = "track"
}
ci := &models.ContentItem{
Source: "STORED_MUSIC",
SourceAccount: req.Account,
Location: req.Location,
Type: itemType,
ItemName: req.Name,
IsPresetable: true,
}
logPlaybackRequest("library", deviceID, ci.Source, ci.SourceAccount, ci.Location, ci.ItemName)
if err := device.Client.SelectContentItem(ci); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": "Playing " + req.Name},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}