diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index faa5aab..34984f2 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -27,9 +27,9 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov // Player / control API. Per #451 this is the post-merge canonical shape: // device-scoped actions nest under devices/{id}/, so every direct child of - // /api/control is a literal namespace (devices, tunein, radiobrowser, - // version, discover) — no static-vs-param sibling, so routing never depends - // on chi's static-over-param precedence. + // /api/control is a literal namespace (version, discover, devices, + // providers) — no static-vs-param sibling, so routing never depends on + // chi's static-over-param precedence. r.Route("/api/control", func(r chi.Router) { r.Get("/version", app.HandleAPIVersion) @@ -64,10 +64,8 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov r.Post("/power", app.HandleDevicePower) r.Get("/power-status", app.HandleDevicePowerStatus) r.Get("/recents", app.HandleDeviceRecents) + // Low-level "play this ContentItem" primitive (not a provider). r.Post("/play", app.HandleDevicePlay) - r.Post("/play-url", app.HandlePlayURL) - // Proxied to the AfterTouch service's /api/setup/tts/speak. - r.Post("/speak", app.HandleAPISpeakText) // Generic key / preset / source / bass actions. r.Get("/action/{action}", app.HandleAPIControl) r.Post("/action/{action}", app.HandleAPIControl) @@ -81,21 +79,33 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov r.Post("/leave", app.HandleZoneLeave) }) - r.Post("/tunein/play", app.HandlePlayTuneIn) - r.Post("/radiobrowser/play", app.HandlePlayRadioBrowser) + // Play a result from a content provider on this device. + // Browsable providers (tunein, radiobrowser) take a catalog item; + // input providers (url, tts) take the raw input. + r.Route("/providers", func(r chi.Router) { + r.Post("/tunein/play", app.HandlePlayTuneIn) + r.Post("/radiobrowser/play", app.HandlePlayRadioBrowser) + r.Post("/url/play", app.HandlePlayURL) + // Proxied to the AfterTouch service's /api/setup/tts/speak. + r.Post("/tts/play", app.HandleAPISpeakText) + }) }) }) - // Browse / search (global, not device-scoped). - r.Route("/tunein", func(r chi.Router) { - r.Get("/search", app.HandleTuneInSearch) - r.Get("/search/next", app.HandleTuneInSearchNext) - r.Get("/navigate", app.HandleTuneInNavigate) - r.Get("/navigate/*", app.HandleTuneInNavigate) - }) + // Provider browse / search (global, not device-scoped). Only browsable + // providers (a catalog you search/navigate) appear here; input + // providers (url, tts) exist solely as a device play above. + r.Route("/providers", func(r chi.Router) { + r.Route("/tunein", func(r chi.Router) { + r.Get("/search", app.HandleTuneInSearch) + r.Get("/search/next", app.HandleTuneInSearchNext) + r.Get("/navigate", app.HandleTuneInNavigate) + r.Get("/navigate/*", app.HandleTuneInNavigate) + }) - r.Route("/radiobrowser", func(r chi.Router) { - r.Get("/search", app.HandleRadioBrowserSearch) + r.Route("/radiobrowser", func(r chi.Router) { + r.Get("/search", app.HandleRadioBrowserSearch) + }) }) }) diff --git a/pkg/service/soundtouchweb/mount_test.go b/pkg/service/soundtouchweb/mount_test.go index 835a912..8669696 100644 --- a/pkg/service/soundtouchweb/mount_test.go +++ b/pkg/service/soundtouchweb/mount_test.go @@ -44,19 +44,41 @@ func TestMountControlAPIShape(t *testing.T) { } } - // Spot-check a representative endpoint actually registered. - found := false - + registered := make(map[string]bool, len(apiRoutes)) for _, route := range apiRoutes { - if route == "/api/control/version" { - found = true + registered[route] = true + } - break + // The provider infix (#451): browsable providers expose global browse + // routes; every provider play nests under devices/{id}/providers/. + mustExist := []string{ + "/api/control/version", + "/api/control/providers/tunein/search", + "/api/control/providers/radiobrowser/search", + "/api/control/devices/{id}/providers/tunein/play", + "/api/control/devices/{id}/providers/radiobrowser/play", + "/api/control/devices/{id}/providers/url/play", + "/api/control/devices/{id}/providers/tts/play", + } + for _, want := range mustExist { + if !registered[want] { + t.Errorf("expected route %q to be registered; got %v", want, apiRoutes) } } - if !found { - t.Errorf("expected /api/control/version to be registered; got %v", apiRoutes) + // The pre-infix flat paths are gone. + mustNotExist := []string{ + "/api/control/tunein/search", + "/api/control/radiobrowser/search", + "/api/control/devices/{id}/play-url", + "/api/control/devices/{id}/speak", + "/api/control/devices/{id}/tunein/play", + "/api/control/devices/{id}/radiobrowser/play", + } + for _, gone := range mustNotExist { + if registered[gone] { + t.Errorf("pre-infix route %q should have moved under /providers/", gone) + } } } diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index d56b315..12bccd4 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -28,29 +28,29 @@ export const api = { headers: JSON_HEADERS, body: JSON.stringify(item), }), - tuneInBrowse: (path) => req(path ? `/api/control/tunein/navigate/${path}` : '/api/control/tunein/navigate'), - tuneInSearch: (q) => req(`/api/control/tunein/search?q=${encodeURIComponent(q)}`), - tuneInSearchNext: (cursor) => req(`/api/control/tunein/search/next?cursor=${encodeURIComponent(cursor)}`), + tuneInBrowse: (path) => req(path ? `/api/control/providers/tunein/navigate/${path}` : '/api/control/providers/tunein/navigate'), + tuneInSearch: (q) => req(`/api/control/providers/tunein/search?q=${encodeURIComponent(q)}`), + tuneInSearchNext: (cursor) => req(`/api/control/providers/tunein/search/next?cursor=${encodeURIComponent(cursor)}`), control: (id, action, presetId) => req(`/api/control/devices/${id}/action/${action}?id=${presetId}`), storePreset: (id, slotId) => req(`/api/control/devices/${id}/action/storepreset?id=${slotId}`), selectSource: (id, source, account) => req(`/api/control/devices/${id}/action/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`), - tuneInPlay: (deviceId, item) => req(`/api/control/devices/${deviceId}/tunein/play`, { + tuneInPlay: (deviceId, item) => req(`/api/control/devices/${deviceId}/providers/tunein/play`, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(item), }), - radioBrowserSearch: (q) => req(`/api/control/radiobrowser/search?q=${encodeURIComponent(q)}`), - radioBrowserPlay: (deviceId, item) => req(`/api/control/devices/${deviceId}/radiobrowser/play`, { + radioBrowserSearch: (q) => req(`/api/control/providers/radiobrowser/search?q=${encodeURIComponent(q)}`), + radioBrowserPlay: (deviceId, item) => req(`/api/control/devices/${deviceId}/providers/radiobrowser/play`, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(item), }), - playURL: (deviceId, url, name, imageUrl, serviceUrl) => req(`/api/control/devices/${deviceId}/play-url`, { + playURL: (deviceId, url, name, imageUrl, serviceUrl) => req(`/api/control/devices/${deviceId}/providers/url/play`, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({ url, name, imageUrl, serviceUrl }), }), - speak: (deviceId, text) => req(`/api/control/devices/${deviceId}/speak`, { + speak: (deviceId, text) => req(`/api/control/devices/${deviceId}/providers/tts/play`, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({ text }),