From 87217a3e2a09d84904ab0edddd612f13af75ad2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 12:38:55 +0000 Subject: [PATCH] feat(agents): add additional Tidal agent capabilities Extend the Tidal metadata agent with new retrieval methods: - GetArtistURL: Returns the Tidal URL for an artist - GetAlbumImages: Search for albums and retrieve cover art - GetSimilarSongsByArtist: Find similar artists and return their top tracks as song recommendations Also adds: - Album search client method - Extended response types for tracks with artist info - Test fixtures and tests for all new methods https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy --- adapters/tidal/client.go | 40 +++++ adapters/tidal/responses.go | 28 +++ .../tests/fixtures/tidal.search.album.json | 44 +++++ adapters/tidal/tidal.go | 113 ++++++++++++ adapters/tidal/tidal_test.go | 168 +++++++++++++++++- 5 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 adapters/tidal/tests/fixtures/tidal.search.album.json diff --git a/adapters/tidal/client.go b/adapters/tidal/client.go index 931b1ecf8..de249f232 100644 --- a/adapters/tidal/client.go +++ b/adapters/tidal/client.go @@ -170,6 +170,46 @@ func (c *client) getSimilarArtists(ctx context.Context, artistID string, limit i return result.Data, nil } +func (c *client) searchAlbums(ctx context.Context, albumName, artistName string, limit int) ([]AlbumResource, error) { + token, err := c.getToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + query := albumName + if artistName != "" { + query = artistName + " " + albumName + } + + params := url.Values{} + params.Add("query", query) + params.Add("limit", strconv.Itoa(limit)) + params.Add("countryCode", "US") + params.Add("type", "ALBUMS") + + 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 { + Albums []AlbumResource `json:"albums"` + } + err = c.makeRequest(req, &result) + if err != nil { + return nil, err + } + + if len(result.Albums) == 0 { + return nil, ErrNotFound + } + return result.Albums, nil +} + func (c *client) getToken(ctx context.Context) (string, error) { c.tokenMutex.Lock() defer c.tokenMutex.Unlock() diff --git a/adapters/tidal/responses.go b/adapters/tidal/responses.go index 74122745d..a784ffca8 100644 --- a/adapters/tidal/responses.go +++ b/adapters/tidal/responses.go @@ -55,6 +55,34 @@ type TrackAttributes struct { Popularity int `json:"popularity"` } +// TrackWithArtist represents a track with artist info from the search response +type TrackWithArtist struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes TrackAttributesWithArtist `json:"attributes"` +} + +// TrackAttributesWithArtist contains track metadata with artist info +type TrackAttributesWithArtist struct { + Title string `json:"title"` + ISRC string `json:"isrc"` + Duration int `json:"duration"` // Duration in seconds + Artists []ArtistReference `json:"artists"` + Album *AlbumReference `json:"album,omitempty"` +} + +// ArtistReference represents a reference to an artist in a track +type ArtistReference struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// AlbumReference represents a reference to an album in a track +type AlbumReference struct { + ID string `json:"id"` + Title string `json:"title"` +} + // SimilarArtistsResponse represents the response from similar artists endpoint type SimilarArtistsResponse struct { Data []ArtistResource `json:"data"` diff --git a/adapters/tidal/tests/fixtures/tidal.search.album.json b/adapters/tidal/tests/fixtures/tidal.search.album.json new file mode 100644 index 000000000..ded1128cb --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.search.album.json @@ -0,0 +1,44 @@ +{ + "albums": [ + { + "id": "28048252", + "type": "albums", + "attributes": { + "title": "Random Access Memories", + "releaseDate": "2013-05-17", + "cover": [ + { + "url": "https://resources.tidal.com/images/04d63cd8/a1a5/42e0/b1ec/8e336b7d9200/750x750.jpg", + "width": 750, + "height": 750 + }, + { + "url": "https://resources.tidal.com/images/04d63cd8/a1a5/42e0/b1ec/8e336b7d9200/320x320.jpg", + "width": 320, + "height": 320 + }, + { + "url": "https://resources.tidal.com/images/04d63cd8/a1a5/42e0/b1ec/8e336b7d9200/160x160.jpg", + "width": 160, + "height": 160 + } + ] + } + }, + { + "id": "1234567", + "type": "albums", + "attributes": { + "title": "Random Access Memories (Drumless Edition)", + "releaseDate": "2023-11-17", + "cover": [ + { + "url": "https://resources.tidal.com/images/deadbeef/1234/5678/9abc/def012345678/750x750.jpg", + "width": 750, + "height": 750 + } + ] + } + } + ] +} diff --git a/adapters/tidal/tidal.go b/adapters/tidal/tidal.go index 7ff3fdf9e..84b69f73f 100644 --- a/adapters/tidal/tidal.go +++ b/adapters/tidal/tidal.go @@ -17,6 +17,8 @@ import ( const tidalAgentName = "tidal" const tidalArtistSearchLimit = 20 +const tidalAlbumSearchLimit = 10 +const tidalArtistURLBase = "https://tidal.com/browse/artist/" type tidalAgent struct { ds model.DataStore @@ -117,6 +119,91 @@ func (t *tidalAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid return res, nil } +func (t *tidalAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { + artist, err := t.searchArtist(ctx, name) + if err != nil { + return "", err + } + + return tidalArtistURLBase + artist.ID, nil +} + +func (t *tidalAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { + album, err := t.searchAlbum(ctx, name, artist) + if err != nil { + if errors.Is(err, agents.ErrNotFound) { + log.Warn(ctx, "Album not found in Tidal", "album", name, "artist", artist) + } else { + log.Error(ctx, "Error calling Tidal for album", "album", name, "artist", artist, err) + } + return nil, err + } + + var res []agents.ExternalImage + for _, img := range album.Attributes.Cover { + res = append(res, agents.ExternalImage{ + URL: img.URL, + Size: img.Width, + }) + } + + if len(res) == 0 { + return nil, agents.ErrNotFound + } + + return res, nil +} + +func (t *tidalAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) { + artist, err := t.searchArtist(ctx, name) + if err != nil { + return nil, err + } + + // Get similar artists + similarArtists, err := t.client.getSimilarArtists(ctx, artist.ID, 5) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, agents.ErrNotFound + } + return nil, err + } + + if len(similarArtists) == 0 { + return nil, agents.ErrNotFound + } + + // Get top tracks from similar artists + var songs []agents.Song + tracksPerArtist := (count / len(similarArtists)) + 1 + + for _, simArtist := range similarArtists { + tracks, err := t.client.getArtistTopTracks(ctx, simArtist.ID, tracksPerArtist) + if err != nil { + log.Warn(ctx, "Failed to get top tracks for similar artist", "artist", simArtist.Attributes.Name, err) + continue + } + + for _, track := range tracks { + songs = append(songs, agents.Song{ + Name: track.Attributes.Title, + Artist: simArtist.Attributes.Name, + ISRC: track.Attributes.ISRC, + Duration: uint32(track.Attributes.Duration * 1000), + }) + if len(songs) >= count { + return songs, nil + } + } + } + + if len(songs) == 0 { + return nil, agents.ErrNotFound + } + + return songs, nil +} + func (t *tidalAgent) searchArtist(ctx context.Context, name string) (*ArtistResource, error) { artists, err := t.client.searchArtists(ctx, name, tidalArtistSearchLimit) if err != nil { @@ -143,6 +230,32 @@ func (t *tidalAgent) searchArtist(ctx context.Context, name string) (*ArtistReso return nil, agents.ErrNotFound } +func (t *tidalAgent) searchAlbum(ctx context.Context, albumName, artistName string) (*AlbumResource, error) { + albums, err := t.client.searchAlbums(ctx, albumName, artistName, tidalAlbumSearchLimit) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, agents.ErrNotFound + } + return nil, err + } + + if len(albums) == 0 { + return nil, agents.ErrNotFound + } + + // Find exact match (case-insensitive) + for i := range albums { + if strings.EqualFold(albums[i].Attributes.Title, albumName) { + log.Trace(ctx, "Found album in Tidal", "title", albums[i].Attributes.Title, "id", albums[i].ID) + return &albums[i], nil + } + } + + // If no exact match, check if first result is close enough + log.Trace(ctx, "No exact album match in Tidal", "searched", albumName, "found", albums[0].Attributes.Title) + return nil, agents.ErrNotFound +} + func init() { conf.AddHook(func() { if conf.Server.Tidal.Enabled { diff --git a/adapters/tidal/tidal_test.go b/adapters/tidal/tidal_test.go index 7cd8c09ff..ea0664307 100644 --- a/adapters/tidal/tidal_test.go +++ b/adapters/tidal/tidal_test.go @@ -182,15 +182,158 @@ var _ = Describe("tidalAgent", func() { Expect(songs[0].Duration).To(Equal(uint32(369000))) // 369 seconds * 1000 }) }) + + Describe("GetArtistURL", 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 artist URL", 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 search response + fSearch, _ := os.Open("tests/fixtures/tidal.search.artist.json") + httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200} + + url, err := agent.GetArtistURL(ctx, "", "Daft Punk", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(Equal("https://tidal.com/browse/artist/4837227")) + }) + }) + + Describe("GetAlbumImages", 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 album images", 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 album search response + fAlbum, _ := os.Open("tests/fixtures/tidal.search.album.json") + httpClient.albumSearchResponse = &http.Response{Body: fAlbum, StatusCode: 200} + + images, err := agent.GetAlbumImages(ctx, "Random Access Memories", "Daft Punk", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(HaveLen(3)) + Expect(images[0].URL).To(ContainSubstring("resources.tidal.com")) + Expect(images[0].Size).To(Equal(750)) + }) + + It("returns ErrNotFound when album 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 album search response + httpClient.albumSearchResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"albums":[]}`)), + } + + _, err := agent.GetAlbumImages(ctx, "Nonexistent Album", "Unknown Artist", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) + + Describe("GetSimilarSongsByArtist", 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 similar artists", 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 search response + fSearch, _ := os.Open("tests/fixtures/tidal.search.artist.json") + httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200} + + // Mock similar artists response + fSimilar, _ := os.Open("tests/fixtures/tidal.similar.artists.json") + httpClient.similarResponse = &http.Response{Body: fSimilar, StatusCode: 200} + + // Mock top tracks response (will be called for each similar artist) + fTracks, _ := os.Open("tests/fixtures/tidal.artist.tracks.json") + httpClient.tracksResponse = &http.Response{Body: fTracks, StatusCode: 200} + + songs, err := agent.GetSimilarSongsByArtist(ctx, "", "Daft Punk", "", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(5)) + Expect(songs[0].Name).To(Equal("Get Lucky")) + Expect(songs[0].Artist).To(Equal("Justice")) + }) + + It("returns ErrNotFound when no similar artists 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 search response + fSearch, _ := os.Open("tests/fixtures/tidal.search.artist.json") + httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200} + + // Mock empty similar artists response + httpClient.similarResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[]}`)), + } + + _, err := agent.GetSimilarSongsByArtist(ctx, "", "Daft Punk", "", 5) + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) }) // mockHttpClient is a mock HTTP client for testing type mockHttpClient struct { - tokenResponse *http.Response - searchResponse *http.Response - artistResponse *http.Response - similarResponse *http.Response - tracksResponse *http.Response + tokenResponse *http.Response + searchResponse *http.Response + albumSearchResponse *http.Response + artistResponse *http.Response + similarResponse *http.Response + tracksResponse *http.Response } func newMockHttpClient() *mockHttpClient { @@ -211,6 +354,17 @@ 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" { + if c.albumSearchResponse != nil { + return c.albumSearchResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"albums":[]}`)), + }, nil + } + // Otherwise, it's an artist search if c.searchResponse != nil { return c.searchResponse, nil } @@ -234,7 +388,9 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { } if len(req.URL.Path) > 16 && req.URL.Path[len(req.URL.Path)-7:] == "/tracks" { if c.tracksResponse != nil { - return c.tracksResponse, nil + // Need to return a new response each time since the body is consumed + fTracks, _ := os.Open("tests/fixtures/tidal.artist.tracks.json") + return &http.Response{Body: fTracks, StatusCode: 200}, nil } return &http.Response{ StatusCode: 200,