mirror of
https://git.sr.ht/~phw/scotty
synced 2025-06-06 12:58:35 +02:00
Moved general LB related code to separate package
This commit is contained in:
parent
34b6bb9aa3
commit
5c56e480f1
10 changed files with 54 additions and 49 deletions
|
@ -1,161 +0,0 @@
|
|||
/*
|
||||
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"go.uploadedlobster.com/scotty/internal/version"
|
||||
"go.uploadedlobster.com/scotty/pkg/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
listenBrainzBaseURL = "https://api.listenbrainz.org/1/"
|
||||
DefaultItemsPerGet = 25
|
||||
MaxItemsPerGet = 1000
|
||||
MaxListensPerRequest = 1000
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
HTTPClient *resty.Client
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
func NewClient(token string) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(listenBrainzBaseURL)
|
||||
client.SetAuthScheme("Token")
|
||||
client.SetAuthToken(token)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetHeader("User-Agent", version.UserAgent())
|
||||
|
||||
// Handle rate limiting (see https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#rate-limiting)
|
||||
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
|
||||
|
||||
return Client{
|
||||
HTTPClient: client,
|
||||
MaxResults: DefaultItemsPerGet,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetListens(ctx context.Context, user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
||||
const path = "/user/{username}/listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetPathParam("username", user).
|
||||
SetQueryParams(map[string]string{
|
||||
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
|
||||
"min_ts": strconv.FormatInt(minTime.Unix(), 10),
|
||||
"count": strconv.Itoa(c.MaxResults),
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) SubmitListens(ctx context.Context, listens ListenSubmission) (result StatusResult, err error) {
|
||||
const path = "/submit-listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetBody(listens).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) GetFeedback(ctx context.Context, user string, status int, offset int) (result GetFeedbackResult, err error) {
|
||||
const path = "/feedback/user/{username}/get-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetPathParam("username", user).
|
||||
SetQueryParams(map[string]string{
|
||||
"status": strconv.Itoa(status),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"count": strconv.Itoa(c.MaxResults),
|
||||
"metadata": "true",
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) SendFeedback(ctx context.Context, feedback Feedback) (result StatusResult, err error) {
|
||||
const path = "/feedback/recording-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetBody(feedback).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) Lookup(ctx context.Context, recordingName string, artistName string) (result LookupResult, err error) {
|
||||
const path = "/metadata/lookup"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HTTPClient.R().
|
||||
SetContext(ctx).
|
||||
SetQueryParams(map[string]string{
|
||||
"recording_name": recordingName,
|
||||
"artist_name": artistName,
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
|
@ -1,175 +0,0 @@
|
|||
/*
|
||||
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
token := "foobar123"
|
||||
client := listenbrainz.NewClient(token)
|
||||
assert.Equal(t, token, client.HTTPClient.Token)
|
||||
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
|
||||
}
|
||||
|
||||
func TestGetListens(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
client.MaxResults = 2
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
|
||||
"testdata/listens.json")
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := client.GetListens(ctx, "outsidecontext",
|
||||
time.Now(), time.Now().Add(-2*time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(2, result.Payload.Count)
|
||||
assert.Equal(int64(1699718723), result.Payload.LatestListenTimestamp)
|
||||
assert.Equal(int64(1152911863), result.Payload.OldestListenTimestamp)
|
||||
require.Len(t, result.Payload.Listens, 2)
|
||||
assert.Equal("Shadowplay", result.Payload.Listens[0].TrackMetadata.TrackName)
|
||||
}
|
||||
|
||||
func TestSubmitListens(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/submit-listens"
|
||||
httpmock.RegisterResponder("POST", url, responder)
|
||||
|
||||
listens := listenbrainz.ListenSubmission{
|
||||
ListenType: listenbrainz.Import,
|
||||
Payload: []listenbrainz.Listen{
|
||||
{
|
||||
ListenedAt: time.Now().Unix(),
|
||||
TrackMetadata: listenbrainz.Track{
|
||||
TrackName: "Oweynagat",
|
||||
ArtistName: "Dool",
|
||||
},
|
||||
},
|
||||
{
|
||||
ListenedAt: time.Now().Add(-2 * time.Minute).Unix(),
|
||||
TrackMetadata: listenbrainz.Track{
|
||||
TrackName: "Say Just Words",
|
||||
ArtistName: "Paradise Lost",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx := context.Background()
|
||||
result, err := client.SubmitListens(ctx, listens)
|
||||
|
||||
assert.Equal(t, "ok", result.Status)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := client.GetFeedback(ctx, "outsidecontext", 1, 0)
|
||||
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(mbtypes.MBID("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,
|
||||
}
|
||||
ctx := context.Background()
|
||||
result, err := client.SendFeedback(ctx, feedback)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "ok", result.Status)
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/metadata/lookup",
|
||||
"testdata/lookup.json")
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := client.Lookup(ctx, "Paradise Lost", "Say Just Words")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal("Say Just Words", result.RecordingName)
|
||||
assert.Equal("Paradise Lost", result.ArtistCreditName)
|
||||
assert.Equal(mbtypes.MBID("569436a1-234a-44bc-a370-8f4d252bef21"), result.RecordingMBID)
|
||||
}
|
||||
|
||||
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpmock.RegisterResponder("GET", url, responder)
|
||||
}
|
|
@ -29,10 +29,11 @@ import (
|
|||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/scotty/internal/similarity"
|
||||
"go.uploadedlobster.com/scotty/internal/version"
|
||||
"go.uploadedlobster.com/scotty/pkg/listenbrainz"
|
||||
)
|
||||
|
||||
type ListenBrainzApiBackend struct {
|
||||
client Client
|
||||
client listenbrainz.Client
|
||||
mbClient musicbrainzws2.Client
|
||||
username string
|
||||
checkDuplicates bool
|
||||
|
@ -58,13 +59,13 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption {
|
|||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.client = NewClient(config.GetString("token"))
|
||||
b.client = listenbrainz.NewClient(config.GetString("token"), version.UserAgent())
|
||||
b.mbClient = *musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
|
||||
Name: version.AppName,
|
||||
Version: version.AppVersion,
|
||||
URL: version.AppURL,
|
||||
})
|
||||
b.client.MaxResults = MaxItemsPerGet
|
||||
b.client.MaxResults = listenbrainz.MaxItemsPerGet
|
||||
b.username = config.GetString("username")
|
||||
b.checkDuplicates = config.GetBool("check-duplicate-listens", false)
|
||||
return nil
|
||||
|
@ -116,7 +117,7 @@ func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimest
|
|||
|
||||
for _, listen := range result.Payload.Listens {
|
||||
if listen.ListenedAt > oldestTimestamp.Unix() {
|
||||
listens = append(listens, listen.AsListen())
|
||||
listens = append(listens, AsListen(listen))
|
||||
} else {
|
||||
// result contains listens older then oldestTimestamp
|
||||
break
|
||||
|
@ -138,16 +139,16 @@ func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimest
|
|||
func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
|
||||
total := len(export.Items)
|
||||
p := models.TransferProgress{}.FromImportResult(importResult, false)
|
||||
for i := 0; i < total; i += MaxListensPerRequest {
|
||||
listens := export.Items[i:min(i+MaxListensPerRequest, total)]
|
||||
for i := 0; i < total; i += listenbrainz.MaxListensPerRequest {
|
||||
listens := export.Items[i:min(i+listenbrainz.MaxListensPerRequest, total)]
|
||||
count := len(listens)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
submission := ListenSubmission{
|
||||
ListenType: Import,
|
||||
Payload: make([]Listen, 0, count),
|
||||
submission := listenbrainz.ListenSubmission{
|
||||
ListenType: listenbrainz.Import,
|
||||
Payload: make([]listenbrainz.Listen, 0, count),
|
||||
}
|
||||
|
||||
for _, l := range listens {
|
||||
|
@ -167,9 +168,9 @@ func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export model
|
|||
}
|
||||
|
||||
l.FillAdditionalInfo()
|
||||
listen := Listen{
|
||||
listen := listenbrainz.Listen{
|
||||
ListenedAt: l.ListenedAt.Unix(),
|
||||
TrackMetadata: Track{
|
||||
TrackMetadata: listenbrainz.Track{
|
||||
TrackName: l.TrackName,
|
||||
ReleaseName: l.ReleaseName,
|
||||
ArtistName: l.ArtistName(),
|
||||
|
@ -228,7 +229,7 @@ func (b *ListenBrainzApiBackend) ExportLoves(ctx context.Context, oldestTimestam
|
|||
func (b *ListenBrainzApiBackend) exportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult) {
|
||||
offset := 0
|
||||
defer close(results)
|
||||
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
||||
loves := make(models.LovesList, 0, 2*listenbrainz.MaxItemsPerGet)
|
||||
|
||||
out:
|
||||
for {
|
||||
|
@ -254,7 +255,7 @@ out:
|
|||
}
|
||||
}
|
||||
|
||||
love := feedback.AsLove()
|
||||
love := AsLove(feedback)
|
||||
if love.Created.After(oldestTimestamp) {
|
||||
loves = append(loves, love)
|
||||
} else {
|
||||
|
@ -262,7 +263,7 @@ out:
|
|||
}
|
||||
}
|
||||
|
||||
offset += MaxItemsPerGet
|
||||
offset += listenbrainz.MaxItemsPerGet
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
|
@ -278,7 +279,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.
|
|||
go b.exportLoves(ctx, time.Unix(0, 0), existingLovesChan)
|
||||
|
||||
// TODO: Store MBIDs directly
|
||||
b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet)
|
||||
b.existingMBIDs = make(map[mbtypes.MBID]bool, listenbrainz.MaxItemsPerGet)
|
||||
|
||||
for existingLoves := range existingLovesChan {
|
||||
if existingLoves.Error != nil {
|
||||
|
@ -316,7 +317,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.
|
|||
if b.existingMBIDs[recordingMBID] {
|
||||
ok = true
|
||||
} else {
|
||||
resp, err := b.client.SendFeedback(ctx, Feedback{
|
||||
resp, err := b.client.SendFeedback(ctx, listenbrainz.Feedback{
|
||||
RecordingMBID: recordingMBID,
|
||||
Score: 1,
|
||||
})
|
||||
|
@ -366,7 +367,7 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, liste
|
|||
}
|
||||
|
||||
for _, c := range candidates.Payload.Listens {
|
||||
sim := similarity.CompareTracks(listen.Track, c.TrackMetadata.AsTrack())
|
||||
sim := similarity.CompareTracks(listen.Track, AsTrack(c.TrackMetadata))
|
||||
if sim >= trackSimilarityThreshold {
|
||||
return true, nil
|
||||
}
|
||||
|
@ -375,7 +376,8 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, liste
|
|||
return false, nil
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) lookupRecording(ctx context.Context, mbid mbtypes.MBID) (*Track, error) {
|
||||
func (b *ListenBrainzApiBackend) lookupRecording(
|
||||
ctx context.Context, mbid mbtypes.MBID) (*listenbrainz.Track, error) {
|
||||
filter := musicbrainzws2.IncludesFilter{
|
||||
Includes: []string{"artist-credits"},
|
||||
}
|
||||
|
@ -388,10 +390,10 @@ func (b *ListenBrainzApiBackend) lookupRecording(ctx context.Context, mbid mbtyp
|
|||
for _, artist := range recording.ArtistCredit {
|
||||
artistMBIDs = append(artistMBIDs, artist.Artist.ID)
|
||||
}
|
||||
track := Track{
|
||||
track := listenbrainz.Track{
|
||||
TrackName: recording.Title,
|
||||
ArtistName: recording.ArtistCredit.String(),
|
||||
MBIDMapping: &MBIDMapping{
|
||||
MBIDMapping: &listenbrainz.MBIDMapping{
|
||||
// In case of redirects this MBID differs from the looked up MBID
|
||||
RecordingMBID: recording.ID,
|
||||
ArtistMBIDs: artistMBIDs,
|
||||
|
@ -400,26 +402,26 @@ func (b *ListenBrainzApiBackend) lookupRecording(ctx context.Context, mbid mbtyp
|
|||
return &track, nil
|
||||
}
|
||||
|
||||
func (lbListen Listen) AsListen() models.Listen {
|
||||
func AsListen(lbListen listenbrainz.Listen) models.Listen {
|
||||
listen := models.Listen{
|
||||
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
||||
UserName: lbListen.UserName,
|
||||
Track: lbListen.TrackMetadata.AsTrack(),
|
||||
Track: AsTrack(lbListen.TrackMetadata),
|
||||
}
|
||||
return listen
|
||||
}
|
||||
|
||||
func (f Feedback) AsLove() models.Love {
|
||||
func AsLove(f listenbrainz.Feedback) models.Love {
|
||||
recordingMBID := f.RecordingMBID
|
||||
track := f.TrackMetadata
|
||||
if track == nil {
|
||||
track = &Track{}
|
||||
track = &listenbrainz.Track{}
|
||||
}
|
||||
love := models.Love{
|
||||
UserName: f.UserName,
|
||||
RecordingMBID: recordingMBID,
|
||||
Created: time.Unix(f.Created, 0),
|
||||
Track: track.AsTrack(),
|
||||
Track: AsTrack(*track),
|
||||
}
|
||||
|
||||
if love.Track.RecordingMBID == "" {
|
||||
|
@ -429,7 +431,7 @@ func (f Feedback) AsLove() models.Love {
|
|||
return love
|
||||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
func AsTrack(t listenbrainz.Track) models.Track {
|
||||
track := models.Track{
|
||||
TrackName: t.TrackName,
|
||||
ReleaseName: t.ReleaseName,
|
||||
|
|
|
@ -24,15 +24,16 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
lbapi "go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/pkg/listenbrainz"
|
||||
)
|
||||
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("token", "thetoken")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := listenbrainz.ListenBrainzApiBackend{}
|
||||
backend := lbapi.ListenBrainzApiBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
@ -57,7 +58,7 @@ func TestListenBrainzListenAsListen(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
listen := lbListen.AsListen()
|
||||
listen := lbapi.AsListen(lbListen)
|
||||
assert.Equal(t, time.Unix(1699289873, 0), listen.ListenedAt)
|
||||
assert.Equal(t, lbListen.UserName, listen.UserName)
|
||||
assert.Equal(t, time.Duration(413787*time.Millisecond), listen.Duration)
|
||||
|
@ -93,7 +94,7 @@ func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
love := feedback.AsLove()
|
||||
love := lbapi.AsLove(feedback)
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
||||
assert.Equal(feedback.UserName, love.UserName)
|
||||
|
@ -114,7 +115,7 @@ func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
|||
RecordingMBID: recordingMBID,
|
||||
Score: 1,
|
||||
}
|
||||
love := feedback.AsLove()
|
||||
love := lbapi.AsLove(feedback)
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
||||
assert.Equal(recordingMBID, love.RecordingMBID)
|
||||
|
|
|
@ -1,241 +0,0 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"golang.org/x/exp/constraints"
|
||||
)
|
||||
|
||||
type GetListensResult struct {
|
||||
Payload GetListenPayload `json:"payload"`
|
||||
}
|
||||
|
||||
type GetListenPayload struct {
|
||||
Count int `json:"count"`
|
||||
UserName string `json:"user_id"`
|
||||
LatestListenTimestamp int64 `json:"latest_listen_ts"`
|
||||
OldestListenTimestamp int64 `json:"oldest_listen_ts"`
|
||||
Listens []Listen `json:"listens"`
|
||||
}
|
||||
|
||||
type listenType string
|
||||
|
||||
const (
|
||||
PlayingNow listenType = "playing_now"
|
||||
Single listenType = "single"
|
||||
Import listenType = "import"
|
||||
)
|
||||
|
||||
type ListenSubmission struct {
|
||||
ListenType listenType `json:"listen_type"`
|
||||
Payload []Listen `json:"payload"`
|
||||
}
|
||||
|
||||
type Listen struct {
|
||||
InsertedAt int64 `json:"inserted_at,omitempty"`
|
||||
ListenedAt int64 `json:"listened_at"`
|
||||
RecordingMSID string `json:"recording_msid,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
TrackMetadata Track `json:"track_metadata"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
ReleaseName string `json:"release_name,omitempty"`
|
||||
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
||||
MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"`
|
||||
}
|
||||
|
||||
type MBIDMapping struct {
|
||||
RecordingName string `json:"recording_name,omitempty"`
|
||||
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||
ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"`
|
||||
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
|
||||
Artists []Artist `json:"artists,omitempty"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ArtistCreditName string `json:"artist_credit_name,omitempty"`
|
||||
ArtistMBID string `json:"artist_mbid,omitempty"`
|
||||
JoinPhrase string `json:"join_phrase,omitempty"`
|
||||
}
|
||||
|
||||
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,omitempty"`
|
||||
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||
RecordingMSID mbtypes.MBID `json:"recording_msid,omitempty"`
|
||||
Score int `json:"score,omitempty"`
|
||||
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
||||
UserName string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type LookupResult struct {
|
||||
ArtistCreditName string `json:"artist_credit_name"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
RecordingName string `json:"recording_name"`
|
||||
RecordingMBID mbtypes.MBID `json:"recording_mbid"`
|
||||
ReleaseMBID mbtypes.MBID `json:"release_mbid"`
|
||||
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids"`
|
||||
}
|
||||
|
||||
type StatusResult struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ErrorResult struct {
|
||||
Code int `json:"code"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (t Track) Duration() time.Duration {
|
||||
info := t.AdditionalInfo
|
||||
millisecondsF, ok := tryGetFloat[float64](info, "duration_ms")
|
||||
if ok {
|
||||
return time.Duration(int64(millisecondsF * float64(time.Millisecond)))
|
||||
}
|
||||
|
||||
millisecondsI, ok := tryGetInteger[int64](info, "duration_ms")
|
||||
if ok {
|
||||
return time.Duration(millisecondsI * int64(time.Millisecond))
|
||||
}
|
||||
|
||||
secondsF, ok := tryGetFloat[float64](info, "duration")
|
||||
if ok {
|
||||
return time.Duration(int64(secondsF * float64(time.Second)))
|
||||
}
|
||||
|
||||
secondsI, ok := tryGetInteger[int64](info, "duration")
|
||||
if ok {
|
||||
return time.Duration(secondsI * int64(time.Second))
|
||||
}
|
||||
|
||||
return time.Duration(0)
|
||||
}
|
||||
|
||||
func (t Track) TrackNumber() int {
|
||||
value, ok := tryGetInteger[int](t.AdditionalInfo, "tracknumber")
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t Track) DiscNumber() int {
|
||||
value, ok := tryGetInteger[int](t.AdditionalInfo, "discnumber")
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t Track) ISRC() mbtypes.ISRC {
|
||||
return mbtypes.ISRC(tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc"))
|
||||
}
|
||||
|
||||
func (t Track) RecordingMBID() mbtypes.MBID {
|
||||
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid"))
|
||||
if mbid == "" && t.MBIDMapping != nil {
|
||||
return t.MBIDMapping.RecordingMBID
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseMBID() mbtypes.MBID {
|
||||
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid"))
|
||||
if mbid == "" && t.MBIDMapping != nil {
|
||||
return t.MBIDMapping.ReleaseMBID
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseGroupMBID() mbtypes.MBID {
|
||||
return mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid"))
|
||||
}
|
||||
|
||||
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {
|
||||
var result T
|
||||
value, ok := dict[key].(T)
|
||||
if ok {
|
||||
result = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tryGetFloat[T constraints.Float](dict map[string]any, key string) (T, bool) {
|
||||
valueFloat64, ok := dict[key].(float64)
|
||||
if ok {
|
||||
return T(valueFloat64), ok
|
||||
}
|
||||
|
||||
valueFloat32, ok := dict[key].(float32)
|
||||
if ok {
|
||||
return T(valueFloat32), ok
|
||||
}
|
||||
|
||||
valueStr, ok := dict[key].(string)
|
||||
if ok {
|
||||
valueFloat64, err := strconv.ParseFloat(valueStr, 64)
|
||||
if err == nil {
|
||||
return T(valueFloat64), ok
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func tryGetInteger[T constraints.Integer](dict map[string]any, key string) (T, bool) {
|
||||
valueInt64, ok := dict[key].(int64)
|
||||
if ok {
|
||||
return T(valueInt64), ok
|
||||
}
|
||||
|
||||
valueInt32, ok := dict[key].(int32)
|
||||
if ok {
|
||||
return T(valueInt32), ok
|
||||
}
|
||||
|
||||
valueInt, ok := dict[key].(int)
|
||||
if ok {
|
||||
return T(valueInt), ok
|
||||
}
|
||||
|
||||
valueFloat, ok := tryGetFloat[float64](dict, key)
|
||||
if ok {
|
||||
return T(valueFloat), ok
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
|
@ -1,184 +0,0 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
)
|
||||
|
||||
func TestTrackDurationMillisecondsInt(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": 528235,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationMillisecondsInt64(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": int64(528235),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationMillisecondsFloat(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": 528235.0,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsInt(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": 528,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528*time.Second), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsInt64(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": int64(528),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528*time.Second), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsFloat(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": 528.235,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsString(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": "528.235",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationEmpty(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{},
|
||||
}
|
||||
assert.Empty(t, track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackTrackNumber(t *testing.T) {
|
||||
expected := 7
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"tracknumber": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.TrackNumber())
|
||||
}
|
||||
|
||||
func TestTrackDiscNumber(t *testing.T) {
|
||||
expected := 7
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"discnumber": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.DiscNumber())
|
||||
}
|
||||
|
||||
func TestTrackTrackNumberString(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"tracknumber": "12",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, 12, track.TrackNumber())
|
||||
}
|
||||
|
||||
func TestTrackISRC(t *testing.T) {
|
||||
expected := mbtypes.ISRC("TCAEJ1934417")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"isrc": string(expected),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ISRC())
|
||||
}
|
||||
|
||||
func TestTrackRecordingMBID(t *testing.T) {
|
||||
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"recording_mbid": string(expected),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.RecordingMBID())
|
||||
}
|
||||
|
||||
func TestTrackReleaseMBID(t *testing.T) {
|
||||
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_mbid": string(expected),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ReleaseMBID())
|
||||
}
|
||||
|
||||
func TestReleaseGroupMBID(t *testing.T) {
|
||||
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_group_mbid": string(expected),
|
||||
},
|
||||
}
|
||||
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))
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
{
|
||||
"inserted_at": 1700580352,
|
||||
"listened_at": 1700580273,
|
||||
"recording_msid": "0a3144ea-f85c-4238-b0e3-e3d7a422df9d",
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"artist_names": [
|
||||
"Dool"
|
||||
],
|
||||
"discnumber": 1,
|
||||
"duration_ms": 413826,
|
||||
"isrc": "DES561620801",
|
||||
"music_service": "spotify.com",
|
||||
"origin_url": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"recording_msid": "0a3144ea-f85c-4238-b0e3-e3d7a422df9d",
|
||||
"release_artist_name": "Dool",
|
||||
"release_artist_names": [
|
||||
"Dool"
|
||||
],
|
||||
"spotify_album_artist_ids": [
|
||||
"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
],
|
||||
"spotify_album_id": "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA",
|
||||
"spotify_artist_ids": [
|
||||
"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
],
|
||||
"spotify_id": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"submission_client": "listenbrainz",
|
||||
"tracknumber": 5
|
||||
},
|
||||
"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",
|
||||
"recording_name": "Oweynagat",
|
||||
"release_mbid": "aa1ea1ac-7ec4-4542-a494-105afbfe547d"
|
||||
},
|
||||
"release_name": "Here Now, There Then",
|
||||
"track_name": "Oweynagat"
|
||||
},
|
||||
"user_name": "outsidecontext"
|
||||
}
|
116
internal/backends/listenbrainz/testdata/listens.json
vendored
116
internal/backends/listenbrainz/testdata/listens.json
vendored
|
@ -1,116 +0,0 @@
|
|||
{
|
||||
"payload": {
|
||||
"count": 2,
|
||||
"latest_listen_ts": 1699718723,
|
||||
"oldest_listen_ts": 1152911863,
|
||||
"listens": [
|
||||
{
|
||||
"inserted_at": 1699719320,
|
||||
"listened_at": 1699718723,
|
||||
"recording_msid": "94794568-ddd5-43be-a770-b6da011c6872",
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"artist_names": [
|
||||
"Joy Division"
|
||||
],
|
||||
"discnumber": 1,
|
||||
"duration_ms": 242933,
|
||||
"isrc": "NLEM80819612",
|
||||
"music_service": "spotify.com",
|
||||
"origin_url": "https://open.spotify.com/track/4pzYKPOjn1ITfEanoWIvrn",
|
||||
"recording_msid": "94794568-ddd5-43be-a770-b6da011c6872",
|
||||
"release_artist_name": "Warsaw",
|
||||
"release_artist_names": [
|
||||
"Warsaw"
|
||||
],
|
||||
"spotify_album_artist_ids": [
|
||||
"https://open.spotify.com/artist/0SS65FajB9S7ZILHdNOCsp"
|
||||
],
|
||||
"spotify_album_id": "https://open.spotify.com/album/3kDMRpbBe5eFMMo1pSYFhN",
|
||||
"spotify_artist_ids": [
|
||||
"https://open.spotify.com/artist/432R46LaYsJZV2Gmc4jUV5"
|
||||
],
|
||||
"spotify_id": "https://open.spotify.com/track/4pzYKPOjn1ITfEanoWIvrn",
|
||||
"submission_client": "listenbrainz",
|
||||
"tracknumber": 1
|
||||
},
|
||||
"artist_name": "Joy Division",
|
||||
"mbid_mapping": {
|
||||
"artist_mbids": [
|
||||
"9a58fda3-f4ed-4080-a3a5-f457aac9fcdd"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"artist_credit_name": "Joy Division",
|
||||
"artist_mbid": "9a58fda3-f4ed-4080-a3a5-f457aac9fcdd",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"caa_id": 3880053972,
|
||||
"caa_release_mbid": "d2f506bb-cfb5-327e-b8d6-cf4036c77cfa",
|
||||
"recording_mbid": "17ddd699-a35f-4f80-8064-9a807ad2799f",
|
||||
"recording_name": "Shadowplay",
|
||||
"release_mbid": "d2f506bb-cfb5-327e-b8d6-cf4036c77cfa"
|
||||
},
|
||||
"release_name": "Warsaw",
|
||||
"track_name": "Shadowplay"
|
||||
},
|
||||
"user_name": "outsidecontext"
|
||||
},
|
||||
{
|
||||
"inserted_at": 1699718945,
|
||||
"listened_at": 1699718480,
|
||||
"recording_msid": "5b6a3471-8f22-414b-a061-e45627ed26b8",
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"artist_names": [
|
||||
"SubRosa"
|
||||
],
|
||||
"discnumber": 1,
|
||||
"duration_ms": 350760,
|
||||
"isrc": "USN681110018",
|
||||
"music_service": "spotify.com",
|
||||
"origin_url": "https://open.spotify.com/track/0L0oz4yFk5hMmo52qAUQRF",
|
||||
"recording_msid": "5b6a3471-8f22-414b-a061-e45627ed26b8",
|
||||
"release_artist_name": "SubRosa",
|
||||
"release_artist_names": [
|
||||
"SubRosa"
|
||||
],
|
||||
"spotify_album_artist_ids": [
|
||||
"https://open.spotify.com/artist/4hAqIOkN2Q4apnbcOUUb7h"
|
||||
],
|
||||
"spotify_album_id": "https://open.spotify.com/album/3mYNFe9G85URf09SmoX2sB",
|
||||
"spotify_artist_ids": [
|
||||
"https://open.spotify.com/artist/4hAqIOkN2Q4apnbcOUUb7h"
|
||||
],
|
||||
"spotify_id": "https://open.spotify.com/track/0L0oz4yFk5hMmo52qAUQRF",
|
||||
"submission_client": "listenbrainz",
|
||||
"tracknumber": 1
|
||||
},
|
||||
"artist_name": "SubRosa",
|
||||
"mbid_mapping": {
|
||||
"artist_mbids": [
|
||||
"aa1c41d7-7836-42d0-8e0e-b5d565767db6"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"artist_credit_name": "SubRosa",
|
||||
"artist_mbid": "aa1c41d7-7836-42d0-8e0e-b5d565767db6",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"caa_id": 6163307004,
|
||||
"caa_release_mbid": "eb5dec80-ec5d-49a3-a622-3c02eefa0774",
|
||||
"recording_mbid": "c2374d60-7bfa-44ef-b5dc-f7bc6004b4a7",
|
||||
"recording_name": "Borrowed Time, Borrowed Eyes",
|
||||
"release_mbid": "9fba6ca8-4acb-44a9-951a-6c1fb8511443"
|
||||
},
|
||||
"release_name": "No Help for the Mighty Ones",
|
||||
"track_name": "Borrowed Time, Borrowed Eyes"
|
||||
},
|
||||
"user_name": "outsidecontext"
|
||||
}
|
||||
],
|
||||
"user_id": "outsidecontext"
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"artist_credit_name": "Paradise Lost",
|
||||
"artist_mbids": [
|
||||
"10bf95b6-30e3-44f1-817f-45762cdc0de0"
|
||||
],
|
||||
"recording_mbid": "569436a1-234a-44bc-a370-8f4d252bef21",
|
||||
"recording_name": "Say Just Words",
|
||||
"release_mbid": "90b2d144-e5f3-3192-9da5-0d72d67c61be",
|
||||
"release_name": "One Second"
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue