mirror of
https://git.sr.ht/~phw/scotty
synced 2025-04-29 21:27:05 +02:00
Restructured code, moved all modules into internal
For now all modules are considered internal. This might change later
This commit is contained in:
parent
f94e0f1e85
commit
857661ebf9
76 changed files with 121 additions and 68 deletions
173
internal/backends/listenbrainz/client.go
Normal file
173
internal/backends/listenbrainz/client.go
Normal file
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
const listenBrainzBaseURL = "https://api.listenbrainz.org/1/"
|
||||
|
||||
const (
|
||||
DefaultItemsPerGet = 25
|
||||
MaxItemsPerGet = 1000
|
||||
MaxListensPerRequest = 1000
|
||||
DefaultRateLimitWaitSeconds = 5
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
// Handle rate limiting (see https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#rate-limiting)
|
||||
client.SetRetryCount(5)
|
||||
client.AddRetryCondition(
|
||||
func(r *resty.Response, err error) bool {
|
||||
code := r.StatusCode()
|
||||
return code == http.StatusTooManyRequests || code >= http.StatusInternalServerError
|
||||
},
|
||||
)
|
||||
client.SetRetryMaxWaitTime(time.Duration(1 * time.Minute))
|
||||
client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) {
|
||||
var err error
|
||||
var retryAfter int = DefaultRateLimitWaitSeconds
|
||||
if resp.StatusCode() == http.StatusTooManyRequests {
|
||||
retryAfter, err = strconv.Atoi(resp.Header().Get("X-RateLimit-Reset-In"))
|
||||
if err != nil {
|
||||
retryAfter = DefaultRateLimitWaitSeconds
|
||||
}
|
||||
}
|
||||
return time.Duration(retryAfter * int(time.Second)), err
|
||||
})
|
||||
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
MaxResults: DefaultItemsPerGet,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
||||
const path = "/user/{username}/listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
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.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
|
||||
const path = "/submit-listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetBody(listens).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
|
||||
const path = "/feedback/user/{username}/get-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
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),
|
||||
"metadata": "true",
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
|
||||
const path = "/feedback/recording-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetBody(feedback).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
|
||||
const path = "/metadata/lookup"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"recording_name": recordingName,
|
||||
"artist_name": artistName,
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
166
internal/backends/listenbrainz/client_test.go
Normal file
166
internal/backends/listenbrainz/client_test.go
Normal file
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
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 (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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")
|
||||
|
||||
result, err := client.GetListens("outsidecontext", time.Now(), time.Now().Add(-2*time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(2, result.Payload.Count)
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := client.SubmitListens(listens)
|
||||
require.NoError(t, err)
|
||||
|
||||
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")
|
||||
|
||||
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 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")
|
||||
|
||||
result, err := client.Lookup("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("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)
|
||||
}
|
290
internal/backends/listenbrainz/listenbrainz.go
Normal file
290
internal/backends/listenbrainz/listenbrainz.go
Normal file
|
@ -0,0 +1,290 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type ListenBrainzApiBackend struct {
|
||||
client Client
|
||||
username string
|
||||
existingMbids map[string]bool
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
|
||||
|
||||
func (b *ListenBrainzApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.client = NewClient(config.GetString("token"))
|
||||
b.client.MaxResults = MaxItemsPerGet
|
||||
b.username = config.GetString("username")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
|
||||
func (b *ListenBrainzApiBackend) FinishImport() error { return nil }
|
||||
|
||||
func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
startTime := time.Now()
|
||||
maxTime := startTime
|
||||
minTime := time.Unix(0, 0)
|
||||
|
||||
totalDuration := startTime.Sub(oldestTimestamp)
|
||||
|
||||
defer close(results)
|
||||
|
||||
// FIXME: Optimize by fetching the listens in reverse listen time order
|
||||
listens := make(models.ListensList, 0, 2*MaxItemsPerGet)
|
||||
p := models.Progress{Total: int64(totalDuration.Seconds())}
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.GetListens(b.username, maxTime, minTime)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
count := len(result.Payload.Listens)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Set maxTime to the oldest returned listen
|
||||
maxTime = time.Unix(result.Payload.Listens[count-1].ListenedAt, 0)
|
||||
remainingTime := maxTime.Sub(oldestTimestamp)
|
||||
|
||||
for _, listen := range result.Payload.Listens {
|
||||
if listen.ListenedAt > oldestTimestamp.Unix() {
|
||||
listens = append(listens, listen.AsListen())
|
||||
} else {
|
||||
// result contains listens older then oldestTimestamp,
|
||||
// we can stop requesting more
|
||||
p.Total = int64(startTime.Sub(time.Unix(listen.ListenedAt, 0)).Seconds())
|
||||
break out
|
||||
}
|
||||
}
|
||||
|
||||
p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
|
||||
progress <- p
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Listens: listens, OldestTimestamp: oldestTimestamp}
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
total := len(export.Listens)
|
||||
for i := 0; i < total; i += MaxListensPerRequest {
|
||||
listens := export.Listens[i:min(i+MaxItemsPerGet, total)]
|
||||
count := len(listens)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
submission := ListenSubmission{
|
||||
ListenType: Import,
|
||||
Payload: make([]Listen, 0, count),
|
||||
}
|
||||
|
||||
for _, l := range listens {
|
||||
l.FillAdditionalInfo()
|
||||
listen := Listen{
|
||||
ListenedAt: l.ListenedAt.Unix(),
|
||||
TrackMetadata: Track{
|
||||
TrackName: l.TrackName,
|
||||
ReleaseName: l.ReleaseName,
|
||||
ArtistName: l.ArtistName(),
|
||||
AdditionalInfo: l.AdditionalInfo,
|
||||
},
|
||||
}
|
||||
listen.TrackMetadata.AdditionalInfo["submission_client"] = "Scotty"
|
||||
submission.Payload = append(submission.Payload, listen)
|
||||
}
|
||||
|
||||
_, err := b.client.SubmitListens(submission)
|
||||
if err != nil {
|
||||
return importResult, err
|
||||
}
|
||||
|
||||
importResult.UpdateTimestamp(listens[count-1].ListenedAt)
|
||||
importResult.ImportCount += count
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
}
|
||||
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
offset := 0
|
||||
defer close(results)
|
||||
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
||||
p := models.Progress{}
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.GetFeedback(b.username, 1, offset)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
count := len(result.Feedback)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
for _, feedback := range result.Feedback {
|
||||
love := feedback.AsLove()
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
loves = append(loves, love)
|
||||
p.Elapsed += 1
|
||||
progress <- p
|
||||
} else {
|
||||
break out
|
||||
}
|
||||
}
|
||||
|
||||
p.Total = int64(result.TotalCount)
|
||||
p.Elapsed += int64(count)
|
||||
|
||||
offset += MaxItemsPerGet
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Loves: loves}
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
if len(b.existingMbids) == 0 {
|
||||
existingLovesChan := make(chan models.LovesResult)
|
||||
go b.ExportLoves(time.Unix(0, 0), existingLovesChan, progress)
|
||||
existingLoves := <-existingLovesChan
|
||||
if existingLoves.Error != nil {
|
||||
return importResult, existingLoves.Error
|
||||
}
|
||||
|
||||
// TODO: Store MBIDs directly
|
||||
b.existingMbids = make(map[string]bool, len(existingLoves.Loves))
|
||||
for _, love := range existingLoves.Loves {
|
||||
b.existingMbids[string(love.RecordingMbid)] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, love := range export.Loves {
|
||||
recordingMbid := string(love.RecordingMbid)
|
||||
|
||||
if recordingMbid == "" {
|
||||
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
|
||||
if err == nil {
|
||||
recordingMbid = lookup.RecordingMbid
|
||||
}
|
||||
}
|
||||
|
||||
if recordingMbid != "" {
|
||||
ok := false
|
||||
errMsg := ""
|
||||
if b.existingMbids[recordingMbid] {
|
||||
ok = true
|
||||
} else {
|
||||
resp, err := b.client.SendFeedback(Feedback{
|
||||
RecordingMbid: recordingMbid,
|
||||
Score: 1,
|
||||
})
|
||||
ok = err == nil && resp.Status == "ok"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
importResult.UpdateTimestamp(love.Created)
|
||||
importResult.ImportCount += 1
|
||||
} else {
|
||||
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
|
||||
love.TrackName, love.ArtistName(), errMsg)
|
||||
importResult.ImportErrors = append(importResult.ImportErrors, msg)
|
||||
}
|
||||
}
|
||||
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
}
|
||||
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func (lbListen Listen) AsListen() models.Listen {
|
||||
listen := models.Listen{
|
||||
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
||||
UserName: lbListen.UserName,
|
||||
Track: lbListen.TrackMetadata.AsTrack(),
|
||||
}
|
||||
return listen
|
||||
}
|
||||
|
||||
func (f Feedback) AsLove() models.Love {
|
||||
recordingMbid := models.MBID(f.RecordingMbid)
|
||||
track := f.TrackMetadata
|
||||
if track == nil {
|
||||
track = &Track{}
|
||||
}
|
||||
love := models.Love{
|
||||
UserName: f.UserName,
|
||||
RecordingMbid: recordingMbid,
|
||||
Created: time.Unix(f.Created, 0),
|
||||
Track: track.AsTrack(),
|
||||
}
|
||||
|
||||
if love.Track.RecordingMbid == "" {
|
||||
love.Track.RecordingMbid = love.RecordingMbid
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
track := models.Track{
|
||||
TrackName: t.TrackName,
|
||||
ReleaseName: t.ReleaseName,
|
||||
ArtistNames: []string{t.ArtistName},
|
||||
Duration: t.Duration(),
|
||||
TrackNumber: t.TrackNumber(),
|
||||
DiscNumber: t.DiscNumber(),
|
||||
RecordingMbid: models.MBID(t.RecordingMbid()),
|
||||
ReleaseMbid: models.MBID(t.ReleaseMbid()),
|
||||
ReleaseGroupMbid: models.MBID(t.ReleaseGroupMbid()),
|
||||
ISRC: t.ISRC(),
|
||||
AdditionalInfo: t.AdditionalInfo,
|
||||
}
|
||||
|
||||
if t.MbidMapping != nil && len(track.ArtistMbids) == 0 {
|
||||
for _, artistMbid := range t.MbidMapping.ArtistMbids {
|
||||
track.ArtistMbids = append(track.ArtistMbids, models.MBID(artistMbid))
|
||||
}
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
120
internal/backends/listenbrainz/listenbrainz_test.go
Normal file
120
internal/backends/listenbrainz/listenbrainz_test.go
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package listenbrainz_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("token", "thetoken")
|
||||
backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestListenBrainzListenAsListen(t *testing.T) {
|
||||
lbListen := listenbrainz.Listen{
|
||||
ListenedAt: 1699289873,
|
||||
UserName: "outsidecontext",
|
||||
TrackMetadata: listenbrainz.Track{
|
||||
TrackName: "The Track",
|
||||
ArtistName: "Dool",
|
||||
ReleaseName: "Here Now, There Then",
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": 413787,
|
||||
"foo": "bar",
|
||||
"isrc": "DES561620801",
|
||||
"tracknumber": 5,
|
||||
"discnumber": 1,
|
||||
"recording_mbid": "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
"release_group_mbid": "80aca1ee-aa51-41be-9f75-024710d92ff4",
|
||||
"release_mbid": "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
},
|
||||
},
|
||||
}
|
||||
listen := lbListen.AsListen()
|
||||
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)
|
||||
assert.Equal(t, lbListen.TrackMetadata.TrackName, listen.TrackName)
|
||||
assert.Equal(t, lbListen.TrackMetadata.ReleaseName, listen.ReleaseName)
|
||||
assert.Equal(t, []string{lbListen.TrackMetadata.ArtistName}, listen.ArtistNames)
|
||||
assert.Equal(t, 5, listen.TrackNumber)
|
||||
assert.Equal(t, 1, listen.DiscNumber)
|
||||
assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid)
|
||||
assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid)
|
||||
assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid)
|
||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
||||
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
|
||||
}
|
||||
|
||||
func TestListenBrainzFeedbackAsLove(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.AsLove()
|
||||
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])
|
||||
}
|
||||
|
||||
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
||||
feedback := listenbrainz.Feedback{
|
||||
Created: 1699859066,
|
||||
RecordingMbid: recordingMbid,
|
||||
Score: 1,
|
||||
}
|
||||
love := feedback.AsLove()
|
||||
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)
|
||||
}
|
239
internal/backends/listenbrainz/models.go
Normal file
239
internal/backends/listenbrainz/models.go
Normal file
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
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"
|
||||
|
||||
"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"`
|
||||
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 string `json:"recording_mbid,omitempty"`
|
||||
ReleaseMbid string `json:"release_mbid,omitempty"`
|
||||
ArtistMbids []string `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 string `json:"recording_mbid,omitempty"`
|
||||
RecordingMsid string `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 string `json:"recording_mbid"`
|
||||
ReleaseMbid string `json:"release_mbid"`
|
||||
ArtistMbids []string `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() string {
|
||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")
|
||||
}
|
||||
|
||||
func (t Track) RecordingMbid() string {
|
||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")
|
||||
if mbid == "" && t.MbidMapping != nil {
|
||||
return t.MbidMapping.RecordingMbid
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseMbid() string {
|
||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")
|
||||
if mbid == "" && t.MbidMapping != nil {
|
||||
return t.MbidMapping.ReleaseMbid
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseGroupMbid() string {
|
||||
return 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
|
||||
}
|
183
internal/backends/listenbrainz/models_test.go
Normal file
183
internal/backends/listenbrainz/models_test.go
Normal file
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
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/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 := "TCAEJ1934417"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"isrc": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ISRC())
|
||||
}
|
||||
|
||||
func TestTrackRecordingMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"recording_mbid": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.RecordingMbid())
|
||||
}
|
||||
|
||||
func TestTrackReleaseMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_mbid": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ReleaseMbid())
|
||||
}
|
||||
|
||||
func TestReleaseGroupMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_group_mbid": 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))
|
||||
}
|
63
internal/backends/listenbrainz/testdata/feedback.json
vendored
Normal file
63
internal/backends/listenbrainz/testdata/feedback.json
vendored
Normal 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
|
||||
}
|
53
internal/backends/listenbrainz/testdata/listen.json
vendored
Normal file
53
internal/backends/listenbrainz/testdata/listen.json
vendored
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"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"
|
||||
}
|
115
internal/backends/listenbrainz/testdata/listens.json
vendored
Normal file
115
internal/backends/listenbrainz/testdata/listens.json
vendored
Normal file
|
@ -0,0 +1,115 @@
|
|||
{
|
||||
"payload": {
|
||||
"count": 2,
|
||||
"latest_listen_ts": 1699718723,
|
||||
"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"
|
||||
}
|
||||
}
|
10
internal/backends/listenbrainz/testdata/lookup.json
vendored
Normal file
10
internal/backends/listenbrainz/testdata/lookup.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"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