diff --git a/adapters/tidal/client.go b/adapters/tidal/client.go index de249f232..4c7d3cb2e 100644 --- a/adapters/tidal/client.go +++ b/adapters/tidal/client.go @@ -210,6 +210,76 @@ func (c *client) searchAlbums(ctx context.Context, albumName, artistName string, return result.Albums, nil } +func (c *client) searchTracks(ctx context.Context, trackName, artistName string, limit int) ([]TrackResource, error) { + token, err := c.getToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + query := trackName + if artistName != "" { + query = artistName + " " + trackName + } + + params := url.Values{} + params.Add("query", query) + params.Add("limit", strconv.Itoa(limit)) + params.Add("countryCode", "US") + params.Add("type", "TRACKS") + + req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search", nil) + if err != nil { + return nil, err + } + req.URL.RawQuery = params.Encode() + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.tidal.v1+json") + req.Header.Set("Content-Type", "application/vnd.tidal.v1+json") + + var result struct { + Tracks []TrackResource `json:"tracks"` + } + err = c.makeRequest(req, &result) + if err != nil { + return nil, err + } + + if len(result.Tracks) == 0 { + return nil, ErrNotFound + } + return result.Tracks, nil +} + +func (c *client) getTrackRadio(ctx context.Context, trackID string, limit int) ([]TrackResource, error) { + token, err := c.getToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + params := url.Values{} + params.Add("countryCode", "US") + params.Add("limit", strconv.Itoa(limit)) + + req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/tracks/"+trackID+"/radio", nil) + if err != nil { + return nil, err + } + req.URL.RawQuery = params.Encode() + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.tidal.v1+json") + req.Header.Set("Content-Type", "application/vnd.tidal.v1+json") + + var result struct { + Data []TrackResource `json:"data"` + } + err = c.makeRequest(req, &result) + if err != nil { + return nil, err + } + + return result.Data, nil +} + func (c *client) getToken(ctx context.Context) (string, error) { c.tokenMutex.Lock() defer c.tokenMutex.Unlock() diff --git a/adapters/tidal/tests/fixtures/tidal.search.track.json b/adapters/tidal/tests/fixtures/tidal.search.track.json new file mode 100644 index 000000000..e37e59b01 --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.search.track.json @@ -0,0 +1,24 @@ +{ + "tracks": [ + { + "id": "28048253", + "type": "tracks", + "attributes": { + "title": "Get Lucky", + "isrc": "USQX91300104", + "duration": 369, + "popularity": 95 + } + }, + { + "id": "1234567", + "type": "tracks", + "attributes": { + "title": "Get Lucky (Radio Edit)", + "isrc": "USQX91300105", + "duration": 248, + "popularity": 75 + } + } + ] +} diff --git a/adapters/tidal/tests/fixtures/tidal.track.radio.json b/adapters/tidal/tests/fixtures/tidal.track.radio.json new file mode 100644 index 000000000..229a62907 --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.track.radio.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "id": "12345678", + "type": "tracks", + "attributes": { + "title": "Starboy", + "isrc": "USUG11601092", + "duration": 230, + "popularity": 92 + } + }, + { + "id": "23456789", + "type": "tracks", + "attributes": { + "title": "Blinding Lights", + "isrc": "USUG11904154", + "duration": 200, + "popularity": 98 + } + }, + { + "id": "34567890", + "type": "tracks", + "attributes": { + "title": "Uptown Funk", + "isrc": "GBAHS1400099", + "duration": 270, + "popularity": 90 + } + } + ] +} diff --git a/adapters/tidal/tidal.go b/adapters/tidal/tidal.go index 84b69f73f..bf734057d 100644 --- a/adapters/tidal/tidal.go +++ b/adapters/tidal/tidal.go @@ -18,6 +18,7 @@ import ( const tidalAgentName = "tidal" const tidalArtistSearchLimit = 20 const tidalAlbumSearchLimit = 10 +const tidalTrackSearchLimit = 10 const tidalArtistURLBase = "https://tidal.com/browse/artist/" type tidalAgent struct { @@ -204,6 +205,68 @@ func (t *tidalAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid return songs, nil } +func (t *tidalAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { + track, err := t.searchTrack(ctx, name, artist) + if err != nil { + if errors.Is(err, agents.ErrNotFound) { + log.Warn(ctx, "Track not found in Tidal", "track", name, "artist", artist) + } else { + log.Error(ctx, "Error searching track in Tidal", "track", name, "artist", artist, err) + } + return nil, err + } + + // Get track radio (similar tracks) + similarTracks, err := t.client.getTrackRadio(ctx, track.ID, count) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, agents.ErrNotFound + } + log.Error(ctx, "Error getting track radio from Tidal", "trackId", track.ID, err) + return nil, err + } + + if len(similarTracks) == 0 { + return nil, agents.ErrNotFound + } + + res := slice.Map(similarTracks, func(track TrackResource) agents.Song { + return agents.Song{ + Name: track.Attributes.Title, + ISRC: track.Attributes.ISRC, + Duration: uint32(track.Attributes.Duration * 1000), + } + }) + + return res, nil +} + +func (t *tidalAgent) searchTrack(ctx context.Context, trackName, artistName string) (*TrackResource, error) { + tracks, err := t.client.searchTracks(ctx, trackName, artistName, tidalTrackSearchLimit) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, agents.ErrNotFound + } + return nil, err + } + + if len(tracks) == 0 { + return nil, agents.ErrNotFound + } + + // Find exact match (case-insensitive) + for i := range tracks { + if strings.EqualFold(tracks[i].Attributes.Title, trackName) { + log.Trace(ctx, "Found track in Tidal", "title", tracks[i].Attributes.Title, "id", tracks[i].ID) + return &tracks[i], nil + } + } + + // If no exact match, check if first result is close enough + log.Trace(ctx, "No exact track match in Tidal", "searched", trackName, "found", tracks[0].Attributes.Title) + return nil, agents.ErrNotFound +} + func (t *tidalAgent) searchArtist(ctx context.Context, name string) (*ArtistResource, error) { artists, err := t.client.searchArtists(ctx, name, tidalArtistSearchLimit) if err != nil { diff --git a/adapters/tidal/tidal_test.go b/adapters/tidal/tidal_test.go index ea0664307..969191627 100644 --- a/adapters/tidal/tidal_test.go +++ b/adapters/tidal/tidal_test.go @@ -324,6 +324,82 @@ var _ = Describe("tidalAgent", func() { Expect(err).To(MatchError(agents.ErrNotFound)) }) }) + + Describe("GetSimilarSongsByTrack", func() { + var agent *tidalAgent + var httpClient *mockHttpClient + + BeforeEach(func() { + httpClient = newMockHttpClient() + agent = &tidalAgent{ + ds: &tests.MockDataStore{}, + client: newClient("test-id", "test-secret", httpClient), + } + }) + + It("returns similar songs from track radio", func() { + // Mock token response + httpClient.tokenResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token","token_type":"Bearer","expires_in":86400}`)), + } + + // Mock track search response + fTrackSearch, _ := os.Open("tests/fixtures/tidal.search.track.json") + httpClient.trackSearchResponse = &http.Response{Body: fTrackSearch, StatusCode: 200} + + // Mock track radio response + fTrackRadio, _ := os.Open("tests/fixtures/tidal.track.radio.json") + httpClient.trackRadioResponse = &http.Response{Body: fTrackRadio, StatusCode: 200} + + songs, err := agent.GetSimilarSongsByTrack(ctx, "", "Get Lucky", "Daft Punk", "", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + Expect(songs[0].Name).To(Equal("Starboy")) + Expect(songs[0].Duration).To(Equal(uint32(230000))) // 230 seconds * 1000 + }) + + It("returns ErrNotFound when track is not found", func() { + // Mock token response + httpClient.tokenResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token","token_type":"Bearer","expires_in":86400}`)), + } + + // Mock empty track search response + httpClient.trackSearchResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"tracks":[]}`)), + } + + _, err := agent.GetSimilarSongsByTrack(ctx, "", "Nonexistent Track", "Unknown Artist", "", 5) + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + + It("returns ErrNotFound when track radio returns no results", func() { + // Mock token response + httpClient.tokenResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token","token_type":"Bearer","expires_in":86400}`)), + } + + // Mock track search response + fTrackSearch, _ := os.Open("tests/fixtures/tidal.search.track.json") + httpClient.trackSearchResponse = &http.Response{Body: fTrackSearch, StatusCode: 200} + + // Mock empty track radio response + httpClient.trackRadioResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[]}`)), + } + + _, err := agent.GetSimilarSongsByTrack(ctx, "", "Get Lucky", "Daft Punk", "", 5) + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) }) // mockHttpClient is a mock HTTP client for testing @@ -331,9 +407,11 @@ type mockHttpClient struct { tokenResponse *http.Response searchResponse *http.Response albumSearchResponse *http.Response + trackSearchResponse *http.Response artistResponse *http.Response similarResponse *http.Response tracksResponse *http.Response + trackRadioResponse *http.Response } func newMockHttpClient() *mockHttpClient { @@ -354,8 +432,9 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { // Handle search request if req.URL.Host == "openapi.tidal.com" && req.URL.Path == "/search" { - // Check if it's an album search (has type=ALBUMS parameter) - if req.URL.Query().Get("type") == "ALBUMS" { + searchType := req.URL.Query().Get("type") + // Check if it's an album search + if searchType == "ALBUMS" { if c.albumSearchResponse != nil { return c.albumSearchResponse, nil } @@ -364,6 +443,16 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { Body: io.NopCloser(bytes.NewBufferString(`{"albums":[]}`)), }, nil } + // Check if it's a track search + if searchType == "TRACKS" { + if c.trackSearchResponse != nil { + return c.trackSearchResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"tracks":[]}`)), + }, nil + } // Otherwise, it's an artist search if c.searchResponse != nil { return c.searchResponse, nil @@ -374,6 +463,19 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { }, nil } + // Handle track radio request + if req.URL.Host == "openapi.tidal.com" && len(req.URL.Path) > 8 && req.URL.Path[:8] == "/tracks/" { + if len(req.URL.Path) > 14 && req.URL.Path[len(req.URL.Path)-6:] == "/radio" { + if c.trackRadioResponse != nil { + return c.trackRadioResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[]}`)), + }, nil + } + } + // Handle artist request if req.URL.Host == "openapi.tidal.com" && len(req.URL.Path) > 9 && req.URL.Path[:9] == "/artists/" { // Check if it's a similar artists or tracks request