diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index fb74a67..70fedfe 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -1,40 +1,21 @@ -// Package handlers provides HTTP handlers for the SoundTouch service. +// Package handlers — BMX registry / availability and shared helpers. +// +// Per-service handlers live in handlers_bmx_.go: +// - handlers_bmx_tunein.go (TuneIn — playback / podcasts / navigate / search / favorites / report) +// - handlers_bmx_orion.go (Orion — LOCAL_INTERNET_RADIO token + station) +// - handlers_bmx_custom.go (our own custom-playback adapter) +// +// The split happened on 2026-05-17 as a pure refactor — no logic change. +// A future iteration may extract a common BMX-service interface (see +// memory project_bmx_service_interface.md) once enough services are +// fully implemented to make the common shape observable. package handlers import ( - "encoding/base64" - "encoding/json" - "log" "net/http" - "net/url" - "strconv" "strings" - - "github.com/gesellix/bose-soundtouch/pkg/service/bmx" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/go-chi/chi/v5" ) -// tuneInStreamFormats returns the formats= list AfterTouch should send -// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when -// set. Empty (the default) lets bmx.TuneInStream fall back to -// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible -// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set -// the field to "mp3,aac,ogg,hls" (or any other comma-separated list) -// in settings.json. -func (s *Server) tuneInStreamFormats() string { - if s == nil || s.ds == nil { - return "" - } - - settings, err := s.ds.GetSettings() - if err != nil { - return "" - } - - return settings.TuneInStreamFormats -} - // HandleBMXRegistry returns the BMX service registry. func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) { baseURL := s.serverURL @@ -62,6 +43,10 @@ func (s *Server) HandleBMXServicesAvailability(w http.ResponseWriter, _ *http.Re _, _ = w.Write(bmxServicesAvailabilityJSON) } +// writeBMXUnauthorized writes the canonical 401 used by every BMX adapter +// handler that requires an Authorization header (TuneIn variants, Orion +// playback). Kept here so all per-service files can share it without +// duplicating the body markup. func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) @@ -72,355 +57,3 @@ func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {

Authorization not set. No access token found.

`)) } - -// HandleTuneInPlayback returns TuneIn playback information. -func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - stationID := chi.URLParam(r, "stationID") - - resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - -// HandleTuneInPodcastInfo returns TuneIn podcast information. -func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - podcastID := chi.URLParam(r, "podcastID") - encodedName := r.URL.Query().Get("encoded_name") - - resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - -// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information. -func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - podcastID := chi.URLParam(r, "podcastID") - - resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - -// HandleTuneInToken returns a TuneIn access token. -func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) { - var req struct { - GrantType string `json:"grant_type"` - RefreshToken string `json:"refresh_token"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - // For now, we return the provided refresh_token as access_token and refresh_token, - // mirroring the behavior seen in the recordings. - resp := map[string]string{ - "access_token": req.RefreshToken, - "refresh_token": req.RefreshToken, - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - -// HandleOrionToken returns an anonymous Orion access token. -// The token is a base64-encoded JSON serial, matching the pattern used by the real Bose BMX Orion service. -func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) { - token := datastore.GenerateSerialSecret("orion") - - resp := map[string]interface{}{ - "_embedded": map[string]interface{}{ - "bmx_account": map[string]string{ - "displayName": "", - "username": "", - }, - }, - "access_token": token, - "refresh_token": token, - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } -} - -// HandleOrionPlayback returns Orion playback information for the -// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint -// the speaker reaches by following its stored LOCAL_INTERNET_RADIO -// preset's `location` attribute. The `data` query string is the -// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker -// constructed when the preset was first saved; we just decode and -// rewrap it into the Bose BmxPlaybackResponse shape via -// bmx.PlayCustomStream. -// -// Requires a Bearer token in the `Authorization` header — same as -// the rest of the BMX playback surface (TuneIn variants and the -// orion token endpoint). Real speakers obtain the token via -// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken) -// before they ever follow a LOCAL_INTERNET_RADIO preset, so this -// check shouldn't cost any legitimate caller. -func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - data := r.URL.Query().Get("data") - - resp, err := bmx.PlayCustomStream(data) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - -// HandleCustomPlayback returns custom playback information for a given stream URL. -func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) { - encodedURL := chi.URLParam(r, "encodedURL") - imageUrl := r.URL.Query().Get("imageUrl") - name := r.URL.Query().Get("name") - - // Decode URL if it's base64 encoded - var streamUrl string - - decoded, err := base64.URLEncoding.DecodeString(encodedURL) - if err != nil { - decoded, err = base64.StdEncoding.DecodeString(encodedURL) - } - - if err == nil { - streamUrl = string(decoded) - } else { - // Try unescaping if it's not base64 - streamUrl, err = url.PathUnescape(encodedURL) - if err != nil { - streamUrl = encodedURL - } - } - - resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - -// HandleTuneInReport handles TuneIn playback reporting. -func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - var req struct { - EventType string `json:"eventType"` - } - - // We don't strictly need the body to determine the response, - // but we decode it to see the eventType. - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - if req.EventType == "START" { - // Mirroring the response from 0196-20260329-233306.072-POST.http - resp := map[string]interface{}{ - "_links": map[string]interface{}{ - "self": map[string]interface{}{ - "href": "/v1/report?" + r.URL.RawQuery, - }, - }, - "nextReportIn": 1800, - } - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } - - return - } - - // For STOP and other events, return an empty object - _, _ = w.Write([]byte("{}")) -} - -// HandleTuneInNavigate returns live TuneIn navigation results. -// Path variants handled via chi wildcard: -// - (empty) → top-level browse -// - {encodedURI} → browse the given TuneIn URI -// - sub/{n}/{encodedURI} → single subsection of a browse page -// - profiles/{type}/{id}/{encodedURI} → artist/program profile page -func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - wildcard := chi.URLParam(r, "*") - - resp, err := parseTuneInNavigatePath(wildcard) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } -} - -func parseTuneInNavigatePath(wildcard string) (interface{}, error) { - if wildcard == "" { - return bmx.TuneInNavigate("", nil) - } - - firstSlash := strings.Index(wildcard, "/") - if firstSlash == -1 { - return bmx.TuneInNavigate(wildcard, nil) - } - - prefix := wildcard[:firstSlash] - rest := wildcard[firstSlash+1:] - - switch prefix { - case "sub": - secondSlash := strings.Index(rest, "/") - if secondSlash == -1 { - return bmx.TuneInNavigate(rest, nil) - } - - n, err := strconv.Atoi(rest[:secondSlash]) - if err != nil { - return bmx.TuneInNavigate(wildcard, nil) - } - - return bmx.TuneInNavigate(rest[secondSlash+1:], &n) - - case "profiles": - // profiles/{type}/{id}/{encodedURI} - parts := strings.SplitN(rest, "/", 3) - if len(parts) < 3 { - return bmx.TuneInNavigate(wildcard, nil) - } - - return bmx.TuneInNavigateProfile(parts[2]) - - default: - return bmx.TuneInNavigate(wildcard, nil) - } -} - -// HandleTuneInSearch returns live TuneIn search results for the given query. -func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "" { - s.writeBMXUnauthorized(w) - return - } - - query := r.URL.Query().Get("q") - if query == "" { - http.Error(w, "query parameter 'q' is required", http.StatusBadRequest) - return - } - - resp, err := bmx.TuneInSearch(query) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } -} - -// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}. -func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) { - stationID := chi.URLParam(r, "stationID") - if err := s.ds.SaveTuneInFavorite(stationID); err != nil { - log.Printf("Failed to persist TuneIn favorite %s: %v", stationID, err) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte("{}")) -} - -// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}. -func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) { - stationID := chi.URLParam(r, "stationID") - if err := s.ds.DeleteTuneInFavorite(stationID); err != nil { - log.Printf("Failed to delete TuneIn favorite %s: %v", stationID, err) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte("{}")) -} diff --git a/pkg/service/handlers/handlers_bmx_custom.go b/pkg/service/handlers/handlers_bmx_custom.go new file mode 100644 index 0000000..e435c6d --- /dev/null +++ b/pkg/service/handlers/handlers_bmx_custom.go @@ -0,0 +1,55 @@ +// Package handlers — AfterTouch's own custom-playback adapter (not a +// Bose-official BMX service). Reached via /custom/v1/playback/{encodedURL} +// by speakers that follow our LOCAL_INTERNET_RADIO preset locations. +// +// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic +// change. +package handlers + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + + "github.com/gesellix/bose-soundtouch/pkg/service/bmx" + "github.com/go-chi/chi/v5" +) + +// HandleCustomPlayback returns custom playback information for a given stream URL. +func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) { + encodedURL := chi.URLParam(r, "encodedURL") + imageUrl := r.URL.Query().Get("imageUrl") + name := r.URL.Query().Get("name") + + // Decode URL if it's base64 encoded + var streamUrl string + + decoded, err := base64.URLEncoding.DecodeString(encodedURL) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(encodedURL) + } + + if err == nil { + streamUrl = string(decoded) + } else { + // Try unescaping if it's not base64 + streamUrl, err = url.PathUnescape(encodedURL) + if err != nil { + streamUrl = encodedURL + } + } + + resp, err := bmx.BuildCustomStreamResponse(streamUrl, imageUrl, name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} diff --git a/pkg/service/handlers/handlers_bmx_orion.go b/pkg/service/handlers/handlers_bmx_orion.go new file mode 100644 index 0000000..519c397 --- /dev/null +++ b/pkg/service/handlers/handlers_bmx_orion.go @@ -0,0 +1,74 @@ +// Package handlers — Orion BMX adapter handlers (LOCAL_INTERNET_RADIO). +// +// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic +// change. Shared helpers (writeBMXUnauthorized) still live in +// handlers_bmx.go. +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gesellix/bose-soundtouch/pkg/service/bmx" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// HandleOrionToken returns an anonymous Orion access token. +// The token is a base64-encoded JSON serial, matching the pattern used by the real Bose BMX Orion service. +func (s *Server) HandleOrionToken(w http.ResponseWriter, _ *http.Request) { + token := datastore.GenerateSerialSecret("orion") + + resp := map[string]interface{}{ + "_embedded": map[string]interface{}{ + "bmx_account": map[string]string{ + "displayName": "", + "username": "", + }, + }, + "access_token": token, + "refresh_token": token, + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// HandleOrionPlayback returns Orion playback information for the +// /core02/svc-bmx-adapter-orion/prod/orion/station?data=... endpoint +// the speaker reaches by following its stored LOCAL_INTERNET_RADIO +// preset's `location` attribute. The `data` query string is the +// base64-encoded JSON blob (streamUrl/imageUrl/name) that the speaker +// constructed when the preset was first saved; we just decode and +// rewrap it into the Bose BmxPlaybackResponse shape via +// bmx.PlayCustomStream. +// +// Requires a Bearer token in the `Authorization` header — same as +// the rest of the BMX playback surface (TuneIn variants and the +// orion token endpoint). Real speakers obtain the token via +// POST /core02/svc-bmx-adapter-orion/prod/orion/token (HandleOrionToken) +// before they ever follow a LOCAL_INTERNET_RADIO preset, so this +// check shouldn't cost any legitimate caller. +func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + + data := r.URL.Query().Get("data") + + resp, err := bmx.PlayCustomStream(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} diff --git a/pkg/service/handlers/handlers_bmx_tunein.go b/pkg/service/handlers/handlers_bmx_tunein.go new file mode 100644 index 0000000..4b33f33 --- /dev/null +++ b/pkg/service/handlers/handlers_bmx_tunein.go @@ -0,0 +1,291 @@ +// Package handlers — TuneIn BMX adapter handlers. +// +// Split out of handlers_bmx.go on 2026-05-17; pure file move, no logic +// change. Shared helpers (writeBMXUnauthorized, bmxServicesJSON) still +// live in handlers_bmx.go. +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/service/bmx" + "github.com/go-chi/chi/v5" +) + +// tuneInStreamFormats returns the formats= list AfterTouch should send +// to TuneIn's Tune.ashx, honouring Settings.TuneInStreamFormats when +// set. Empty (the default) lets bmx.TuneInStream fall back to +// bmx.DefaultTuneInStreamFormats — the SoundTouch-line-compatible +// "mp3,aac,ogg" shape. Operators with HLS-capable speakers can set +// the field to "mp3,aac,ogg,hls" (or any other comma-separated list) +// in settings.json. +func (s *Server) tuneInStreamFormats() string { + if s == nil || s.ds == nil { + return "" + } + + settings, err := s.ds.GetSettings() + if err != nil { + return "" + } + + return settings.TuneInStreamFormats +} + +// HandleTuneInPlayback returns TuneIn playback information. +func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + + stationID := chi.URLParam(r, "stationID") + + resp, err := bmx.TuneInPlayback(stationID, s.tuneInStreamFormats()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleTuneInPodcastInfo returns TuneIn podcast information. +func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + + podcastID := chi.URLParam(r, "podcastID") + encodedName := r.URL.Query().Get("encoded_name") + + resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleTuneInPlaybackPodcast returns TuneIn podcast playback information. +func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + + podcastID := chi.URLParam(r, "podcastID") + + resp, err := bmx.TuneInPlaybackPodcast(podcastID, s.tuneInStreamFormats()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleTuneInToken returns a TuneIn access token. +func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) { + var req struct { + GrantType string `json:"grant_type"` + RefreshToken string `json:"refresh_token"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // For now, we return the provided refresh_token as access_token and refresh_token, + // mirroring the behavior seen in the recordings. + resp := map[string]string{ + "access_token": req.RefreshToken, + "refresh_token": req.RefreshToken, + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleTuneInReport handles TuneIn playback reporting. +func (s *Server) HandleTuneInReport(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + + var req struct { + EventType string `json:"eventType"` + } + + // We don't strictly need the body to determine the response, + // but we decode it to see the eventType. + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + + if req.EventType == "START" { + // Mirroring the response from 0196-20260329-233306.072-POST.http + resp := map[string]interface{}{ + "_links": map[string]interface{}{ + "self": map[string]interface{}{ + "href": "/v1/report?" + r.URL.RawQuery, + }, + }, + "nextReportIn": 1800, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + + return + } + + // For STOP and other events, return an empty object + _, _ = w.Write([]byte("{}")) +} + +// HandleTuneInNavigate returns live TuneIn navigation results. +// Path variants handled via chi wildcard: +// - (empty) → top-level browse +// - {encodedURI} → browse the given TuneIn URI +// - sub/{n}/{encodedURI} → single subsection of a browse page +// - profiles/{type}/{id}/{encodedURI} → artist/program profile page +func (s *Server) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + + wildcard := chi.URLParam(r, "*") + + resp, err := parseTuneInNavigatePath(wildcard) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +func parseTuneInNavigatePath(wildcard string) (interface{}, error) { + if wildcard == "" { + return bmx.TuneInNavigate("", nil) + } + + firstSlash := strings.Index(wildcard, "/") + if firstSlash == -1 { + return bmx.TuneInNavigate(wildcard, nil) + } + + prefix := wildcard[:firstSlash] + rest := wildcard[firstSlash+1:] + + switch prefix { + case "sub": + secondSlash := strings.Index(rest, "/") + if secondSlash == -1 { + return bmx.TuneInNavigate(rest, nil) + } + + n, err := strconv.Atoi(rest[:secondSlash]) + if err != nil { + return bmx.TuneInNavigate(wildcard, nil) + } + + return bmx.TuneInNavigate(rest[secondSlash+1:], &n) + + case "profiles": + // profiles/{type}/{id}/{encodedURI} + parts := strings.SplitN(rest, "/", 3) + if len(parts) < 3 { + return bmx.TuneInNavigate(wildcard, nil) + } + + return bmx.TuneInNavigateProfile(parts[2]) + + default: + return bmx.TuneInNavigate(wildcard, nil) + } +} + +// HandleTuneInSearch returns live TuneIn search results for the given query. +func (s *Server) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + s.writeBMXUnauthorized(w) + return + } + + query := r.URL.Query().Get("q") + if query == "" { + http.Error(w, "query parameter 'q' is required", http.StatusBadRequest) + return + } + + resp, err := bmx.TuneInSearch(query) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if encErr := json.NewEncoder(w).Encode(resp); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// HandleTuneInFavorite handles POST /bmx/tunein/v1/favorite/{stationID}. +func (s *Server) HandleTuneInFavorite(w http.ResponseWriter, r *http.Request) { + stationID := chi.URLParam(r, "stationID") + if err := s.ds.SaveTuneInFavorite(stationID); err != nil { + log.Printf("Failed to persist TuneIn favorite %s: %v", stationID, err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("{}")) +} + +// HandleTuneInDeleteFavorite handles DELETE /bmx/tunein/v1/favorite/{stationID}. +func (s *Server) HandleTuneInDeleteFavorite(w http.ResponseWriter, r *http.Request) { + stationID := chi.URLParam(r, "stationID") + if err := s.ds.DeleteTuneInFavorite(stationID); err != nil { + log.Printf("Failed to delete TuneIn favorite %s: %v", stationID, err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("{}")) +}