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:
Claude 2026-02-02 12:38:55 +00:00
parent 7992866057
commit 87217a3e2a
No known key found for this signature in database
5 changed files with 387 additions and 6 deletions

View File

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

View File

@ -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"`

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

@ -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 {

View File

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