Compare commits

...

8 commits

Author SHA1 Message Date
Philipp Wolfer
20853f7601
Simplify context cancellation checks 2025-05-22 14:13:31 +02:00
Philipp Wolfer
4a66e3d432
Pass context to import backends 2025-05-22 11:53:08 +02:00
Philipp Wolfer
26d9f5e840
Pass context to export backends 2025-05-22 11:53:05 +02:00
Philipp Wolfer
b5bca1d4ab
Use context aware musicbrainzws2 2025-05-22 11:51:53 +02:00
Philipp Wolfer
d1642b7f1f
Make web service clients context aware 2025-05-22 11:51:53 +02:00
Philipp Wolfer
adfe3f5771
Use the transfer context also for the progress bars 2025-05-22 11:51:52 +02:00
Philipp Wolfer
3b545a0fd6
Prepare using a context for export / import
This will allow cancelling the export if the import fails
before the export finished.

For now the context isn't passed on to the actual export functions,
hence there is not yet any cancellation happening.
2025-05-22 11:51:51 +02:00
Philipp Wolfer
536fae6a46 ScrobblerLog.ReadHeader now accepts io.Reader 2025-05-22 11:51:23 +02:00
29 changed files with 260 additions and 141 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.0 go.uploadedlobster.com/musicbrainzws2 v0.14.1-0.20250522060150-50bf4bea5400
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.0 h1:YaEtxNwLSNT1gzFipQ4XlaThNfXjBpzzb4I6WhIeUwg= go.uploadedlobster.com/musicbrainzws2 v0.14.1-0.20250522060150-50bf4bea5400 h1:wMJloSsyWjfXznQNjvsrqAeL61BGoil7t4H9hPt18fc=
go.uploadedlobster.com/musicbrainzws2 v0.14.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao= go.uploadedlobster.com/musicbrainzws2 v0.14.1-0.20250522060150-50bf4bea5400/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 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -23,6 +23,7 @@ THE SOFTWARE.
package deezer package deezer
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -52,14 +53,14 @@ func NewClient(token oauth2.TokenSource) Client {
} }
} }
func (c Client) UserHistory(offset int, limit int) (result HistoryResult, err error) { func (c Client) UserHistory(ctx context.Context, offset int, limit int) (result HistoryResult, err error) {
const path = "/user/me/history" const path = "/user/me/history"
return listRequest[HistoryResult](c, path, offset, limit) return listRequest[HistoryResult](ctx, c, path, offset, limit)
} }
func (c Client) UserTracks(offset int, limit int) (TracksResult, error) { func (c Client) UserTracks(ctx context.Context, offset int, limit int) (TracksResult, error) {
const path = "/user/me/tracks" const path = "/user/me/tracks"
return listRequest[TracksResult](c, path, offset, limit) return listRequest[TracksResult](ctx, c, path, offset, limit)
} }
func (c Client) setToken(req *resty.Request) error { func (c Client) setToken(req *resty.Request) error {
@ -72,8 +73,9 @@ func (c Client) setToken(req *resty.Request) error {
return nil return nil
} }
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) { func listRequest[T Result](ctx context.Context, 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 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -23,6 +23,7 @@ THE SOFTWARE.
package deezer_test package deezer_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -48,7 +49,8 @@ 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")
result, err := client.UserHistory(0, 2) ctx := context.Background()
result, err := client.UserHistory(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -69,7 +71,8 @@ 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")
result, err := client.UserTracks(0, 2) ctx := context.Background()
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,6 +16,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package deezer package deezer
import ( import (
"context"
"fmt" "fmt"
"math" "math"
"net/url" "net/url"
@ -77,7 +78,7 @@ func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *DeezerApiBackend) ExportListens(ctx context.Context, 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
@ -96,7 +97,7 @@ func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan
out: out:
for { for {
result, err := b.client.UserHistory(offset, perPage) result, err := b.client.UserHistory(ctx, offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -153,7 +154,7 @@ out:
progress <- p progress <- p
} }
func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *DeezerApiBackend) ExportLoves(ctx context.Context, 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
@ -168,7 +169,7 @@ func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m
out: out:
for { for {
result, err := b.client.UserTracks(offset, perPage) result, err := b.client.UserTracks(ctx, offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p

View file

@ -17,6 +17,7 @@ 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"
@ -36,8 +37,12 @@ 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(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *DumpBackend) ImportListens(ctx context.Context, 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)",
@ -49,8 +54,12 @@ func (b *DumpBackend) ImportListens(export models.ListensResult, importResult mo
return importResult, nil return importResult, nil
} }
func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *DumpBackend) ImportLoves(ctx context.Context, 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,6 +16,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package backends package backends
import ( import (
"context"
"sync" "sync"
"time" "time"
@ -24,7 +25,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(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress) Process(ctx context.Context, wg *sync.WaitGroup, oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress)
} }
type ListensExportProcessor struct { type ListensExportProcessor struct {
@ -35,11 +36,11 @@ func (p ListensExportProcessor) ExportBackend() models.Backend {
return p.Backend return p.Backend
} }
func (p ListensExportProcessor) Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (p ListensExportProcessor) Process(ctx context.Context, 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(oldestTimestamp, results, progress) p.Backend.ExportListens(ctx, oldestTimestamp, results, progress)
} }
type LovesExportProcessor struct { type LovesExportProcessor struct {
@ -50,9 +51,9 @@ func (p LovesExportProcessor) ExportBackend() models.Backend {
return p.Backend return p.Backend
} }
func (p LovesExportProcessor) Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (p LovesExportProcessor) Process(ctx context.Context, 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(oldestTimestamp, results, progress) p.Backend.ExportLoves(ctx, oldestTimestamp, results, progress)
} }

View file

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

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package funkwhale_test package funkwhale_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -49,7 +50,8 @@ 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")
result, err := client.GetHistoryListenings("outsidecontext", 0, 2) ctx := context.Background()
result, err := client.GetHistoryListenings(ctx, "outsidecontext", 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -73,7 +75,8 @@ 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")
result, err := client.GetFavoriteTracks(0, 2) ctx := context.Background()
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,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package funkwhale package funkwhale
import ( import (
"context"
"sort" "sort"
"time" "time"
@ -60,7 +61,7 @@ func (b *FunkwhaleApiBackend) InitConfig(config *config.ServiceConfig) error {
return nil return nil
} }
func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *FunkwhaleApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -74,7 +75,7 @@ func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results c
out: out:
for { for {
result, err := b.client.GetHistoryListenings(b.username, page, perPage) result, err := b.client.GetHistoryListenings(ctx, b.username, page, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -117,7 +118,7 @@ out:
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *FunkwhaleApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -131,7 +132,7 @@ func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results cha
out: out:
for { for {
result, err := b.client.GetFavoriteTracks(page, perPage) result, err := b.client.GetFavoriteTracks(ctx, page, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p

View file

@ -18,6 +18,7 @@ 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"
@ -25,8 +26,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(wg *sync.WaitGroup, results chan T, out chan models.ImportResult, progress chan models.TransferProgress) Process(ctx context.Context, wg *sync.WaitGroup, results chan T, out chan models.ImportResult, progress chan models.TransferProgress)
Import(export T, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) Import(ctx context.Context, export T, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error)
} }
type ListensImportProcessor struct { type ListensImportProcessor struct {
@ -37,11 +38,11 @@ func (p ListensImportProcessor) ImportBackend() models.ImportBackend {
return p.Backend return p.Backend
} }
func (p ListensImportProcessor) Process(wg *sync.WaitGroup, results chan models.ListensResult, out chan models.ImportResult, progress chan models.TransferProgress) { func (p ListensImportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, results chan models.ListensResult, out chan models.ImportResult, progress chan models.TransferProgress) {
process(wg, p, results, out, progress) process(ctx, wg, p, results, out, progress)
} }
func (p ListensImportProcessor) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (p ListensImportProcessor) Import(ctx context.Context, 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
} }
@ -51,7 +52,7 @@ func (p ListensImportProcessor) Import(export models.ListensResult, result model
} else { } else {
result.TotalCount += len(export.Items) result.TotalCount += len(export.Items)
} }
importResult, err := p.Backend.ImportListens(export, result, progress) importResult, err := p.Backend.ImportListens(ctx, export, result, progress)
if err != nil { if err != nil {
return importResult, err return importResult, err
} }
@ -66,11 +67,11 @@ func (p LovesImportProcessor) ImportBackend() models.ImportBackend {
return p.Backend return p.Backend
} }
func (p LovesImportProcessor) Process(wg *sync.WaitGroup, results chan models.LovesResult, out chan models.ImportResult, progress chan models.TransferProgress) { func (p LovesImportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, results chan models.LovesResult, out chan models.ImportResult, progress chan models.TransferProgress) {
process(wg, p, results, out, progress) process(ctx, wg, p, results, out, progress)
} }
func (p LovesImportProcessor) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (p LovesImportProcessor) Import(ctx context.Context, 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
} }
@ -80,14 +81,19 @@ func (p LovesImportProcessor) Import(export models.LovesResult, result models.Im
} else { } else {
result.TotalCount += len(export.Items) result.TotalCount += len(export.Items)
} }
importResult, err := p.Backend.ImportLoves(export, result, progress) importResult, err := p.Backend.ImportLoves(ctx, 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]](wg *sync.WaitGroup, processor P, results chan R, out chan models.ImportResult, progress chan models.TransferProgress) { func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](
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)
@ -100,7 +106,13 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](
} }
for exportResult := range results { for exportResult := range results {
importResult, err := processor.Import(exportResult, result, out, progress) if err := ctx.Err(); err != nil {
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,6 +18,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package jspf package jspf
import ( import (
"context"
"errors" "errors"
"os" "os"
"sort" "sort"
@ -93,7 +94,7 @@ func (b *JSPFBackend) FinishImport() error {
return b.writeJSPF() return b.writeJSPF()
} }
func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *JSPFBackend) ExportListens(ctx context.Context, 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{},
@ -120,8 +121,12 @@ func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan mode
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *JSPFBackend) ImportListens(ctx context.Context, 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
@ -132,7 +137,7 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo
return importResult, nil return importResult, nil
} }
func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *JSPFBackend) ExportLoves(ctx context.Context, 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{},
@ -159,8 +164,12 @@ func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models
results <- models.LovesResult{Items: loves} results <- models.LovesResult{Items: loves}
} }
func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *JSPFBackend) ImportLoves(ctx context.Context, 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,6 +16,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package lastfm package lastfm
import ( import (
"context"
"fmt" "fmt"
"net/url" "net/url"
"sort" "sort"
@ -88,7 +89,7 @@ func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *LastfmApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
page := MaxPage page := MaxPage
minTime := oldestTimestamp minTime := oldestTimestamp
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -102,6 +103,13 @@ func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan
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,
@ -182,9 +190,13 @@ out:
progress <- p progress <- p
} }
func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
total := len(export.Items) total := len(export.Items)
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 {
@ -258,7 +270,7 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
return importResult, nil return importResult, nil
} }
func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *LastfmApiBackend) ExportLoves(ctx context.Context, 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
@ -274,6 +286,13 @@ func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m
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,
@ -335,8 +354,12 @@ out:
results <- models.LovesResult{Items: loves, Total: totalCount} results <- models.LovesResult{Items: loves, Total: totalCount}
} }
func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *LastfmApiBackend) ImportLoves(ctx context.Context, 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 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package listenbrainz package listenbrainz
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
"time" "time"
@ -60,10 +61,11 @@ func NewClient(token string) Client {
} }
} }
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) { func (c Client) GetListens(ctx context.Context, 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),
@ -81,10 +83,11 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
return return
} }
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) { func (c Client) SubmitListens(ctx context.Context, 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).
@ -97,10 +100,11 @@ func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, er
return return
} }
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) { func (c Client) GetFeedback(ctx context.Context, 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),
@ -119,10 +123,11 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
return return
} }
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) { func (c Client) SendFeedback(ctx context.Context, 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).
@ -135,10 +140,11 @@ func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error)
return return
} }
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) { func (c Client) Lookup(ctx context.Context, 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 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package listenbrainz_test package listenbrainz_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
"time" "time"
@ -49,7 +50,9 @@ 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")
result, err := client.GetListens("outsidecontext", time.Now(), time.Now().Add(-2*time.Hour)) ctx := context.Background()
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)
@ -92,8 +95,8 @@ func TestSubmitListens(t *testing.T) {
}, },
}, },
} }
result, err := client.SubmitListens(listens) ctx := context.Background()
require.NoError(t, err) result, err := client.SubmitListens(ctx, listens)
assert.Equal(t, "ok", result.Status) assert.Equal(t, "ok", result.Status)
} }
@ -107,7 +110,8 @@ 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")
result, err := client.GetFeedback("outsidecontext", 1, 3) ctx := context.Background()
result, err := client.GetFeedback(ctx, "outsidecontext", 1, 0)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -135,7 +139,8 @@ func TestSendFeedback(t *testing.T) {
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
Score: 1, Score: 1,
} }
result, err := client.SendFeedback(feedback) ctx := context.Background()
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)
@ -149,7 +154,8 @@ 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")
result, err := client.Lookup("Paradise Lost", "Say Just Words") ctx := context.Background()
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,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package listenbrainz package listenbrainz
import ( import (
"context"
"fmt" "fmt"
"sort" "sort"
"time" "time"
@ -72,7 +73,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(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, 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 {
@ -87,7 +88,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result
} }
for { for {
result, err := b.client.GetListens(b.username, time.Now(), minTime) result, err := b.client.GetListens(ctx, b.username, time.Now(), minTime)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -134,7 +135,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result
progress <- p progress <- p
} }
func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
total := len(export.Items) total := len(export.Items)
p := models.TransferProgress{}.FromImportResult(importResult, false) p := models.TransferProgress{}.FromImportResult(importResult, false)
for i := 0; i < total; i += MaxListensPerRequest { for i := 0; i < total; i += MaxListensPerRequest {
@ -151,7 +152,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
for _, l := range listens { for _, l := range listens {
if b.checkDuplicates { if b.checkDuplicates {
isDupe, err := b.checkDuplicateListen(l) isDupe, err := b.checkDuplicateListen(ctx, l)
p.Import.Elapsed += 1 p.Import.Elapsed += 1
progress <- p progress <- p
if err != nil { if err != nil {
@ -182,7 +183,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
} }
if len(submission.Payload) > 0 { if len(submission.Payload) > 0 {
_, err := b.client.SubmitListens(submission) _, err := b.client.SubmitListens(ctx, submission)
if err != nil { if err != nil {
return importResult, err return importResult, err
} }
@ -198,13 +199,13 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
return importResult, nil return importResult, nil
} }
func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *ListenBrainzApiBackend) ExportLoves(ctx context.Context, 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(oldestTimestamp, exportChan) go b.exportLoves(ctx, oldestTimestamp, exportChan)
for existingLoves := range exportChan { for existingLoves := range exportChan {
if existingLoves.Error != nil { if existingLoves.Error != nil {
p.Export.Abort() p.Export.Abort()
@ -224,14 +225,14 @@ func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results
progress <- p progress <- p
} }
func (b *ListenBrainzApiBackend) exportLoves(oldestTimestamp time.Time, results chan models.LovesResult) { func (b *ListenBrainzApiBackend) exportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult) {
offset := 0 offset := 0
defer close(results) defer close(results)
loves := make(models.LovesList, 0, 2*MaxItemsPerGet) loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
out: out:
for { for {
result, err := b.client.GetFeedback(b.username, 1, offset) result, err := b.client.GetFeedback(ctx, b.username, 1, offset)
if err != nil { if err != nil {
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
@ -247,7 +248,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(feedback.RecordingMBID) track, err := b.lookupRecording(ctx, feedback.RecordingMBID)
if err == nil { if err == nil {
feedback.TrackMetadata = track feedback.TrackMetadata = track
} }
@ -271,10 +272,10 @@ out:
} }
} }
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, 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(time.Unix(0, 0), existingLovesChan) go b.exportLoves(ctx, time.Unix(0, 0), existingLovesChan)
// TODO: Store MBIDs directly // TODO: Store MBIDs directly
b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet) b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet)
@ -303,7 +304,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
} }
if recordingMBID == "" { if recordingMBID == "" {
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName()) lookup, err := b.client.Lookup(ctx, love.TrackName, love.ArtistName())
if err == nil { if err == nil {
recordingMBID = lookup.RecordingMBID recordingMBID = lookup.RecordingMBID
} }
@ -315,7 +316,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
if b.existingMBIDs[recordingMBID] { if b.existingMBIDs[recordingMBID] {
ok = true ok = true
} else { } else {
resp, err := b.client.SendFeedback(Feedback{ resp, err := b.client.SendFeedback(ctx, Feedback{
RecordingMBID: recordingMBID, RecordingMBID: recordingMBID,
Score: 1, Score: 1,
}) })
@ -351,7 +352,7 @@ var defaultDuration = time.Duration(3 * time.Minute)
const trackSimilarityThreshold = 0.9 const trackSimilarityThreshold = 0.9
func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (bool, error) { func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, listen models.Listen) (bool, error) {
// Find listens // Find listens
duration := listen.Duration duration := listen.Duration
if duration == 0 { if duration == 0 {
@ -359,7 +360,7 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (boo
} }
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(b.username, maxTime, minTime) candidates, err := b.client.GetListens(ctx, b.username, maxTime, minTime)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -374,11 +375,11 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (boo
return false, nil return false, nil
} }
func (b *ListenBrainzApiBackend) lookupRecording(mbid mbtypes.MBID) (*Track, error) { func (b *ListenBrainzApiBackend) lookupRecording(ctx context.Context, mbid mbtypes.MBID) (*Track, error) {
filter := musicbrainzws2.IncludesFilter{ filter := musicbrainzws2.IncludesFilter{
Includes: []string{"artist-credits"}, Includes: []string{"artist-credits"},
} }
recording, err := b.mbClient.LookupRecording(mbid, filter) recording, err := b.mbClient.LookupRecording(ctx, mbid, filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package maloja package maloja
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -48,9 +49,10 @@ func NewClient(serverURL string, token string) Client {
} }
} }
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) { func (c Client) GetScrobbles(ctx context.Context, 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),
@ -65,10 +67,11 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
return return
} }
func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) { func (c Client) NewScrobble(ctx context.Context, 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 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package maloja_test package maloja_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -48,7 +49,8 @@ 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")
result, err := client.GetScrobbles(0, 2) ctx := context.Background()
result, err := client.GetScrobbles(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -69,12 +71,13 @@ 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(scrobble) result, err := client.NewScrobble(ctx, scrobble)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "success", result.Status) assert.Equal(t, "success", result.Status)

View file

@ -17,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package maloja package maloja
import ( import (
"context"
"errors" "errors"
"sort" "sort"
"strings" "strings"
@ -63,7 +64,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(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *MalojaApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
page := 0 page := 0
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
@ -77,7 +78,7 @@ func (b *MalojaApiBackend) ExportListens(oldestTimestamp time.Time, results chan
out: out:
for { for {
result, err := b.client.GetScrobbles(page, perPage) result, err := b.client.GetScrobbles(ctx, page, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -111,7 +112,7 @@ out:
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *MalojaApiBackend) ImportListens(ctx context.Context, 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{
@ -124,7 +125,7 @@ func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResu
Nofix: b.nofix, Nofix: b.nofix,
} }
resp, err := b.client.NewScrobble(scrobble) resp, err := b.client.NewScrobble(ctx, 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 (
"bufio" "context"
"fmt" "fmt"
"os" "os"
"sort" "sort"
@ -105,8 +105,7 @@ 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
reader := bufio.NewReader(file) if err = b.log.ReadHeader(file); err != nil {
if err = b.log.ReadHeader(reader); err != nil {
file.Close() file.Close()
return err return err
} }
@ -131,7 +130,7 @@ func (b *ScrobblerLogBackend) FinishImport() error {
return b.file.Close() return b.file.Close()
} }
func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *ScrobblerLogBackend) ExportListens(ctx context.Context, 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{},
@ -168,7 +167,7 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *ScrobblerLogBackend) ImportListens(ctx context.Context, 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 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -59,17 +59,18 @@ func NewClient(token oauth2.TokenSource) Client {
} }
} }
func (c Client) RecentlyPlayedAfter(after time.Time, limit int) (RecentlyPlayedResult, error) { func (c Client) RecentlyPlayedAfter(ctx context.Context, after time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(&after, nil, limit) return c.recentlyPlayed(ctx, &after, nil, limit)
} }
func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) { func (c Client) RecentlyPlayedBefore(ctx context.Context, before time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(nil, &before, limit) return c.recentlyPlayed(ctx, nil, &before, limit)
} }
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) { func (c Client) recentlyPlayed(ctx context.Context, 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 {
@ -85,9 +86,10 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
return return
} }
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) { func (c Client) UserTracks(ctx context.Context, 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 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package spotify_test package spotify_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
"time" "time"
@ -47,7 +48,8 @@ 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")
result, err := client.RecentlyPlayedAfter(time.Now(), 3) ctx := context.Background()
result, err := client.RecentlyPlayedAfter(ctx, time.Now(), 3)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -67,7 +69,8 @@ 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")
result, err := client.UserTracks(0, 2) ctx := context.Background()
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,6 +18,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package spotify package spotify
import ( import (
"context"
"math" "math"
"net/url" "net/url"
"sort" "sort"
@ -95,7 +96,7 @@ func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
startTime := time.Now() startTime := time.Now()
minTime := oldestTimestamp minTime := oldestTimestamp
@ -107,7 +108,7 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha
} }
for { for {
result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet) result, err := b.client.RecentlyPlayedAfter(ctx, minTime, MaxItemsPerGet)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p
@ -162,7 +163,7 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha
progress <- p progress <- p
} }
func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *SpotifyApiBackend) ExportLoves(ctx context.Context, 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
@ -178,7 +179,7 @@ func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan
out: out:
for { for {
result, err := b.client.UserTracks(offset, perPage) result, err := b.client.UserTracks(ctx, offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() p.Export.Abort()
progress <- p progress <- p

View file

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

View file

@ -17,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package subsonic package subsonic
import ( import (
"context"
"net/http" "net/http"
"sort" "sort"
"time" "time"
@ -63,7 +64,7 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error {
return nil return nil
} }
func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *SubsonicApiBackend) ExportLoves(ctx context.Context, 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,6 +18,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package cli package cli
import ( import (
"context"
"sync" "sync"
"time" "time"
@ -39,9 +40,10 @@ type progressBarUpdater struct {
importedItems int importedItems int
} }
func setupProgressBars(updateChan chan models.TransferProgress) progressBarUpdater { func setupProgressBars(ctx context.Context, updateChan chan models.TransferProgress) progressBarUpdater {
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
p := mpb.New( p := mpb.NewWithContext(
ctx,
mpb.WithWaitGroup(wg), mpb.WithWaitGroup(wg),
mpb.WithOutput(color.Output), mpb.WithOutput(color.Output),
// mpb.WithWidth(64), // mpb.WithWidth(64),

View file

@ -16,6 +16,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package cli package cli
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
@ -109,20 +110,32 @@ 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(progressChan) progress := setupProgressBars(ctx, 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(wg, timestamp, exportChan, progressChan) go exp.Process(ctx, wg, timestamp, exportChan, progressChan)
// Import into target // Import into target
resultChan := make(chan models.ImportResult) resultChan := make(chan models.ImportResult)
go imp.Process(wg, exportChan, resultChan, progressChan) go imp.Process(ctx, 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,6 +17,7 @@ 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"
@ -55,7 +56,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(oldestTimestamp time.Time, results chan ListensResult, progress chan TransferProgress) ExportListens(ctx context.Context, 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.
@ -63,7 +64,7 @@ type ListensImport interface {
ImportBackend ImportBackend
// Imports the given list of listens. // Imports the given list of listens.
ImportListens(export ListensResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error) ImportListens(ctx context.Context, 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.
@ -73,7 +74,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(oldestTimestamp time.Time, results chan LovesResult, progress chan TransferProgress) ExportLoves(ctx context.Context, 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.
@ -81,5 +82,5 @@ type LovesImport interface {
ImportBackend ImportBackend
// Imports the given list of loves. // Imports the given list of loves.
ImportLoves(export LovesResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error) ImportLoves(ctx context.Context, 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,7 +173,11 @@ 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 *bufio.Reader) error { func (l *ScrobblerLog) ReadHeader(reader io.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()