feat(service): add 3 speaker-contract routes for parity (refs #451)

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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-06 19:11:24 +02:00
co-authored by Claude Opus 4.8
parent ea6ee3e097
commit 2dd0143e10
9 changed files with 156 additions and 0 deletions
+2
View File
@@ -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 \
+4
View File
@@ -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)
+3
View File
@@ -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
@@ -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))))
}
@@ -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))))
}
+23
View File
@@ -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")
+24
View File
@@ -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()
@@ -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);
});
%}
@@ -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) + "'");
});
%}