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:
Tobias Gesellchen
2026-06-06 23:05:59 +02:00
co-authored by Claude Opus 4.8
parent cd47ae0c5a
commit d9581dc10f
3 changed files with 65 additions and 33 deletions
+27 -17
View File
@@ -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)
})
})
})
+30 -8
View File
@@ -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)
}
}
}
+8 -8
View File
@@ -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 }),