Files
Bose-SoundTouch/pkg/service/soundtouchweb/handler_library.go
T
Tobias GesellchenandClaude Opus 4.8 836e985c58 feat(player,cli): nudge sources refresh after adding a media server
Adding a DLNA media server (setMusicServiceAccount) can leave the new
STORED_MUSIC source not fully registered on the speaker, so playing a track
fails with INVALID_SOURCE until a power-cycle. AfterTouch's health
diagnostic already recommends the no-reboot fix: a sourcesUpdated
notification makes the speaker re-fetch its account /full and re-register
its source list.

Fire that nudge automatically right after a successful registration, in
both the player (HandleAddLibraryServer) and the CLI (account add-nas), via
the existing client.NotifySourcesUpdated. It is best-effort: registration
already succeeded, so a failed nudge never fails the request (the handler
returns {account, refreshed}, the CLI prints a warning that a power-cycle
may still be needed). The handler resolves the Bose device ID from the
cached DeviceConnection.DeviceInfo, falling back to GetDeviceInfo.

Note: per the diagnostic, a power-cycle is still occasionally required, so
the nudge is an improvement, not a guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:56:04 +02:00

444 lines
13 KiB
Go

package soundtouchweb
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"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 and returns them as a JSON array. An optional ?timeout= query
// parameter (in seconds, integer) overrides the default 5-second budget.
// This handler is global (not device-scoped) and lives under
// /api/control/providers/library/servers.
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
}
out := make([]libraryServer, 0, len(servers))
for _, s := range servers {
out = append(out, libraryServer{
UDN: normalizeUDN(s.UDN),
Name: s.FriendlyName,
Manufacturer: s.Manufacturer,
Model: s.ModelName,
CDSControlURL: s.CDSControlURL,
})
}
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)
}
}
// 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)
}
}