Moved general LB related code to separate package

This commit is contained in:
Philipp Wolfer 2025-05-23 16:33:28 +02:00
parent 34b6bb9aa3
commit 5c56e480f1
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
10 changed files with 54 additions and 49 deletions

View file

@ -29,10 +29,11 @@ import (
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/similarity" "go.uploadedlobster.com/scotty/internal/similarity"
"go.uploadedlobster.com/scotty/internal/version" "go.uploadedlobster.com/scotty/internal/version"
"go.uploadedlobster.com/scotty/pkg/listenbrainz"
) )
type ListenBrainzApiBackend struct { type ListenBrainzApiBackend struct {
client Client client listenbrainz.Client
mbClient musicbrainzws2.Client mbClient musicbrainzws2.Client
username string username string
checkDuplicates bool checkDuplicates bool
@ -58,13 +59,13 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption {
} }
func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error { 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{ b.mbClient = *musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
Name: version.AppName, Name: version.AppName,
Version: version.AppVersion, Version: version.AppVersion,
URL: version.AppURL, URL: version.AppURL,
}) })
b.client.MaxResults = MaxItemsPerGet b.client.MaxResults = listenbrainz.MaxItemsPerGet
b.username = config.GetString("username") b.username = config.GetString("username")
b.checkDuplicates = config.GetBool("check-duplicate-listens", false) b.checkDuplicates = config.GetBool("check-duplicate-listens", false)
return nil return nil
@ -116,7 +117,7 @@ func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimest
for _, listen := range result.Payload.Listens { for _, listen := range result.Payload.Listens {
if listen.ListenedAt > oldestTimestamp.Unix() { if listen.ListenedAt > oldestTimestamp.Unix() {
listens = append(listens, listen.AsListen()) listens = append(listens, AsListen(listen))
} else { } else {
// result contains listens older then oldestTimestamp // result contains listens older then oldestTimestamp
break 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) { func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
total := len(export.Items) total := len(export.Items)
p := models.TransferProgress{}.FromImportResult(importResult, false) p := models.TransferProgress{}.FromImportResult(importResult, false)
for i := 0; i < total; i += MaxListensPerRequest { for i := 0; i < total; i += listenbrainz.MaxListensPerRequest {
listens := export.Items[i:min(i+MaxListensPerRequest, total)] listens := export.Items[i:min(i+listenbrainz.MaxListensPerRequest, total)]
count := len(listens) count := len(listens)
if count == 0 { if count == 0 {
break break
} }
submission := ListenSubmission{ submission := listenbrainz.ListenSubmission{
ListenType: Import, ListenType: listenbrainz.Import,
Payload: make([]Listen, 0, count), Payload: make([]listenbrainz.Listen, 0, count),
} }
for _, l := range listens { for _, l := range listens {
@ -167,9 +168,9 @@ func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export model
} }
l.FillAdditionalInfo() l.FillAdditionalInfo()
listen := Listen{ listen := listenbrainz.Listen{
ListenedAt: l.ListenedAt.Unix(), ListenedAt: l.ListenedAt.Unix(),
TrackMetadata: Track{ TrackMetadata: listenbrainz.Track{
TrackName: l.TrackName, TrackName: l.TrackName,
ReleaseName: l.ReleaseName, ReleaseName: l.ReleaseName,
ArtistName: l.ArtistName(), 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) { func (b *ListenBrainzApiBackend) exportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult) {
offset := 0 offset := 0
defer close(results) defer close(results)
loves := make(models.LovesList, 0, 2*MaxItemsPerGet) loves := make(models.LovesList, 0, 2*listenbrainz.MaxItemsPerGet)
out: out:
for { for {
@ -254,7 +255,7 @@ out:
} }
} }
love := feedback.AsLove() love := AsLove(feedback)
if love.Created.After(oldestTimestamp) { if love.Created.After(oldestTimestamp) {
loves = append(loves, love) loves = append(loves, love)
} else { } else {
@ -262,7 +263,7 @@ out:
} }
} }
offset += MaxItemsPerGet offset += listenbrainz.MaxItemsPerGet
} }
sort.Sort(loves) 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) go b.exportLoves(ctx, time.Unix(0, 0), existingLovesChan)
// TODO: Store MBIDs directly // 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 { for existingLoves := range existingLovesChan {
if existingLoves.Error != nil { if existingLoves.Error != nil {
@ -316,7 +317,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.
if b.existingMBIDs[recordingMBID] { if b.existingMBIDs[recordingMBID] {
ok = true ok = true
} else { } else {
resp, err := b.client.SendFeedback(ctx, Feedback{ resp, err := b.client.SendFeedback(ctx, listenbrainz.Feedback{
RecordingMBID: recordingMBID, RecordingMBID: recordingMBID,
Score: 1, Score: 1,
}) })
@ -366,7 +367,7 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, liste
} }
for _, c := range candidates.Payload.Listens { 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 { if sim >= trackSimilarityThreshold {
return true, nil return true, nil
} }
@ -375,7 +376,8 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, liste
return false, nil 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{ filter := musicbrainzws2.IncludesFilter{
Includes: []string{"artist-credits"}, Includes: []string{"artist-credits"},
} }
@ -388,10 +390,10 @@ func (b *ListenBrainzApiBackend) lookupRecording(ctx context.Context, mbid mbtyp
for _, artist := range recording.ArtistCredit { for _, artist := range recording.ArtistCredit {
artistMBIDs = append(artistMBIDs, artist.Artist.ID) artistMBIDs = append(artistMBIDs, artist.Artist.ID)
} }
track := Track{ track := listenbrainz.Track{
TrackName: recording.Title, TrackName: recording.Title,
ArtistName: recording.ArtistCredit.String(), ArtistName: recording.ArtistCredit.String(),
MBIDMapping: &MBIDMapping{ MBIDMapping: &listenbrainz.MBIDMapping{
// In case of redirects this MBID differs from the looked up MBID // In case of redirects this MBID differs from the looked up MBID
RecordingMBID: recording.ID, RecordingMBID: recording.ID,
ArtistMBIDs: artistMBIDs, ArtistMBIDs: artistMBIDs,
@ -400,26 +402,26 @@ func (b *ListenBrainzApiBackend) lookupRecording(ctx context.Context, mbid mbtyp
return &track, nil return &track, nil
} }
func (lbListen Listen) AsListen() models.Listen { func AsListen(lbListen listenbrainz.Listen) models.Listen {
listen := models.Listen{ listen := models.Listen{
ListenedAt: time.Unix(lbListen.ListenedAt, 0), ListenedAt: time.Unix(lbListen.ListenedAt, 0),
UserName: lbListen.UserName, UserName: lbListen.UserName,
Track: lbListen.TrackMetadata.AsTrack(), Track: AsTrack(lbListen.TrackMetadata),
} }
return listen return listen
} }
func (f Feedback) AsLove() models.Love { func AsLove(f listenbrainz.Feedback) models.Love {
recordingMBID := f.RecordingMBID recordingMBID := f.RecordingMBID
track := f.TrackMetadata track := f.TrackMetadata
if track == nil { if track == nil {
track = &Track{} track = &listenbrainz.Track{}
} }
love := models.Love{ love := models.Love{
UserName: f.UserName, UserName: f.UserName,
RecordingMBID: recordingMBID, RecordingMBID: recordingMBID,
Created: time.Unix(f.Created, 0), Created: time.Unix(f.Created, 0),
Track: track.AsTrack(), Track: AsTrack(*track),
} }
if love.Track.RecordingMBID == "" { if love.Track.RecordingMBID == "" {
@ -429,7 +431,7 @@ func (f Feedback) AsLove() models.Love {
return love return love
} }
func (t Track) AsTrack() models.Track { func AsTrack(t listenbrainz.Track) models.Track {
track := models.Track{ track := models.Track{
TrackName: t.TrackName, TrackName: t.TrackName,
ReleaseName: t.ReleaseName, ReleaseName: t.ReleaseName,

View file

@ -24,15 +24,16 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes" "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/internal/config"
"go.uploadedlobster.com/scotty/pkg/listenbrainz"
) )
func TestInitConfig(t *testing.T) { func TestInitConfig(t *testing.T) {
c := viper.New() c := viper.New()
c.Set("token", "thetoken") c.Set("token", "thetoken")
service := config.NewServiceConfig("test", c) service := config.NewServiceConfig("test", c)
backend := listenbrainz.ListenBrainzApiBackend{} backend := lbapi.ListenBrainzApiBackend{}
err := backend.InitConfig(&service) err := backend.InitConfig(&service)
assert.NoError(t, err) 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, time.Unix(1699289873, 0), listen.ListenedAt)
assert.Equal(t, lbListen.UserName, listen.UserName) assert.Equal(t, lbListen.UserName, listen.UserName)
assert.Equal(t, time.Duration(413787*time.Millisecond), listen.Duration) 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 := assert.New(t)
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix()) assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
assert.Equal(feedback.UserName, love.UserName) assert.Equal(feedback.UserName, love.UserName)
@ -114,7 +115,7 @@ func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
RecordingMBID: recordingMBID, RecordingMBID: recordingMBID,
Score: 1, Score: 1,
} }
love := feedback.AsLove() love := lbapi.AsLove(feedback)
assert := assert.New(t) assert := assert.New(t)
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix()) assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
assert.Equal(recordingMBID, love.RecordingMBID) assert.Equal(recordingMBID, love.RecordingMBID)

View file

@ -28,7 +28,6 @@ import (
"time" "time"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"go.uploadedlobster.com/scotty/internal/version"
"go.uploadedlobster.com/scotty/pkg/ratelimit" "go.uploadedlobster.com/scotty/pkg/ratelimit"
) )
@ -44,13 +43,13 @@ type Client struct {
MaxResults int MaxResults int
} }
func NewClient(token string) Client { func NewClient(token string, userAgent string) Client {
client := resty.New() client := resty.New()
client.SetBaseURL(listenBrainzBaseURL) client.SetBaseURL(listenBrainzBaseURL)
client.SetAuthScheme("Token") client.SetAuthScheme("Token")
client.SetAuthToken(token) client.SetAuthToken(token)
client.SetHeader("Accept", "application/json") client.SetHeader("Accept", "application/json")
client.SetHeader("User-Agent", version.UserAgent()) client.SetHeader("User-Agent", userAgent)
// 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)
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In") ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")

View file

@ -31,12 +31,12 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz" "go.uploadedlobster.com/scotty/pkg/listenbrainz"
) )
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
token := "foobar123" token := "foobar123"
client := listenbrainz.NewClient(token) client := listenbrainz.NewClient(token, "test/1.0")
assert.Equal(t, token, client.HTTPClient.Token) assert.Equal(t, token, client.HTTPClient.Token)
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults) assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
} }
@ -44,7 +44,7 @@ func TestNewClient(t *testing.T) {
func TestGetListens(t *testing.T) { func TestGetListens(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken", "test/1.0")
client.MaxResults = 2 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",
@ -64,7 +64,7 @@ func TestGetListens(t *testing.T) {
} }
func TestSubmitListens(t *testing.T) { func TestSubmitListens(t *testing.T) {
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken", "test/1.0")
httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
@ -104,7 +104,7 @@ func TestSubmitListens(t *testing.T) {
func TestGetFeedback(t *testing.T) { func TestGetFeedback(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken", "test/1.0")
client.MaxResults = 2 client.MaxResults = 2
setupHTTPMock(t, client.HTTPClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback", "https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
@ -123,7 +123,7 @@ func TestGetFeedback(t *testing.T) {
} }
func TestSendFeedback(t *testing.T) { func TestSendFeedback(t *testing.T) {
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken", "test/1.0")
httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
@ -149,7 +149,7 @@ func TestSendFeedback(t *testing.T) {
func TestLookup(t *testing.T) { func TestLookup(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken", "test/1.0")
setupHTTPMock(t, client.HTTPClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/metadata/lookup", "https://api.listenbrainz.org/1/metadata/lookup",
"testdata/lookup.json") "testdata/lookup.json")

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -66,16 +66,19 @@ type Track struct {
TrackName string `json:"track_name,omitempty"` TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"` ArtistName string `json:"artist_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"` ReleaseName string `json:"release_name,omitempty"`
RecordingMSID string `json:"recording_msid,omitempty"`
AdditionalInfo map[string]any `json:"additional_info,omitempty"` AdditionalInfo map[string]any `json:"additional_info,omitempty"`
MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"` MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"`
} }
type MBIDMapping struct { type MBIDMapping struct {
RecordingName string `json:"recording_name,omitempty"` ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"` Artists []Artist `json:"artists,omitempty"`
ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"` RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"` RecordingName string `json:"recording_name,omitempty"`
Artists []Artist `json:"artists,omitempty"` ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"`
CAAID int `json:"caa_id,omitempty"`
CAAReleaseMBID mbtypes.MBID `json:"caa_release_mbid,omitempty"`
} }
type Artist struct { type Artist struct {

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -29,7 +29,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz" "go.uploadedlobster.com/scotty/pkg/listenbrainz"
) )
func TestTrackDurationMillisecondsInt(t *testing.T) { func TestTrackDurationMillisecondsInt(t *testing.T) {