mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-05 20:41:07 -05:00
Compare commits
4 Commits
transcodin
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca809e4ed | ||
|
|
89d4d68304 | ||
|
|
87217a3e2a | ||
|
|
7992866057 |
430
adapters/tidal/client.go
Normal file
430
adapters/tidal/client.go
Normal file
@@ -0,0 +1,430 @@
|
||||
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) 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) 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) 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 {
|
||||
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()
|
||||
|
||||
// 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)
|
||||
}
|
||||
156
adapters/tidal/client_test.go
Normal file
156
adapters/tidal/client_test.go
Normal file
@@ -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())
|
||||
}
|
||||
128
adapters/tidal/responses.go
Normal file
128
adapters/tidal/responses.go
Normal file
@@ -0,0 +1,128 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
3
adapters/tidal/tests/fixtures/tidal.album.review.json
vendored
Normal file
3
adapters/tidal/tests/fixtures/tidal.album.review.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
3
adapters/tidal/tests/fixtures/tidal.artist.bio.json
vendored
Normal file
3
adapters/tidal/tests/fixtures/tidal.artist.bio.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
34
adapters/tidal/tests/fixtures/tidal.artist.tracks.json
vendored
Normal file
34
adapters/tidal/tests/fixtures/tidal.artist.tracks.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
44
adapters/tidal/tests/fixtures/tidal.search.album.json
vendored
Normal file
44
adapters/tidal/tests/fixtures/tidal.search.album.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
adapters/tidal/tests/fixtures/tidal.search.artist.json
vendored
Normal file
38
adapters/tidal/tests/fixtures/tidal.search.artist.json
vendored
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
adapters/tidal/tests/fixtures/tidal.search.track.json
vendored
Normal file
24
adapters/tidal/tests/fixtures/tidal.search.track.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
49
adapters/tidal/tests/fixtures/tidal.similar.artists.json
vendored
Normal file
49
adapters/tidal/tests/fixtures/tidal.similar.artists.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
34
adapters/tidal/tests/fixtures/tidal.track.radio.json
vendored
Normal file
34
adapters/tidal/tests/fixtures/tidal.track.radio.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
365
adapters/tidal/tidal.go
Normal file
365
adapters/tidal/tidal.go
Normal file
@@ -0,0 +1,365 @@
|
||||
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
|
||||
const tidalAlbumSearchLimit = 10
|
||||
const tidalTrackSearchLimit = 10
|
||||
const tidalArtistURLBase = "https://tidal.com/browse/artist/"
|
||||
|
||||
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) 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) 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 {
|
||||
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) 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 {
|
||||
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 (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 {
|
||||
agents.Register(tidalAgentName, tidalConstructor)
|
||||
}
|
||||
})
|
||||
}
|
||||
17
adapters/tidal/tidal_suite_test.go
Normal file
17
adapters/tidal/tidal_suite_test.go
Normal file
@@ -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")
|
||||
}
|
||||
655
adapters/tidal/tidal_test.go
Normal file
655
adapters/tidal/tidal_test.go
Normal file
@@ -0,0 +1,655 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
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("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
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
type mockHttpClient struct {
|
||||
tokenResponse *http.Response
|
||||
searchResponse *http.Response
|
||||
albumSearchResponse *http.Response
|
||||
trackSearchResponse *http.Response
|
||||
artistResponse *http.Response
|
||||
artistBioResponse *http.Response
|
||||
albumReviewResponse *http.Response
|
||||
similarResponse *http.Response
|
||||
tracksResponse *http.Response
|
||||
trackRadioResponse *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" {
|
||||
searchType := req.URL.Query().Get("type")
|
||||
// Check if it's an album search
|
||||
if searchType == "ALBUMS" {
|
||||
if c.albumSearchResponse != nil {
|
||||
return c.albumSearchResponse, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
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
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"artists":[]}`)),
|
||||
}, 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 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 {
|
||||
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 {
|
||||
// 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,
|
||||
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
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user