Compare commits

...

4 Commits

Author SHA1 Message Date
Claude
1ca809e4ed feat(agents): add artist biography and album info for Tidal agent
Implement additional metadata retrieval methods:
- GetArtistBiography: Fetch artist bio from /artists/{id}/bio endpoint
- GetAlbumInfo: Fetch album metadata including review/description from
  /albums/{id}/review endpoint

New client methods:
- getArtistBio: Get artist biography text
- getAlbumReview: Get album review/description text

The album info includes the album name, Tidal URL, and description
(when available from the review endpoint).

https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy
2026-02-02 12:49:33 +00:00
Claude
89d4d68304 feat(agents): add SimilarSongsByTrack support for Tidal agent
Implement GetSimilarSongsByTrack using Tidal's track radio endpoint
(/tracks/{id}/radio) which returns a mix of similar tracks.

New client methods:
- searchTracks: Search for tracks by name and artist
- getTrackRadio: Get similar tracks for a given track ID

This enables song-based recommendations in addition to the existing
artist-based similar songs feature.

https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy
2026-02-02 12:44:46 +00:00
Claude
87217a3e2a feat(agents): add additional Tidal agent capabilities
Extend the Tidal metadata agent with new retrieval methods:
- GetArtistURL: Returns the Tidal URL for an artist
- GetAlbumImages: Search for albums and retrieve cover art
- GetSimilarSongsByArtist: Find similar artists and return their top
  tracks as song recommendations

Also adds:
- Album search client method
- Extended response types for tracks with artist info
- Test fixtures and tests for all new methods

https://claude.ai/code/session_01P4bEnAgYS5dHuZBdsJ2XGy
2026-02-02 12:38:55 +00:00
Claude
7992866057 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
2026-02-02 12:26:45 +00:00
15 changed files with 1991 additions and 0 deletions

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

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

128
adapters/tidal/responses.go Normal file
View 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"`
}

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

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

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

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

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

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

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

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