mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.8
parent
ea6ee3e097
commit
2dd0143e10
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) + "'");
|
||||
});
|
||||
%}
|
||||
Reference in New Issue
Block a user