diff --git a/Makefile b/Makefile index af71d6b..5cd5f3e 100644 --- a/Makefile +++ b/Makefile @@ -111,6 +111,9 @@ test-http-client: @docker build -t soundtouch-service-test . @docker run -d --name soundtouch-service --network soundtouch-test-net \ -e PORT=8000 \ + -e SPOTIFY_CLIENT_ID=mock-id \ + -e SPOTIFY_CLIENT_SECRET=mock-secret \ + -v $(PWD)/tests/integration/testdata:/app/data \ soundtouch-service-test @echo "Waiting for service to start..." @sleep 5 @@ -128,10 +131,12 @@ test-http-client: /workdir/get_software_update.http \ /workdir/get_soundtouch_updates.http \ /workdir/get_streaming_token.http \ + /workdir/post_oauth_token.http \ /workdir/get_provider_settings.http \ /workdir/tunein_playback_station.http \ /workdir/set_preset_6.http \ /workdir/set_preset_5.http \ + /workdir/post_recent.http \ /workdir/get_full_account.http \ /workdir/get_group.http \ /workdir/unregister_device.http \ diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 8846e92..62df1fa 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -63,8 +63,40 @@ func initializeDefaultSources(ds *datastore.DataStore) { if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil { log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID) - if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil { - log.Printf("Failed to save default sources for %s: %v", dev.DeviceID, errSave) + // Find default sources and merge them if missing or outdated tokens + defaults := ds.GetDefaultSources() + modified := false + + for i := range defaults { + def := defaults[i] + found := false + + for j := range sources { + if sources[j].SourceKeyType == def.SourceKeyType { + found = true + + if sources[j].Secret == "" && def.Secret != "" { + log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID) + sources[j].Secret = def.Secret + sources[j].SecretType = def.SecretType + modified = true + } + + break + } + } + + if !found { + log.Printf("Adding missing default source %s to device %s", def.SourceKeyType, dev.DeviceID) + sources = append(sources, def) + modified = true + } + } + + if modified { + if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil { + log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave) + } } } } diff --git a/pkg/models/models.go b/pkg/models/models.go index 84e9e72..11e3339 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -229,10 +229,6 @@ type RecentItemParity struct { Source *RecentItemParitySource `xml:"source,omitempty"` SourceID string `xml:"sourceid"` UpdatedOn string `xml:"updatedOn"` - Username string `xml:"username"` - ContainerArt string `xml:"containerArt"` - SourceAccount string `xml:"sourceAccount"` - IsPresetable string `xml:"isPresetable"` } // RecentItemParitySource represents the source in a RecentItemParity. @@ -240,7 +236,7 @@ type RecentItemParitySource struct { ID string `xml:"id,attr"` Type string `xml:"type,attr"` CreatedOn string `xml:"createdOn"` - Credential *RecentItemParityCredential `xml:"credential,omitempty"` + Credential *RecentItemParityCredential `xml:"credential"` Name string `xml:"name"` SourceProviderID string `xml:"sourceproviderid"` SourceName string `xml:"sourcename"` diff --git a/pkg/models/service_recent_parity_test.go b/pkg/models/service_recent_parity_test.go index 6319a37..2a7bb84 100644 --- a/pkg/models/service_recent_parity_test.go +++ b/pkg/models/service_recent_parity_test.go @@ -120,6 +120,14 @@ func TestServiceRecent_Parity(t *testing.T) { CreatedOn: "2026-03-22T10:00:04.000+00:00", UpdatedOn: "2026-03-22T10:53:50.719+00:00", LastPlayedAt: "2026-03-22T10:53:48.000+00:00", + Source: &RecentItemParitySource{ + ID: "10863533", + Type: "Audio", + Credential: &RecentItemParityCredential{ + Type: "token", + Value: "", + }, + }, } data, err := xml.MarshalIndent(recent, "", " ") @@ -137,6 +145,7 @@ func TestServiceRecent_Parity(t *testing.T) { `Dopamine`, `10863533`, `2026-03-22T10:53:50.719+00:00`, + ``, } for _, expected := range expectedElements { diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 926481d..0b9621e 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -1180,6 +1180,11 @@ func GenerateSerialSecret(serial string) string { return base64.StdEncoding.EncodeToString(b) } +// GetDefaultSources returns the list of default sources. +func (ds *DataStore) GetDefaultSources() []models.ConfiguredSource { + return ds.getDefaultSources() +} + func (ds *DataStore) getDefaultSources() []models.ConfiguredSource { sources := []models.ConfiguredSource{ { diff --git a/pkg/service/handlers/parity_mismatch_repro_test.go b/pkg/service/handlers/parity_mismatch_repro_test.go index b0278cb..aa1ac78 100644 --- a/pkg/service/handlers/parity_mismatch_repro_test.go +++ b/pkg/service/handlers/parity_mismatch_repro_test.go @@ -90,11 +90,6 @@ func TestParityMismatchReproduction_New(t *testing.T) { if !strings.Contains(bodyStr, `2017-07-20T16:43:48.000+00:00`) { t.Errorf("Source CreatedOn was not learned from POST in element. Body: %s", bodyStr) } - - // 7. sourceAccount should be present (parity) - if !strings.Contains(bodyStr, ``) { - t.Errorf("Missing in flat response. Body: %s", bodyStr) - } }) t.Run("Subsequent GET /recents should also show learned source details", func(t *testing.T) { diff --git a/pkg/service/handlers/recent_parity_test.go b/pkg/service/handlers/recent_parity_test.go index 9d02f8a..8bb4eda 100644 --- a/pkg/service/handlers/recent_parity_test.go +++ b/pkg/service/handlers/recent_parity_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) @@ -36,13 +37,32 @@ func TestMargeRecentConsistencyAndIDParity(t *testing.T) { t.Run("POST recent creates consistent IDs and persists unknown sources", func(t *testing.T) { payload := ` - tracklisturl - 2026-03-14T21:33:22.000+00:00 - /playback/container/c3BvdGlmeTphbGJ1bTo3RjUwdWg3b0dpdG1BRVNjUktWNnBE - Terminal Caribe - 10863533 + stationurl + 2026-03-29T21:33:00+00:00 + /v1/playback/station/s166521 + SMOOTH JAZZ + 14774275 ` + expectedToken := datastore.GenerateSerialSecret("tunein") + // Pre-configure source 14774275 as TUNEIN (ID 25) + ds.SaveConfiguredSources(account, deviceID, []models.ConfiguredSource{ + { + ID: "14774275", + SourceProviderID: "25", + Type: "Audio", + DisplayName: "TuneIn", + Secret: expectedToken, + SecretType: "token", + SourceKey: struct { + Type string `xml:"type,attr"` + Account string `xml:"account,attr"` + }{ + Type: "TUNEIN", + }, + }, + }) + // 1. POST /recent res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload)) if err != nil { @@ -50,13 +70,21 @@ func TestMargeRecentConsistencyAndIDParity(t *testing.T) { } defer res.Body.Close() - if res.StatusCode != http.StatusCreated { - t.Fatalf("Expected status 201, got %d", res.StatusCode) - } - postBody, _ := io.ReadAll(res.Body) postBodyStr := string(postBody) + if res.StatusCode != http.StatusCreated { + t.Fatalf("Expected status 201, got %d. Body: %s", res.StatusCode, postBodyStr) + } + + // Verify constant token for TUNEIN + if !strings.Contains(postBodyStr, expectedToken) { + t.Errorf("Response missing expected constant token for TuneIn. Body: %s", postBodyStr) + } + if !strings.Contains(postBodyStr, ``) { + t.Errorf("Response missing expected credential tag for TuneIn. Body: %s", postBodyStr) + } + // Verify ID format: YYMMDDXXX (9 digits) // Today's prefix: prefix := time.Now().UTC().Format("060102") @@ -92,31 +120,28 @@ func TestMargeRecentConsistencyAndIDParity(t *testing.T) { if !strings.Contains(getRecentsStr, `id="`+recentID+`"`) { t.Errorf("GET /recents missing ID %s. Body: %s", recentID, getRecentsStr) } - if !strings.Contains(getRecentsStr, `Terminal Caribe`) { - t.Errorf("GET /recents missing Name 'Terminal Caribe'. Body: %s", getRecentsStr) + if !strings.Contains(getRecentsStr, `SMOOTH JAZZ`) { + t.Errorf("GET /recents missing Name 'SMOOTH JAZZ'. Body: %s", getRecentsStr) } - if !strings.Contains(getRecentsStr, `Terminal Caribe`) { + if !strings.Contains(getRecentsStr, `SMOOTH JAZZ`) { t.Errorf("GET /recents should use nested for ServiceRecent. Body: %s", getRecentsStr) } // 4. Verify source persistence - // Check if source 10863533 was learned and is now in Sources.xml + // Check if source 14774275 was learned and is now in Sources.xml sources, err := ds.GetConfiguredSources(account, deviceID) if err != nil { t.Errorf("Failed to get configured sources: %v", err) } found := false for _, s := range sources { - if s.ID == "10863533" { + if s.ID == "14774275" { found = true - if s.SourceKeyType != "SPOTIFY" { - t.Errorf("Learned source should be SPOTIFY based on location, got %s", s.SourceKeyType) - } break } } if !found { - t.Errorf("Source 10863533 was not learned and persisted") + t.Errorf("Source 14774275 was not learned and persisted") } }) } diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 478f2c5..f3e6864 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -119,6 +119,14 @@ func PrepareConfiguredSource(s *models.ConfiguredSource) { } } + if s.Credential.Type == "" { + s.Credential.Type = s.SecretType + } + + if s.Credential.Value == "" { + s.Credential.Value = s.Secret + } + // Ensure SourceKey fields are synced with legacy fields if they were used if s.SourceKey.Type == "" && s.SourceKeyType != "" { s.SourceKey.Type = s.SourceKeyType @@ -850,17 +858,30 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte sourceName := getSourceNameFromXML(sourceXML, input) + // 1. learnSource handles persistence AND returns a matching source. matchingSrc, learned := learnSource(ds, account, device, sources, input.SourceID, input.Location, sourceName, input.Source.Credential.Value, input.Source.SourceProviderID, input.Source.CreatedOn, input.Source.UpdatedOn) + + // Parity: ensure generic tokens for TUNEIN and LOCAL_INTERNET_RADIO if missing. + // This covers both learned and already existing sources. + if (matchingSrc.SourceProviderID == "25" || matchingSrc.ID == "TUNEIN" || strings.Contains(input.Location, "/v1/playback/station/")) && matchingSrc.Secret == "" { + matchingSrc.Secret = datastore.GenerateSerialSecret("tunein") + matchingSrc.SecretType = "token" + } else if matchingSrc.ID == "LOCAL_INTERNET_RADIO" && matchingSrc.Secret == "" { + matchingSrc.Secret = datastore.GenerateSerialSecret("local-internet-radio") + matchingSrc.SecretType = "token" + } + if learned { - // Re-fetch sources to ensure we have the newly learned one + // Re-fetch sources to ensure we have the newly learned one in the slice if updatedSources, err := ds.GetConfiguredSources(account, device); err == nil { sources = updatedSources + _ = sources // avoid ineffectual assignment } - - matchingSrc = findMatchingSource(sources, input.SourceID) } if matchingSrc == nil { + // This should technically not happen as learnSource always returns something, + // but we keep it as a fallback for safety. matchingSrc = &models.ConfiguredSource{ ID: input.SourceID, SourceProviderID: input.Source.SourceProviderID, @@ -895,6 +916,15 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode } if sourceLearned { + // Ensure generic tokens for TUNEIN and LOCAL_INTERNET_RADIO if missing + if (matchingSrc.SourceProviderID == "25" || matchingSrc.ID == "TUNEIN" || strings.Contains(location, "/v1/playback/station/")) && matchingSrc.Secret == "" { + matchingSrc.Secret = datastore.GenerateSerialSecret("tunein") + matchingSrc.SecretType = "token" + } else if matchingSrc.ID == "LOCAL_INTERNET_RADIO" && matchingSrc.Secret == "" { + matchingSrc.Secret = datastore.GenerateSerialSecret("local-internet-radio") + matchingSrc.SecretType = "token" + } + persistLearnedSource(ds, account, device, sources, matchingSrc) } @@ -929,9 +959,26 @@ func createLearnedSource(sourceID, location, sourceName, credentialValue, source src.Type = "Audio" src.SecretType = "token" + if src.Secret == "" { + src.Secret = datastore.GenerateSerialSecret("tunein") + } + if src.DisplayName == "Other" || src.DisplayName == "TuneIn" || src.DisplayName == "" { src.DisplayName = "TuneIn" } + case sourceID == "LOCAL_INTERNET_RADIO": + src.SourceKey.Type = "LOCAL_INTERNET_RADIO" + src.SourceKeyType = "LOCAL_INTERNET_RADIO" + src.Type = "Audio" + src.SecretType = "token" + + if src.Secret == "" { + src.Secret = datastore.GenerateSerialSecret("local-internet-radio") + } + + if src.DisplayName == "Other" || src.DisplayName == "Local Internet Radio" || src.DisplayName == "" { + src.DisplayName = "Local Internet Radio" + } case strings.Contains(location, "spotify") || strings.Contains(location, "c3BvdGlme") || sourceID == "SPOTIFY": src.SourceKey.Type = "SPOTIFY" src.SourceKeyType = "SPOTIFY" @@ -1117,17 +1164,15 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C ID: recentObj.ID, ContentItemType: recentObj.ContentItemType, CreatedOn: createdOn, - UpdatedOn: createdOn, + UpdatedOn: recentObj.UpdatedOn, LastPlayedAt: time.Unix(utcTime, 0).UTC().Format("2006-01-02T15:04:05.000+00:00"), Location: recentObj.Location, Name: recentObj.Name, SourceID: recentObj.SourceID, - SourceAccount: recentObj.SourceAccount, - IsPresetable: recentObj.IsPresetable, } - if res.SourceAccount == "" { - res.SourceAccount = "" // Ensure it's not nil if it was a pointer, but it's a string. + if res.UpdatedOn == "" { + res.UpdatedOn = createdOn } if matchingSrc != nil { @@ -1143,16 +1188,34 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C Username: matchingSrc.Username, } - if matchingSrc.Secret != "" { + if res.Source.Name == "TuneIn" || res.Source.Name == "LOCAL_INTERNET_RADIO" { + res.Source.Name = "" + } + + switch { + case matchingSrc.Secret != "": res.Source.Credential = &models.RecentItemParityCredential{ Type: matchingSrc.SecretType, Value: matchingSrc.Secret, } - } else if matchingSrc.Credential.Value != "" { + case matchingSrc.Credential.Value != "": res.Source.Credential = &models.RecentItemParityCredential{ Type: matchingSrc.Credential.Type, Value: matchingSrc.Credential.Value, } + default: + res.Source.Credential = &models.RecentItemParityCredential{ + Type: "token", + Value: "", + } + } + + if res.Source.Credential.Value == "" && matchingSrc.Secret != "" { + res.Source.Credential.Value = matchingSrc.Secret + } + + if res.Source.Credential.Type == "" && matchingSrc.SecretType != "" { + res.Source.Credential.Type = matchingSrc.SecretType } } diff --git a/tests/.gitignore b/tests/.gitignore index f589fcf..59a82cd 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1,2 +1,4 @@ 2026*/ data/ +integration/testdata/ +!integration/testdata/spotify/accounts.json diff --git a/tests/integration/http-client/post_oauth_token.http b/tests/integration/http-client/post_oauth_token.http index 54b1b50..871e501 100644 --- a/tests/integration/http-client/post_oauth_token.http +++ b/tests/integration/http-client/post_oauth_token.http @@ -23,7 +23,7 @@ Content-Type: application/json client.test("Response body has expected structure", function() { client.assert(response.body.hasOwnProperty("access_token"), "Response body missing 'access_token'"); - client.assert(response.body.access_token.length > 0, "access_token is empty"); + client.assert(response.body.access_token === "mock-access-token", "access_token is not 'mock-access-token'"); client.assert(response.body.hasOwnProperty("expires_in"), "Response body missing 'expires_in'"); client.assert(typeof response.body.expires_in === "number", "expires_in is not a number"); diff --git a/tests/integration/testdata/spotify/accounts.json b/tests/integration/testdata/spotify/accounts.json new file mode 100644 index 0000000..08e7002 --- /dev/null +++ b/tests/integration/testdata/spotify/accounts.json @@ -0,0 +1,10 @@ +{ + "test-user": { + "user_id": "test-user", + "display_name": "Integration Test User", + "email": "test@example.com", + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": 2147483647 + } +} diff --git a/tests/interactions_20260328-103522-477978.md b/tests/interactions_20260328-103522-477978.md index e672807..23124a6 100644 --- a/tests/interactions_20260328-103522-477978.md +++ b/tests/interactions_20260328-103522-477978.md @@ -34,7 +34,7 @@ Interactions for `20260328-103522-477978/`: | 0030 | mirror | ☑ | GET /streaming/account/{{accountId}}/provider_settings | 200 OK | ./mirror/streaming/account/{accountId}/provider_settings/0030-20260328-103736.065-GET.http | | 0031 | mirror | ☑ | GET /streaming/account/{{accountId}}/device/{{device_id}}/group/ | 200 OK | ./mirror/streaming/account/{accountId}/device/{device_id}/group/0031-20260328-103736.093-GET.http | | 0032 | mirror | ☑ | GET /streaming/account/{{accountId}}/provider_settings | 200 OK | ./mirror/streaming/account/{accountId}/provider_settings/0032-20260328-103736.130-GET.http | -| 0033 | self | | POST /oauth/device/{{device_id}}/music/musicprovider/15/token/cs3 | 200 OK | ./self/oauth/device/{device_id}/music/musicprovider/15/token/cs3/0033-20260328-103737.874-POST.http | +| 0033 | self | ☑ | POST /oauth/device/{{device_id}}/music/musicprovider/15/token/cs3 | 200 OK | ./self/oauth/device/{device_id}/music/musicprovider/15/token/cs3/0033-20260328-103737.874-POST.http | | 0034 | self | | GET /streaming/account/{{accountId}}/device/{{device_id}}/presets | 200 OK | ./self/streaming/account/{accountId}/device/{device_id}/presets/0034-20260328-103905.297-GET.http | | 0035 | self | ☑ | GET /streaming/software/update/account/{{accountId}} | 200 OK | ./self/streaming/software/update/account/{accountId}/0035-20260328-103905.306-GET.http | | 0036 | mirror | | GET /streaming/account/{{accountId}}/device/{{device_id}}/presets | 200 OK | ./mirror/streaming/account/{accountId}/device/{device_id}/presets/0036-20260328-103905.479-GET.http | @@ -110,7 +110,7 @@ Interactions for `20260328-103522-477978/`: | 0106 | mirror | ☑ | GET /streaming/account/{{accountId}}/provider_settings | 200 OK | ./mirror/streaming/account/{accountId}/provider_settings/0106-20260328-105554.347-GET.http | | 0107 | mirror | ☑ | GET /streaming/account/{{accountId}}/provider_settings | 200 OK | ./mirror/streaming/account/{accountId}/provider_settings/0107-20260328-105554.395-GET.http | | 0108 | mirror | ☑ | GET /streaming/account/{{accountId}}/device/{{device_id}}/group/ | 200 OK | ./mirror/streaming/account/{accountId}/device/{device_id}/group/0108-20260328-105554.406-GET.http | -| 0109 | self | | POST /oauth/device/{{device_id}}/music/musicprovider/15/token/cs3 | 200 OK | ./self/oauth/device/{device_id}/music/musicprovider/15/token/cs3/0109-20260328-105556.138-POST.http | +| 0109 | self | ☑ | POST /oauth/device/{{device_id}}/music/musicprovider/15/token/cs3 | 200 OK | ./self/oauth/device/{device_id}/music/musicprovider/15/token/cs3/0109-20260328-105556.138-POST.http | | 0110 | self | ☑ | GET /streaming/software/update/account/{{accountId}} | 200 OK | ./self/streaming/software/update/account/{accountId}/0110-20260328-105606.529-GET.http | | 0111 | mirror | ☑ | GET /streaming/software/update/account/{{accountId}} | 200 OK | ./mirror/streaming/software/update/account/{accountId}/0111-20260328-105606.699-GET.http | | 0112 | self | ☑ | GET /updates/soundtouch?serialnumber=_serial_ | 200 OK | ./self/updates/soundtouch/0112-20260328-105606.961-GET.http | @@ -186,11 +186,11 @@ Interactions for `20260328-103522-477978/`: | 0182 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0182-20260329-233302.393-POST.http | | 0183 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0183-20260329-233302.636-POST.http | | 0184 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0184-20260329-233302.655-POST.http | -| 0185 | self | | POST /streaming/account/{{accountId}}/device/{{device_id}}/recent | 201 Created | ./self/streaming/account/{accountId}/device/{device_id}/recent/0185-20260329-233302.671-POST.http | +| 0185 | self | ☑ | POST /streaming/account/{{accountId}}/device/{{device_id}}/recent | 201 Created | ./self/streaming/account/{accountId}/device/{device_id}/recent/0185-20260329-233302.671-POST.http | | 0186 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0186-20260329-233302.906-POST.http | | 0187 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0187-20260329-233303.115-POST.http | | 0188 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0188-20260329-233303.118-POST.http | -| 0189 | mirror | | POST /streaming/account/{{accountId}}/device/{{device_id}}/recent | 201 Created | ./mirror/streaming/account/{accountId}/device/{device_id}/recent/0189-20260329-233303.759-POST.http | +| 0189 | mirror | ☑ | POST /streaming/account/{{accountId}}/device/{{device_id}}/recent | 201 Created | ./mirror/streaming/account/{accountId}/device/{device_id}/recent/0189-20260329-233303.759-POST.http | | 0190 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0190-20260329-233304.143-POST.http | | 0191 | self | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./self/v1/scmudc/{device_id}/0191-20260329-233304.269-POST.http | | 0192 | mirror | ☑ | POST /v1/scmudc/{{device_id}} | 200 OK | ./mirror/v1/scmudc/{device_id}/0192-20260329-233304.554-POST.http | @@ -246,4 +246,4 @@ Interactions for `20260328-103522-477978/`: | 0242 | self | ☑ | GET /streaming/account/{{accountId}}/provider_settings | 200 OK | ./self/streaming/account/{accountId}/provider_settings/0242-20260329-233508.866-GET.http | | 0243 | mirror | ☑ | GET /streaming/account/{{accountId}}/provider_settings | 200 OK | ./mirror/streaming/account/{accountId}/provider_settings/0243-20260329-233508.968-GET.http | | 0244 | mirror | ☑ | GET /streaming/account/{{accountId}}/provider_settings | 200 OK | ./mirror/streaming/account/{accountId}/provider_settings/0244-20260329-233509.041-GET.http | -| 0245 | self | | POST /oauth/device/{{device_id}}/music/musicprovider/15/token/cs3 | 200 OK | ./self/oauth/device/{device_id}/music/musicprovider/15/token/cs3/0245-20260329-233510.807-POST.http | +| 0245 | self | ☑ | POST /oauth/device/{{device_id}}/music/musicprovider/15/token/cs3 | 200 OK | ./self/oauth/device/{device_id}/music/musicprovider/15/token/cs3/0245-20260329-233510.807-POST.http |