mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
refactor(web): group content sources under a /providers infix (refs #451)
Model tunein, radiobrowser, playurl and tts as content "providers" and
give them a uniform /providers namespace, so the surface is consistent
and extensible (Spotify/Amazon slot in later as new providers).
Two kinds of provider operation fall out naturally:
- Browsable providers (a catalog you search/navigate) expose global
browse routes:
GET /api/control/providers/tunein/{search,search/next,navigate,navigate/*}
GET /api/control/providers/radiobrowser/search
- Every provider plays on a device via a uniform `play` verb:
POST /api/control/devices/{id}/providers/tunein/play
POST /api/control/devices/{id}/providers/radiobrowser/play
POST /api/control/devices/{id}/providers/url/play (was play-url)
POST /api/control/devices/{id}/providers/tts/play (was speak)
Input providers (url, tts) have no catalog, so they appear only as a
device play. The generic POST /devices/{id}/play (raw ContentItem) stays
the low-level primitive, not a provider. /providers stays a literal
namespace with literal provider children (no {provider} param), so there
is still zero static-vs-param ambiguity.
The bundled api.js is updated in lockstep. mount_test.go now asserts the
provider routes exist and the pre-infix flat paths are gone.
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
cd47ae0c5a
commit
d9581dc10f
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user