Extend TuneIn support, add e2e tests

This commit is contained in:
Tobias Gesellchen
2026-03-30 00:52:00 +02:00
parent bc1b70b8a5
commit 71e3260823
9 changed files with 480 additions and 7 deletions
+58
View File
@@ -33,8 +33,24 @@ func (s *Server) HandleBMXRegistry(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(content))
}
func (s *Server) writeBMXUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`<!doctype html>
<html lang=en>
<title>401 Unauthorized</title>
<h1>Unauthorized</h1>
<p>Authorization not set. No access token found.</p>
`))
}
// 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)
@@ -53,6 +69,11 @@ func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) {
// 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")
@@ -72,6 +93,11 @@ func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request)
// 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)
@@ -88,8 +114,40 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
}
}
// 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
}
}
// HandleOrionPlayback returns Orion playback information.
func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
s.writeBMXUnauthorized(w)
return
}
data := chi.URLParam(r, "data")
resp, err := bmx.PlayCustomStream(data)
+103 -1
View File
@@ -87,7 +87,9 @@ func TestOrionPlayback(t *testing.T) {
// Base64 encoded: {"streamUrl": "http://example.com/stream", "imageUrl": "http://example.com/img.jpg", "name": "Test Orion"}
data := "eyJzdHJlYW1VcmwiOiAiaHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbSIsICJpbWFnZVVybCI6ICJodHRwOi8vZXhhbXBsZS5jb20vaW1nLmpwZyIsICJuYW1lIjogIlRlc3QgT3Jpb24ifQ=="
res, err := http.Post(ts.URL+"/bmx/orion/v1/playback/station/"+data, "application/json", nil)
req, _ := http.NewRequest("POST", ts.URL+"/bmx/orion/v1/playback/station/"+data, nil)
req.Header.Set("Authorization", "Bearer mock-token")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
@@ -149,3 +151,103 @@ func TestCustomPlayback(t *testing.T) {
t.Errorf("Expected imageUrl %s, got %v", imageUrl, resp["imageUrl"])
}
}
func TestBMXUnauthorized(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
paths := []struct {
method string
path string
}{
{"GET", "/bmx/tunein/v1/playback/station/s123"},
{"GET", "/bmx/tunein/v1/playback/episodes/p123"},
{"GET", "/bmx/tunein/v1/playback/episode/p123"},
{"POST", "/bmx/orion/v1/playback/station/data"},
}
for _, tc := range paths {
req, _ := http.NewRequest(tc.method, ts.URL+tc.path, nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Errorf("%s %s: %v", tc.method, tc.path, err)
continue
}
defer res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s: Expected status 401, got %v", tc.method, tc.path, res.Status)
}
body, _ := io.ReadAll(res.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "401 Unauthorized") || !strings.Contains(bodyStr, "No access token found.") {
t.Errorf("%s %s: Unexpected response body: %s", tc.method, tc.path, bodyStr)
}
}
}
func TestHandleTuneInToken(t *testing.T) {
r, s := setupRouter("http://localhost:8001", nil)
s.SetMirrorSettings(false, nil, nil, "")
ts := httptest.NewServer(r)
defer ts.Close()
payload := `{"grant_type":"refresh_token","refresh_token":"test-refresh-token"}`
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]string
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["access_token"] != "test-refresh-token" {
t.Errorf("Expected access_token 'test-refresh-token', got %v", resp["access_token"])
}
if resp["refresh_token"] != "test-refresh-token" {
t.Errorf("Expected refresh_token 'test-refresh-token', got %v", resp["refresh_token"])
}
}
func TestHandleTuneInPlayback_Authorized(t *testing.T) {
r, s := setupRouter("http://localhost:8001", nil)
s.SetMirrorSettings(false, nil, nil, "")
ts := httptest.NewServer(r)
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/bmx/tunein/v1/playback/station/s166521", nil)
req.Header.Set("Authorization", "Bearer mock-token")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["name"] == "" {
t.Errorf("Expected station name, got empty")
}
if audio, ok := resp["audio"].(map[string]interface{}); !ok || audio["streamUrl"] == "" {
t.Errorf("Expected audio streamUrl, got %v", resp["audio"])
}
}
+1 -6
View File
@@ -26,15 +26,10 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/tunein/v1/token", server.HandleTuneInToken)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
})
// Legacy or direct domain calls without /bmx prefix
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
streamingRoutes := func(r chi.Router) {