From 79928660574a15548b19beee9180df73b3bd3c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 12:26:45 +0000 Subject: [PATCH 1/4] feat(agents): add Tidal metadata agent Add a new metadata agent for fetching artist information from TIDAL's official API. The agent supports: - Artist image retrieval - Similar artists discovery - Artist top songs/tracks The implementation follows the same patterns as the existing Deezer and Spotify agents, using OAuth2 client credentials flow for authentication. Configuration options: - Tidal.Enabled: Enable/disable the agent - Tidal.ClientID: TIDAL API client ID - Tidal.ClientSecret: TIDAL API client secret https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy --- adapters/tidal/client.go | 256 ++++++++++++++++++ adapters/tidal/client_test.go | 156 +++++++++++ adapters/tidal/responses.go | 100 +++++++ .../tests/fixtures/tidal.artist.tracks.json | 34 +++ .../tests/fixtures/tidal.search.artist.json | 38 +++ .../tests/fixtures/tidal.similar.artists.json | 49 ++++ adapters/tidal/tidal.go | 152 +++++++++++ adapters/tidal/tidal_suite_test.go | 17 ++ adapters/tidal/tidal_test.go | 254 +++++++++++++++++ conf/configuration.go | 11 + 10 files changed, 1067 insertions(+) create mode 100644 adapters/tidal/client.go create mode 100644 adapters/tidal/client_test.go create mode 100644 adapters/tidal/responses.go create mode 100644 adapters/tidal/tests/fixtures/tidal.artist.tracks.json create mode 100644 adapters/tidal/tests/fixtures/tidal.search.artist.json create mode 100644 adapters/tidal/tests/fixtures/tidal.similar.artists.json create mode 100644 adapters/tidal/tidal.go create mode 100644 adapters/tidal/tidal_suite_test.go create mode 100644 adapters/tidal/tidal_test.go diff --git a/adapters/tidal/client.go b/adapters/tidal/client.go new file mode 100644 index 000000000..931b1ecf8 --- /dev/null +++ b/adapters/tidal/client.go @@ -0,0 +1,256 @@ +package tidal + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/navidrome/navidrome/log" +) + +const ( + apiBaseURL = "https://openapi.tidal.com" + authTokenURL = "https://auth.tidal.com/v1/oauth2/token" +) + +var ( + ErrNotFound = errors.New("tidal: not found") +) + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +type client struct { + id string + secret string + hc httpDoer + token string + tokenExp time.Time + tokenMutex sync.Mutex +} + +func newClient(id, secret string, hc httpDoer) *client { + return &client{ + id: id, + secret: secret, + hc: hc, + } +} + +func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]ArtistResource, error) { + token, err := c.getToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + + params := url.Values{} + params.Add("query", name) + params.Add("limit", strconv.Itoa(limit)) + params.Add("countryCode", "US") + + 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 { + Artists []ArtistResource `json:"artists"` + } + err = c.makeRequest(req, &result) + if err != nil { + return nil, err + } + + if len(result.Artists) == 0 { + return nil, ErrNotFound + } + return result.Artists, nil +} + +func (c *client) getArtist(ctx context.Context, artistID string) (*ArtistResource, 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") + + req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/artists/"+artistID, 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 { + Resource ArtistResource `json:"resource"` + } + err = c.makeRequest(req, &result) + if err != nil { + return nil, err + } + + return &result.Resource, nil +} + +func (c *client) getArtistTopTracks(ctx context.Context, artistID 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+"/artists/"+artistID+"/tracks", 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) getSimilarArtists(ctx context.Context, artistID string, limit int) ([]ArtistResource, 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+"/artists/"+artistID+"/similar", 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 []ArtistResource `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() + + // Return cached token if still valid (with 1 minute buffer) + if c.token != "" && time.Now().Add(time.Minute).Before(c.tokenExp) { + return c.token, nil + } + + // Request new token + payload := url.Values{} + payload.Add("grant_type", "client_credentials") + + req, err := http.NewRequestWithContext(ctx, "POST", authTokenURL, strings.NewReader(payload.Encode())) + if err != nil { + return "", err + } + + auth := c.id + ":" + c.secret + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + log.Trace(ctx, "Requesting Tidal OAuth token") + resp, err := c.hc.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("tidal: failed to get token: %s", string(data)) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(data, &tokenResp); err != nil { + return "", fmt.Errorf("tidal: failed to parse token response: %w", err) + } + + c.token = tokenResp.AccessToken + c.tokenExp = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + log.Trace(ctx, "Obtained Tidal OAuth token", "expiresIn", tokenResp.ExpiresIn) + + return c.token, nil +} + +func (c *client) makeRequest(req *http.Request, response any) error { + log.Trace(req.Context(), fmt.Sprintf("Sending Tidal %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMultiStatus { + return c.parseError(data, resp.StatusCode) + } + + return json.Unmarshal(data, response) +} + +func (c *client) parseError(data []byte, statusCode int) error { + var errResp ErrorResponse + if err := json.Unmarshal(data, &errResp); err != nil { + return fmt.Errorf("tidal error (status %d): %s", statusCode, string(data)) + } + if len(errResp.Errors) > 0 { + return fmt.Errorf("tidal error (%s): %s", errResp.Errors[0].Code, errResp.Errors[0].Detail) + } + return fmt.Errorf("tidal error (status %d)", statusCode) +} diff --git a/adapters/tidal/client_test.go b/adapters/tidal/client_test.go new file mode 100644 index 000000000..e6e467f99 --- /dev/null +++ b/adapters/tidal/client_test.go @@ -0,0 +1,156 @@ +package tidal + +import ( + "bytes" + "io" + "net/http" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("client", func() { + var httpClient *fakeHttpClient + var c *client + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + c = newClient("test-client-id", "test-client-secret", httpClient) + }) + + Describe("searchArtists", func() { + BeforeEach(func() { + // Mock token response + httpClient.mock("https://auth.tidal.com/v1/oauth2/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token","token_type":"Bearer","expires_in":86400}`)), + }) + }) + + It("returns artists from a successful request", func() { + f, err := os.Open("tests/fixtures/tidal.search.artist.json") + Expect(err).To(BeNil()) + httpClient.mock("https://openapi.tidal.com/search", http.Response{Body: f, StatusCode: 200}) + + artists, err := c.searchArtists(GinkgoT().Context(), "Daft Punk", 20) + Expect(err).To(BeNil()) + Expect(artists).To(HaveLen(2)) + Expect(artists[0].Attributes.Name).To(Equal("Daft Punk")) + }) + + It("returns ErrNotFound when no artists found", func() { + httpClient.mock("https://openapi.tidal.com/search", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"artists":[]}`)), + }) + + _, err := c.searchArtists(GinkgoT().Context(), "Nonexistent Artist", 20) + Expect(err).To(MatchError(ErrNotFound)) + }) + }) + + Describe("getArtistTopTracks", func() { + BeforeEach(func() { + // Mock token response + httpClient.mock("https://auth.tidal.com/v1/oauth2/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token","token_type":"Bearer","expires_in":86400}`)), + }) + }) + + It("returns tracks from a successful request", func() { + f, err := os.Open("tests/fixtures/tidal.artist.tracks.json") + Expect(err).To(BeNil()) + httpClient.mock("https://openapi.tidal.com/artists/4837227/tracks", http.Response{Body: f, StatusCode: 200}) + + tracks, err := c.getArtistTopTracks(GinkgoT().Context(), "4837227", 10) + Expect(err).To(BeNil()) + Expect(tracks).To(HaveLen(3)) + Expect(tracks[0].Attributes.Title).To(Equal("Get Lucky")) + Expect(tracks[0].Attributes.Duration).To(Equal(369)) + }) + }) + + Describe("getSimilarArtists", func() { + BeforeEach(func() { + // Mock token response + httpClient.mock("https://auth.tidal.com/v1/oauth2/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token","token_type":"Bearer","expires_in":86400}`)), + }) + }) + + It("returns similar artists from a successful request", func() { + f, err := os.Open("tests/fixtures/tidal.similar.artists.json") + Expect(err).To(BeNil()) + httpClient.mock("https://openapi.tidal.com/artists/4837227/similar", http.Response{Body: f, StatusCode: 200}) + + artists, err := c.getSimilarArtists(GinkgoT().Context(), "4837227", 10) + Expect(err).To(BeNil()) + Expect(artists).To(HaveLen(3)) + Expect(artists[0].Attributes.Name).To(Equal("Justice")) + }) + }) + + Describe("getToken", func() { + It("returns token from a successful request", func() { + httpClient.mock("https://auth.tidal.com/v1/oauth2/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token-123","token_type":"Bearer","expires_in":86400}`)), + }) + + token, err := c.getToken(GinkgoT().Context()) + Expect(err).To(BeNil()) + Expect(token).To(Equal("test-token-123")) + }) + + It("caches token for subsequent requests", func() { + httpClient.mock("https://auth.tidal.com/v1/oauth2/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token":"test-token-123","token_type":"Bearer","expires_in":86400}`)), + }) + + token1, err := c.getToken(GinkgoT().Context()) + Expect(err).To(BeNil()) + + // Second call should use cached token + token2, err := c.getToken(GinkgoT().Context()) + Expect(err).To(BeNil()) + Expect(token2).To(Equal(token1)) + }) + + It("returns error on failed token request", func() { + httpClient.mock("https://auth.tidal.com/v1/oauth2/token", http.Response{ + StatusCode: 401, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client"}`)), + }) + + _, err := c.getToken(GinkgoT().Context()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get token")) + }) + }) +}) + +type fakeHttpClient struct { + responses map[string]*http.Response + lastRequest *http.Request +} + +func (c *fakeHttpClient) mock(url string, response http.Response) { + if c.responses == nil { + c.responses = make(map[string]*http.Response) + } + c.responses[url] = &response +} + +func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) { + c.lastRequest = req + u := req.URL + u.RawQuery = "" + if resp, ok := c.responses[u.String()]; ok { + return resp, nil + } + panic("URL not mocked: " + u.String()) +} diff --git a/adapters/tidal/responses.go b/adapters/tidal/responses.go new file mode 100644 index 000000000..74122745d --- /dev/null +++ b/adapters/tidal/responses.go @@ -0,0 +1,100 @@ +package tidal + +// SearchResponse represents the JSON:API response from Tidal search endpoint +type SearchResponse struct { + Artists []ArtistResource `json:"data"` +} + +// ArtistResource represents an artist in Tidal's JSON:API format +type ArtistResource struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes ArtistAttributes `json:"attributes"` +} + +// ArtistAttributes contains the artist's metadata +type ArtistAttributes struct { + Name string `json:"name"` + Popularity int `json:"popularity"` + Picture []Image `json:"picture"` + ExternalLinks []Link `json:"externalLinks,omitempty"` +} + +// Image represents an image resource from Tidal +type Image struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` +} + +// Link represents an external link +type Link struct { + Href string `json:"href"` + Meta struct { + Type string `json:"type"` + } `json:"meta,omitempty"` +} + +// TracksResponse represents the response from artist top tracks endpoint +type TracksResponse struct { + Data []TrackResource `json:"data"` +} + +// TrackResource represents a track in Tidal's JSON:API format +type TrackResource struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes TrackAttributes `json:"attributes"` +} + +// TrackAttributes contains track metadata +type TrackAttributes struct { + Title string `json:"title"` + ISRC string `json:"isrc"` + Duration int `json:"duration"` // Duration in seconds + Popularity int `json:"popularity"` +} + +// SimilarArtistsResponse represents the response from similar artists endpoint +type SimilarArtistsResponse struct { + Data []ArtistResource `json:"data"` +} + +// AlbumsResponse represents the response from albums endpoint +type AlbumsResponse struct { + Data []AlbumResource `json:"data"` +} + +// AlbumResource represents an album in Tidal's JSON:API format +type AlbumResource struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes AlbumAttributes `json:"attributes"` +} + +// AlbumAttributes contains album metadata +type AlbumAttributes struct { + Title string `json:"title"` + ReleaseDate string `json:"releaseDate"` + Cover []Image `json:"cover"` +} + +// TokenResponse represents the OAuth token response +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// ErrorResponse represents an error from the Tidal API +type ErrorResponse struct { + Errors []APIError `json:"errors"` +} + +// APIError represents a single error in the errors array +type APIError struct { + ID string `json:"id"` + Status int `json:"status"` + Code string `json:"code"` + Detail string `json:"detail"` +} diff --git a/adapters/tidal/tests/fixtures/tidal.artist.tracks.json b/adapters/tidal/tests/fixtures/tidal.artist.tracks.json new file mode 100644 index 000000000..05a0b8b5e --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.artist.tracks.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "id": "28048253", + "type": "tracks", + "attributes": { + "title": "Get Lucky", + "isrc": "USQX91300104", + "duration": 369, + "popularity": 95 + } + }, + { + "id": "28048256", + "type": "tracks", + "attributes": { + "title": "Instant Crush", + "isrc": "USQX91300107", + "duration": 337, + "popularity": 88 + } + }, + { + "id": "1269012", + "type": "tracks", + "attributes": { + "title": "Around the World", + "isrc": "FRZ119700490", + "duration": 429, + "popularity": 85 + } + } + ] +} diff --git a/adapters/tidal/tests/fixtures/tidal.search.artist.json b/adapters/tidal/tests/fixtures/tidal.search.artist.json new file mode 100644 index 000000000..38fd60199 --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.search.artist.json @@ -0,0 +1,38 @@ +{ + "artists": [ + { + "id": "4837227", + "type": "artists", + "attributes": { + "name": "Daft Punk", + "popularity": 85, + "picture": [ + { + "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/480x480.jpg", + "width": 480, + "height": 480 + }, + { + "url": "https://resources.tidal.com/images/04d63cd8/a1a5/42e0/b1ec/8e336b7d9200/320x320.jpg", + "width": 320, + "height": 320 + } + ] + } + }, + { + "id": "12345678", + "type": "artists", + "attributes": { + "name": "Daft Punk Tribute", + "popularity": 20, + "picture": [] + } + } + ] +} diff --git a/adapters/tidal/tests/fixtures/tidal.similar.artists.json b/adapters/tidal/tests/fixtures/tidal.similar.artists.json new file mode 100644 index 000000000..561f03eac --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.similar.artists.json @@ -0,0 +1,49 @@ +{ + "data": [ + { + "id": "1234567", + "type": "artists", + "attributes": { + "name": "Justice", + "popularity": 72, + "picture": [ + { + "url": "https://resources.tidal.com/images/abc12345/1234/5678/9abc/def012345678/750x750.jpg", + "width": 750, + "height": 750 + } + ] + } + }, + { + "id": "2345678", + "type": "artists", + "attributes": { + "name": "Kavinsky", + "popularity": 65, + "picture": [ + { + "url": "https://resources.tidal.com/images/bcd23456/2345/6789/abcd/ef0123456789/750x750.jpg", + "width": 750, + "height": 750 + } + ] + } + }, + { + "id": "3456789", + "type": "artists", + "attributes": { + "name": "Breakbot", + "popularity": 58, + "picture": [ + { + "url": "https://resources.tidal.com/images/cde34567/3456/789a/bcde/f01234567890/750x750.jpg", + "width": 750, + "height": 750 + } + ] + } + } + ] +} diff --git a/adapters/tidal/tidal.go b/adapters/tidal/tidal.go new file mode 100644 index 000000000..7ff3fdf9e --- /dev/null +++ b/adapters/tidal/tidal.go @@ -0,0 +1,152 @@ +package tidal + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/slice" +) + +const tidalAgentName = "tidal" +const tidalArtistSearchLimit = 20 + +type tidalAgent struct { + ds model.DataStore + client *client +} + +func tidalConstructor(ds model.DataStore) agents.Interface { + if conf.Server.Tidal.ClientID == "" || conf.Server.Tidal.ClientSecret == "" { + return nil + } + l := &tidalAgent{ + ds: ds, + } + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) + l.client = newClient(conf.Server.Tidal.ClientID, conf.Server.Tidal.ClientSecret, chc) + return l +} + +func (t *tidalAgent) AgentName() string { + return tidalAgentName +} + +func (t *tidalAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) { + artist, err := t.searchArtist(ctx, name) + if err != nil { + if errors.Is(err, agents.ErrNotFound) { + log.Warn(ctx, "Artist not found in Tidal", "artist", name) + } else { + log.Error(ctx, "Error calling Tidal", "artist", name, err) + } + return nil, err + } + + var res []agents.ExternalImage + for _, img := range artist.Attributes.Picture { + res = append(res, agents.ExternalImage{ + URL: img.URL, + Size: img.Width, + }) + } + + // Sort images by size descending + if len(res) == 0 { + return nil, agents.ErrNotFound + } + + return res, nil +} + +func (t *tidalAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { + artist, err := t.searchArtist(ctx, name) + if err != nil { + return nil, err + } + + similar, err := t.client.getSimilarArtists(ctx, artist.ID, limit) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, agents.ErrNotFound + } + return nil, err + } + + res := slice.Map(similar, func(a ArtistResource) agents.Artist { + return agents.Artist{ + Name: a.Attributes.Name, + } + }) + + return res, nil +} + +func (t *tidalAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { + artist, err := t.searchArtist(ctx, artistName) + if err != nil { + return nil, err + } + + tracks, err := t.client.getArtistTopTracks(ctx, artist.ID, count) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, agents.ErrNotFound + } + return nil, err + } + + res := slice.Map(tracks, func(track TrackResource) agents.Song { + return agents.Song{ + Name: track.Attributes.Title, + ISRC: track.Attributes.ISRC, + Duration: uint32(track.Attributes.Duration * 1000), // Convert seconds to milliseconds + } + }) + + return res, nil +} + +func (t *tidalAgent) searchArtist(ctx context.Context, name string) (*ArtistResource, error) { + artists, err := t.client.searchArtists(ctx, name, tidalArtistSearchLimit) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, agents.ErrNotFound + } + return nil, err + } + + if len(artists) == 0 { + return nil, agents.ErrNotFound + } + + // Find exact match (case-insensitive) + for i := range artists { + if strings.EqualFold(artists[i].Attributes.Name, name) { + log.Trace(ctx, "Found artist in Tidal", "name", artists[i].Attributes.Name, "id", artists[i].ID) + return &artists[i], nil + } + } + + // If no exact match, check if first result is close enough + log.Trace(ctx, "No exact artist match in Tidal", "searched", name, "found", artists[0].Attributes.Name) + return nil, agents.ErrNotFound +} + +func init() { + conf.AddHook(func() { + if conf.Server.Tidal.Enabled { + agents.Register(tidalAgentName, tidalConstructor) + } + }) +} diff --git a/adapters/tidal/tidal_suite_test.go b/adapters/tidal/tidal_suite_test.go new file mode 100644 index 000000000..d45e3a224 --- /dev/null +++ b/adapters/tidal/tidal_suite_test.go @@ -0,0 +1,17 @@ +package tidal + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTidal(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Tidal Test Suite") +} diff --git a/adapters/tidal/tidal_test.go b/adapters/tidal/tidal_test.go new file mode 100644 index 000000000..7cd8c09ff --- /dev/null +++ b/adapters/tidal/tidal_test.go @@ -0,0 +1,254 @@ +package tidal + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("tidalAgent", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + conf.Server.Tidal.Enabled = true + conf.Server.Tidal.ClientID = "test-client-id" + conf.Server.Tidal.ClientSecret = "test-client-secret" + }) + + Describe("tidalConstructor", func() { + It("returns nil when client ID is empty", func() { + conf.Server.Tidal.ClientID = "" + agent := tidalConstructor(&tests.MockDataStore{}) + Expect(agent).To(BeNil()) + }) + + It("returns nil when client secret is empty", func() { + conf.Server.Tidal.ClientSecret = "" + agent := tidalConstructor(&tests.MockDataStore{}) + Expect(agent).To(BeNil()) + }) + + It("returns agent when credentials are configured", func() { + agent := tidalConstructor(&tests.MockDataStore{}) + Expect(agent).ToNot(BeNil()) + Expect(agent.AgentName()).To(Equal("tidal")) + }) + }) + + Describe("GetArtistImages", 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 images from search result", 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} + + images, err := agent.GetArtistImages(ctx, "", "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 artist 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 search response + httpClient.searchResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"artists":[]}`)), + } + + _, err := agent.GetArtistImages(ctx, "", "Nonexistent Artist", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + + It("returns ErrNotFound when artist name doesn't match", 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 with different artist + fSearch, _ := os.Open("tests/fixtures/tidal.search.artist.json") + httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200} + + _, err := agent.GetArtistImages(ctx, "", "Wrong Artist Name", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) + + Describe("GetSimilarArtists", 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 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} + + similar, err := agent.GetSimilarArtists(ctx, "", "Daft Punk", "", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(similar).To(HaveLen(3)) + Expect(similar[0].Name).To(Equal("Justice")) + }) + }) + + Describe("GetArtistTopSongs", 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 top songs", 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 top tracks response + fTracks, _ := os.Open("tests/fixtures/tidal.artist.tracks.json") + httpClient.tracksResponse = &http.Response{Body: fTracks, StatusCode: 200} + + songs, err := agent.GetArtistTopSongs(ctx, "", "Daft Punk", "", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + Expect(songs[0].Name).To(Equal("Get Lucky")) + Expect(songs[0].Duration).To(Equal(uint32(369000))) // 369 seconds * 1000 + }) + }) +}) + +// 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 +} + +func newMockHttpClient() *mockHttpClient { + return &mockHttpClient{} +} + +func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { + // Handle token request + if req.URL.Host == "auth.tidal.com" && req.URL.Path == "/v1/oauth2/token" { + if c.tokenResponse != nil { + return c.tokenResponse, nil + } + return &http.Response{ + StatusCode: 500, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)), + }, nil + } + + // Handle search request + if req.URL.Host == "openapi.tidal.com" && req.URL.Path == "/search" { + if c.searchResponse != nil { + return c.searchResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"artists":[]}`)), + }, 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 + if len(req.URL.Path) > 17 && req.URL.Path[len(req.URL.Path)-8:] == "/similar" { + if c.similarResponse != nil { + return c.similarResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[]}`)), + }, nil + } + if len(req.URL.Path) > 16 && req.URL.Path[len(req.URL.Path)-7:] == "/tracks" { + if c.tracksResponse != nil { + return c.tracksResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[]}`)), + }, nil + } + if c.artistResponse != nil { + return c.artistResponse, nil + } + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewBufferString(`{"errors":[{"status":404,"code":"NOT_FOUND"}]}`)), + }, nil + } + + panic("URL not mocked: " + req.URL.String()) +} diff --git a/conf/configuration.go b/conf/configuration.go index aa1056f75..128e298fa 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -103,6 +103,7 @@ type configOptions struct { Spotify spotifyOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` + Tidal tidalOptions `json:",omitzero"` EnableScrobbleHistory bool Tags map[string]TagConf `json:",omitempty"` Agents string @@ -198,6 +199,12 @@ type listenBrainzOptions struct { BaseURL string } +type tidalOptions struct { + Enabled bool + ClientID string + ClientSecret string +} + type httpHeaderOptions struct { FrameOptions string } @@ -457,6 +464,7 @@ func disableExternalServices() { Server.Spotify.ID = "" Server.Deezer.Enabled = false Server.ListenBrainz.Enabled = false + Server.Tidal.Enabled = false Server.Agents = "" if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL { Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline @@ -656,6 +664,9 @@ func setViperDefaults() { viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("listenbrainz.enabled", true) viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") + viper.SetDefault("tidal.enabled", false) + viper.SetDefault("tidal.clientid", "") + viper.SetDefault("tidal.clientsecret", "") viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") From 87217a3e2a09d84904ab0edddd612f13af75ad2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 12:38:55 +0000 Subject: [PATCH 2/4] 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, From 89d4d683045e1be0273b330f35e544cfd1116947 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 12:44:46 +0000 Subject: [PATCH 3/4] feat(agents): add SimilarSongsByTrack support for Tidal agent Implement GetSimilarSongsByTrack using Tidal's track radio endpoint (/tracks/{id}/radio) which returns a mix of similar tracks. New client methods: - searchTracks: Search for tracks by name and artist - getTrackRadio: Get similar tracks for a given track ID This enables song-based recommendations in addition to the existing artist-based similar songs feature. https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy --- adapters/tidal/client.go | 70 ++++++++++++ .../tests/fixtures/tidal.search.track.json | 24 ++++ .../tests/fixtures/tidal.track.radio.json | 34 ++++++ adapters/tidal/tidal.go | 63 +++++++++++ adapters/tidal/tidal_test.go | 106 +++++++++++++++++- 5 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 adapters/tidal/tests/fixtures/tidal.search.track.json create mode 100644 adapters/tidal/tests/fixtures/tidal.track.radio.json 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 From 1ca809e4ed35da17285cbdc00972a0f8aea6c422 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 12:49:33 +0000 Subject: [PATCH 4/4] feat(agents): add artist biography and album info for Tidal agent Implement additional metadata retrieval methods: - GetArtistBiography: Fetch artist bio from /artists/{id}/bio endpoint - GetAlbumInfo: Fetch album metadata including review/description from /albums/{id}/review endpoint New client methods: - getArtistBio: Get artist biography text - getAlbumReview: Get album review/description text The album info includes the album name, Tidal URL, and description (when available from the review endpoint). https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy --- adapters/tidal/client.go | 64 ++++++++ .../tests/fixtures/tidal.album.review.json | 3 + .../tests/fixtures/tidal.artist.bio.json | 3 + adapters/tidal/tidal.go | 37 +++++ adapters/tidal/tidal_test.go | 143 ++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 adapters/tidal/tests/fixtures/tidal.album.review.json create mode 100644 adapters/tidal/tests/fixtures/tidal.artist.bio.json diff --git a/adapters/tidal/client.go b/adapters/tidal/client.go index 4c7d3cb2e..a4e13505b 100644 --- a/adapters/tidal/client.go +++ b/adapters/tidal/client.go @@ -250,6 +250,70 @@ func (c *client) searchTracks(ctx context.Context, trackName, artistName string, return result.Tracks, nil } +func (c *client) getArtistBio(ctx context.Context, artistID string) (string, error) { + token, err := c.getToken(ctx) + if err != nil { + return "", fmt.Errorf("failed to get token: %w", err) + } + + params := url.Values{} + params.Add("countryCode", "US") + + req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/artists/"+artistID+"/bio", nil) + if err != nil { + return "", 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 { + Text string `json:"text"` + } + err = c.makeRequest(req, &result) + if err != nil { + return "", err + } + + if result.Text == "" { + return "", ErrNotFound + } + return result.Text, nil +} + +func (c *client) getAlbumReview(ctx context.Context, albumID string) (string, error) { + token, err := c.getToken(ctx) + if err != nil { + return "", fmt.Errorf("failed to get token: %w", err) + } + + params := url.Values{} + params.Add("countryCode", "US") + + req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/albums/"+albumID+"/review", nil) + if err != nil { + return "", 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 { + Text string `json:"text"` + } + err = c.makeRequest(req, &result) + if err != nil { + return "", err + } + + if result.Text == "" { + return "", ErrNotFound + } + return result.Text, nil +} + func (c *client) getTrackRadio(ctx context.Context, trackID string, limit int) ([]TrackResource, error) { token, err := c.getToken(ctx) if err != nil { diff --git a/adapters/tidal/tests/fixtures/tidal.album.review.json b/adapters/tidal/tests/fixtures/tidal.album.review.json new file mode 100644 index 000000000..d6f77deb4 --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.album.review.json @@ -0,0 +1,3 @@ +{ + "text": "Random Access Memories is the fourth studio album by French electronic music duo Daft Punk. It was released on 17 May 2013 through Columbia Records. The album pays tribute to the late 1970s and early 1980s era of music." +} diff --git a/adapters/tidal/tests/fixtures/tidal.artist.bio.json b/adapters/tidal/tests/fixtures/tidal.artist.bio.json new file mode 100644 index 000000000..bb3d5bc31 --- /dev/null +++ b/adapters/tidal/tests/fixtures/tidal.artist.bio.json @@ -0,0 +1,3 @@ +{ + "text": "Daft Punk was a French electronic music duo formed in Paris in 1993. The duo consisted of musicians Thomas Bangalter and Guy-Manuel de Homem-Christo. They achieved popularity in the late 1990s as part of the French house movement." +} diff --git a/adapters/tidal/tidal.go b/adapters/tidal/tidal.go index bf734057d..f29a5547f 100644 --- a/adapters/tidal/tidal.go +++ b/adapters/tidal/tidal.go @@ -129,6 +129,43 @@ func (t *tidalAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (s return tidalArtistURLBase + artist.ID, nil } +func (t *tidalAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { + artist, err := t.searchArtist(ctx, name) + if err != nil { + return "", err + } + + bio, err := t.client.getArtistBio(ctx, artist.ID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return "", agents.ErrNotFound + } + log.Error(ctx, "Error getting artist bio from Tidal", "artist", name, err) + return "", err + } + + return bio, nil +} + +func (t *tidalAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { + album, err := t.searchAlbum(ctx, name, artist) + if err != nil { + return nil, err + } + + // Try to get album review/description + description, err := t.client.getAlbumReview(ctx, album.ID) + if err != nil && !errors.Is(err, ErrNotFound) { + log.Warn(ctx, "Error getting album review from Tidal", "album", name, err) + } + + return &agents.AlbumInfo{ + Name: album.Attributes.Title, + Description: description, + URL: "https://tidal.com/browse/album/" + album.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 { diff --git a/adapters/tidal/tidal_test.go b/adapters/tidal/tidal_test.go index 969191627..1439bfecb 100644 --- a/adapters/tidal/tidal_test.go +++ b/adapters/tidal/tidal_test.go @@ -213,6 +213,123 @@ var _ = Describe("tidalAgent", func() { }) }) + Describe("GetArtistBiography", 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 biography", 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 bio response + fBio, _ := os.Open("tests/fixtures/tidal.artist.bio.json") + httpClient.artistBioResponse = &http.Response{Body: fBio, StatusCode: 200} + + bio, err := agent.GetArtistBiography(ctx, "", "Daft Punk", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(bio).To(ContainSubstring("French electronic music duo")) + }) + + It("returns ErrNotFound when bio is empty", 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 bio response + httpClient.artistBioResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"text":""}`)), + } + + _, err := agent.GetArtistBiography(ctx, "", "Daft Punk", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) + + Describe("GetAlbumInfo", 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 info with description", 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} + + // Mock album review response + fReview, _ := os.Open("tests/fixtures/tidal.album.review.json") + httpClient.albumReviewResponse = &http.Response{Body: fReview, StatusCode: 200} + + info, err := agent.GetAlbumInfo(ctx, "Random Access Memories", "Daft Punk", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(info.Name).To(Equal("Random Access Memories")) + Expect(info.Description).To(ContainSubstring("fourth studio album")) + Expect(info.URL).To(Equal("https://tidal.com/browse/album/28048252")) + }) + + It("returns album info without description when review not available", 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} + + // Mock empty album review response + httpClient.albumReviewResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"text":""}`)), + } + + info, err := agent.GetAlbumInfo(ctx, "Random Access Memories", "Daft Punk", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(info.Name).To(Equal("Random Access Memories")) + Expect(info.Description).To(BeEmpty()) + Expect(info.URL).To(Equal("https://tidal.com/browse/album/28048252")) + }) + }) + Describe("GetAlbumImages", func() { var agent *tidalAgent var httpClient *mockHttpClient @@ -409,6 +526,8 @@ type mockHttpClient struct { albumSearchResponse *http.Response trackSearchResponse *http.Response artistResponse *http.Response + artistBioResponse *http.Response + albumReviewResponse *http.Response similarResponse *http.Response tracksResponse *http.Response trackRadioResponse *http.Response @@ -478,6 +597,16 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { // 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 bio request + if len(req.URL.Path) > 13 && req.URL.Path[len(req.URL.Path)-4:] == "/bio" { + if c.artistBioResponse != nil { + return c.artistBioResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"text":""}`)), + }, nil + } // Check if it's a similar artists or tracks request if len(req.URL.Path) > 17 && req.URL.Path[len(req.URL.Path)-8:] == "/similar" { if c.similarResponse != nil { @@ -508,5 +637,19 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) { }, nil } + // Handle album request + if req.URL.Host == "openapi.tidal.com" && len(req.URL.Path) > 8 && req.URL.Path[:8] == "/albums/" { + // Check if it's a review request + if len(req.URL.Path) > 15 && req.URL.Path[len(req.URL.Path)-7:] == "/review" { + if c.albumReviewResponse != nil { + return c.albumReviewResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"text":""}`)), + }, nil + } + } + panic("URL not mocked: " + req.URL.String()) }