From 2dd0143e10459ff0bf5e590587e495587a4fe2f4 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 6 Jun 2026 19:02:11 +0200 Subject: [PATCH] feat(service): add 3 speaker-contract routes for parity (refs #451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the speaker/service-contract gaps found comparing against a reference implementation — three real Bose routes we did not serve: - DELETE /streaming/account/{account}/source/{sourceID} — removes a configured source from every device of the account (HandleMargeDeleteSource + marge.RemoveSourceFromAccount), mirroring the account-level POST add-source. Bare 200, empty body. Previously source removal was only reachable via the admin /setup surface. - GET /bmx/tunein — bare TuneIn service descriptor (the registry's `self` link), HandleTuneInService. chi routes both /bmx/tunein and /bmx/tunein/. - GET /core02/svc-bmx-adapter-orion/prod/orion — bare Orion (LOCAL_INTERNET_RADIO) adapter descriptor, HandleOrionService. The two descriptors reuse the existing extractBMXService + applyBMXTemplate helpers (same {BMX_SERVER}/{MEDIA_SERVER} substitution the registry applies). Contract tests added (delete_source.http, get_bmx_service_descriptors.http); router + frozen-coverage goldens updated. make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 2 + cmd/soundtouch-service/main.go | 4 ++ .../testdata/router_routes.txt | 3 ++ pkg/service/handlers/handlers_bmx_orion.go | 18 +++++++ pkg/service/handlers/handlers_bmx_tunein.go | 17 +++++++ pkg/service/handlers/handlers_marge.go | 23 +++++++++ pkg/service/marge/marge.go | 24 +++++++++ .../http-client/delete_source.http | 15 ++++++ .../get_bmx_service_descriptors.http | 50 +++++++++++++++++++ 9 files changed, 156 insertions(+) create mode 100644 tests/integration/http-client/delete_source.http create mode 100644 tests/integration/http-client/get_bmx_service_descriptors.http diff --git a/Makefile b/Makefile index 9a30175..a97a54e 100644 --- a/Makefile +++ b/Makefile @@ -189,6 +189,7 @@ test-http-client: /workdir/power_on.http \ /workdir/get_bmx_services.http \ /workdir/get_bmx_services_availability.http \ + /workdir/get_bmx_service_descriptors.http \ /workdir/get_ced_index.http \ /workdir/get_sourceproviders.http \ /workdir/get_software_update.http \ @@ -214,6 +215,7 @@ test-http-client: /workdir/get_account_presets.http \ /workdir/get_account_devices.http \ /workdir/get_account_sources.http \ + /workdir/delete_source.http \ /workdir/get_api_versions.http \ /workdir/post_musicprovider_is_eligible.http \ /workdir/get_full_account.http \ diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index f86e553..09efb2b 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1141,6 +1141,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability) r.Route("/tunein", func(r chi.Router) { + // Bare service descriptor (the registry's `self` link for TuneIn). + r.Get("/", server.HandleTuneInService) r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback) r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) @@ -1161,6 +1163,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * // pkg/service/handlers/static/bmx_services_ustream.json), so speakers // reach the token + station endpoints at exactly these paths under // either DNS-interception or URL-flip migration. + r.Get("/core02/svc-bmx-adapter-orion/prod/orion", server.HandleOrionService) r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken) r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback) @@ -1181,6 +1184,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Post("/account", server.HandleMargeCreateAccount) r.Post("/account/login", server.HandleMargeLogin) r.Post("/account/{account}/source", server.HandleMargeAddSource) + r.Delete("/account/{account}/source/{sourceID}", server.HandleMargeDeleteSource) r.Route("/account/{account}", func(r chi.Router) { r.Get("/emailaddress", server.HandleMargeGetEmailAddress) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 0addba7..42f8912 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -17,6 +17,7 @@ DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} hand DELETE /streaming/account/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm +DELETE /streaming/account/{account}/source/{sourceID} handlers.(*Server).HandleMargeDeleteSource-fm GET / handlers.(*Server).HandleRoot-fm GET /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleUnsupported-fm @@ -30,6 +31,7 @@ GET /accounts/{account}/sources handlers.( GET /bmx-icons/* handlers.(*Server).HandleBmxIcons GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm +GET /bmx/tunein/ handlers.(*Server).HandleTuneInService-fm GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm @@ -38,6 +40,7 @@ GET /bmx/tunein/v1/playback/station/{stationID} handlers.( GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm GET /ced/* handlers.(*Server).HandleCedStatic +GET /core02/svc-bmx-adapter-orion/prod/orion handlers.(*Server).HandleOrionService-fm GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm diff --git a/pkg/service/handlers/handlers_bmx_orion.go b/pkg/service/handlers/handlers_bmx_orion.go index 4d49742..17c0d24 100644 --- a/pkg/service/handlers/handlers_bmx_orion.go +++ b/pkg/service/handlers/handlers_bmx_orion.go @@ -76,3 +76,21 @@ func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { return } } + +// HandleOrionService returns the Orion (LOCAL_INTERNET_RADIO) service +// descriptor — the bare GET /core02/svc-bmx-adapter-orion/prod/orion endpoint +// the registry advertises as the adapter's `self` link. It is the +// LOCAL_INTERNET_RADIO entry of bmx_services.json with the registry's +// {BMX_SERVER} / {MEDIA_SERVER} substitution applied. +func (s *Server) HandleOrionService(w http.ResponseWriter, _ *http.Request) { + svc, err := extractBMXService(bmxServicesJSON, "LOCAL_INTERNET_RADIO") + if err != nil { + log.Printf("[BMX Orion] failed to extract service descriptor: %v", sanitizeErr(err)) + http.Error(w, "service descriptor unavailable", http.StatusInternalServerError) + + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(s.applyBMXTemplate(string(svc)))) +} diff --git a/pkg/service/handlers/handlers_bmx_tunein.go b/pkg/service/handlers/handlers_bmx_tunein.go index c9e0965..707287e 100644 --- a/pkg/service/handlers/handlers_bmx_tunein.go +++ b/pkg/service/handlers/handlers_bmx_tunein.go @@ -340,3 +340,20 @@ func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte("{}")) } + +// HandleTuneInService returns the TuneIn service descriptor (the bare +// GET /bmx/tunein endpoint the registry advertises as the service's `self` +// link). It is the TUNEIN entry of bmx_services.json with the same +// {BMX_SERVER} / {MEDIA_SERVER} substitution the registry applies. +func (s *Server) HandleTuneInService(w http.ResponseWriter, _ *http.Request) { + svc, err := extractBMXService(bmxServicesJSON, "TUNEIN") + if err != nil { + log.Printf("[BMX TuneIn] failed to extract service descriptor: %v", sanitizeErr(err)) + http.Error(w, "service descriptor unavailable", http.StatusInternalServerError) + + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(s.applyBMXTemplate(string(svc)))) +} diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index fa42240..c28f5a8 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -751,6 +751,29 @@ func (s *Server) HandleMargeAddSource(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(resp) } +// HandleMargeDeleteSource removes a configured source from the account +// (DELETE /streaming/account/{account}/source/{sourceID}). It mirrors the +// account-level POST add-source: the source is removed from every device of the +// account. Returns 200 with an empty body, matching the upstream contract. +func (s *Server) HandleMargeDeleteSource(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + sourceID := chi.URLParam(r, "sourceID") + + if !validatePathID(account) || sourceID == "" { + http.Error(w, "Invalid account or source ID", http.StatusBadRequest) + return + } + + if err := marge.RemoveSourceFromAccount(s.ds, account, sourceID); err != nil { + log.Printf("[Marge] Failed to remove source %s: %v", sanitizeLog(sourceID), sanitizeErr(err)) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + + return + } + + w.WriteHeader(http.StatusOK) +} + // HandleMargeProviderSettings returns Marge provider settings. func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 43a41d1..f970e70 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -2298,6 +2298,30 @@ func AddSourceToAccount(ds *datastore.DataStore, account string, sourceXML []byt return append([]byte(header), res...), nil } +// RemoveSourceFromAccount deletes the source with the given id from every device +// of the account, mirroring AddSource (which fans a source out to all devices). +// Idempotent: a source absent from a device is silently skipped. +func RemoveSourceFromAccount(ds *datastore.DataStore, account, sourceID string) error { + devicesDir := ds.AccountDevicesDir(account) + + entries, err := ds.ReadDirUnderBase(devicesDir) + if err != nil { + return err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + if delErr := ds.DeleteSourceByID(account, entry.Name(), sourceID); delErr != nil { + return delErr + } + } + + return nil +} + // AddSource adds a new music source to the account and returns the generated source ID. func AddSource(ds *datastore.DataStore, account, username, providerID, secret, secretType, sourceName string) (string, error) { now := time.Now() diff --git a/tests/integration/http-client/delete_source.http b/tests/integration/http-client/delete_source.http new file mode 100644 index 0000000..689d7c6 --- /dev/null +++ b/tests/integration/http-client/delete_source.http @@ -0,0 +1,15 @@ +### DELETE /streaming/account/{accountId}/source/{sourceId} (remove a configured source) +### +### Frozen Marge route: removes the source from every device of the account +### (HandleMargeDeleteSource), mirroring the account-level POST add-source. +### Idempotent — a 200 is returned whether or not the source was present, so this +### pins the route + contract without depending on prior state. +DELETE {{host}}/streaming/account/{{accountId}}/source/SRC_contract_test +Authorization: Bearer {{token}} +User-Agent: Bose_Lisa/27.0.6 + +> {% + client.test("Source removal accepted (200)", function () { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + }); +%} diff --git a/tests/integration/http-client/get_bmx_service_descriptors.http b/tests/integration/http-client/get_bmx_service_descriptors.http new file mode 100644 index 0000000..2fb0567 --- /dev/null +++ b/tests/integration/http-client/get_bmx_service_descriptors.http @@ -0,0 +1,50 @@ +### GET /bmx/tunein (bare TuneIn service descriptor) +### +### Frozen BMX route — the registry advertises this as TuneIn's `self` link. +### Returns the TUNEIN entry of the service registry with {BMX_SERVER}/{MEDIA_SERVER} +### substituted (HandleTuneInService). +GET {{host}}/bmx/tunein +User-Agent: Bose_Lisa/27.0.6 +Accept: */* + +> {% + client.test("TuneIn descriptor 200 JSON", function () { + client.assert(response.status === 200, "got " + response.status); + client.assert(response.contentType.mimeType === "application/json", + "expected application/json, got '" + response.contentType.mimeType + "'"); + client.assert(response.body.hasOwnProperty("_links"), "descriptor missing _links"); + client.assert(response.body.hasOwnProperty("assets"), "descriptor missing assets"); + }); +%} + +### GET /bmx/tunein/ (trailing-slash form — chi routes both to the same descriptor) +GET {{host}}/bmx/tunein/ +User-Agent: Bose_Lisa/27.0.6 +Accept: */* + +> {% + client.test("TuneIn descriptor 200 (trailing slash)", function () { + client.assert(response.status === 200, "got " + response.status); + client.assert(response.body.hasOwnProperty("assets"), "descriptor missing assets"); + }); +%} + +### GET /core02/svc-bmx-adapter-orion/prod/orion (bare Orion adapter descriptor) +### +### Frozen route — the registry advertises this as the Orion (LOCAL_INTERNET_RADIO) +### adapter's `self` link. Returns the LOCAL_INTERNET_RADIO registry entry with the +### same substitution (HandleOrionService). +GET {{host}}/core02/svc-bmx-adapter-orion/prod/orion +User-Agent: Bose_Lisa/27.0.6 +Accept: */* + +> {% + client.test("Orion descriptor 200 JSON", function () { + client.assert(response.status === 200, "got " + response.status); + client.assert(response.contentType.mimeType === "application/json", + "expected application/json, got '" + response.contentType.mimeType + "'"); + client.assert(response.body.hasOwnProperty("baseUrl"), "descriptor missing baseUrl"); + client.assert(response.body.id.name === "LOCAL_INTERNET_RADIO", + "expected id.name LOCAL_INTERNET_RADIO, got '" + (response.body.id && response.body.id.name) + "'"); + }); +%}