Compare commits

..

No commits in common. "20853f7601c80b1254c59d903b0e46667e98cecc" and "97600d8190f80d0125a83849ad88e533433e8ef3" have entirely different histories.

29 changed files with 144 additions and 263 deletions

2
go.mod
View file

@ -22,7 +22,7 @@ require (
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
github.com/vbauerster/mpb/v8 v8.10.0 github.com/vbauerster/mpb/v8 v8.10.0
go.uploadedlobster.com/mbtypes v0.4.0 go.uploadedlobster.com/mbtypes v0.4.0
go.uploadedlobster.com/musicbrainzws2 v0.14.1-0.20250522060150-50bf4bea5400 go.uploadedlobster.com/musicbrainzws2 v0.14.0
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/text v0.25.0 golang.org/x/text v0.25.0

4
go.sum
View file

@ -132,8 +132,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s= go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s=
go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM= go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM=
go.uploadedlobster.com/musicbrainzws2 v0.14.1-0.20250522060150-50bf4bea5400 h1:wMJloSsyWjfXznQNjvsrqAeL61BGoil7t4H9hPt18fc= go.uploadedlobster.com/musicbrainzws2 v0.14.0 h1:YaEtxNwLSNT1gzFipQ4XlaThNfXjBpzzb4I6WhIeUwg=
go.uploadedlobster.com/musicbrainzws2 v0.14.1-0.20250522060150-50bf4bea5400/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao= go.uploadedlobster.com/musicbrainzws2 v0.14.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -23,7 +23,6 @@ THE SOFTWARE.
package deezer package deezer
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -53,14 +52,14 @@ func NewClient(token oauth2.TokenSource) Client {
} }
} }
func (c Client) UserHistory(ctx context.Context, offset int, limit int) (result HistoryResult, err error) { func (c Client) UserHistory(offset int, limit int) (result HistoryResult, err error) {
const path = "/user/me/history" const path = "/user/me/history"
return listRequest[HistoryResult](ctx, c, path, offset, limit) return listRequest[HistoryResult](c, path, offset, limit)
} }
func (c Client) UserTracks(ctx context.Context, offset int, limit int) (TracksResult, error) { func (c Client) UserTracks(offset int, limit int) (TracksResult, error) {
const path = "/user/me/tracks" const path = "/user/me/tracks"
return listRequest[TracksResult](ctx, c, path, offset, limit) return listRequest[TracksResult](c, path, offset, limit)
} }
func (c Client) setToken(req *resty.Request) error { func (c Client) setToken(req *resty.Request) error {
@ -73,9 +72,8 @@ func (c Client) setToken(req *resty.Request) error {
return nil return nil
} }
func listRequest[T Result](ctx context.Context, c Client, path string, offset int, limit int) (result T, err error) { func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
request := c.HTTPClient.R(). request := c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"index": strconv.Itoa(offset), "index": strconv.Itoa(offset),
"limit": strconv.Itoa(limit), "limit": strconv.Itoa(limit),

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -23,7 +23,6 @@ THE SOFTWARE.
package deezer_test package deezer_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -49,8 +48,7 @@ func TestGetUserHistory(t *testing.T) {
"https://api.deezer.com/user/me/history", "https://api.deezer.com/user/me/history",
"testdata/user-history.json") "testdata/user-history.json")
ctx := context.Background() result, err := client.UserHistory(0, 2)
result, err := client.UserHistory(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -71,8 +69,7 @@ func TestGetUserTracks(t *testing.T) {
"https://api.deezer.com/user/me/tracks", "https://api.deezer.com/user/me/tracks",
"testdata/user-tracks.json") "testdata/user-tracks.json")
ctx := context.Background() result, err := client.UserTracks(0, 2)
result, err := client.UserTracks(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)

View file

@ -16,7 +16,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package deezer package deezer
import ( import (
"context"
"fmt" "fmt"
"math" "math"
"net/url" "net/url"
@ -78,7 +77,7 @@ func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *DeezerApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
offset := math.MaxInt32 offset := math.MaxInt32
@ -97,7 +96,7 @@ func (b *DeezerApiBackend) ExportListens(ctx context.Context, oldestTimestamp ti
out: out:
for { for {
result, err := b.client.UserHistory(ctx, offset, perPage) result, err := b.client.UserHistory(offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -154,7 +153,7 @@ out:
progress <- p progress <- p
} }
func (b *DeezerApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
offset := math.MaxInt32 offset := math.MaxInt32
@ -169,7 +168,7 @@ func (b *DeezerApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time
out: out:
for { for {
result, err := b.client.UserTracks(ctx, offset, perPage) result, err := b.client.UserTracks(offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p

View file

@ -17,7 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package dump package dump
import ( import (
"context"
"fmt" "fmt"
"go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/config"
@ -37,12 +36,8 @@ func (b *DumpBackend) InitConfig(config *config.ServiceConfig) error {
func (b *DumpBackend) StartImport() error { return nil } func (b *DumpBackend) StartImport() error { return nil }
func (b *DumpBackend) FinishImport() error { return nil } func (b *DumpBackend) FinishImport() error { return nil }
func (b *DumpBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *DumpBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
for _, listen := range export.Items { for _, listen := range export.Items {
if err := ctx.Err(); err != nil {
return importResult, err
}
importResult.UpdateTimestamp(listen.ListenedAt) importResult.UpdateTimestamp(listen.ListenedAt)
importResult.ImportCount += 1 importResult.ImportCount += 1
msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)", msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)",
@ -54,12 +49,8 @@ func (b *DumpBackend) ImportListens(ctx context.Context, export models.ListensRe
return importResult, nil return importResult, nil
} }
func (b *DumpBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
for _, love := range export.Items { for _, love := range export.Items {
if err := ctx.Err(); err != nil {
return importResult, err
}
importResult.UpdateTimestamp(love.Created) importResult.UpdateTimestamp(love.Created)
importResult.ImportCount += 1 importResult.ImportCount += 1
msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)", msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)",

View file

@ -16,7 +16,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package backends package backends
import ( import (
"context"
"sync" "sync"
"time" "time"
@ -25,7 +24,7 @@ import (
type ExportProcessor[T models.ListensResult | models.LovesResult] interface { type ExportProcessor[T models.ListensResult | models.LovesResult] interface {
ExportBackend() models.Backend ExportBackend() models.Backend
Process(ctx context.Context, wg *sync.WaitGroup, oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress) Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress)
} }
type ListensExportProcessor struct { type ListensExportProcessor struct {
@ -36,11 +35,11 @@ func (p ListensExportProcessor) ExportBackend() models.Backend {
return p.Backend return p.Backend
} }
func (p ListensExportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (p ListensExportProcessor) Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
wg.Add(1) wg.Add(1)
defer wg.Done() defer wg.Done()
defer close(results) defer close(results)
p.Backend.ExportListens(ctx, oldestTimestamp, results, progress) p.Backend.ExportListens(oldestTimestamp, results, progress)
} }
type LovesExportProcessor struct { type LovesExportProcessor struct {
@ -51,9 +50,9 @@ func (p LovesExportProcessor) ExportBackend() models.Backend {
return p.Backend return p.Backend
} }
func (p LovesExportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (p LovesExportProcessor) Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
wg.Add(1) wg.Add(1)
defer wg.Done() defer wg.Done()
defer close(results) defer close(results)
p.Backend.ExportLoves(ctx, oldestTimestamp, results, progress) p.Backend.ExportLoves(oldestTimestamp, results, progress)
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package funkwhale package funkwhale
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -55,10 +54,15 @@ func NewClient(serverURL string, token string) Client {
} }
} }
func (c Client) GetHistoryListenings(ctx context.Context, user string, page int, perPage int) (result ListeningsResult, err error) { func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
const path = "/api/v1/history/listenings" const path = "/api/v1/history/listenings"
response, err := c.buildListRequest(ctx, page, perPage). response, err := c.HTTPClient.R().
SetQueryParam("username", user). SetQueryParams(map[string]string{
"username": user,
"page": strconv.Itoa(page),
"page_size": strconv.Itoa(perPage),
"ordering": "-creation_date",
}).
SetResult(&result). SetResult(&result).
Get(path) Get(path)
@ -69,25 +73,20 @@ func (c Client) GetHistoryListenings(ctx context.Context, user string, page int,
return return
} }
func (c Client) GetFavoriteTracks(ctx context.Context, page int, perPage int) (result FavoriteTracksResult, err error) { func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) {
const path = "/api/v1/favorites/tracks" const path = "/api/v1/favorites/tracks"
response, err := c.buildListRequest(ctx, page, perPage). response, err := c.HTTPClient.R().
SetResult(&result).
Get(path)
if !response.IsSuccess() {
err = errors.New(response.String())
return
}
return
}
func (c Client) buildListRequest(ctx context.Context, page int, perPage int) *resty.Request {
return c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"page": strconv.Itoa(page), "page": strconv.Itoa(page),
"page_size": strconv.Itoa(perPage), "page_size": strconv.Itoa(perPage),
"ordering": "-creation_date", "ordering": "-creation_date",
}) }).
SetResult(&result).
Get(path)
if !response.IsSuccess() {
err = errors.New(response.String())
return
}
return
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package funkwhale_test package funkwhale_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -50,8 +49,7 @@ func TestGetHistoryListenings(t *testing.T) {
"https://funkwhale.example.com/api/v1/history/listenings", "https://funkwhale.example.com/api/v1/history/listenings",
"testdata/listenings.json") "testdata/listenings.json")
ctx := context.Background() result, err := client.GetHistoryListenings("outsidecontext", 0, 2)
result, err := client.GetHistoryListenings(ctx, "outsidecontext", 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -75,8 +73,7 @@ func TestGetFavoriteTracks(t *testing.T) {
"https://funkwhale.example.com/api/v1/favorites/tracks", "https://funkwhale.example.com/api/v1/favorites/tracks",
"testdata/favorite-tracks.json") "testdata/favorite-tracks.json")
ctx := context.Background() result, err := client.GetFavoriteTracks(0, 2)
result, err := client.GetFavoriteTracks(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)

View file

@ -17,7 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package funkwhale package funkwhale
import ( import (
"context"
"sort" "sort"
"time" "time"
@ -61,7 +60,7 @@ func (b *FunkwhaleApiBackend) InitConfig(config *config.ServiceConfig) error {
return nil return nil
} }
func (b *FunkwhaleApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -75,7 +74,7 @@ func (b *FunkwhaleApiBackend) ExportListens(ctx context.Context, oldestTimestamp
out: out:
for { for {
result, err := b.client.GetHistoryListenings(ctx, b.username, page, perPage) result, err := b.client.GetHistoryListenings(b.username, page, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -118,7 +117,7 @@ out:
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *FunkwhaleApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -132,7 +131,7 @@ func (b *FunkwhaleApiBackend) ExportLoves(ctx context.Context, oldestTimestamp t
out: out:
for { for {
result, err := b.client.GetFavoriteTracks(ctx, page, perPage) result, err := b.client.GetFavoriteTracks(page, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p

View file

@ -18,7 +18,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package backends package backends
import ( import (
"context"
"sync" "sync"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
@ -26,8 +25,8 @@ import (
type ImportProcessor[T models.ListensResult | models.LovesResult] interface { type ImportProcessor[T models.ListensResult | models.LovesResult] interface {
ImportBackend() models.ImportBackend ImportBackend() models.ImportBackend
Process(ctx context.Context, wg *sync.WaitGroup, results chan T, out chan models.ImportResult, progress chan models.TransferProgress) Process(wg *sync.WaitGroup, results chan T, out chan models.ImportResult, progress chan models.TransferProgress)
Import(ctx context.Context, export T, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) Import(export T, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error)
} }
type ListensImportProcessor struct { type ListensImportProcessor struct {
@ -38,11 +37,11 @@ func (p ListensImportProcessor) ImportBackend() models.ImportBackend {
return p.Backend return p.Backend
} }
func (p ListensImportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, results chan models.ListensResult, out chan models.ImportResult, progress chan models.TransferProgress) { func (p ListensImportProcessor) Process(wg *sync.WaitGroup, results chan models.ListensResult, out chan models.ImportResult, progress chan models.TransferProgress) {
process(ctx, wg, p, results, out, progress) process(wg, p, results, out, progress)
} }
func (p ListensImportProcessor) Import(ctx context.Context, export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (p ListensImportProcessor) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
if export.Error != nil { if export.Error != nil {
return result, export.Error return result, export.Error
} }
@ -52,7 +51,7 @@ func (p ListensImportProcessor) Import(ctx context.Context, export models.Listen
} else { } else {
result.TotalCount += len(export.Items) result.TotalCount += len(export.Items)
} }
importResult, err := p.Backend.ImportListens(ctx, export, result, progress) importResult, err := p.Backend.ImportListens(export, result, progress)
if err != nil { if err != nil {
return importResult, err return importResult, err
} }
@ -67,11 +66,11 @@ func (p LovesImportProcessor) ImportBackend() models.ImportBackend {
return p.Backend return p.Backend
} }
func (p LovesImportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, results chan models.LovesResult, out chan models.ImportResult, progress chan models.TransferProgress) { func (p LovesImportProcessor) Process(wg *sync.WaitGroup, results chan models.LovesResult, out chan models.ImportResult, progress chan models.TransferProgress) {
process(ctx, wg, p, results, out, progress) process(wg, p, results, out, progress)
} }
func (p LovesImportProcessor) Import(ctx context.Context, export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (p LovesImportProcessor) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
if export.Error != nil { if export.Error != nil {
return result, export.Error return result, export.Error
} }
@ -81,19 +80,14 @@ func (p LovesImportProcessor) Import(ctx context.Context, export models.LovesRes
} else { } else {
result.TotalCount += len(export.Items) result.TotalCount += len(export.Items)
} }
importResult, err := p.Backend.ImportLoves(ctx, export, result, progress) importResult, err := p.Backend.ImportLoves(export, result, progress)
if err != nil { if err != nil {
return importResult, err return importResult, err
} }
return importResult, nil return importResult, nil
} }
func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](wg *sync.WaitGroup, processor P, results chan R, out chan models.ImportResult, progress chan models.TransferProgress) {
ctx context.Context, wg *sync.WaitGroup,
processor P, results chan R,
out chan models.ImportResult,
progress chan models.TransferProgress,
) {
wg.Add(1) wg.Add(1)
defer wg.Done() defer wg.Done()
defer close(out) defer close(out)
@ -106,13 +100,7 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](
} }
for exportResult := range results { for exportResult := range results {
if err := ctx.Err(); err != nil { importResult, err := processor.Import(exportResult, result, out, progress)
processor.ImportBackend().FinishImport()
out <- handleError(result, err, progress)
return
}
importResult, err := processor.Import(ctx, exportResult, result, out, progress)
result.Update(importResult) result.Update(importResult)
if err != nil { if err != nil {
processor.ImportBackend().FinishImport() processor.ImportBackend().FinishImport()

View file

@ -18,7 +18,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package jspf package jspf
import ( import (
"context"
"errors" "errors"
"os" "os"
"sort" "sort"
@ -94,7 +93,7 @@ func (b *JSPFBackend) FinishImport() error {
return b.writeJSPF() return b.writeJSPF()
} }
func (b *JSPFBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
err := b.readJSPF() err := b.readJSPF()
p := models.TransferProgress{ p := models.TransferProgress{
Export: &models.Progress{}, Export: &models.Progress{},
@ -121,12 +120,8 @@ func (b *JSPFBackend) ExportListens(ctx context.Context, oldestTimestamp time.Ti
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *JSPFBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
for _, listen := range export.Items { for _, listen := range export.Items {
if err := ctx.Err(); err != nil {
return importResult, err
}
track := listenAsTrack(listen) track := listenAsTrack(listen)
b.playlist.Tracks = append(b.playlist.Tracks, track) b.playlist.Tracks = append(b.playlist.Tracks, track)
importResult.ImportCount += 1 importResult.ImportCount += 1
@ -137,7 +132,7 @@ func (b *JSPFBackend) ImportListens(ctx context.Context, export models.ListensRe
return importResult, nil return importResult, nil
} }
func (b *JSPFBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
err := b.readJSPF() err := b.readJSPF()
p := models.TransferProgress{ p := models.TransferProgress{
Export: &models.Progress{}, Export: &models.Progress{},
@ -164,12 +159,8 @@ func (b *JSPFBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time
results <- models.LovesResult{Items: loves} results <- models.LovesResult{Items: loves}
} }
func (b *JSPFBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
for _, love := range export.Items { for _, love := range export.Items {
if err := ctx.Err(); err != nil {
return importResult, err
}
track := loveAsTrack(love) track := loveAsTrack(love)
b.playlist.Tracks = append(b.playlist.Tracks, track) b.playlist.Tracks = append(b.playlist.Tracks, track)
importResult.ImportCount += 1 importResult.ImportCount += 1

View file

@ -16,7 +16,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package lastfm package lastfm
import ( import (
"context"
"fmt" "fmt"
"net/url" "net/url"
"sort" "sort"
@ -89,7 +88,7 @@ func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *LastfmApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
page := MaxPage page := MaxPage
minTime := oldestTimestamp minTime := oldestTimestamp
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -103,13 +102,6 @@ func (b *LastfmApiBackend) ExportListens(ctx context.Context, oldestTimestamp ti
out: out:
for page > 0 { for page > 0 {
if err := ctx.Err(); err != nil {
results <- models.ListensResult{Error: err}
p.Export.Abort()
progress <- p
return
}
args := lastfm.P{ args := lastfm.P{
"user": b.username, "user": b.username,
"limit": MaxListensPerGet, "limit": MaxListensPerGet,
@ -190,13 +182,9 @@ out:
progress <- p progress <- p
} }
func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
total := len(export.Items) total := len(export.Items)
for i := 0; i < total; i += MaxListensPerSubmission { for i := 0; i < total; i += MaxListensPerSubmission {
if err := ctx.Err(); err != nil {
return importResult, err
}
listens := export.Items[i:min(i+MaxListensPerSubmission, total)] listens := export.Items[i:min(i+MaxListensPerSubmission, total)]
count := len(listens) count := len(listens)
if count == 0 { if count == 0 {
@ -270,7 +258,7 @@ func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.List
return importResult, nil return importResult, nil
} }
func (b *LastfmApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
page := 1 page := 1
@ -286,13 +274,6 @@ func (b *LastfmApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time
out: out:
for { for {
if err := ctx.Err(); err != nil {
results <- models.LovesResult{Error: err}
p.Export.Abort()
progress <- p
return
}
result, err := b.client.User.GetLovedTracks(lastfm.P{ result, err := b.client.User.GetLovedTracks(lastfm.P{
"user": b.username, "user": b.username,
"limit": MaxItemsPerGet, "limit": MaxItemsPerGet,
@ -354,12 +335,8 @@ out:
results <- models.LovesResult{Items: loves, Total: totalCount} results <- models.LovesResult{Items: loves, Total: totalCount}
} }
func (b *LastfmApiBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
for _, love := range export.Items { for _, love := range export.Items {
if err := ctx.Err(); err != nil {
return importResult, err
}
err := b.client.Track.Love(lastfm.P{ err := b.client.Track.Love(lastfm.P{
"track": love.TrackName, "track": love.TrackName,
"artist": love.ArtistName(), "artist": love.ArtistName(),

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package listenbrainz package listenbrainz
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
"time" "time"
@ -61,11 +60,10 @@ func NewClient(token string) Client {
} }
} }
func (c Client) GetListens(ctx context.Context, user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) { func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
const path = "/user/{username}/listens" const path = "/user/{username}/listens"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetPathParam("username", user). SetPathParam("username", user).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"max_ts": strconv.FormatInt(maxTime.Unix(), 10), "max_ts": strconv.FormatInt(maxTime.Unix(), 10),
@ -83,11 +81,10 @@ func (c Client) GetListens(ctx context.Context, user string, maxTime time.Time,
return return
} }
func (c Client) SubmitListens(ctx context.Context, listens ListenSubmission) (result StatusResult, err error) { func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
const path = "/submit-listens" const path = "/submit-listens"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(listens). SetBody(listens).
SetResult(&result). SetResult(&result).
SetError(&errorResult). SetError(&errorResult).
@ -100,11 +97,10 @@ func (c Client) SubmitListens(ctx context.Context, listens ListenSubmission) (re
return return
} }
func (c Client) GetFeedback(ctx context.Context, user string, status int, offset int) (result GetFeedbackResult, err error) { func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
const path = "/feedback/user/{username}/get-feedback" const path = "/feedback/user/{username}/get-feedback"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetPathParam("username", user). SetPathParam("username", user).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"status": strconv.Itoa(status), "status": strconv.Itoa(status),
@ -123,11 +119,10 @@ func (c Client) GetFeedback(ctx context.Context, user string, status int, offset
return return
} }
func (c Client) SendFeedback(ctx context.Context, feedback Feedback) (result StatusResult, err error) { func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
const path = "/feedback/recording-feedback" const path = "/feedback/recording-feedback"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(feedback). SetBody(feedback).
SetResult(&result). SetResult(&result).
SetError(&errorResult). SetError(&errorResult).
@ -140,11 +135,10 @@ func (c Client) SendFeedback(ctx context.Context, feedback Feedback) (result Sta
return return
} }
func (c Client) Lookup(ctx context.Context, recordingName string, artistName string) (result LookupResult, err error) { func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
const path = "/metadata/lookup" const path = "/metadata/lookup"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"recording_name": recordingName, "recording_name": recordingName,
"artist_name": artistName, "artist_name": artistName,

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package listenbrainz_test package listenbrainz_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
"time" "time"
@ -50,9 +49,7 @@ func TestGetListens(t *testing.T) {
"https://api.listenbrainz.org/1/user/outsidecontext/listens", "https://api.listenbrainz.org/1/user/outsidecontext/listens",
"testdata/listens.json") "testdata/listens.json")
ctx := context.Background() result, err := client.GetListens("outsidecontext", time.Now(), time.Now().Add(-2*time.Hour))
result, err := client.GetListens(ctx, "outsidecontext",
time.Now(), time.Now().Add(-2*time.Hour))
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -95,8 +92,8 @@ func TestSubmitListens(t *testing.T) {
}, },
}, },
} }
ctx := context.Background() result, err := client.SubmitListens(listens)
result, err := client.SubmitListens(ctx, listens) require.NoError(t, err)
assert.Equal(t, "ok", result.Status) assert.Equal(t, "ok", result.Status)
} }
@ -110,8 +107,7 @@ func TestGetFeedback(t *testing.T) {
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback", "https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
"testdata/feedback.json") "testdata/feedback.json")
ctx := context.Background() result, err := client.GetFeedback("outsidecontext", 1, 3)
result, err := client.GetFeedback(ctx, "outsidecontext", 1, 0)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -139,8 +135,7 @@ func TestSendFeedback(t *testing.T) {
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
Score: 1, Score: 1,
} }
ctx := context.Background() result, err := client.SendFeedback(feedback)
result, err := client.SendFeedback(ctx, feedback)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "ok", result.Status) assert.Equal(t, "ok", result.Status)
@ -154,8 +149,7 @@ func TestLookup(t *testing.T) {
"https://api.listenbrainz.org/1/metadata/lookup", "https://api.listenbrainz.org/1/metadata/lookup",
"testdata/lookup.json") "testdata/lookup.json")
ctx := context.Background() result, err := client.Lookup("Paradise Lost", "Say Just Words")
result, err := client.Lookup(ctx, "Paradise Lost", "Say Just Words")
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)

View file

@ -17,7 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package listenbrainz package listenbrainz
import ( import (
"context"
"fmt" "fmt"
"sort" "sort"
"time" "time"
@ -73,7 +72,7 @@ func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error
func (b *ListenBrainzApiBackend) StartImport() error { return nil } func (b *ListenBrainzApiBackend) StartImport() error { return nil }
func (b *ListenBrainzApiBackend) FinishImport() error { return nil } func (b *ListenBrainzApiBackend) FinishImport() error { return nil }
func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
startTime := time.Now() startTime := time.Now()
minTime := oldestTimestamp minTime := oldestTimestamp
if minTime.Unix() < 1 { if minTime.Unix() < 1 {
@ -88,7 +87,7 @@ func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimest
} }
for { for {
result, err := b.client.GetListens(ctx, b.username, time.Now(), minTime) result, err := b.client.GetListens(b.username, time.Now(), minTime)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -135,7 +134,7 @@ func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimest
progress <- p progress <- p
} }
func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
total := len(export.Items) total := len(export.Items)
p := models.TransferProgress{}.FromImportResult(importResult, false) p := models.TransferProgress{}.FromImportResult(importResult, false)
for i := 0; i < total; i += MaxListensPerRequest { for i := 0; i < total; i += MaxListensPerRequest {
@ -152,7 +151,7 @@ func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export model
for _, l := range listens { for _, l := range listens {
if b.checkDuplicates { if b.checkDuplicates {
isDupe, err := b.checkDuplicateListen(ctx, l) isDupe, err := b.checkDuplicateListen(l)
p.Import.Elapsed += 1 p.Import.Elapsed += 1
progress <- p progress <- p
if err != nil { if err != nil {
@ -183,7 +182,7 @@ func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export model
} }
if len(submission.Payload) > 0 { if len(submission.Payload) > 0 {
_, err := b.client.SubmitListens(ctx, submission) _, err := b.client.SubmitListens(submission)
if err != nil { if err != nil {
return importResult, err return importResult, err
} }
@ -199,13 +198,13 @@ func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export model
return importResult, nil return importResult, nil
} }
func (b *ListenBrainzApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
exportChan := make(chan models.LovesResult) exportChan := make(chan models.LovesResult)
p := models.TransferProgress{ p := models.TransferProgress{
Export: &models.Progress{}, Export: &models.Progress{},
} }
go b.exportLoves(ctx, oldestTimestamp, exportChan) go b.exportLoves(oldestTimestamp, exportChan)
for existingLoves := range exportChan { for existingLoves := range exportChan {
if existingLoves.Error != nil { if existingLoves.Error != nil {
p.Export.Abort() p.Export.Abort()
@ -225,14 +224,14 @@ func (b *ListenBrainzApiBackend) ExportLoves(ctx context.Context, oldestTimestam
progress <- p progress <- p
} }
func (b *ListenBrainzApiBackend) exportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult) { func (b *ListenBrainzApiBackend) exportLoves(oldestTimestamp time.Time, results chan models.LovesResult) {
offset := 0 offset := 0
defer close(results) defer close(results)
loves := make(models.LovesList, 0, 2*MaxItemsPerGet) loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
out: out:
for { for {
result, err := b.client.GetFeedback(ctx, b.username, 1, offset) result, err := b.client.GetFeedback(b.username, 1, offset)
if err != nil { if err != nil {
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
@ -248,7 +247,7 @@ out:
// longer available and might have been merged. Try fetching details // longer available and might have been merged. Try fetching details
// from MusicBrainz. // from MusicBrainz.
if feedback.TrackMetadata == nil { if feedback.TrackMetadata == nil {
track, err := b.lookupRecording(ctx, feedback.RecordingMBID) track, err := b.lookupRecording(feedback.RecordingMBID)
if err == nil { if err == nil {
feedback.TrackMetadata = track feedback.TrackMetadata = track
} }
@ -272,10 +271,10 @@ out:
} }
} }
func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
if len(b.existingMBIDs) == 0 { if len(b.existingMBIDs) == 0 {
existingLovesChan := make(chan models.LovesResult) existingLovesChan := make(chan models.LovesResult)
go b.exportLoves(ctx, time.Unix(0, 0), existingLovesChan) go b.exportLoves(time.Unix(0, 0), existingLovesChan)
// TODO: Store MBIDs directly // TODO: Store MBIDs directly
b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet) b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet)
@ -304,7 +303,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.
} }
if recordingMBID == "" { if recordingMBID == "" {
lookup, err := b.client.Lookup(ctx, love.TrackName, love.ArtistName()) lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
if err == nil { if err == nil {
recordingMBID = lookup.RecordingMBID recordingMBID = lookup.RecordingMBID
} }
@ -316,7 +315,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.
if b.existingMBIDs[recordingMBID] { if b.existingMBIDs[recordingMBID] {
ok = true ok = true
} else { } else {
resp, err := b.client.SendFeedback(ctx, Feedback{ resp, err := b.client.SendFeedback(Feedback{
RecordingMBID: recordingMBID, RecordingMBID: recordingMBID,
Score: 1, Score: 1,
}) })
@ -352,7 +351,7 @@ var defaultDuration = time.Duration(3 * time.Minute)
const trackSimilarityThreshold = 0.9 const trackSimilarityThreshold = 0.9
func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, listen models.Listen) (bool, error) { func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (bool, error) {
// Find listens // Find listens
duration := listen.Duration duration := listen.Duration
if duration == 0 { if duration == 0 {
@ -360,7 +359,7 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, liste
} }
minTime := listen.ListenedAt.Add(-duration) minTime := listen.ListenedAt.Add(-duration)
maxTime := listen.ListenedAt.Add(duration) maxTime := listen.ListenedAt.Add(duration)
candidates, err := b.client.GetListens(ctx, b.username, maxTime, minTime) candidates, err := b.client.GetListens(b.username, maxTime, minTime)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -375,11 +374,11 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, liste
return false, nil return false, nil
} }
func (b *ListenBrainzApiBackend) lookupRecording(ctx context.Context, mbid mbtypes.MBID) (*Track, error) { func (b *ListenBrainzApiBackend) lookupRecording(mbid mbtypes.MBID) (*Track, error) {
filter := musicbrainzws2.IncludesFilter{ filter := musicbrainzws2.IncludesFilter{
Includes: []string{"artist-credits"}, Includes: []string{"artist-credits"},
} }
recording, err := b.mbClient.LookupRecording(ctx, mbid, filter) recording, err := b.mbClient.LookupRecording(mbid, filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package maloja package maloja
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -49,10 +48,9 @@ func NewClient(serverURL string, token string) Client {
} }
} }
func (c Client) GetScrobbles(ctx context.Context, page int, perPage int) (result GetScrobblesResult, err error) { func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
const path = "/apis/mlj_1/scrobbles" const path = "/apis/mlj_1/scrobbles"
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"page": strconv.Itoa(page), "page": strconv.Itoa(page),
"perpage": strconv.Itoa(perPage), "perpage": strconv.Itoa(perPage),
@ -67,11 +65,10 @@ func (c Client) GetScrobbles(ctx context.Context, page int, perPage int) (result
return return
} }
func (c Client) NewScrobble(ctx context.Context, scrobble NewScrobble) (result NewScrobbleResult, err error) { func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) {
const path = "/apis/mlj_1/newscrobble" const path = "/apis/mlj_1/newscrobble"
scrobble.Key = c.token scrobble.Key = c.token
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(scrobble). SetBody(scrobble).
SetResult(&result). SetResult(&result).
Post(path) Post(path)

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package maloja_test package maloja_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -49,8 +48,7 @@ func TestGetScrobbles(t *testing.T) {
"https://maloja.example.com/apis/mlj_1/scrobbles", "https://maloja.example.com/apis/mlj_1/scrobbles",
"testdata/scrobbles.json") "testdata/scrobbles.json")
ctx := context.Background() result, err := client.GetScrobbles(0, 2)
result, err := client.GetScrobbles(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -71,13 +69,12 @@ func TestNewScrobble(t *testing.T) {
url := server + "/apis/mlj_1/newscrobble" url := server + "/apis/mlj_1/newscrobble"
httpmock.RegisterResponder("POST", url, responder) httpmock.RegisterResponder("POST", url, responder)
ctx := context.Background()
scrobble := maloja.NewScrobble{ scrobble := maloja.NewScrobble{
Title: "Oweynagat", Title: "Oweynagat",
Artist: "Dool", Artist: "Dool",
Time: 1699574369, Time: 1699574369,
} }
result, err := client.NewScrobble(ctx, scrobble) result, err := client.NewScrobble(scrobble)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "success", result.Status) assert.Equal(t, "success", result.Status)

View file

@ -17,7 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package maloja package maloja
import ( import (
"context"
"errors" "errors"
"sort" "sort"
"strings" "strings"
@ -64,7 +63,7 @@ func (b *MalojaApiBackend) InitConfig(config *config.ServiceConfig) error {
func (b *MalojaApiBackend) StartImport() error { return nil } func (b *MalojaApiBackend) StartImport() error { return nil }
func (b *MalojaApiBackend) FinishImport() error { return nil } func (b *MalojaApiBackend) FinishImport() error { return nil }
func (b *MalojaApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *MalojaApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
page := 0 page := 0
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -78,7 +77,7 @@ func (b *MalojaApiBackend) ExportListens(ctx context.Context, oldestTimestamp ti
out: out:
for { for {
result, err := b.client.GetScrobbles(ctx, page, perPage) result, err := b.client.GetScrobbles(page, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -112,7 +111,7 @@ out:
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *MalojaApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
p := models.TransferProgress{}.FromImportResult(importResult, false) p := models.TransferProgress{}.FromImportResult(importResult, false)
for _, listen := range export.Items { for _, listen := range export.Items {
scrobble := NewScrobble{ scrobble := NewScrobble{
@ -125,7 +124,7 @@ func (b *MalojaApiBackend) ImportListens(ctx context.Context, export models.List
Nofix: b.nofix, Nofix: b.nofix,
} }
resp, err := b.client.NewScrobble(ctx, scrobble) resp, err := b.client.NewScrobble(scrobble)
if err != nil { if err != nil {
return importResult, err return importResult, err
} else if resp.Status != "success" { } else if resp.Status != "success" {

View file

@ -17,7 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package scrobblerlog package scrobblerlog
import ( import (
"context" "bufio"
"fmt" "fmt"
"os" "os"
"sort" "sort"
@ -105,7 +105,8 @@ func (b *ScrobblerLogBackend) StartImport() error {
b.append = false b.append = false
} else { } else {
// Verify existing file is a scrobbler log // Verify existing file is a scrobbler log
if err = b.log.ReadHeader(file); err != nil { reader := bufio.NewReader(file)
if err = b.log.ReadHeader(reader); err != nil {
file.Close() file.Close()
return err return err
} }
@ -130,7 +131,7 @@ func (b *ScrobblerLogBackend) FinishImport() error {
return b.file.Close() return b.file.Close()
} }
func (b *ScrobblerLogBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
file, err := os.Open(b.filePath) file, err := os.Open(b.filePath)
p := models.TransferProgress{ p := models.TransferProgress{
Export: &models.Progress{}, Export: &models.Progress{},
@ -167,7 +168,7 @@ func (b *ScrobblerLogBackend) ExportListens(ctx context.Context, oldestTimestamp
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *ScrobblerLogBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
records := make([]scrobblerlog.Record, len(export.Items)) records := make([]scrobblerlog.Record, len(export.Items))
for i, listen := range export.Items { for i, listen := range export.Items {
records[i] = listenToRecord(listen) records[i] = listenToRecord(listen)

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -59,18 +59,17 @@ func NewClient(token oauth2.TokenSource) Client {
} }
} }
func (c Client) RecentlyPlayedAfter(ctx context.Context, after time.Time, limit int) (RecentlyPlayedResult, error) { func (c Client) RecentlyPlayedAfter(after time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(ctx, &after, nil, limit) return c.recentlyPlayed(&after, nil, limit)
} }
func (c Client) RecentlyPlayedBefore(ctx context.Context, before time.Time, limit int) (RecentlyPlayedResult, error) { func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(ctx, nil, &before, limit) return c.recentlyPlayed(nil, &before, limit)
} }
func (c Client) recentlyPlayed(ctx context.Context, after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) { func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
const path = "/me/player/recently-played" const path = "/me/player/recently-played"
request := c.HTTPClient.R(). request := c.HTTPClient.R().
SetContext(ctx).
SetQueryParam("limit", strconv.Itoa(limit)). SetQueryParam("limit", strconv.Itoa(limit)).
SetResult(&result) SetResult(&result)
if after != nil { if after != nil {
@ -86,10 +85,9 @@ func (c Client) recentlyPlayed(ctx context.Context, after *time.Time, before *ti
return return
} }
func (c Client) UserTracks(ctx context.Context, offset int, limit int) (result TracksResult, err error) { func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
const path = "/me/tracks" const path = "/me/tracks"
response, err := c.HTTPClient.R(). response, err := c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"offset": strconv.Itoa(offset), "offset": strconv.Itoa(offset),
"limit": strconv.Itoa(limit), "limit": strconv.Itoa(limit),

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package spotify_test package spotify_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
"time" "time"
@ -48,8 +47,7 @@ func TestRecentlyPlayedAfter(t *testing.T) {
"https://api.spotify.com/v1/me/player/recently-played", "https://api.spotify.com/v1/me/player/recently-played",
"testdata/recently-played.json") "testdata/recently-played.json")
ctx := context.Background() result, err := client.RecentlyPlayedAfter(time.Now(), 3)
result, err := client.RecentlyPlayedAfter(ctx, time.Now(), 3)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -69,8 +67,7 @@ func TestGetUserTracks(t *testing.T) {
"https://api.spotify.com/v1/me/tracks", "https://api.spotify.com/v1/me/tracks",
"testdata/user-tracks.json") "testdata/user-tracks.json")
ctx := context.Background() result, err := client.UserTracks(0, 2)
result, err := client.UserTracks(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)

View file

@ -18,7 +18,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package spotify package spotify
import ( import (
"context"
"math" "math"
"net/url" "net/url"
"sort" "sort"
@ -96,7 +95,7 @@ func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
startTime := time.Now() startTime := time.Now()
minTime := oldestTimestamp minTime := oldestTimestamp
@ -108,7 +107,7 @@ func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp t
} }
for { for {
result, err := b.client.RecentlyPlayedAfter(ctx, minTime, MaxItemsPerGet) result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -163,7 +162,7 @@ func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp t
progress <- p progress <- p
} }
func (b *SpotifyApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
offset := math.MaxInt32 offset := math.MaxInt32
@ -179,7 +178,7 @@ func (b *SpotifyApiBackend) ExportLoves(ctx context.Context, oldestTimestamp tim
out: out:
for { for {
result, err := b.client.UserTracks(ctx, offset, perPage) result, err := b.client.UserTracks(offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p

View file

@ -18,7 +18,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package spotifyhistory package spotifyhistory
import ( import (
"context"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@ -73,7 +72,7 @@ func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error {
return nil return nil
} }
func (b *SpotifyHistoryBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob)) files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob))
p := models.TransferProgress{ p := models.TransferProgress{
Export: &models.Progress{}, Export: &models.Progress{},
@ -90,18 +89,11 @@ func (b *SpotifyHistoryBackend) ExportListens(ctx context.Context, oldestTimesta
fileCount := int64(len(files)) fileCount := int64(len(files))
p.Export.Total = fileCount p.Export.Total = fileCount
for i, filePath := range files { for i, filePath := range files {
if err := ctx.Err(); err != nil {
results <- models.ListensResult{Error: err}
p.Export.Abort()
progress <- p
return
}
history, err := readHistoryFile(filePath) history, err := readHistoryFile(filePath)
if err != nil { if err != nil {
results <- models.ListensResult{Error: err}
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
results <- models.ListensResult{Error: err}
return return
} }
listens := history.AsListenList(ListenListOptions{ listens := history.AsListenList(ListenListOptions{

View file

@ -17,7 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package subsonic package subsonic
import ( import (
"context"
"net/http" "net/http"
"sort" "sort"
"time" "time"
@ -64,7 +63,7 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error {
return nil return nil
} }
func (b *SubsonicApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
err := b.client.Authenticate(b.password) err := b.client.Authenticate(b.password)
p := models.TransferProgress{ p := models.TransferProgress{
Export: &models.Progress{}, Export: &models.Progress{},

View file

@ -18,7 +18,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package cli package cli
import ( import (
"context"
"sync" "sync"
"time" "time"
@ -40,10 +39,9 @@ type progressBarUpdater struct {
importedItems int importedItems int
} }
func setupProgressBars(ctx context.Context, updateChan chan models.TransferProgress) progressBarUpdater { func setupProgressBars(updateChan chan models.TransferProgress) progressBarUpdater {
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
p := mpb.NewWithContext( p := mpb.New(
ctx,
mpb.WithWaitGroup(wg), mpb.WithWaitGroup(wg),
mpb.WithOutput(color.Output), mpb.WithOutput(color.Output),
// mpb.WithWidth(64), // mpb.WithWidth(64),

View file

@ -16,7 +16,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package cli package cli
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
@ -110,32 +109,20 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
} }
printTimestamp("From timestamp: %v (%v)", timestamp) printTimestamp("From timestamp: %v (%v)", timestamp)
// Use a context with cancel to abort the transfer
ctx, cancel := context.WithCancel(context.Background())
// Prepare progress bars // Prepare progress bars
progressChan := make(chan models.TransferProgress) progressChan := make(chan models.TransferProgress)
progress := setupProgressBars(ctx, progressChan) progress := setupProgressBars(progressChan)
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
// Export from source // Export from source
exportChan := make(chan R, 1000) exportChan := make(chan R, 1000)
go exp.Process(ctx, wg, timestamp, exportChan, progressChan) go exp.Process(wg, timestamp, exportChan, progressChan)
// Import into target // Import into target
resultChan := make(chan models.ImportResult) resultChan := make(chan models.ImportResult)
go imp.Process(ctx, wg, exportChan, resultChan, progressChan) go imp.Process(wg, exportChan, resultChan, progressChan)
result := <-resultChan result := <-resultChan
// If the import has errored, the context can be cancelled immediately
if result.Error != nil {
cancel()
} else {
defer cancel()
}
// Wait for all goroutines to finish
wg.Wait() wg.Wait()
progress.close() progress.close()

View file

@ -17,7 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package models package models
import ( import (
"context"
"time" "time"
// "go.uploadedlobster.com/scotty/internal/auth" // "go.uploadedlobster.com/scotty/internal/auth"
@ -56,7 +55,7 @@ type ListensExport interface {
// Returns a list of all listens newer then oldestTimestamp. // Returns a list of all listens newer then oldestTimestamp.
// The returned list of listens is supposed to be ordered by the // The returned list of listens is supposed to be ordered by the
// Listen.ListenedAt timestamp, with the oldest entry first. // Listen.ListenedAt timestamp, with the oldest entry first.
ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan ListensResult, progress chan TransferProgress) ExportListens(oldestTimestamp time.Time, results chan ListensResult, progress chan TransferProgress)
} }
// Must be implemented by services supporting the import of listens. // Must be implemented by services supporting the import of listens.
@ -64,7 +63,7 @@ type ListensImport interface {
ImportBackend ImportBackend
// Imports the given list of listens. // Imports the given list of listens.
ImportListens(ctx context.Context, export ListensResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error) ImportListens(export ListensResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error)
} }
// Must be implemented by services supporting the export of loves. // Must be implemented by services supporting the export of loves.
@ -74,7 +73,7 @@ type LovesExport interface {
// Returns a list of all loves newer then oldestTimestamp. // Returns a list of all loves newer then oldestTimestamp.
// The returned list of listens is supposed to be ordered by the // The returned list of listens is supposed to be ordered by the
// Love.Created timestamp, with the oldest entry first. // Love.Created timestamp, with the oldest entry first.
ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan LovesResult, progress chan TransferProgress) ExportLoves(oldestTimestamp time.Time, results chan LovesResult, progress chan TransferProgress)
} }
// Must be implemented by services supporting the import of loves. // Must be implemented by services supporting the import of loves.
@ -82,5 +81,5 @@ type LovesImport interface {
ImportBackend ImportBackend
// Imports the given list of loves. // Imports the given list of loves.
ImportLoves(ctx context.Context, export LovesResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error) ImportLoves(export LovesResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error)
} }

View file

@ -94,7 +94,7 @@ func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error {
l.Records = make([]Record, 0) l.Records = make([]Record, 0)
reader := bufio.NewReader(data) reader := bufio.NewReader(data)
err := l.readHeader(reader) err := l.ReadHeader(reader)
if err != nil { if err != nil {
return err return err
} }
@ -173,11 +173,7 @@ func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp t
// Parses just the header of a scrobbler log file from the given reader. // Parses just the header of a scrobbler log file from the given reader.
// //
// This function sets [ScrobblerLog.TZ] and [ScrobblerLog.Client]. // This function sets [ScrobblerLog.TZ] and [ScrobblerLog.Client].
func (l *ScrobblerLog) ReadHeader(reader io.Reader) error { func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error {
return l.readHeader(bufio.NewReader(reader))
}
func (l *ScrobblerLog) readHeader(reader *bufio.Reader) error {
// Skip header // Skip header
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
line, _, err := reader.ReadLine() line, _, err := reader.ReadLine()