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/vbauerster/mpb/v8 v8.10.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/oauth2 v0.30.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.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s=
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.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao=
go.uploadedlobster.com/musicbrainzws2 v0.14.1-0.20250522060150-50bf4bea5400 h1:wMJloSsyWjfXznQNjvsrqAeL61BGoil7t4H9hPt18fc=
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
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
of this software and associated documentation files (the "Software"), to deal
@ -23,6 +23,7 @@ THE SOFTWARE.
package deezer
import (
"context"
"errors"
"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"
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"
return listRequest[TracksResult](c, path, offset, limit)
return listRequest[TracksResult](ctx, c, path, offset, limit)
}
func (c Client) setToken(req *resty.Request) error {
@ -72,8 +73,9 @@ func (c Client) setToken(req *resty.Request) error {
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().
SetContext(ctx).
SetQueryParams(map[string]string{
"index": strconv.Itoa(offset),
"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
of this software and associated documentation files (the "Software"), to deal
@ -23,6 +23,7 @@ THE SOFTWARE.
package deezer_test
import (
"context"
"net/http"
"testing"
@ -48,7 +49,8 @@ func TestGetUserHistory(t *testing.T) {
"https://api.deezer.com/user/me/history",
"testdata/user-history.json")
result, err := client.UserHistory(0, 2)
ctx := context.Background()
result, err := client.UserHistory(ctx, 0, 2)
require.NoError(t, err)
assert := assert.New(t)
@ -69,7 +71,8 @@ func TestGetUserTracks(t *testing.T) {
"https://api.deezer.com/user/me/tracks",
"testdata/user-tracks.json")
result, err := client.UserTracks(0, 2)
ctx := context.Background()
result, err := client.UserTracks(ctx, 0, 2)
require.NoError(t, err)
assert := assert.New(t)

View file

@ -16,6 +16,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package deezer
import (
"context"
"fmt"
"math"
"net/url"
@ -77,7 +78,7 @@ func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
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
// at the oldest one.
offset := math.MaxInt32
@ -96,7 +97,7 @@ func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan
out:
for {
result, err := b.client.UserHistory(offset, perPage)
result, err := b.client.UserHistory(ctx, offset, perPage)
if err != nil {
p.Export.Abort()
progress <- p
@ -153,7 +154,7 @@ out:
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
// at the oldest one.
offset := math.MaxInt32
@ -168,7 +169,7 @@ func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m
out:
for {
result, err := b.client.UserTracks(offset, perPage)
result, err := b.client.UserTracks(ctx, offset, perPage)
if err != nil {
p.Export.Abort()
progress <- p

View file

@ -17,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package dump
import (
"context"
"fmt"
"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) 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 {
if err := ctx.Err(); err != nil {
return importResult, err
}
importResult.UpdateTimestamp(listen.ListenedAt)
importResult.ImportCount += 1
msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)",
@ -49,8 +54,12 @@ func (b *DumpBackend) ImportListens(export models.ListensResult, importResult mo
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 {
if err := ctx.Err(); err != nil {
return importResult, err
}
importResult.UpdateTimestamp(love.Created)
importResult.ImportCount += 1
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
import (
"context"
"sync"
"time"
@ -24,7 +25,7 @@ import (
type ExportProcessor[T models.ListensResult | models.LovesResult] interface {
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 {
@ -35,11 +36,11 @@ func (p ListensExportProcessor) ExportBackend() models.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)
defer wg.Done()
defer close(results)
p.Backend.ExportListens(oldestTimestamp, results, progress)
p.Backend.ExportListens(ctx, oldestTimestamp, results, progress)
}
type LovesExportProcessor struct {
@ -50,9 +51,9 @@ func (p LovesExportProcessor) ExportBackend() models.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)
defer wg.Done()
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
of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package funkwhale
import (
"context"
"errors"
"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"
response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{
"username": user,
"page": strconv.Itoa(page),
"page_size": strconv.Itoa(perPage),
"ordering": "-creation_date",
}).
response, err := c.buildListRequest(ctx, page, perPage).
SetQueryParam("username", user).
SetResult(&result).
Get(path)
@ -73,14 +69,9 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result
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"
response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{
"page": strconv.Itoa(page),
"page_size": strconv.Itoa(perPage),
"ordering": "-creation_date",
}).
response, err := c.buildListRequest(ctx, page, perPage).
SetResult(&result).
Get(path)
@ -90,3 +81,13 @@ func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksR
}
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
of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package funkwhale_test
import (
"context"
"net/http"
"testing"
@ -49,7 +50,8 @@ func TestGetHistoryListenings(t *testing.T) {
"https://funkwhale.example.com/api/v1/history/listenings",
"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)
assert := assert.New(t)
@ -73,7 +75,8 @@ func TestGetFavoriteTracks(t *testing.T) {
"https://funkwhale.example.com/api/v1/favorites/tracks",
"testdata/favorite-tracks.json")
result, err := client.GetFavoriteTracks(0, 2)
ctx := context.Background()
result, err := client.GetFavoriteTracks(ctx, 0, 2)
require.NoError(t, err)
assert := assert.New(t)

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package lastfm
import (
"context"
"fmt"
"net/url"
"sort"
@ -88,7 +89,7 @@ func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
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
minTime := oldestTimestamp
perPage := MaxItemsPerGet
@ -102,6 +103,13 @@ func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan
out:
for page > 0 {
if err := ctx.Err(); err != nil {
results <- models.ListensResult{Error: err}
p.Export.Abort()
progress <- p
return
}
args := lastfm.P{
"user": b.username,
"limit": MaxListensPerGet,
@ -182,9 +190,13 @@ out:
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)
for i := 0; i < total; i += MaxListensPerSubmission {
if err := ctx.Err(); err != nil {
return importResult, err
}
listens := export.Items[i:min(i+MaxListensPerSubmission, total)]
count := len(listens)
if count == 0 {
@ -258,7 +270,7 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
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
// at the oldest one.
page := 1
@ -274,6 +286,13 @@ func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m
out:
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{
"user": b.username,
"limit": MaxItemsPerGet,
@ -335,8 +354,12 @@ out:
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 {
if err := ctx.Err(); err != nil {
return importResult, err
}
err := b.client.Track.Love(lastfm.P{
"track": love.TrackName,
"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
of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package listenbrainz
import (
"context"
"errors"
"strconv"
"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"
errorResult := ErrorResult{}
response, err := c.HTTPClient.R().
SetContext(ctx).
SetPathParam("username", user).
SetQueryParams(map[string]string{
"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
}
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"
errorResult := ErrorResult{}
response, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(listens).
SetResult(&result).
SetError(&errorResult).
@ -97,10 +100,11 @@ func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, er
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"
errorResult := ErrorResult{}
response, err := c.HTTPClient.R().
SetContext(ctx).
SetPathParam("username", user).
SetQueryParams(map[string]string{
"status": strconv.Itoa(status),
@ -119,10 +123,11 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
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"
errorResult := ErrorResult{}
response, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(feedback).
SetResult(&result).
SetError(&errorResult).
@ -135,10 +140,11 @@ func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error)
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"
errorResult := ErrorResult{}
response, err := c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"recording_name": recordingName,
"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
of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package listenbrainz_test
import (
"context"
"net/http"
"testing"
"time"
@ -49,7 +50,9 @@ func TestGetListens(t *testing.T) {
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
"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)
assert := assert.New(t)
@ -92,8 +95,8 @@ func TestSubmitListens(t *testing.T) {
},
},
}
result, err := client.SubmitListens(listens)
require.NoError(t, err)
ctx := context.Background()
result, err := client.SubmitListens(ctx, listens)
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",
"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)
assert := assert.New(t)
@ -135,7 +139,8 @@ func TestSendFeedback(t *testing.T) {
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
Score: 1,
}
result, err := client.SendFeedback(feedback)
ctx := context.Background()
result, err := client.SendFeedback(ctx, feedback)
require.NoError(t, err)
assert.Equal(t, "ok", result.Status)
@ -149,7 +154,8 @@ func TestLookup(t *testing.T) {
"https://api.listenbrainz.org/1/metadata/lookup",
"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)
assert := assert.New(t)

View file

@ -17,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package listenbrainz
import (
"context"
"fmt"
"sort"
"time"
@ -72,7 +73,7 @@ func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
func (b *ListenBrainzApiBackend) FinishImport() error { return nil }
func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
startTime := time.Now()
minTime := oldestTimestamp
if minTime.Unix() < 1 {
@ -87,7 +88,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result
}
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 {
p.Export.Abort()
progress <- p
@ -134,7 +135,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result
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)
p := models.TransferProgress{}.FromImportResult(importResult, false)
for i := 0; i < total; i += MaxListensPerRequest {
@ -151,7 +152,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
for _, l := range listens {
if b.checkDuplicates {
isDupe, err := b.checkDuplicateListen(l)
isDupe, err := b.checkDuplicateListen(ctx, l)
p.Import.Elapsed += 1
progress <- p
if err != nil {
@ -182,7 +183,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
}
if len(submission.Payload) > 0 {
_, err := b.client.SubmitListens(submission)
_, err := b.client.SubmitListens(ctx, submission)
if err != nil {
return importResult, err
}
@ -198,13 +199,13 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
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)
p := models.TransferProgress{
Export: &models.Progress{},
}
go b.exportLoves(oldestTimestamp, exportChan)
go b.exportLoves(ctx, oldestTimestamp, exportChan)
for existingLoves := range exportChan {
if existingLoves.Error != nil {
p.Export.Abort()
@ -224,14 +225,14 @@ func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results
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
defer close(results)
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
out:
for {
result, err := b.client.GetFeedback(b.username, 1, offset)
result, err := b.client.GetFeedback(ctx, b.username, 1, offset)
if err != nil {
results <- models.LovesResult{Error: err}
return
@ -247,7 +248,7 @@ out:
// longer available and might have been merged. Try fetching details
// from MusicBrainz.
if feedback.TrackMetadata == nil {
track, err := b.lookupRecording(feedback.RecordingMBID)
track, err := b.lookupRecording(ctx, feedback.RecordingMBID)
if err == nil {
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 {
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
b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet)
@ -303,7 +304,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
}
if recordingMBID == "" {
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
lookup, err := b.client.Lookup(ctx, love.TrackName, love.ArtistName())
if err == nil {
recordingMBID = lookup.RecordingMBID
}
@ -315,7 +316,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
if b.existingMBIDs[recordingMBID] {
ok = true
} else {
resp, err := b.client.SendFeedback(Feedback{
resp, err := b.client.SendFeedback(ctx, Feedback{
RecordingMBID: recordingMBID,
Score: 1,
})
@ -351,7 +352,7 @@ var defaultDuration = time.Duration(3 * time.Minute)
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
duration := listen.Duration
if duration == 0 {
@ -359,7 +360,7 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (boo
}
minTime := 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 {
return false, err
}
@ -374,11 +375,11 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (boo
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{
Includes: []string{"artist-credits"},
}
recording, err := b.mbClient.LookupRecording(mbid, filter)
recording, err := b.mbClient.LookupRecording(ctx, mbid, filter)
if err != nil {
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
of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package maloja
import (
"context"
"errors"
"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"
response, err := c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"page": strconv.Itoa(page),
"perpage": strconv.Itoa(perPage),
@ -65,10 +67,11 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
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"
scrobble.Key = c.token
response, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(scrobble).
SetResult(&result).
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
of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package maloja_test
import (
"context"
"net/http"
"testing"
@ -48,7 +49,8 @@ func TestGetScrobbles(t *testing.T) {
"https://maloja.example.com/apis/mlj_1/scrobbles",
"testdata/scrobbles.json")
result, err := client.GetScrobbles(0, 2)
ctx := context.Background()
result, err := client.GetScrobbles(ctx, 0, 2)
require.NoError(t, err)
assert := assert.New(t)
@ -69,12 +71,13 @@ func TestNewScrobble(t *testing.T) {
url := server + "/apis/mlj_1/newscrobble"
httpmock.RegisterResponder("POST", url, responder)
ctx := context.Background()
scrobble := maloja.NewScrobble{
Title: "Oweynagat",
Artist: "Dool",
Time: 1699574369,
}
result, err := client.NewScrobble(scrobble)
result, err := client.NewScrobble(ctx, scrobble)
require.NoError(t, err)
assert.Equal(t, "success", result.Status)

View file

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

View file

@ -17,7 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package scrobblerlog
import (
"bufio"
"context"
"fmt"
"os"
"sort"
@ -105,8 +105,7 @@ func (b *ScrobblerLogBackend) StartImport() error {
b.append = false
} else {
// Verify existing file is a scrobbler log
reader := bufio.NewReader(file)
if err = b.log.ReadHeader(reader); err != nil {
if err = b.log.ReadHeader(file); err != nil {
file.Close()
return err
}
@ -131,7 +130,7 @@ func (b *ScrobblerLogBackend) FinishImport() error {
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)
p := models.TransferProgress{
Export: &models.Progress{},
@ -168,7 +167,7 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c
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))
for i, listen := range export.Items {
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
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) {
return c.recentlyPlayed(&after, nil, limit)
func (c Client) RecentlyPlayedAfter(ctx context.Context, after time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(ctx, &after, nil, limit)
}
func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(nil, &before, limit)
func (c Client) RecentlyPlayedBefore(ctx context.Context, before time.Time, limit int) (RecentlyPlayedResult, error) {
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"
request := c.HTTPClient.R().
SetContext(ctx).
SetQueryParam("limit", strconv.Itoa(limit)).
SetResult(&result)
if after != nil {
@ -85,9 +86,10 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
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"
response, err := c.HTTPClient.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"offset": strconv.Itoa(offset),
"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
of this software and associated documentation files (the "Software"), to deal
@ -22,6 +22,7 @@ THE SOFTWARE.
package spotify_test
import (
"context"
"net/http"
"testing"
"time"
@ -47,7 +48,8 @@ func TestRecentlyPlayedAfter(t *testing.T) {
"https://api.spotify.com/v1/me/player/recently-played",
"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)
assert := assert.New(t)
@ -67,7 +69,8 @@ func TestGetUserTracks(t *testing.T) {
"https://api.spotify.com/v1/me/tracks",
"testdata/user-tracks.json")
result, err := client.UserTracks(0, 2)
ctx := context.Background()
result, err := client.UserTracks(ctx, 0, 2)
require.NoError(t, err)
assert := assert.New(t)

View file

@ -18,6 +18,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package spotify
import (
"context"
"math"
"net/url"
"sort"
@ -95,7 +96,7 @@ func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
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()
minTime := oldestTimestamp
@ -107,7 +108,7 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha
}
for {
result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet)
result, err := b.client.RecentlyPlayedAfter(ctx, minTime, MaxItemsPerGet)
if err != nil {
p.Export.Abort()
progress <- p
@ -162,7 +163,7 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha
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
// at the oldest one.
offset := math.MaxInt32
@ -178,7 +179,7 @@ func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan
out:
for {
result, err := b.client.UserTracks(offset, perPage)
result, err := b.client.UserTracks(ctx, offset, perPage)
if err != nil {
p.Export.Abort()
progress <- p

View file

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

View file

@ -17,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package subsonic
import (
"context"
"net/http"
"sort"
"time"
@ -63,7 +64,7 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error {
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)
p := models.TransferProgress{
Export: &models.Progress{},

View file

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

View file

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

View file

@ -17,6 +17,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"context"
"time"
// "go.uploadedlobster.com/scotty/internal/auth"
@ -55,7 +56,7 @@ type ListensExport interface {
// Returns a list of all listens newer then oldestTimestamp.
// The returned list of listens is supposed to be ordered by the
// 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.
@ -63,7 +64,7 @@ type ListensImport interface {
ImportBackend
// 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.
@ -73,7 +74,7 @@ type LovesExport interface {
// Returns a list of all loves newer then oldestTimestamp.
// The returned list of listens is supposed to be ordered by the
// 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.
@ -81,5 +82,5 @@ type LovesImport interface {
ImportBackend
// 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)
reader := bufio.NewReader(data)
err := l.ReadHeader(reader)
err := l.readHeader(reader)
if err != nil {
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.
//
// 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
for i := 0; i < 3; i++ {
line, _, err := reader.ReadLine()