ListenBrainz: Love export and basic import

Love import currently works only for tracks with existing recording MBID
This commit is contained in:
Philipp Wolfer 2023-11-13 09:21:22 +01:00
parent ead323eaed
commit 0020594ea3
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
6 changed files with 300 additions and 4 deletions

View file

@ -60,7 +60,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"max_ts": strconv.FormatInt(maxTime.Unix(), 10), "max_ts": strconv.FormatInt(maxTime.Unix(), 10),
"min_ts": strconv.FormatInt(minTime.Unix(), 10), "min_ts": strconv.FormatInt(minTime.Unix(), 10),
"count": strconv.FormatInt(int64(c.MaxResults), 10), "count": strconv.Itoa(c.MaxResults),
}). }).
SetResult(&result). SetResult(&result).
Get(path) Get(path)
@ -71,3 +71,36 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
} }
return 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
}

View file

@ -42,8 +42,8 @@ func TestNewClient(t *testing.T) {
func TestGetListens(t *testing.T) { func TestGetListens(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
token := "thetoken" client := listenbrainz.NewClient("thetoken")
client := listenbrainz.NewClient(token) client.MaxResults = 2
setupHttpMock(t, client.HttpClient.GetClient(), setupHttpMock(t, client.HttpClient.GetClient(),
"https://api.listenbrainz.org/1/user/outsidecontext/listens", "https://api.listenbrainz.org/1/user/outsidecontext/listens",
"testdata/listens.json") "testdata/listens.json")
@ -57,6 +57,49 @@ func TestGetListens(t *testing.T) {
assert.Equal("Shadowplay", result.Payload.Listens[0].TrackMetadata.TrackName) 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) { func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client) httpmock.ActivateNonDefault(client)

View file

@ -44,7 +44,7 @@ func (b ListenBrainzApiBackend) FromConfig(config *viper.Viper) models.Backend {
func (b ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time) ([]models.Listen, error) { func (b ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time) ([]models.Listen, error) {
maxTime := time.Now() maxTime := time.Now()
minTime := time.Unix(0, 0) minTime := time.Unix(0, 0)
listens := make([]models.Listen, 0) listens := make([]models.Listen, 0, 2*MaxItemsPerGet)
out: out:
for { for {
@ -76,6 +76,69 @@ out:
return listens, nil 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 { func (lbListen Listen) ToListen() models.Listen {
track := lbListen.TrackMetadata track := lbListen.TrackMetadata
listen := models.Listen{ listen := models.Listen{
@ -96,3 +159,27 @@ func (lbListen Listen) ToListen() models.Listen {
} }
return 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
}

View file

@ -27,6 +27,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"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"
"go.uploadedlobster.com/scotty/models" "go.uploadedlobster.com/scotty/models"
) )
@ -71,3 +72,37 @@ func TestListenBrainzListenToListen(t *testing.T) {
assert.Equal(t, "DES561620801", listen.Isrc) assert.Equal(t, "DES561620801", listen.Isrc)
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"]) 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])
}

View file

@ -52,6 +52,41 @@ type Track struct {
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
ReleaseName string `json:"release_name"` ReleaseName string `json:"release_name"`
AdditionalInfo map[string]any `json:"additional_info"` 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 { func (t Track) Duration() time.Duration {

View file

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