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", "")