mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(agents): add Tidal metadata agent
Add a new metadata agent for fetching artist information from TIDAL's official API. The agent supports: - Artist image retrieval - Similar artists discovery - Artist top songs/tracks The implementation follows the same patterns as the existing Deezer and Spotify agents, using OAuth2 client credentials flow for authentication. Configuration options: - Tidal.Enabled: Enable/disable the agent - Tidal.ClientID: TIDAL API client ID - Tidal.ClientSecret: TIDAL API client secret https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy
This commit is contained in:
parent
9bce7677f5
commit
7992866057
256
adapters/tidal/client.go
Normal file
256
adapters/tidal/client.go
Normal file
@ -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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
100
adapters/tidal/responses.go
Normal file
100
adapters/tidal/responses.go
Normal file
@ -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"`
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
152
adapters/tidal/tidal.go
Normal file
152
adapters/tidal/tidal.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
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")
|
||||
}
|
||||
254
adapters/tidal/tidal_test.go
Normal file
254
adapters/tidal/tidal_test.go
Normal file
@ -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())
|
||||
}
|
||||
@ -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", "")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user