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:
Claude 2026-02-02 12:26:45 +00:00
parent 9bce7677f5
commit 7992866057
No known key found for this signature in database
10 changed files with 1067 additions and 0 deletions

256
adapters/tidal/client.go Normal file
View 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)
}

View 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
View 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"`
}

View 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
}
}
]
}

View 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": []
}
}
]
}

View 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
View 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)
}
})
}

View 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")
}

View 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())
}

View File

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