mirror of
https://git.sr.ht/~phw/scotty
synced 2025-06-06 12:58:35 +02:00
Keep listenbrainz package internal for now
This commit is contained in:
parent
1025277ba9
commit
8462b9395e
12 changed files with 5 additions and 5 deletions
|
@ -28,8 +28,8 @@ import (
|
|||
lbapi "go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/scotty/pkg/listenbrainz"
|
||||
)
|
||||
|
||||
const batchSize = 2000
|
||||
|
|
|
@ -26,10 +26,10 @@ import (
|
|||
"go.uploadedlobster.com/musicbrainzws2"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/listenbrainz"
|
||||
"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 {
|
||||
|
|
|
@ -26,7 +26,7 @@ import (
|
|||
"go.uploadedlobster.com/mbtypes"
|
||||
lbapi "go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/pkg/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/listenbrainz"
|
||||
)
|
||||
|
||||
func TestInitConfig(t *testing.T) {
|
||||
|
|
220
internal/listenbrainz/archive.go
Normal file
220
internal/listenbrainz/archive.go
Normal file
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
Copyright © 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 (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"iter"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/simonfrey/jsonl"
|
||||
"go.uploadedlobster.com/scotty/internal/archive"
|
||||
)
|
||||
|
||||
// Represents a ListenBrainz export archive.
|
||||
//
|
||||
// The export contains the user's listen history, favorite tracks and
|
||||
// user information.
|
||||
type Archive struct {
|
||||
backend archive.Archive
|
||||
}
|
||||
|
||||
// Open a ListenBrainz archive from file path.
|
||||
func OpenArchive(path string) (*Archive, error) {
|
||||
backend, err := archive.OpenArchive(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Archive{backend: backend}, nil
|
||||
}
|
||||
|
||||
// Close the archive and release any resources.
|
||||
func (a *Archive) Close() error {
|
||||
return a.backend.Close()
|
||||
}
|
||||
|
||||
// Read the user information from the archive.
|
||||
func (a *Archive) UserInfo() (UserInfo, error) {
|
||||
f, err := a.backend.OpenFile("user.json")
|
||||
if err != nil {
|
||||
return UserInfo{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
userInfo := UserInfo{}
|
||||
bytes, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return userInfo, err
|
||||
}
|
||||
|
||||
json.Unmarshal(bytes, &userInfo)
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func (a *Archive) ListListenExports() ([]ListenExportFileInfo, error) {
|
||||
re := regexp.MustCompile(`^listens/(\d{4})/(\d{1,2})\.jsonl$`)
|
||||
result := make([]ListenExportFileInfo, 0)
|
||||
|
||||
files, err := a.backend.Glob("listens/*/*.jsonl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
match := re.FindStringSubmatch(file.Name)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
year := match[1]
|
||||
month := match[2]
|
||||
times, err := getMonthTimeRange(year, month)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := ListenExportFileInfo{
|
||||
Name: file.Name,
|
||||
TimeRange: *times,
|
||||
f: file.File,
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Yields all listens from the archive that are newer than the given timestamp.
|
||||
// The listens are yielded in ascending order of their listened_at timestamp.
|
||||
func (a *Archive) IterListens(minTimestamp time.Time) iter.Seq2[Listen, error] {
|
||||
return func(yield func(Listen, error) bool) {
|
||||
files, err := a.ListListenExports()
|
||||
if err != nil {
|
||||
yield(Listen{}, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].TimeRange.Start.Before(files[j].TimeRange.Start)
|
||||
})
|
||||
|
||||
for _, file := range files {
|
||||
if file.TimeRange.End.Before(minTimestamp) {
|
||||
continue
|
||||
}
|
||||
|
||||
f := NewExportFile(file.f)
|
||||
for l, err := range f.IterListens() {
|
||||
if err != nil {
|
||||
yield(Listen{}, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !time.Unix(l.ListenedAt, 0).After(minTimestamp) {
|
||||
continue
|
||||
}
|
||||
if !yield(l, nil) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
ID string `json:"user_id"`
|
||||
Name string `json:"username"`
|
||||
}
|
||||
|
||||
type timeRange struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type ListenExportFileInfo struct {
|
||||
Name string
|
||||
TimeRange timeRange
|
||||
f archive.OpenableFile
|
||||
}
|
||||
|
||||
type ListenExportFile struct {
|
||||
file archive.OpenableFile
|
||||
}
|
||||
|
||||
func NewExportFile(f archive.OpenableFile) ListenExportFile {
|
||||
return ListenExportFile{file: f}
|
||||
}
|
||||
|
||||
func (f *ListenExportFile) openReader() (*jsonl.Reader, error) {
|
||||
fio, err := f.file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := jsonl.NewReader(fio)
|
||||
return &reader, nil
|
||||
}
|
||||
|
||||
func (f *ListenExportFile) IterListens() iter.Seq2[Listen, error] {
|
||||
return func(yield func(Listen, error) bool) {
|
||||
reader, err := f.openReader()
|
||||
if err != nil {
|
||||
yield(Listen{}, err)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for {
|
||||
listen := Listen{}
|
||||
err := reader.ReadSingleLine(&listen)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !yield(listen, nil) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getMonthTimeRange(year string, month string) (*timeRange, error) {
|
||||
yearInt, err := strconv.Atoi(year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
monthInt, err := strconv.Atoi(month)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := &timeRange{}
|
||||
r.Start = time.Date(yearInt, time.Month(monthInt), 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Get the end of the month
|
||||
nextMonth := monthInt + 1
|
||||
r.End = time.Date(
|
||||
yearInt, time.Month(nextMonth), 1, 0, 0, 0, 0, time.UTC).Add(-time.Second)
|
||||
return r, nil
|
||||
}
|
160
internal/listenbrainz/client.go
Normal file
160
internal/listenbrainz/client.go
Normal file
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
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/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, userAgent string) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(listenBrainzBaseURL)
|
||||
client.SetAuthScheme("Token")
|
||||
client.SetAuthToken(token)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetHeader("User-Agent", 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
|
||||
}
|
175
internal/listenbrainz/client_test.go
Normal file
175
internal/listenbrainz/client_test.go
Normal file
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
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/listenbrainz"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
token := "foobar123"
|
||||
client := listenbrainz.NewClient(token, "test/1.0")
|
||||
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", "test/1.0")
|
||||
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", "test/1.0")
|
||||
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", "test/1.0")
|
||||
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", "test/1.0")
|
||||
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", "test/1.0")
|
||||
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)
|
||||
}
|
244
internal/listenbrainz/models.go
Normal file
244
internal/listenbrainz/models.go
Normal file
|
@ -0,0 +1,244 @@
|
|||
/*
|
||||
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 (
|
||||
"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 float64 `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"`
|
||||
RecordingMSID string `json:"recording_msid,omitempty"`
|
||||
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
||||
MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"`
|
||||
}
|
||||
|
||||
type MBIDMapping struct {
|
||||
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
|
||||
Artists []Artist `json:"artists,omitempty"`
|
||||
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||
RecordingName string `json:"recording_name,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 {
|
||||
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
|
||||
}
|
184
internal/listenbrainz/models_test.go
Normal file
184
internal/listenbrainz/models_test.go
Normal file
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
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 (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/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))
|
||||
}
|
63
internal/listenbrainz/testdata/feedback.json
vendored
Normal file
63
internal/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/listenbrainz/testdata/listen.json
vendored
Normal file
53
internal/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"
|
||||
}
|
116
internal/listenbrainz/testdata/listens.json
vendored
Normal file
116
internal/listenbrainz/testdata/listens.json
vendored
Normal file
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
10
internal/listenbrainz/testdata/lookup.json
vendored
Normal file
10
internal/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