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
This commit is contained in:
Claude 2026-02-02 12:44:46 +00:00
parent 87217a3e2a
commit 89d4d68304
No known key found for this signature in database
5 changed files with 295 additions and 2 deletions

View File

@ -210,6 +210,76 @@ func (c *client) searchAlbums(ctx context.Context, albumName, artistName string,
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) 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()

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

View File

@ -18,6 +18,7 @@ import (
const tidalAgentName = "tidal"
const tidalArtistSearchLimit = 20
const tidalAlbumSearchLimit = 10
const tidalTrackSearchLimit = 10
const tidalArtistURLBase = "https://tidal.com/browse/artist/"
type tidalAgent struct {
@ -204,6 +205,68 @@ func (t *tidalAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid
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 {

View File

@ -324,6 +324,82 @@ var _ = Describe("tidalAgent", func() {
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
@ -331,9 +407,11 @@ type mockHttpClient struct {
tokenResponse *http.Response
searchResponse *http.Response
albumSearchResponse *http.Response
trackSearchResponse *http.Response
artistResponse *http.Response
similarResponse *http.Response
tracksResponse *http.Response
trackRadioResponse *http.Response
}
func newMockHttpClient() *mockHttpClient {
@ -354,8 +432,9 @@ 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" {
searchType := req.URL.Query().Get("type")
// Check if it's an album search
if searchType == "ALBUMS" {
if c.albumSearchResponse != nil {
return c.albumSearchResponse, nil
}
@ -364,6 +443,16 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) {
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
@ -374,6 +463,19 @@ func (c *mockHttpClient) Do(req *http.Request) (*http.Response, error) {
}, 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 similar artists or tracks request