From 0020594ea3d4ecefa378e8066b9251e311540c2c Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 13 Nov 2023 09:21:22 +0100 Subject: [PATCH] ListenBrainz: Love export and basic import Love import currently works only for tracks with existing recording MBID --- backends/listenbrainz/client.go | 35 +++++++- backends/listenbrainz/client_test.go | 47 ++++++++++- backends/listenbrainz/listenbrainz.go | 89 +++++++++++++++++++- backends/listenbrainz/listenbrainz_test.go | 35 ++++++++ backends/listenbrainz/models.go | 35 ++++++++ backends/listenbrainz/testdata/feedback.json | 63 ++++++++++++++ 6 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 backends/listenbrainz/testdata/feedback.json diff --git a/backends/listenbrainz/client.go b/backends/listenbrainz/client.go index 32416c6..26f1a35 100644 --- a/backends/listenbrainz/client.go +++ b/backends/listenbrainz/client.go @@ -60,7 +60,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r SetQueryParams(map[string]string{ "max_ts": strconv.FormatInt(maxTime.Unix(), 10), "min_ts": strconv.FormatInt(minTime.Unix(), 10), - "count": strconv.FormatInt(int64(c.MaxResults), 10), + "count": strconv.Itoa(c.MaxResults), }). SetResult(&result). Get(path) @@ -71,3 +71,36 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r } return } + +func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) { + const path = "/feedback/user/{username}/get-feedback" + response, err := c.HttpClient.R(). + SetPathParam("username", user). + SetQueryParams(map[string]string{ + "status": strconv.Itoa(status), + "offset": strconv.Itoa(offset), + "count": strconv.Itoa(c.MaxResults), + }). + SetResult(&result). + Get(path) + + if response.StatusCode() != 200 { + err = errors.New(response.String()) + return + } + return +} + +func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) { + const path = "/feedback/recording-feedback" + response, err := c.HttpClient.R(). + SetBody(feedback). + SetResult(&result). + Post(path) + + if response.StatusCode() != 200 { + err = errors.New(response.String()) + return + } + return +} diff --git a/backends/listenbrainz/client_test.go b/backends/listenbrainz/client_test.go index 54b130a..c3ddd64 100644 --- a/backends/listenbrainz/client_test.go +++ b/backends/listenbrainz/client_test.go @@ -42,8 +42,8 @@ func TestNewClient(t *testing.T) { func TestGetListens(t *testing.T) { defer httpmock.DeactivateAndReset() - token := "thetoken" - client := listenbrainz.NewClient(token) + client := listenbrainz.NewClient("thetoken") + client.MaxResults = 2 setupHttpMock(t, client.HttpClient.GetClient(), "https://api.listenbrainz.org/1/user/outsidecontext/listens", "testdata/listens.json") @@ -57,6 +57,49 @@ func TestGetListens(t *testing.T) { assert.Equal("Shadowplay", result.Payload.Listens[0].TrackMetadata.TrackName) } +func TestGetFeedback(t *testing.T) { + defer httpmock.DeactivateAndReset() + + client := listenbrainz.NewClient("thetoken") + client.MaxResults = 2 + setupHttpMock(t, client.HttpClient.GetClient(), + "https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback", + "testdata/feedback.json") + + result, err := client.GetFeedback("outsidecontext", 1, 3) + require.NoError(t, err) + + assert := assert.New(t) + assert.Equal(2, result.Count) + assert.Equal(302, result.TotalCount) + assert.Equal(3, result.Offset) + require.Len(t, result.Feedback, 2) + assert.Equal("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", result.Feedback[0].RecordingMbid) +} + +func TestSendFeedback(t *testing.T) { + client := listenbrainz.NewClient("thetoken") + httpmock.ActivateNonDefault(client.HttpClient.GetClient()) + + responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ + Status: "ok", + }) + if err != nil { + t.Fatal(err) + } + url := "https://api.listenbrainz.org/1/feedback/recording-feedback" + httpmock.RegisterResponder("POST", url, responder) + + feedback := listenbrainz.Feedback{ + RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", + Score: 1, + } + result, err := client.SendFeedback(feedback) + require.NoError(t, err) + + assert.Equal(t, "ok", result.Status) +} + func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) diff --git a/backends/listenbrainz/listenbrainz.go b/backends/listenbrainz/listenbrainz.go index 140f259..0ea7a15 100644 --- a/backends/listenbrainz/listenbrainz.go +++ b/backends/listenbrainz/listenbrainz.go @@ -44,7 +44,7 @@ func (b ListenBrainzApiBackend) FromConfig(config *viper.Viper) models.Backend { func (b ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time) ([]models.Listen, error) { maxTime := time.Now() minTime := time.Unix(0, 0) - listens := make([]models.Listen, 0) + listens := make([]models.Listen, 0, 2*MaxItemsPerGet) out: for { @@ -76,6 +76,69 @@ out: return listens, nil } +func (b ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time) ([]models.Love, error) { + offset := 0 + // perPage := MaxItemsPerGet + + loves := make([]models.Love, 0, 2*MaxItemsPerGet) + +out: + for { + result, err := b.client.GetFeedback(b.username, 1, offset) + if err != nil { + return nil, err + } + + count := len(result.Feedback) + if count == 0 { + break out + } + + for _, feedback := range result.Feedback { + love := feedback.ToLove() + if love.Created.Unix() > oldestTimestamp.Unix() { + loves = append(loves, love) + } else { + break out + } + } + + offset += 1 + } + + slices.Reverse(loves) + return loves, nil +} + +func (b ListenBrainzApiBackend) ImportLoves(loves []models.Love, oldestTimestamp time.Time) (models.ImportResult, error) { + result := models.ImportResult{ + TotalCount: len(loves), + ImportCount: 0, + LastTimestamp: oldestTimestamp, + } + for _, love := range loves { + if love.Created.Unix() <= oldestTimestamp.Unix() { + continue + } + + // TODO: Support love import without recording MBID + if love.RecordingMbid != "" { + _, err := b.client.SendFeedback(Feedback{ + RecordingMbid: string(love.RecordingMbid), + Score: 1, + }) + + if err != nil { + result.ImportCount += 1 + if love.Created.Unix() > result.LastTimestamp.Unix() { + result.LastTimestamp = love.Created + } + } + } + } + return result, nil +} + func (lbListen Listen) ToListen() models.Listen { track := lbListen.TrackMetadata listen := models.Listen{ @@ -96,3 +159,27 @@ func (lbListen Listen) ToListen() models.Listen { } return listen } + +func (f Feedback) ToLove() models.Love { + track := f.TrackMetadata + recordingMbid := models.MBID(f.RecordingMbid) + love := models.Love{ + UserName: f.UserName, + RecordingMbid: recordingMbid, + Created: time.Unix(f.Created, 0), + Track: models.Track{ + TrackName: track.TrackName, + ReleaseName: track.ReleaseName, + ArtistNames: []string{track.ArtistName}, + RecordingMbid: recordingMbid, + ReleaseMbid: models.MBID(track.MbidMapping.ReleaseMbid), + ArtistMbids: make([]models.MBID, 0, len(track.MbidMapping.ArtistMbids)), + }, + } + + for _, artistMbid := range track.MbidMapping.ArtistMbids { + love.Track.ArtistMbids = append(love.Track.ArtistMbids, models.MBID(artistMbid)) + } + + return love +} diff --git a/backends/listenbrainz/listenbrainz_test.go b/backends/listenbrainz/listenbrainz_test.go index 96a2c6f..f2f8bd3 100644 --- a/backends/listenbrainz/listenbrainz_test.go +++ b/backends/listenbrainz/listenbrainz_test.go @@ -27,6 +27,7 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uploadedlobster.com/scotty/backends/listenbrainz" "go.uploadedlobster.com/scotty/models" ) @@ -71,3 +72,37 @@ func TestListenBrainzListenToListen(t *testing.T) { assert.Equal(t, "DES561620801", listen.Isrc) assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"]) } + +func TestListenBrainzFeedbackToLove(t *testing.T) { + recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12" + releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68" + artistMbid := "d7f22677-9803-4d21-ba42-081b633a6f68" + feedback := listenbrainz.Feedback{ + Created: 1699859066, + RecordingMbid: recordingMbid, + Score: 1, + UserName: "ousidecontext", + TrackMetadata: listenbrainz.Track{ + TrackName: "Oweynagat", + ArtistName: "Dool", + ReleaseName: "Here Now, There Then", + MbidMapping: listenbrainz.MbidMapping{ + RecordingMbid: recordingMbid, + ReleaseMbid: releaseMbid, + ArtistMbids: []string{artistMbid}, + }, + }, + } + love := feedback.ToLove() + assert := assert.New(t) + assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix()) + assert.Equal(feedback.UserName, love.UserName) + assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName) + assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName) + assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames) + assert.Equal(models.MBID(recordingMbid), love.RecordingMbid) + assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid) + assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid) + require.Len(t, love.Track.ArtistMbids, 1) + assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0]) +} diff --git a/backends/listenbrainz/models.go b/backends/listenbrainz/models.go index 602ee11..0b8bc89 100644 --- a/backends/listenbrainz/models.go +++ b/backends/listenbrainz/models.go @@ -52,6 +52,41 @@ type Track struct { ArtistName string `json:"artist_name"` ReleaseName string `json:"release_name"` AdditionalInfo map[string]any `json:"additional_info"` + MbidMapping MbidMapping `json:"mbid_mapping"` +} + +type MbidMapping struct { + RecordingName string `json:"recording_name"` + RecordingMbid string `json:"recording_mbid"` + ReleaseMbid string `json:"release_mbid"` + ArtistMbids []string `json:"artist_mbids"` + Artists []Artist `json:"artists"` +} + +type Artist struct { + ArtistCreditName string `json:"artist_credit_name"` + ArtistMbid string `json:"artist_mbid"` + JoinPhrase string `json:"join_phrase"` +} + +type GetFeedbackResult struct { + Count int `json:"count"` + TotalCount int `json:"total_count"` + Offset int `json:"offset"` + Feedback []Feedback `json:"feedback"` +} + +type Feedback struct { + Created int64 `json:"created"` + RecordingMbid string `json:"recording_mbid"` + RecordingMsid string `json:"recording_msid"` + Score int `json:"score"` + TrackMetadata Track `json:"track_metadata"` + UserName string `json:"user_id"` +} + +type StatusResult struct { + Status string `json:"status"` } func (t Track) Duration() time.Duration { diff --git a/backends/listenbrainz/testdata/feedback.json b/backends/listenbrainz/testdata/feedback.json new file mode 100644 index 0000000..37dc228 --- /dev/null +++ b/backends/listenbrainz/testdata/feedback.json @@ -0,0 +1,63 @@ +{ + "count": 2, + "feedback": [ + { + "created": 1699859066, + "recording_mbid": "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", + "recording_msid": null, + "score": 1, + "track_metadata": { + "artist_name": "Dool", + "mbid_mapping": { + "artist_mbids": [ + "24412926-c7bd-48e8-afad-8a285b42e131" + ], + "artists": [ + { + "artist_credit_name": "Dool", + "artist_mbid": "24412926-c7bd-48e8-afad-8a285b42e131", + "join_phrase": "" + } + ], + "caa_id": 15991300316, + "caa_release_mbid": "d7f22677-9803-4d21-ba42-081b633a6f68", + "recording_mbid": "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", + "release_mbid": "aa1ea1ac-7ec4-4542-a494-105afbfe547d" + }, + "release_name": "Here Now, There Then", + "track_name": "Oweynagat" + }, + "user_id": "outsidecontext" + }, + { + "created": 1698911509, + "recording_mbid": "ba49cada-9873-4bdb-9506-533cb63372c8", + "recording_msid": null, + "score": 1, + "track_metadata": { + "artist_name": "Hazeshuttle", + "mbid_mapping": { + "artist_mbids": [ + "54292079-790c-4e99-bf8d-12efa29fa3e9" + ], + "artists": [ + { + "artist_credit_name": "Hazeshuttle", + "artist_mbid": "54292079-790c-4e99-bf8d-12efa29fa3e9", + "join_phrase": "" + } + ], + "caa_id": 35325252352, + "caa_release_mbid": "6d0ee27f-dc9f-4dab-8d7d-f4dcd14dc54a", + "recording_mbid": "ba49cada-9873-4bdb-9506-533cb63372c8", + "release_mbid": "6d0ee27f-dc9f-4dab-8d7d-f4dcd14dc54a" + }, + "release_name": "Hazeshuttle", + "track_name": "Homosativa" + }, + "user_id": "outsidecontext" + } + ], + "offset": 3, + "total_count": 302 +}