mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
87217a3e2a
commit
89d4d68304
@ -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()
|
||||
|
||||
24
adapters/tidal/tests/fixtures/tidal.search.track.json
vendored
Normal file
24
adapters/tidal/tests/fixtures/tidal.search.track.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
34
adapters/tidal/tests/fixtures/tidal.track.radio.json
vendored
Normal file
34
adapters/tidal/tests/fixtures/tidal.track.radio.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user