diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 0371451..2cfab7d 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -720,6 +720,7 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo) r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast) r.Post("/v1/token", server.HandleTuneInToken) + r.Post("/v1/report", server.HandleTuneInReport) }) r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index a9fb10c..9c57360 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -74,6 +74,7 @@ POST /accounts/{account}/devices handlers.( POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm +POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 51fc743..bc9524d 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -201,3 +201,42 @@ func (s *Server) HandleCustomPlayback(w http.ResponseWriter, r *http.Request) { 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("{}")) +} diff --git a/pkg/service/handlers/handlers_bmx_report_test.go b/pkg/service/handlers/handlers_bmx_report_test.go new file mode 100644 index 0000000..eeb6390 --- /dev/null +++ b/pkg/service/handlers/handlers_bmx_report_test.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleTuneInReport(t *testing.T) { + r, s := setupRouter("http://localhost:8001", nil) + s.SetMirrorSettings(false, nil, nil, "") + + ts := httptest.NewServer(r) + defer ts.Close() + + t.Run("START event", func(t *testing.T) { + payload := `{"timeStamp":"2026-03-29T21:33:04+0000","eventType":"START","reason":"USER_SELECT_PLAYABLE","timeIntoTrack":0,"playbackDelay":7419}` + req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio", strings.NewReader(payload)) + req.Header.Set("Authorization", "Bearer mock-token") + req.Header.Set("Content-Type", "application/json") + + 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["nextReportIn"] != float64(1800) { + t.Errorf("Expected nextReportIn 1800, got %v", resp["nextReportIn"]) + } + links := resp["_links"].(map[string]interface{}) + self := links["self"].(map[string]interface{}) + if !strings.Contains(self["href"].(string), "/v1/report") { + t.Errorf("Expected href to contain /v1/report, got %v", self["href"]) + } + }) + + t.Run("STOP event", func(t *testing.T) { + payload := `{"timeStamp":"2026-03-29T21:33:44+0000","eventType":"STOP","reason":"USER_STOP","timeIntoTrack":39,"playbackDelay":0}` + req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio", strings.NewReader(payload)) + req.Header.Set("Authorization", "Bearer mock-token") + req.Header.Set("Content-Type", "application/json") + + 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 len(resp) != 0 { + t.Errorf("Expected empty response object, got %v", resp) + } + }) + + t.Run("Unauthorized", func(t *testing.T) { + req, _ := http.NewRequest("POST", ts.URL+"/bmx/tunein/v1/report", nil) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %v", res.Status) + } + }) +} diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 171032e..e9e3154 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -27,6 +27,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) 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("/tunein/v1/report", server.HandleTuneInReport) r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback) }) diff --git a/tests/interactions_20260328-103522-477978.md b/tests/interactions_20260328-103522-477978.md index a55fc6a..bcaf49a 100644 --- a/tests/interactions_20260328-103522-477978.md +++ b/tests/interactions_20260328-103522-477978.md @@ -197,9 +197,9 @@ Interactions for `20260328-103522-477978/`: | 0193 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0193-20260329-233304.685-POST.http | | 0194 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0194-20260329-233305.629-POST.http | | 0195 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0195-20260329-233306.040-POST.http | -| 0196 | mirror | | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio | 200 OK | ./mirror/bmx/tunein/v1/report/0196-20260329-233306.072-POST.http | -| 0197 | upstream | | POST https://content.api.bose.io/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio | 200 OK | ./upstream/bmx/tunein/v1/report/0197-20260329-233306.182-POST.http | -| 0198 | self | | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio | 200 OK | ./self/bmx/tunein/v1/report/0198-20260329-233306.184-POST.http | +| 0196 | mirror | ☑ | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio | 200 OK | ./mirror/bmx/tunein/v1/report/0196-20260329-233306.072-POST.http | +| 0197 | upstream | ☑ | POST https://content.api.bose.io/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio | 200 OK | ./upstream/bmx/tunein/v1/report/0197-20260329-233306.182-POST.http | +| 0198 | self | ☑ | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&stream_type=liveRadio | 200 OK | ./self/bmx/tunein/v1/report/0198-20260329-233306.184-POST.http | | 0199 | self | ☑ | GET /streaming/software/update/account/{{accountId}} | 200 OK | ./self/streaming/software/update/account/{accountId}/0199-20260329-233317.196-GET.http | | 0200 | self | ☑ | GET /streaming/account/{{accountId}}/device/{{device_id}}/presets | 200 OK | ./self/streaming/account/{accountId}/device/{device_id}/presets/0200-20260329-233317.206-GET.http | | 0201 | mirror | ☑ | GET /streaming/account/{{accountId}}/device/{{device_id}}/presets | 200 OK | ./mirror/streaming/account/{accountId}/device/{device_id}/presets/0201-20260329-233317.394-GET.http | @@ -218,9 +218,9 @@ Interactions for `20260328-103522-477978/`: | 0214 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0214-20260329-233345.001-POST.http | | 0215 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0215-20260329-233345.427-POST.http | | 0216 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0216-20260329-233345.430-POST.http | -| 0217 | mirror | | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio | 200 OK | ./mirror/bmx/tunein/v1/report/0217-20260329-233345.547-POST.http | -| 0218 | upstream | | POST https://content.api.bose.io/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio | 200 OK | ./upstream/bmx/tunein/v1/report/0218-20260329-233345.651-POST.http | -| 0219 | self | | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio | 200 OK | ./self/bmx/tunein/v1/report/0219-20260329-233345.651-POST.http | +| 0217 | mirror | ☑ | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio | 200 OK | ./mirror/bmx/tunein/v1/report/0217-20260329-233345.547-POST.http | +| 0218 | upstream | ☑ | POST https://content.api.bose.io/bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio | 200 OK | ./upstream/bmx/tunein/v1/report/0218-20260329-233345.651-POST.http | +| 0219 | self | ☑ | POST /bmx/tunein/v1/report?stream_id=e536753726&guide_id=s166521&listen_id=1774819980&last_titt=0&duration_balance=0&stream_type=liveRadio | 200 OK | ./self/bmx/tunein/v1/report/0219-20260329-233345.651-POST.http | | 0220 | self | ☑ | GET /bmx/registry/v1/services | 200 OK | ./self/bmx/registry/v1/services/0220-20260329-233504.445-GET.http | | 0221 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0221-20260329-233504.467-POST.http | | 0222 | self | ☑ | POST /streaming/support/power_on | 200 OK | ./self/streaming/support/power_on/0222-20260329-233504.475-POST.http |