ListenBrainz: Fix love import and rate limit check

This commit is contained in:
Philipp Wolfer 2023-11-13 11:42:09 +01:00
parent 161ada7aff
commit aa453e4dc2
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
8 changed files with 101 additions and 33 deletions

View file

@ -46,6 +46,7 @@ func NewClient(token string) Client {
client.SetAuthScheme("Token") client.SetAuthScheme("Token")
client.SetAuthToken(token) client.SetAuthToken(token)
client.SetHeader("Accept", "application/json") client.SetHeader("Accept", "application/json")
client.SetHeader("Content-Type", "application/json")
// Handle rate limiting (see https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#rate-limiting) // Handle rate limiting (see https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#rate-limiting)
client.SetRetryCount(5) client.SetRetryCount(5)
@ -54,6 +55,7 @@ func NewClient(token string) Client {
return r.StatusCode() == http.StatusTooManyRequests return r.StatusCode() == http.StatusTooManyRequests
}, },
) )
client.SetRetryMaxWaitTime(time.Duration(1 * time.Minute))
client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) { client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) {
resetIn, err := strconv.Atoi(resp.Header().Get("X-RateLimit-Reset-In")) resetIn, err := strconv.Atoi(resp.Header().Get("X-RateLimit-Reset-In"))
// fmt.Printf("R %v: %v, %v\n", resp.Request.URL, resetIn, err) // fmt.Printf("R %v: %v, %v\n", resp.Request.URL, resetIn, err)
@ -68,6 +70,7 @@ func NewClient(token string) Client {
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) { func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
const path = "/user/{username}/listens" const path = "/user/{username}/listens"
errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HttpClient.R().
SetPathParam("username", user). SetPathParam("username", user).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
@ -76,10 +79,11 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
"count": strconv.Itoa(c.MaxResults), "count": strconv.Itoa(c.MaxResults),
}). }).
SetResult(&result). SetResult(&result).
SetError(&errorResult).
Get(path) Get(path)
if response.StatusCode() != 200 { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(errorResult.Error)
return return
} }
return return
@ -87,6 +91,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) { func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
const path = "/feedback/user/{username}/get-feedback" const path = "/feedback/user/{username}/get-feedback"
errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HttpClient.R().
SetPathParam("username", user). SetPathParam("username", user).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
@ -96,10 +101,11 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
"metadata": "true", "metadata": "true",
}). }).
SetResult(&result). SetResult(&result).
SetError(&errorResult).
Get(path) Get(path)
if response.StatusCode() != 200 { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(errorResult.Error)
return return
} }
return return
@ -107,13 +113,15 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) { func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
const path = "/feedback/recording-feedback" const path = "/feedback/recording-feedback"
errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HttpClient.R().
SetBody(feedback). SetBody(feedback).
SetResult(&result). SetResult(&result).
SetError(&errorResult).
Post(path) Post(path)
if response.StatusCode() != 200 { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(errorResult.Error)
return return
} }
return return

View file

@ -22,6 +22,7 @@ THE SOFTWARE.
package listenbrainz package listenbrainz
import ( import (
"fmt"
"slices" "slices"
"time" "time"
@ -113,6 +114,7 @@ func (b ListenBrainzApiBackend) ImportLoves(loves []models.Love, oldestTimestamp
TotalCount: len(loves), TotalCount: len(loves),
ImportCount: 0, ImportCount: 0,
LastTimestamp: oldestTimestamp, LastTimestamp: oldestTimestamp,
ImportErrors: make([]string, 0),
} }
for _, love := range loves { for _, love := range loves {
if love.Created.Unix() <= oldestTimestamp.Unix() { if love.Created.Unix() <= oldestTimestamp.Unix() {
@ -121,16 +123,20 @@ func (b ListenBrainzApiBackend) ImportLoves(loves []models.Love, oldestTimestamp
// TODO: Support love import without recording MBID // TODO: Support love import without recording MBID
if love.RecordingMbid != "" { if love.RecordingMbid != "" {
_, err := b.client.SendFeedback(Feedback{ resp, err := b.client.SendFeedback(Feedback{
RecordingMbid: string(love.RecordingMbid), RecordingMbid: string(love.RecordingMbid),
Score: 1, Score: 1,
}) })
if err != nil { if err == nil && resp.Status == "ok" {
result.ImportCount += 1 result.ImportCount += 1
if love.Created.Unix() > result.LastTimestamp.Unix() { if love.Created.Unix() > result.LastTimestamp.Unix() {
result.LastTimestamp = love.Created result.LastTimestamp = love.Created
} }
} else {
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
love.TrackName, love.ArtistName(), err.Error())
result.ImportErrors = append(result.ImportErrors, msg)
} }
} }
} }
@ -166,18 +172,23 @@ func (f Feedback) ToLove() models.Love {
RecordingMbid: recordingMbid, RecordingMbid: recordingMbid,
Created: time.Unix(f.Created, 0), Created: time.Unix(f.Created, 0),
Track: models.Track{ Track: models.Track{
TrackName: track.TrackName,
ReleaseName: track.ReleaseName,
ArtistNames: []string{track.ArtistName},
RecordingMbid: recordingMbid, RecordingMbid: recordingMbid,
ReleaseMbid: models.MBID(track.MbidMapping.ReleaseMbid),
ArtistMbids: make([]models.MBID, 0, len(track.MbidMapping.ArtistMbids)),
}, },
} }
if track != nil {
love.Track.TrackName = track.TrackName
love.Track.ReleaseName = track.ReleaseName
love.ArtistNames = []string{track.ArtistName}
love.ReleaseMbid = models.MBID(track.MbidMapping.ReleaseMbid)
love.ArtistMbids = make([]models.MBID, 0, len(track.MbidMapping.ArtistMbids))
if track.MbidMapping != nil {
for _, artistMbid := range track.MbidMapping.ArtistMbids { for _, artistMbid := range track.MbidMapping.ArtistMbids {
love.Track.ArtistMbids = append(love.Track.ArtistMbids, models.MBID(artistMbid)) love.Track.ArtistMbids = append(love.Track.ArtistMbids, models.MBID(artistMbid))
} }
}
}
return love return love
} }

View file

@ -82,11 +82,11 @@ func TestListenBrainzFeedbackToLove(t *testing.T) {
RecordingMbid: recordingMbid, RecordingMbid: recordingMbid,
Score: 1, Score: 1,
UserName: "ousidecontext", UserName: "ousidecontext",
TrackMetadata: listenbrainz.Track{ TrackMetadata: &listenbrainz.Track{
TrackName: "Oweynagat", TrackName: "Oweynagat",
ArtistName: "Dool", ArtistName: "Dool",
ReleaseName: "Here Now, There Then", ReleaseName: "Here Now, There Then",
MbidMapping: listenbrainz.MbidMapping{ MbidMapping: &listenbrainz.MbidMapping{
RecordingMbid: recordingMbid, RecordingMbid: recordingMbid,
ReleaseMbid: releaseMbid, ReleaseMbid: releaseMbid,
ArtistMbids: []string{artistMbid}, ArtistMbids: []string{artistMbid},
@ -106,3 +106,18 @@ func TestListenBrainzFeedbackToLove(t *testing.T) {
require.Len(t, love.Track.ArtistMbids, 1) require.Len(t, love.Track.ArtistMbids, 1)
assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0]) assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0])
} }
func TestListenBrainzPartialFeedbackToLove(t *testing.T) {
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
feedback := listenbrainz.Feedback{
Created: 1699859066,
RecordingMbid: recordingMbid,
Score: 1,
}
love := feedback.ToLove()
assert := assert.New(t)
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
assert.Empty(love.Track.TrackName)
}

View file

@ -48,25 +48,25 @@ type Listen struct {
} }
type Track struct { type Track struct {
TrackName string `json:"track_name"` TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name,omitempty"`
ReleaseName string `json:"release_name"` ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo map[string]any `json:"additional_info"` AdditionalInfo map[string]any `json:"additional_info,omitempty"`
MbidMapping MbidMapping `json:"mbid_mapping"` MbidMapping *MbidMapping `json:"mbid_mapping,omitempty"`
} }
type MbidMapping struct { type MbidMapping struct {
RecordingName string `json:"recording_name"` RecordingName string `json:"recording_name,omitempty"`
RecordingMbid string `json:"recording_mbid"` RecordingMbid string `json:"recording_mbid,omitempty"`
ReleaseMbid string `json:"release_mbid"` ReleaseMbid string `json:"release_mbid,omitempty"`
ArtistMbids []string `json:"artist_mbids"` ArtistMbids []string `json:"artist_mbids,omitempty"`
Artists []Artist `json:"artists"` Artists []Artist `json:"artists,omitempty"`
} }
type Artist struct { type Artist struct {
ArtistCreditName string `json:"artist_credit_name"` ArtistCreditName string `json:"artist_credit_name,omitempty"`
ArtistMbid string `json:"artist_mbid"` ArtistMbid string `json:"artist_mbid,omitempty"`
JoinPhrase string `json:"join_phrase"` JoinPhrase string `json:"join_phrase,omitempty"`
} }
type GetFeedbackResult struct { type GetFeedbackResult struct {
@ -77,18 +77,23 @@ type GetFeedbackResult struct {
} }
type Feedback struct { type Feedback struct {
Created int64 `json:"created"` Created int64 `json:"created,omitempty"`
RecordingMbid string `json:"recording_mbid"` RecordingMbid string `json:"recording_mbid,omitempty"`
RecordingMsid string `json:"recording_msid"` RecordingMsid string `json:"recording_msid,omitempty"`
Score int `json:"score"` Score int `json:"score,omitempty"`
TrackMetadata Track `json:"track_metadata"` TrackMetadata *Track `json:"track_metadata,omitempty"`
UserName string `json:"user_id"` UserName string `json:"user_id,omitempty"`
} }
type StatusResult struct { type StatusResult struct {
Status string `json:"status"` Status string `json:"status"`
} }
type ErrorResult struct {
Code int `json:"code"`
Error string `json:"error"`
}
func (t Track) Duration() time.Duration { func (t Track) Duration() time.Duration {
info := t.AdditionalInfo info := t.AdditionalInfo
millisecondsF, ok := tryGetFloat[float64](info, "duration_ms") millisecondsF, ok := tryGetFloat[float64](info, "duration_ms")

View file

@ -22,10 +22,12 @@ THE SOFTWARE.
package listenbrainz_test package listenbrainz_test
import ( import (
"encoding/json"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/scotty/backends/listenbrainz" "go.uploadedlobster.com/scotty/backends/listenbrainz"
) )
@ -157,3 +159,15 @@ func TestReleaseGroupMbid(t *testing.T) {
} }
assert.Equal(t, expected, track.ReleaseGroupMbid()) assert.Equal(t, expected, track.ReleaseGroupMbid())
} }
func TestMarshalPartialFeedback(t *testing.T) {
feedback := listenbrainz.Feedback{
Created: 1699859066,
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
}
b, err := json.Marshal(feedback)
require.NoError(t, err)
assert.Equal(t,
"{\"created\":1699859066,\"recording_mbid\":\"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12\"}",
string(b))
}

View file

@ -50,6 +50,13 @@ var listensCmd = &cobra.Command{
cobra.CheckErr(err) cobra.CheckErr(err)
fmt.Printf("Imported %v of %v listens (last timestamp %v)\n", fmt.Printf("Imported %v of %v listens (last timestamp %v)\n",
result.ImportCount, result.TotalCount, result.LastTimestamp) result.ImportCount, result.TotalCount, result.LastTimestamp)
if len(result.ImportErrors) > 0 {
fmt.Printf("\nDuring the import the following errors occurred:\n")
for _, err := range result.ImportErrors {
fmt.Printf("Error: %v\n", err)
}
}
}, },
} }

View file

@ -50,6 +50,13 @@ var lovesCmd = &cobra.Command{
cobra.CheckErr(err) cobra.CheckErr(err)
fmt.Printf("Imported %v of %v loves (last timestamp %v)\n", fmt.Printf("Imported %v of %v loves (last timestamp %v)\n",
result.ImportCount, result.TotalCount, result.LastTimestamp) result.ImportCount, result.TotalCount, result.LastTimestamp)
if len(result.ImportErrors) > 0 {
fmt.Printf("\nDuring the import the following errors occurred:\n")
for _, err := range result.ImportErrors {
fmt.Printf("Error: %v\n", err)
}
}
}, },
} }

View file

@ -51,4 +51,5 @@ type ImportResult struct {
TotalCount int TotalCount int
ImportCount int ImportCount int
LastTimestamp time.Time LastTimestamp time.Time
ImportErrors []string
} }