mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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
This commit is contained in:
parent
7992866057
commit
87217a3e2a
@ -170,6 +170,46 @@ func (c *client) getSimilarArtists(ctx context.Context, artistID string, limit i
|
||||
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) getToken(ctx context.Context) (string, error) {
|
||||
c.tokenMutex.Lock()
|
||||
defer c.tokenMutex.Unlock()
|
||||
|
||||
@ -55,6 +55,34 @@ type TrackAttributes struct {
|
||||
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"`
|
||||
|
||||
44
adapters/tidal/tests/fixtures/tidal.search.album.json
vendored
Normal file
44
adapters/tidal/tests/fixtures/tidal.search.album.json
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"albums": [
|
||||
{
|
||||
"id": "28048252",
|
||||
"type": "albums",
|
||||
"attributes": {
|
||||
"title": "Random Access Memories",
|
||||
"releaseDate": "2013-05-17",
|
||||
"cover": [
|
||||
{
|
||||
"url": "https://resources.tidal.com/images/04d63cd8/a1a5/42e0/b1ec/8e336b7d9200/750x750.jpg",
|
||||
"width": 750,
|
||||
"height": 750
|
||||
},
|
||||
{
|
||||
"url": "https://resources.tidal.com/images/04d63cd8/a1a5/42e0/b1ec/8e336b7d9200/320x320.jpg",
|
||||
"width": 320,
|
||||
"height": 320
|
||||
},
|
||||
{
|
||||
"url": "https://resources.tidal.com/images/04d63cd8/a1a5/42e0/b1ec/8e336b7d9200/160x160.jpg",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1234567",
|
||||
"type": "albums",
|
||||
"attributes": {
|
||||
"title": "Random Access Memories (Drumless Edition)",
|
||||
"releaseDate": "2023-11-17",
|
||||
"cover": [
|
||||
{
|
||||
"url": "https://resources.tidal.com/images/deadbeef/1234/5678/9abc/def012345678/750x750.jpg",
|
||||
"width": 750,
|
||||
"height": 750
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -17,6 +17,8 @@ import (
|
||||
|
||||
const tidalAgentName = "tidal"
|
||||
const tidalArtistSearchLimit = 20
|
||||
const tidalAlbumSearchLimit = 10
|
||||
const tidalArtistURLBase = "https://tidal.com/browse/artist/"
|
||||
|
||||
type tidalAgent struct {
|
||||
ds model.DataStore
|
||||
@ -117,6 +119,91 @@ func (t *tidalAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
||||
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) 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) searchArtist(ctx context.Context, name string) (*ArtistResource, error) {
|
||||
artists, err := t.client.searchArtists(ctx, name, tidalArtistSearchLimit)
|
||||
if err != nil {
|
||||
@ -143,6 +230,32 @@ func (t *tidalAgent) searchArtist(ctx context.Context, name string) (*ArtistReso
|
||||
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 {
|
||||
|
||||
@ -182,15 +182,158 @@ var _ = Describe("tidalAgent", func() {
|
||||
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("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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 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
|
||||
tokenResponse *http.Response
|
||||
searchResponse *http.Response
|
||||
albumSearchResponse *http.Response
|
||||
artistResponse *http.Response
|
||||
similarResponse *http.Response
|
||||
tracksResponse *http.Response
|
||||
}
|
||||
|
||||
func newMockHttpClient() *mockHttpClient {
|
||||
@ -211,6 +354,17 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
|
||||
// Handle search request
|
||||
if req.URL.Host == "openapi.tidal.com" && req.URL.Path == "/search" {
|
||||
// Check if it's an album search (has type=ALBUMS parameter)
|
||||
if req.URL.Query().Get("type") == "ALBUMS" {
|
||||
if c.albumSearchResponse != nil {
|
||||
return c.albumSearchResponse, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"albums":[]}`)),
|
||||
}, nil
|
||||
}
|
||||
// Otherwise, it's an artist search
|
||||
if c.searchResponse != nil {
|
||||
return c.searchResponse, nil
|
||||
}
|
||||
@ -234,7 +388,9 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
if len(req.URL.Path) > 16 && req.URL.Path[len(req.URL.Path)-7:] == "/tracks" {
|
||||
if c.tracksResponse != nil {
|
||||
return 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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user