From 588a6cf96f41a31e427396eb885e80b20bfd0e65 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 1 May 2025 13:22:20 +0200 Subject: [PATCH 01/26] Document the scrobblerlog package --- pkg/scrobblerlog/parser.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go index d355c62..6b9d1ba 100644 --- a/pkg/scrobblerlog/parser.go +++ b/pkg/scrobblerlog/parser.go @@ -20,11 +20,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Package to parse and writer .scrobbler.log files as written by Rockbox. +// Package to parse and write .scrobbler.log files as written by Rockbox. +// +// The parser supports reading version 1.1 and 1.0 of the scrobbler log file +// format. The latter is only supported if encoded in UTF-8. +// +// When written it always writes version 1.1 of the scrobbler log file format, +// which includes the MusicBrainz recording ID as the last field of each row. // // See // - https://www.rockbox.org/wiki/LastFMLog // - https://git.rockbox.org/cgit/rockbox.git/tree/apps/plugins/lastfm_scrobbler.c +// - https://web.archive.org/web/20110110053056/http://www.audioscrobbler.net/wiki/Portable_Player_Logging package scrobblerlog import ( @@ -79,6 +86,10 @@ type ScrobblerLog struct { FallbackTimezone *time.Location } +// Parses a scrobbler log file from the given reader. +// +// The reader must provide a valid scrobbler log file with a valid header. +// This function implicitly calls [ScrobblerLog.ReadHeader]. func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { l.Records = make([]Record, 0) @@ -106,6 +117,7 @@ func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { // fmt.Printf("row: %v\n", row) // We consider only the last field (recording MBID) optional + // This was added in the 1.1 file format. if len(row) < 7 { line, _ := tsvReader.FieldPos(0) return fmt.Errorf("invalid record in scrobblerlog line %v", line) @@ -126,6 +138,11 @@ func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { return nil } +// Append writes the given records to the writer. +// +// The writer should be for an existing scrobbler log file or +// [ScrobblerLog.WriteHeader] should be called before this function. +// Returns the last timestamp of the records written. func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp time.Time, err error) { tsvWriter := csv.NewWriter(data) tsvWriter.Comma = '\t' @@ -153,6 +170,9 @@ func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp t return } +// 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 { // Skip header for i := 0; i < 3; i++ { @@ -191,6 +211,7 @@ func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error { return nil } +// Writes the header of a scrobbler log file to the given writer. func (l *ScrobblerLog) WriteHeader(writer io.Writer) error { headers := []string{ "#AUDIOSCROBBLER/1.1\n", From 443734e4c76c396a7c696aae6c4880344572640e Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 1 May 2025 13:48:21 +0200 Subject: [PATCH 02/26] jspf: write duration to exported JSPF --- internal/backends/jspf/jspf.go | 1 + pkg/jspf/models.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index 3e6866d..f98349a 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -150,6 +150,7 @@ func trackAsTrack(t models.Track) jspf.Track { Album: t.ReleaseName, Creator: t.ArtistName(), TrackNum: t.TrackNumber, + Duration: t.Duration.Milliseconds(), Extension: map[string]any{}, } diff --git a/pkg/jspf/models.go b/pkg/jspf/models.go index d910367..d7a540c 100644 --- a/pkg/jspf/models.go +++ b/pkg/jspf/models.go @@ -57,7 +57,7 @@ type Track struct { Info string `json:"info,omitempty"` Album string `json:"album,omitempty"` TrackNum int `json:"trackNum,omitempty"` - Duration int `json:"duration,omitempty"` + Duration int64 `json:"duration,omitempty"` Links []Link `json:"link,omitempty"` Meta []Meta `json:"meta,omitempty"` Extension map[string]any `json:"extension,omitempty"` From cfc3cd522d693271c5adf6cefa5d32fb2b506845 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 1 May 2025 14:09:12 +0200 Subject: [PATCH 03/26] scrobblerlog: fix listen export not considering latest timestamp --- internal/backends/scrobblerlog/scrobblerlog.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 19ed30b..7955c15 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -151,9 +151,12 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c listens := make(models.ListensList, 0, len(b.log.Records)) client := strings.Split(b.log.Client, " ")[0] for _, record := range b.log.Records { - listens = append(listens, recordToListen(record, client)) + listen := recordToListen(record, client) + if listen.ListenedAt.After(oldestTimestamp) { + listens = append(listens, recordToListen(record, client)) + } } - sort.Sort(listens.NewerThan(oldestTimestamp)) + sort.Sort(listens) progress <- models.Progress{Total: int64(len(listens))}.Complete() results <- models.ListensResult{Items: listens} } From a645ec5c78aa3c0921405f6cad776ade85bf5dd6 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 1 May 2025 15:10:00 +0200 Subject: [PATCH 04/26] JSPF: Implemented export as loves and listens --- CHANGES.md | 4 + README.md | 2 +- internal/backends/jspf/jspf.go | 143 ++++++++++++++++++++++++++++++--- pkg/jspf/extensions.go | 33 +++++++- pkg/jspf/extensions_test.go | 31 ++++++- pkg/jspf/models.go | 54 ++++++------- 6 files changed, 225 insertions(+), 42 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 5ac83ab..5dfd892 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Scotty Changelog +## 0.6.0 - WIP +- JSPF: Implemented export as loves and listens + + ## 0.5.2 - 2025-05-01 - ListenBrainz: fixed loves export not considering latest timestamp diff --git a/README.md b/README.md index 9f9f5c9..97e6120 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Backend | Listens Export | Listens Import | Loves Export | Loves Import ----------------|----------------|----------------|--------------|------------- deezer | ✓ | ⨯ | ✓ | - funkwhale | ✓ | ⨯ | ✓ | - -jspf | - | ✓ | - | ✓ +jspf | ✓ | ✓ | ✓ | ✓ lastfm | ✓ | ✓ | ✓ | ✓ listenbrainz | ✓ | ✓ | ✓ | ✓ maloja | ✓ | ✓ | ⨯ | ⨯ diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index f98349a..a9e5c90 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2024 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -18,15 +18,25 @@ Scotty. If not, see . package jspf import ( + "errors" "os" + "sort" + "strings" "time" + "go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/pkg/jspf" ) +const ( + artistMBIDPrefix = "https://musicbrainz.org/artist/" + recordingMBIDPrefix = "https://musicbrainz.org/recording/" + releaseMBIDPrefix = "https://musicbrainz.org/release/" +) + type JSPFBackend struct { filePath string playlist jspf.Playlist @@ -68,7 +78,7 @@ func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error { Creator: config.GetString("username"), Identifier: config.GetString("identifier"), Tracks: make([]jspf.Track, 0), - Extension: map[string]any{ + Extension: jspf.ExtensionMap{ jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{ LastModifiedAt: time.Now(), Public: true, @@ -86,6 +96,28 @@ func (b *JSPFBackend) FinishImport() error { return b.writeJSPF() } +func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { + defer close(results) + + err := b.readJSPF() + if err != nil { + progress <- models.Progress{}.Complete() + results <- models.ListensResult{Error: err} + return + } + + listens := make(models.ListensList, 0, len(b.playlist.Tracks)) + for _, track := range b.playlist.Tracks { + listen, err := trackAsListen(track) + if err == nil && listen != nil && listen.ListenedAt.After(oldestTimestamp) { + listens = append(listens, *listen) + } + } + sort.Sort(listens) + progress <- models.Progress{Total: int64(len(listens))}.Complete() + results <- models.ListensResult{Items: listens} +} + func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { for _, listen := range export.Items { track := listenAsTrack(listen) @@ -98,6 +130,28 @@ 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.Progress) { + defer close(results) + + err := b.readJSPF() + if err != nil { + progress <- models.Progress{}.Complete() + results <- models.LovesResult{Error: err} + return + } + + loves := make(models.LovesList, 0, len(b.playlist.Tracks)) + for _, track := range b.playlist.Tracks { + love, err := trackAsLove(track) + if err == nil && love != nil && love.Created.After(oldestTimestamp) { + loves = append(loves, *love) + } + } + sort.Sort(loves) + progress <- models.Progress{Total: int64(len(loves))}.Complete() + results <- models.LovesResult{Items: loves} +} + func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { for _, love := range export.Items { track := loveAsTrack(love) @@ -112,22 +166,35 @@ func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models func listenAsTrack(l models.Listen) jspf.Track { l.FillAdditionalInfo() - track := trackAsTrack(l.Track) + track := trackAsJSPFTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.ListenedAt extension.AddedBy = l.UserName track.Extension[jspf.MusicBrainzTrackExtensionID] = extension if l.RecordingMBID != "" { - track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID)) + track.Identifier = append(track.Identifier, recordingMBIDPrefix+string(l.RecordingMBID)) } return track } +func trackAsListen(t jspf.Track) (*models.Listen, error) { + track, addedAt, err := jspfTrackAsTrack(t) + if err != nil { + return nil, err + } + + listen := models.Listen{ + ListenedAt: *addedAt, + Track: *track, + } + return &listen, err +} + func loveAsTrack(l models.Love) jspf.Track { l.FillAdditionalInfo() - track := trackAsTrack(l.Track) + track := trackAsJSPFTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.Created extension.AddedBy = l.UserName @@ -138,25 +205,62 @@ func loveAsTrack(l models.Love) jspf.Track { recordingMBID = l.RecordingMBID } if recordingMBID != "" { - track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMBID)) + track.Identifier = append(track.Identifier, recordingMBIDPrefix+string(recordingMBID)) } return track } -func trackAsTrack(t models.Track) jspf.Track { +func trackAsLove(t jspf.Track) (*models.Love, error) { + track, addedAt, err := jspfTrackAsTrack(t) + if err != nil { + return nil, err + } + + love := models.Love{ + Created: *addedAt, + RecordingMBID: track.RecordingMBID, + Track: *track, + } + return &love, err +} + +func trackAsJSPFTrack(t models.Track) jspf.Track { track := jspf.Track{ Title: t.TrackName, Album: t.ReleaseName, Creator: t.ArtistName(), TrackNum: t.TrackNumber, Duration: t.Duration.Milliseconds(), - Extension: map[string]any{}, + Extension: jspf.ExtensionMap{}, } return track } +func jspfTrackAsTrack(t jspf.Track) (*models.Track, *time.Time, error) { + track := models.Track{ + ArtistNames: []string{t.Creator}, + ReleaseName: t.Album, + TrackName: t.Title, + TrackNumber: t.TrackNum, + Duration: time.Duration(t.Duration) * time.Millisecond, + } + + for _, id := range t.Identifier { + if strings.HasPrefix(id, recordingMBIDPrefix) { + track.RecordingMBID = mbtypes.MBID(id[len(recordingMBIDPrefix):]) + } + } + + addedAt, err := readMusicBrainzExtension(t, &track) + if err != nil { + return nil, nil, err + } + + return &track, addedAt, nil +} + func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { extension := jspf.MusicBrainzTrackExtension{ AdditionalMetadata: t.AdditionalInfo, @@ -164,11 +268,11 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { } for i, mbid := range t.ArtistMBIDs { - extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid) + extension.ArtistIdentifiers[i] = artistMBIDPrefix + string(mbid) } if t.ReleaseMBID != "" { - extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMBID) + extension.ReleaseIdentifier = releaseMBIDPrefix + string(t.ReleaseMBID) } // The tracknumber tag would be redundant @@ -177,6 +281,25 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { return extension } +func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) (*time.Time, error) { + ext := jspf.MusicBrainzTrackExtension{} + err := jspfTrack.Extension.Get(jspf.MusicBrainzTrackExtensionID, &ext) + if err != nil { + return nil, errors.New("missing MusicBrainz track extension") + } + + outputTrack.AdditionalInfo = ext.AdditionalMetadata + outputTrack.ReleaseMBID = mbtypes.MBID(ext.ReleaseIdentifier) + outputTrack.ArtistMBIDs = make([]mbtypes.MBID, len(ext.ArtistIdentifiers)) + for i, mbid := range ext.ArtistIdentifiers { + if strings.HasPrefix(mbid, artistMBIDPrefix) { + outputTrack.ArtistMBIDs[i] = mbtypes.MBID(mbid[len(artistMBIDPrefix):]) + } + } + + return &ext.AddedAt, nil +} + func (b *JSPFBackend) readJSPF() error { if b.append { file, err := os.Open(b.filePath) diff --git a/pkg/jspf/extensions.go b/pkg/jspf/extensions.go index 0f521c4..7cf99d3 100644 --- a/pkg/jspf/extensions.go +++ b/pkg/jspf/extensions.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer 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,7 +22,28 @@ THE SOFTWARE. package jspf -import "time" +import ( + "encoding/json" + "fmt" + "time" +) + +// Represents a JSPF extension +type Extension any + +// A map of JSPF extensions +type ExtensionMap map[string]Extension + +// Parses the extension with the given ID and unmarshals it into "v". +// If the extensions is not found or the data cannot be unmarshalled, +// an error is returned. +func (e ExtensionMap) Get(id string, v any) error { + ext, ok := e[id] + if !ok { + return fmt.Errorf("extension %q not found", id) + } + return unmarshalExtension(ext, v) +} const ( // The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension @@ -83,3 +104,11 @@ type MusicBrainzTrackExtension struct { // this document. AdditionalMetadata map[string]any `json:"additional_metadata,omitempty"` } + +func unmarshalExtension(ext Extension, v any) error { + asJson, err := json.Marshal(ext) + if err != nil { + return err + } + return json.Unmarshal(asJson, v) +} diff --git a/pkg/jspf/extensions_test.go b/pkg/jspf/extensions_test.go index 883301d..49d1bd5 100644 --- a/pkg/jspf/extensions_test.go +++ b/pkg/jspf/extensions_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -26,6 +26,7 @@ import ( "bytes" "fmt" "log" + "testing" "time" "go.uploadedlobster.com/scotty/pkg/jspf" @@ -38,7 +39,7 @@ func ExampleMusicBrainzTrackExtension() { Tracks: []jspf.Track{ { Title: "Oweynagat", - Extension: map[string]any{ + Extension: jspf.ExtensionMap{ jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), AddedBy: "scotty", @@ -72,3 +73,29 @@ func ExampleMusicBrainzTrackExtension() { // } // } } + +func TestExtensionMapGet(t *testing.T) { + ext := jspf.ExtensionMap{ + jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ + AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), + AddedBy: "scotty", + }, + } + var trackExt jspf.MusicBrainzTrackExtension + err := ext.Get(jspf.MusicBrainzTrackExtensionID, &trackExt) + if err != nil { + t.Fatal(err) + } + if trackExt.AddedBy != "scotty" { + t.Fatalf("expected 'scotty', got '%s'", trackExt.AddedBy) + } +} + +func TestExtensionMapGetNotFound(t *testing.T) { + ext := jspf.ExtensionMap{} + var trackExt jspf.MusicBrainzTrackExtension + err := ext.Get(jspf.MusicBrainzTrackExtensionID, &trackExt) + if err == nil { + t.Fatal("expected ExtensionMap.Get to return an error") + } +} diff --git a/pkg/jspf/models.go b/pkg/jspf/models.go index d7a540c..829e922 100644 --- a/pkg/jspf/models.go +++ b/pkg/jspf/models.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -32,35 +32,35 @@ type JSPF struct { } type Playlist struct { - Title string `json:"title,omitempty"` - Creator string `json:"creator,omitempty"` - Annotation string `json:"annotation,omitempty"` - Info string `json:"info,omitempty"` - Location string `json:"location,omitempty"` - Identifier string `json:"identifier,omitempty"` - Image string `json:"image,omitempty"` - Date time.Time `json:"date,omitempty"` - License string `json:"license,omitempty"` - Attribution []Attribution `json:"attribution,omitempty"` - Links []Link `json:"link,omitempty"` - Meta []Meta `json:"meta,omitempty"` - Extension map[string]any `json:"extension,omitempty"` - Tracks []Track `json:"track"` + Title string `json:"title,omitempty"` + Creator string `json:"creator,omitempty"` + Annotation string `json:"annotation,omitempty"` + Info string `json:"info,omitempty"` + Location string `json:"location,omitempty"` + Identifier string `json:"identifier,omitempty"` + Image string `json:"image,omitempty"` + Date time.Time `json:"date,omitempty"` + License string `json:"license,omitempty"` + Attribution []Attribution `json:"attribution,omitempty"` + Links []Link `json:"link,omitempty"` + Meta []Meta `json:"meta,omitempty"` + Extension ExtensionMap `json:"extension,omitempty"` + Tracks []Track `json:"track"` } type Track struct { - Location []string `json:"location,omitempty"` - Identifier []string `json:"identifier,omitempty"` - Title string `json:"title,omitempty"` - Creator string `json:"creator,omitempty"` - Annotation string `json:"annotation,omitempty"` - Info string `json:"info,omitempty"` - Album string `json:"album,omitempty"` - TrackNum int `json:"trackNum,omitempty"` - Duration int64 `json:"duration,omitempty"` - Links []Link `json:"link,omitempty"` - Meta []Meta `json:"meta,omitempty"` - Extension map[string]any `json:"extension,omitempty"` + Location []string `json:"location,omitempty"` + Identifier []string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Creator string `json:"creator,omitempty"` + Annotation string `json:"annotation,omitempty"` + Info string `json:"info,omitempty"` + Album string `json:"album,omitempty"` + TrackNum int `json:"trackNum,omitempty"` + Duration int64 `json:"duration,omitempty"` + Links []Link `json:"link,omitempty"` + Meta []Meta `json:"meta,omitempty"` + Extension ExtensionMap `json:"extension,omitempty"` } type Attribution map[string]string From d757129bd710d0f05cd59321108db735508179c9 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 1 May 2025 15:20:37 +0200 Subject: [PATCH 05/26] jspf: also set username and recording MSID in exports --- README.md | 2 +- internal/backends/jspf/jspf.go | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 97e6120..c764730 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ You can help translate this project into your language with [Weblate](https://tr ## License -Scotty © 2023-2024 Philipp Wolfer +Scotty © 2023-2025 Philipp Wolfer Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index a9e5c90..6551f15 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -180,13 +180,14 @@ func listenAsTrack(l models.Listen) jspf.Track { } func trackAsListen(t jspf.Track) (*models.Listen, error) { - track, addedAt, err := jspfTrackAsTrack(t) + track, ext, err := jspfTrackAsTrack(t) if err != nil { return nil, err } listen := models.Listen{ - ListenedAt: *addedAt, + ListenedAt: ext.AddedAt, + UserName: ext.AddedBy, Track: *track, } return &listen, err @@ -212,16 +213,23 @@ func loveAsTrack(l models.Love) jspf.Track { } func trackAsLove(t jspf.Track) (*models.Love, error) { - track, addedAt, err := jspfTrackAsTrack(t) + track, ext, err := jspfTrackAsTrack(t) if err != nil { return nil, err } love := models.Love{ - Created: *addedAt, + Created: ext.AddedAt, + UserName: ext.AddedBy, RecordingMBID: track.RecordingMBID, Track: *track, } + + recordingMSID, ok := track.AdditionalInfo["recording_msid"].(string) + if ok { + love.RecordingMSID = mbtypes.MBID(recordingMSID) + } + return &love, err } @@ -238,7 +246,7 @@ func trackAsJSPFTrack(t models.Track) jspf.Track { return track } -func jspfTrackAsTrack(t jspf.Track) (*models.Track, *time.Time, error) { +func jspfTrackAsTrack(t jspf.Track) (*models.Track, *jspf.MusicBrainzTrackExtension, error) { track := models.Track{ ArtistNames: []string{t.Creator}, ReleaseName: t.Album, @@ -253,12 +261,12 @@ func jspfTrackAsTrack(t jspf.Track) (*models.Track, *time.Time, error) { } } - addedAt, err := readMusicBrainzExtension(t, &track) + ext, err := readMusicBrainzExtension(t, &track) if err != nil { return nil, nil, err } - return &track, addedAt, nil + return &track, ext, nil } func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { @@ -281,7 +289,7 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { return extension } -func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) (*time.Time, error) { +func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) (*jspf.MusicBrainzTrackExtension, error) { ext := jspf.MusicBrainzTrackExtension{} err := jspfTrack.Extension.Get(jspf.MusicBrainzTrackExtensionID, &ext) if err != nil { @@ -297,7 +305,7 @@ func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) ( } } - return &ext.AddedAt, nil + return &ext, nil } func (b *JSPFBackend) readJSPF() error { From bd7a35cd68fbc4794dafe5c47b64d3638b734141 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Fri, 2 May 2025 16:28:54 +0200 Subject: [PATCH 06/26] Update dependencies --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index ef1286c..8ed329b 100644 --- a/go.mod +++ b/go.mod @@ -15,12 +15,12 @@ require ( github.com/manifoldco/promptui v0.9.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 - github.com/spf13/cast v1.7.1 + github.com/spf13/cast v1.8.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d - github.com/vbauerster/mpb/v8 v8.9.3 + github.com/vbauerster/mpb/v8 v8.10.0 go.uploadedlobster.com/mbtypes v0.4.0 go.uploadedlobster.com/musicbrainzws2 v0.14.0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 @@ -66,7 +66,7 @@ require ( golang.org/x/tools v0.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/mysql v1.5.7 // indirect - modernc.org/libc v1.64.0 // indirect + modernc.org/libc v1.65.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.10.0 // indirect modernc.org/sqlite v1.37.0 // indirect diff --git a/go.sum b/go.sum index 8ade87a..4d3cb1b 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk= +github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= @@ -125,8 +125,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= -github.com/vbauerster/mpb/v8 v8.9.3 h1:PnMeF+sMvYv9u23l6DO6Q3+Mdj408mjLRXIzmUmU2Z8= -github.com/vbauerster/mpb/v8 v8.9.3/go.mod h1:hxS8Hz4C6ijnppDSIX6LjG8FYJSoPo9iIOcE53Zik0c= +github.com/vbauerster/mpb/v8 v8.10.0 h1:5ZYEWM4ovaZGAibjzW4PlQNb5k+JpzMqVwgNyk+K0M8= +github.com/vbauerster/mpb/v8 v8.10.0/go.mod h1:DYPFebxSahB+f7tuEUGauLQ7w8ij3wMr4clsVuJCV4I= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= @@ -216,8 +216,8 @@ modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA= -modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= From 8885e9cebcd90f5b3011af63bbe38d9c470a01b0 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Fri, 2 May 2025 21:35:14 +0200 Subject: [PATCH 07/26] Fix scrobblerlog timezone not being set from config --- internal/backends/scrobblerlog/scrobblerlog.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 7955c15..14ee24f 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -67,18 +67,19 @@ func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") b.ignoreSkipped = config.GetBool("ignore-skipped", true) b.append = config.GetBool("append", true) - timezone := config.GetString("time-zone") - if timezone != "" { + b.log = scrobblerlog.ScrobblerLog{ + TZ: scrobblerlog.TimezoneUTC, + Client: "Rockbox unknown $Revision$", + } + + if timezone := config.GetString("time-zone"); timezone != "" { location, err := time.LoadLocation(timezone) if err != nil { return fmt.Errorf("Invalid time-zone %q: %w", timezone, err) } b.log.FallbackTimezone = location } - b.log = scrobblerlog.ScrobblerLog{ - TZ: scrobblerlog.TimezoneUTC, - Client: "Rockbox unknown $Revision$", - } + return nil } From b3136bde9a7f7d22a891c544a5b14231cd2226c4 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 11:43:26 +0200 Subject: [PATCH 08/26] jspf: add MB extension, if it does not exist --- internal/backends/jspf/jspf.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index 6551f15..9d72765 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -77,14 +77,11 @@ func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error { Title: config.GetString("title"), Creator: config.GetString("username"), Identifier: config.GetString("identifier"), + Date: time.Now(), Tracks: make([]jspf.Track, 0), - Extension: jspf.ExtensionMap{ - jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{ - LastModifiedAt: time.Now(), - Public: true, - }, - }, } + + b.addMusicBrainzPlaylistExtension() return nil } @@ -331,6 +328,7 @@ func (b *JSPFBackend) readJSPF() error { return err } b.playlist = playlist.Playlist + b.addMusicBrainzPlaylistExtension() } } @@ -350,3 +348,13 @@ func (b *JSPFBackend) writeJSPF() error { defer file.Close() return playlist.Write(file) } + +func (b *JSPFBackend) addMusicBrainzPlaylistExtension() { + if b.playlist.Extension == nil { + b.playlist.Extension = make(jspf.ExtensionMap, 1) + } + extension := jspf.MusicBrainzPlaylistExtension{Public: true} + b.playlist.Extension.Get(jspf.MusicBrainzPlaylistExtensionID, &extension) + extension.LastModifiedAt = time.Now() + b.playlist.Extension[jspf.MusicBrainzPlaylistExtensionID] = extension +} From 9480c69cbb42be234df13a7c6e52400370a3afe0 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Fri, 2 May 2025 08:36:59 +0200 Subject: [PATCH 09/26] Handle wait group for progress bar centrally This does not need to be exposed and caller only needs to wait for the Progress instance. --- internal/cli/progress.go | 5 +++-- internal/cli/transfer.go | 7 ++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/cli/progress.go b/internal/cli/progress.go index 6d4421d..54ee4a8 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -28,7 +28,8 @@ import ( "go.uploadedlobster.com/scotty/internal/models" ) -func progressBar(wg *sync.WaitGroup, exportProgress chan models.Progress, importProgress chan models.Progress) *mpb.Progress { +func progressBar(exportProgress chan models.Progress, importProgress chan models.Progress) *mpb.Progress { + wg := &sync.WaitGroup{} p := mpb.New( mpb.WithWaitGroup(wg), mpb.WithOutput(color.Output), diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go index 0ba04b9..4777042 100644 --- a/internal/cli/transfer.go +++ b/internal/cli/transfer.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -19,7 +19,6 @@ import ( "errors" "fmt" "strconv" - "sync" "time" "github.com/spf13/cobra" @@ -112,8 +111,7 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac // Prepare progress bars exportProgress := make(chan models.Progress) importProgress := make(chan models.Progress) - var wg sync.WaitGroup - progress := progressBar(&wg, exportProgress, importProgress) + progress := progressBar(exportProgress, importProgress) // Export from source exportChan := make(chan R, 1000) @@ -126,7 +124,6 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac if timestamp.After(result.LastTimestamp) { result.LastTimestamp = timestamp } - wg.Wait() progress.Wait() if result.Error != nil { printTimestamp("Import failed, last reported timestamp was %v (%s)", result.LastTimestamp) From 1c3364dad5b788b5ffa7c7b0451ccd2a469b17d8 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Fri, 2 May 2025 08:43:30 +0200 Subject: [PATCH 10/26] Close export results channel in generic implementation This removes the need for every implementation to handle this case. --- internal/backends/deezer/deezer.go | 4 ---- internal/backends/export.go | 8 +++++--- internal/backends/funkwhale/funkwhale.go | 4 ---- internal/backends/jspf/jspf.go | 4 ---- internal/backends/lastfm/lastfm.go | 4 ---- internal/backends/listenbrainz/listenbrainz.go | 3 --- internal/backends/maloja/maloja.go | 2 -- internal/backends/scrobblerlog/scrobblerlog.go | 1 - internal/backends/spotify/spotify.go | 4 ---- internal/backends/spotifyhistory/spotifyhistory.go | 2 -- internal/backends/subsonic/subsonic.go | 1 - 11 files changed, 5 insertions(+), 32 deletions(-) diff --git a/internal/backends/deezer/deezer.go b/internal/backends/deezer/deezer.go index 756e271..1a5cb30 100644 --- a/internal/backends/deezer/deezer.go +++ b/internal/backends/deezer/deezer.go @@ -88,8 +88,6 @@ func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan totalDuration := startTime.Sub(oldestTimestamp) - defer close(results) - p := models.Progress{Total: int64(totalDuration.Seconds())} out: @@ -155,8 +153,6 @@ func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m offset := math.MaxInt32 perPage := MaxItemsPerGet - defer close(results) - p := models.Progress{Total: int64(perPage)} var totalCount int diff --git a/internal/backends/export.go b/internal/backends/export.go index 44b8757..0346af2 100644 --- a/internal/backends/export.go +++ b/internal/backends/export.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -35,8 +35,9 @@ func (p ListensExportProcessor) ExportBackend() models.Backend { } func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { + defer close(results) + defer close(progress) p.Backend.ExportListens(oldestTimestamp, results, progress) - close(progress) } type LovesExportProcessor struct { @@ -48,6 +49,7 @@ func (p LovesExportProcessor) ExportBackend() models.Backend { } func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { + defer close(results) + defer close(progress) p.Backend.ExportLoves(oldestTimestamp, results, progress) - close(progress) } diff --git a/internal/backends/funkwhale/funkwhale.go b/internal/backends/funkwhale/funkwhale.go index 3e296c1..3619869 100644 --- a/internal/backends/funkwhale/funkwhale.go +++ b/internal/backends/funkwhale/funkwhale.go @@ -64,8 +64,6 @@ func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results c page := 1 perPage := MaxItemsPerGet - defer close(results) - // We need to gather the full list of listens in order to sort them listens := make(models.ListensList, 0, 2*perPage) p := models.Progress{Total: int64(perPage)} @@ -113,8 +111,6 @@ func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results cha page := 1 perPage := MaxItemsPerGet - defer close(results) - // We need to gather the full list of listens in order to sort them loves := make(models.LovesList, 0, 2*perPage) p := models.Progress{Total: int64(perPage)} diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index 9d72765..826ea1b 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -94,8 +94,6 @@ func (b *JSPFBackend) FinishImport() error { } func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { - defer close(results) - err := b.readJSPF() if err != nil { progress <- models.Progress{}.Complete() @@ -128,8 +126,6 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo } func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { - defer close(results) - err := b.readJSPF() if err != nil { progress <- models.Progress{}.Complete() diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go index 76fe9c7..444e5b0 100644 --- a/internal/backends/lastfm/lastfm.go +++ b/internal/backends/lastfm/lastfm.go @@ -93,8 +93,6 @@ func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan minTime := oldestTimestamp perPage := MaxItemsPerGet - defer close(results) - // We need to gather the full list of listens in order to sort them p := models.Progress{Total: int64(page)} @@ -258,8 +256,6 @@ func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m page := 1 perPage := MaxItemsPerGet - defer close(results) - loves := make(models.LovesList, 0, 2*MaxItemsPerGet) p := models.Progress{Total: int64(perPage)} var totalCount int diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go index 6c7b747..e1ea53d 100644 --- a/internal/backends/listenbrainz/listenbrainz.go +++ b/internal/backends/listenbrainz/listenbrainz.go @@ -81,8 +81,6 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result totalDuration := startTime.Sub(minTime) - defer close(results) - p := models.Progress{Total: int64(totalDuration.Seconds())} for { @@ -195,7 +193,6 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo } func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { - defer close(results) exportChan := make(chan models.LovesResult) p := models.Progress{} diff --git a/internal/backends/maloja/maloja.go b/internal/backends/maloja/maloja.go index e9e3348..6bcdcc2 100644 --- a/internal/backends/maloja/maloja.go +++ b/internal/backends/maloja/maloja.go @@ -67,8 +67,6 @@ func (b *MalojaApiBackend) ExportListens(oldestTimestamp time.Time, results chan page := 0 perPage := MaxItemsPerGet - defer close(results) - // We need to gather the full list of listens in order to sort them listens := make(models.ListensList, 0, 2*perPage) p := models.Progress{Total: int64(perPage)} diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 14ee24f..a355e3e 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -132,7 +132,6 @@ func (b *ScrobblerLogBackend) FinishImport() error { } func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { - defer close(results) file, err := os.Open(b.filePath) if err != nil { progress <- models.Progress{}.Complete() diff --git a/internal/backends/spotify/spotify.go b/internal/backends/spotify/spotify.go index 8c17903..8b6d9da 100644 --- a/internal/backends/spotify/spotify.go +++ b/internal/backends/spotify/spotify.go @@ -101,8 +101,6 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha totalDuration := startTime.Sub(oldestTimestamp) - defer close(results) - p := models.Progress{Total: int64(totalDuration.Seconds())} for { @@ -163,8 +161,6 @@ func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan offset := math.MaxInt32 perPage := MaxItemsPerGet - defer close(results) - p := models.Progress{Total: int64(perPage)} totalCount := 0 exportCount := 0 diff --git a/internal/backends/spotifyhistory/spotifyhistory.go b/internal/backends/spotifyhistory/spotifyhistory.go index 1c986be..c150d3b 100644 --- a/internal/backends/spotifyhistory/spotifyhistory.go +++ b/internal/backends/spotifyhistory/spotifyhistory.go @@ -73,8 +73,6 @@ func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error { } func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { - defer close(results) - files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob)) if err != nil { progress <- models.Progress{}.Complete() diff --git a/internal/backends/subsonic/subsonic.go b/internal/backends/subsonic/subsonic.go index d605324..a966c68 100644 --- a/internal/backends/subsonic/subsonic.go +++ b/internal/backends/subsonic/subsonic.go @@ -64,7 +64,6 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error { } func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { - defer close(results) err := b.client.Authenticate(b.password) if err != nil { progress <- models.Progress{}.Complete() From 3b1adc9f1f81288efbea8174d8edc642c4173d9e Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Fri, 2 May 2025 09:25:07 +0200 Subject: [PATCH 11/26] Fix duplicate calls to handle import errors This fixes the import process hanging on error --- internal/backends/import.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/backends/import.go b/internal/backends/import.go index 6173a53..9938c10 100644 --- a/internal/backends/import.go +++ b/internal/backends/import.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -41,7 +41,7 @@ func (p ListensImportProcessor) Process(results chan models.ListensResult, out c func (p ListensImportProcessor) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { if export.Error != nil { - return handleError(result, export.Error, progress), export.Error + return result, export.Error } if export.Total > 0 { @@ -51,7 +51,7 @@ func (p ListensImportProcessor) Import(export models.ListensResult, result model } importResult, err := p.Backend.ImportListens(export, result, progress) if err != nil { - return handleError(result, err, progress), err + return result, err } return importResult, nil } @@ -70,7 +70,7 @@ func (p LovesImportProcessor) Process(results chan models.LovesResult, out chan func (p LovesImportProcessor) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { if export.Error != nil { - return handleError(result, export.Error, progress), export.Error + return result, export.Error } if export.Total > 0 { @@ -80,7 +80,7 @@ func (p LovesImportProcessor) Import(export models.LovesResult, result models.Im } importResult, err := p.Backend.ImportLoves(export, result, progress) if err != nil { - return handleError(importResult, err, progress), err + return result, err } return importResult, nil } From 069f0de2ee9db6cdf12a36c09ff5227e5ab18000 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 10:31:36 +0200 Subject: [PATCH 12/26] Call "FinishImport" even on error This gives the importer the chance to close connections and free resources to ensure already imported items are properly handled. --- internal/backends/import.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/backends/import.go b/internal/backends/import.go index 9938c10..0db5547 100644 --- a/internal/backends/import.go +++ b/internal/backends/import.go @@ -90,8 +90,7 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( defer close(progress) result := models.ImportResult{} - err := processor.ImportBackend().StartImport() - if err != nil { + if err := processor.ImportBackend().StartImport(); err != nil { out <- handleError(result, err, progress) return } @@ -99,6 +98,7 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( for exportResult := range results { importResult, err := processor.Import(exportResult, result, out, progress) if err != nil { + processor.ImportBackend().FinishImport() out <- handleError(result, err, progress) return } @@ -106,8 +106,7 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( progress <- models.Progress{}.FromImportResult(result) } - err = processor.ImportBackend().FinishImport() - if err != nil { + if err := processor.ImportBackend().FinishImport(); err != nil { out <- handleError(result, err, progress) return } From 55ac41b147a763651a4e5690ec4282800a6eff85 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 11:52:12 +0200 Subject: [PATCH 13/26] If import fails still save the last reported timestamp This allows continuing a partially failed import run. --- internal/backends/import.go | 11 ++++++----- internal/backends/maloja/maloja.go | 3 ++- internal/cli/transfer.go | 16 +++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/backends/import.go b/internal/backends/import.go index 0db5547..c0d78bc 100644 --- a/internal/backends/import.go +++ b/internal/backends/import.go @@ -51,7 +51,7 @@ func (p ListensImportProcessor) Import(export models.ListensResult, result model } importResult, err := p.Backend.ImportListens(export, result, progress) if err != nil { - return result, err + return importResult, err } return importResult, nil } @@ -80,7 +80,7 @@ func (p LovesImportProcessor) Import(export models.LovesResult, result models.Im } importResult, err := p.Backend.ImportLoves(export, result, progress) if err != nil { - return result, err + return importResult, err } return importResult, nil } @@ -89,6 +89,7 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( defer close(out) defer close(progress) result := models.ImportResult{} + p := models.Progress{} if err := processor.ImportBackend().StartImport(); err != nil { out <- handleError(result, err, progress) @@ -97,13 +98,13 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( for exportResult := range results { importResult, err := processor.Import(exportResult, result, out, progress) + result.Update(importResult) if err != nil { processor.ImportBackend().FinishImport() out <- handleError(result, err, progress) return } - result.Update(importResult) - progress <- models.Progress{}.FromImportResult(result) + progress <- p.FromImportResult(result) } if err := processor.ImportBackend().FinishImport(); err != nil { @@ -111,7 +112,7 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( return } - progress <- models.Progress{}.FromImportResult(result).Complete() + progress <- p.FromImportResult(result).Complete() out <- result } diff --git a/internal/backends/maloja/maloja.go b/internal/backends/maloja/maloja.go index 6bcdcc2..a22393b 100644 --- a/internal/backends/maloja/maloja.go +++ b/internal/backends/maloja/maloja.go @@ -105,6 +105,7 @@ out: } func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + p := models.Progress{}.FromImportResult(importResult) for _, listen := range export.Items { scrobble := NewScrobble{ Title: listen.TrackName, @@ -125,7 +126,7 @@ func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResu importResult.UpdateTimestamp(listen.ListenedAt) importResult.ImportCount += 1 - progress <- models.Progress{}.FromImportResult(importResult) + progress <- p.FromImportResult(importResult) } return importResult, nil diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go index 4777042..83bad2a 100644 --- a/internal/cli/transfer.go +++ b/internal/cli/transfer.go @@ -121,16 +121,7 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac resultChan := make(chan models.ImportResult) go imp.Process(exportChan, resultChan, importProgress) result := <-resultChan - if timestamp.After(result.LastTimestamp) { - result.LastTimestamp = timestamp - } progress.Wait() - if result.Error != nil { - printTimestamp("Import failed, last reported timestamp was %v (%s)", result.LastTimestamp) - return result.Error - } - fmt.Println(i18n.Tr("Imported %v of %v %s into %v.", - result.ImportCount, result.TotalCount, c.entity, c.targetName)) // Update timestamp err = c.updateTimestamp(result, timestamp) @@ -138,6 +129,13 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac return err } + fmt.Println(i18n.Tr("Imported %v of %v %s into %v.", + result.ImportCount, result.TotalCount, c.entity, c.targetName)) + if result.Error != nil { + printTimestamp("Import failed, last reported timestamp was %v (%s)", result.LastTimestamp) + return result.Error + } + // Print errors if len(result.ImportLog) > 0 { fmt.Println() From 15d939e15098895c690bb4c055d84d23e800ceaa Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 12:02:17 +0200 Subject: [PATCH 14/26] Update changelog --- CHANGES.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 5dfd892..ba2685b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,15 @@ # Scotty Changelog ## 0.6.0 - WIP -- JSPF: Implemented export as loves and listens +- Fix program hanging endlessly if import fails (#11) +- If import fails still store the last successfully imported timestamp +- JSPF: implemented export as loves and listens +- JSPF: write track duration +- JSPF: read username and recording MSID +- JSPF: add MusicBrainz playlist extension in append mode, if it does not exist + in the existing JSPF file +- scrobblerlog: fix timezone not being set from config (#6) +- scrobblerlog: fix listen export not considering latest timestamp ## 0.5.2 - 2025-05-01 @@ -20,9 +28,9 @@ - ListenBrainz: log missing recording MBID on love import - Subsonic: support OpenSubsonic fields for recording MBID and genres (#5) - Subsonic: fixed progress for loves export -- scrobblerlog: add "time-zone" config option (#6). +- scrobblerlog: add "time-zone" config option (#6) - scrobblerlog: fixed progress for listen export -- scrobblerlog: renamed setting `include-skipped` to `ignore-skipped`. +- scrobblerlog: renamed setting `include-skipped` to `ignore-skipped` Note: 386 builds for Linux are not available with this release due to an incompatibility with latest version of gorm. From aae5123c3d1ab54de3b784d998ea11bd144039d6 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 12:59:40 +0200 Subject: [PATCH 15/26] Show progress bars as aborted on export / import error --- CHANGES.md | 1 + internal/backends/deezer/deezer.go | 4 ++-- internal/backends/import.go | 2 +- internal/backends/jspf/jspf.go | 4 ++-- internal/backends/lastfm/lastfm.go | 8 ++++---- internal/backends/listenbrainz/listenbrainz.go | 5 +++-- internal/backends/scrobblerlog/scrobblerlog.go | 2 +- internal/backends/spotify/spotify.go | 6 +++--- internal/backends/spotifyhistory/spotifyhistory.go | 4 ++-- internal/backends/subsonic/subsonic.go | 4 ++-- internal/cli/progress.go | 10 ++++++++-- internal/models/models.go | 6 ++++++ 12 files changed, 35 insertions(+), 21 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ba2685b..a0a60f2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 0.6.0 - WIP - Fix program hanging endlessly if import fails (#11) - If import fails still store the last successfully imported timestamp +- Show progress bars as aborted on export / import error - JSPF: implemented export as loves and listens - JSPF: write track duration - JSPF: read username and recording MSID diff --git a/internal/backends/deezer/deezer.go b/internal/backends/deezer/deezer.go index 1a5cb30..4ba367d 100644 --- a/internal/backends/deezer/deezer.go +++ b/internal/backends/deezer/deezer.go @@ -94,7 +94,7 @@ out: for { result, err := b.client.UserHistory(offset, perPage) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.ListensResult{Error: err} return } @@ -160,7 +160,7 @@ out: for { result, err := b.client.UserTracks(offset, perPage) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.LovesResult{Error: err} return } diff --git a/internal/backends/import.go b/internal/backends/import.go index c0d78bc..407082c 100644 --- a/internal/backends/import.go +++ b/internal/backends/import.go @@ -118,6 +118,6 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( func handleError(result models.ImportResult, err error, progress chan models.Progress) models.ImportResult { result.Error = err - progress <- models.Progress{}.FromImportResult(result).Complete() + progress <- models.Progress{}.FromImportResult(result).Abort() return result } diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index 826ea1b..e981741 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -96,7 +96,7 @@ func (b *JSPFBackend) FinishImport() error { func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { err := b.readJSPF() if err != nil { - progress <- models.Progress{}.Complete() + progress <- models.Progress{}.Abort() results <- models.ListensResult{Error: err} return } @@ -128,7 +128,7 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { err := b.readJSPF() if err != nil { - progress <- models.Progress{}.Complete() + progress <- models.Progress{}.Abort() results <- models.LovesResult{Error: err} return } diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go index 444e5b0..d45f793 100644 --- a/internal/backends/lastfm/lastfm.go +++ b/internal/backends/lastfm/lastfm.go @@ -108,7 +108,7 @@ out: result, err := b.client.User.GetRecentTracks(args) if err != nil { results <- models.ListensResult{Error: err} - progress <- p.Complete() + progress <- p.Abort() return } @@ -127,7 +127,7 @@ out: timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64) if err != nil { results <- models.ListensResult{Error: err} - progress <- p.Complete() + progress <- p.Abort() break out } if timestamp > oldestTimestamp.Unix() { @@ -268,7 +268,7 @@ out: "page": page, }) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.LovesResult{Error: err} return } @@ -282,7 +282,7 @@ out: for _, track := range result.Tracks { timestamp, err := strconv.ParseInt(track.Date.Uts, 10, 64) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.LovesResult{Error: err} return } diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go index e1ea53d..fffe0f0 100644 --- a/internal/backends/listenbrainz/listenbrainz.go +++ b/internal/backends/listenbrainz/listenbrainz.go @@ -86,7 +86,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result for { result, err := b.client.GetListens(b.username, time.Now(), minTime) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.ListensResult{Error: err} return } @@ -199,8 +199,9 @@ func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results go b.exportLoves(oldestTimestamp, exportChan) for existingLoves := range exportChan { if existingLoves.Error != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.LovesResult{Error: existingLoves.Error} + return } p.Total = int64(existingLoves.Total) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index a355e3e..7890971 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -134,7 +134,7 @@ func (b *ScrobblerLogBackend) FinishImport() error { func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { file, err := os.Open(b.filePath) if err != nil { - progress <- models.Progress{}.Complete() + progress <- models.Progress{}.Abort() results <- models.ListensResult{Error: err} return } diff --git a/internal/backends/spotify/spotify.go b/internal/backends/spotify/spotify.go index 8b6d9da..be48dfe 100644 --- a/internal/backends/spotify/spotify.go +++ b/internal/backends/spotify/spotify.go @@ -106,7 +106,7 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha for { result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.ListensResult{Error: err} return } @@ -118,7 +118,7 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha // Set minTime to the newest returned listen after, err := strconv.ParseInt(result.Cursors.After, 10, 64) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.ListensResult{Error: err} return } else if after <= minTime.Unix() { @@ -169,7 +169,7 @@ out: for { result, err := b.client.UserTracks(offset, perPage) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.LovesResult{Error: err} return } diff --git a/internal/backends/spotifyhistory/spotifyhistory.go b/internal/backends/spotifyhistory/spotifyhistory.go index c150d3b..23309f3 100644 --- a/internal/backends/spotifyhistory/spotifyhistory.go +++ b/internal/backends/spotifyhistory/spotifyhistory.go @@ -75,7 +75,7 @@ func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error { func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob)) if err != nil { - progress <- models.Progress{}.Complete() + progress <- models.Progress{}.Abort() results <- models.ListensResult{Error: err} return } @@ -86,7 +86,7 @@ func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results for i, filePath := range files { history, err := readHistoryFile(filePath) if err != nil { - progress <- models.Progress{}.Complete() + progress <- models.Progress{}.Abort() results <- models.ListensResult{Error: err} return } diff --git a/internal/backends/subsonic/subsonic.go b/internal/backends/subsonic/subsonic.go index a966c68..370765e 100644 --- a/internal/backends/subsonic/subsonic.go +++ b/internal/backends/subsonic/subsonic.go @@ -66,14 +66,14 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error { func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { err := b.client.Authenticate(b.password) if err != nil { - progress <- models.Progress{}.Complete() + progress <- models.Progress{}.Abort() results <- models.LovesResult{Error: err} return } starred, err := b.client.GetStarred2(map[string]string{}) if err != nil { - progress <- models.Progress{}.Complete() + progress <- models.Progress{}.Abort() results <- models.LovesResult{Error: err} return } diff --git a/internal/cli/progress.go b/internal/cli/progress.go index 54ee4a8..6b60697 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -59,10 +59,12 @@ func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar { ), mpb.AppendDecorators( decor.OnComplete( - decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}), + decor.OnAbort( + decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}), + i18n.Tr("error"), + ), i18n.Tr("done"), ), - // decor.OnComplete(decor.Percentage(decor.WC{W: 5, C: decor.DSyncWidthR}), "done"), decor.Name(" "), ), ) @@ -73,6 +75,10 @@ func updateProgressBar(bar *mpb.Bar, wg *sync.WaitGroup, progressChan chan model defer wg.Done() lastIterTime := time.Now() for progress := range progressChan { + if progress.Aborted { + bar.Abort(false) + return + } oldIterTime := lastIterTime lastIterTime = time.Now() bar.EwmaSetCurrent(progress.Elapsed, lastIterTime.Sub(oldIterTime)) diff --git a/internal/models/models.go b/internal/models/models.go index f2dd71d..b8f9121 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -213,6 +213,7 @@ type Progress struct { Total int64 Elapsed int64 Completed bool + Aborted bool } func (p Progress) FromImportResult(result ImportResult) Progress { @@ -226,3 +227,8 @@ func (p Progress) Complete() Progress { p.Completed = true return p } + +func (p Progress) Abort() Progress { + p.Aborted = true + return p +} From dfe6773744a010fc77ea92fbb9ec6ec42fc52bf0 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 13:07:02 +0200 Subject: [PATCH 16/26] Update translations --- internal/translations/catalog.go | 38 ++++++------- .../translations/locales/de/out.gotext.json | 47 ++++++++-------- .../translations/locales/en/out.gotext.json | 53 +++++++++++-------- 3 files changed, 76 insertions(+), 62 deletions(-) diff --git a/internal/translations/catalog.go b/internal/translations/catalog.go index 3eb2f7e..fbe5eef 100644 --- a/internal/translations/catalog.go +++ b/internal/translations/catalog.go @@ -61,9 +61,9 @@ var messageKeyToIndex = map[string]int{ "Ignore listens in incognito mode": 30, "Ignore skipped listens": 27, "Ignored duplicate listen %v: \"%v\" by %v (%v)": 25, - "Import failed, last reported timestamp was %v (%s)": 45, + "Import failed, last reported timestamp was %v (%s)": 46, "Import log:": 47, - "Imported %v of %v %s into %v.": 46, + "Imported %v of %v %s into %v.": 45, "Latest timestamp: %v (%v)": 50, "Minimum playback duration for skipped tracks (seconds)": 31, "No": 39, @@ -85,6 +85,7 @@ var messageKeyToIndex = map[string]int{ "a service with this name already exists": 4, "backend %s does not implement %s": 13, "done": 37, + "error": 54, "exporting": 35, "importing": 36, "invalid timestamp string \"%v\"": 49, @@ -95,7 +96,7 @@ var messageKeyToIndex = map[string]int{ "unknown backend \"%s\"": 14, } -var deIndex = []uint32{ // 55 elements +var deIndex = []uint32{ // 56 elements // Entry 0 - 1F 0x00000000, 0x00000013, 0x00000027, 0x00000052, 0x0000005e, 0x0000008d, 0x000000bd, 0x00000104, @@ -109,10 +110,10 @@ var deIndex = []uint32{ // 55 elements 0x00000418, 0x00000443, 0x0000046d, 0x000004ad, 0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd, 0x000004d2, 0x000004fb, 0x00000503, 0x0000050b, - 0x00000534, 0x00000552, 0x0000058f, 0x000005ba, + 0x00000534, 0x00000552, 0x0000057d, 0x000005ba, 0x000005c5, 0x000005d2, 0x000005f6, 0x00000619, - 0x0000066a, 0x000006a1, 0x000006c8, -} // Size: 244 bytes + 0x0000066a, 0x000006a1, 0x000006c8, 0x000006c8, +} // Size: 248 bytes const deData string = "" + // Size: 1736 bytes "\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" + @@ -136,15 +137,15 @@ const deData string = "" + // Size: 1736 bytes "rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" + "det werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine" + " bestehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %" + - "[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fe" + - "hlgeschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %" + - "[3]s in %[4]v importiert.\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Ze" + + "[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02%[1]v von" + + " %[2]v %[3]s in %[4]v importiert.\x02Import fehlgeschlagen, letzter Zeit" + + "stempel war %[1]v (%[2]s)\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Ze" + "itstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine Konfigu" + "rationsdatei definiert, Konfiguration kann nicht geschrieben werden\x02S" + "chlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Servicekon" + "figuration „%[1]v“" -var enIndex = []uint32{ // 55 elements +var enIndex = []uint32{ // 56 elements // Entry 0 - 1F 0x00000000, 0x00000013, 0x00000027, 0x00000044, 0x00000051, 0x00000079, 0x000000a1, 0x000000de, @@ -158,12 +159,12 @@ var enIndex = []uint32{ // 55 elements 0x0000039c, 0x000003c3, 0x000003df, 0x00000412, 0x0000041c, 0x00000426, 0x0000042b, 0x0000042f, 0x00000432, 0x00000455, 0x0000045d, 0x00000465, - 0x0000048f, 0x000004ad, 0x000004e6, 0x00000510, + 0x0000048f, 0x000004ad, 0x000004d7, 0x00000510, 0x0000051c, 0x00000529, 0x0000054a, 0x0000056a, - 0x0000059d, 0x000005c2, 0x000005e3, -} // Size: 244 bytes + 0x0000059d, 0x000005c2, 0x000005e3, 0x000005e9, +} // Size: 248 bytes -const enData string = "" + // Size: 1507 bytes +const enData string = "" + // Size: 1513 bytes "\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" + " %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" + " this name already exists\x02Saved service %[1]v using backend %[2]v\x02" + @@ -184,10 +185,11 @@ const enData string = "" + // Size: 1507 bytes "token received, you can use %[1]v now.\x02exporting\x02importing\x02done" + "\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" + "d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" + - "[2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Imp" + - "orted %[1]v of %[2]v %[3]s into %[4]v.\x02Import log:\x02%[1]v: %[2]v" + + "[2]v)\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02Import failed, las" + + "t reported timestamp was %[1]v (%[2]s)\x02Import log:\x02%[1]v: %[2]v" + "\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" + "[2]v)\x02no configuration file defined, cannot write config\x02key must " + - "only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22" + "only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22" + + "\x02error" - // Total table size 3731 bytes (3KiB); checksum: F7951710 + // Total table size 3745 bytes (3KiB); checksum: 5C167C91 diff --git a/internal/translations/locales/de/out.gotext.json b/internal/translations/locales/de/out.gotext.json index 680505e..06f5f82 100644 --- a/internal/translations/locales/de/out.gotext.json +++ b/internal/translations/locales/de/out.gotext.json @@ -378,6 +378,11 @@ "translatorComment": "Copied from source.", "fuzzy": true }, + { + "id": "error", + "message": "error", + "translation": "" + }, { "id": "done", "message": "done", @@ -462,27 +467,6 @@ } ] }, - { - "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]s", - "type": "", - "underlyingType": "string", - "argNum": 2 - } - ] - }, { "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", @@ -522,6 +506,27 @@ } ] }, + { + "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", + "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", + "translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})", + "placeholders": [ + { + "id": "Arg_1", + "string": "%[1]v", + "type": "", + "underlyingType": "interface{}", + "argNum": 1 + }, + { + "id": "Arg_2", + "string": "%[2]s", + "type": "", + "underlyingType": "string", + "argNum": 2 + } + ] + }, { "id": "Import log:", "message": "Import log:", diff --git a/internal/translations/locales/en/out.gotext.json b/internal/translations/locales/en/out.gotext.json index eecf359..0ef12a7 100644 --- a/internal/translations/locales/en/out.gotext.json +++ b/internal/translations/locales/en/out.gotext.json @@ -448,6 +448,13 @@ "translatorComment": "Copied from source.", "fuzzy": true }, + { + "id": "error", + "message": "error", + "translation": "error", + "translatorComment": "Copied from source.", + "fuzzy": true + }, { "id": "done", "message": "done", @@ -546,29 +553,6 @@ ], "fuzzy": true }, - { - "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]s", - "type": "", - "underlyingType": "string", - "argNum": 2 - } - ], - "fuzzy": true - }, { "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", @@ -610,6 +594,29 @@ ], "fuzzy": true }, + { + "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", + "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", + "translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", + "translatorComment": "Copied from source.", + "placeholders": [ + { + "id": "Arg_1", + "string": "%[1]v", + "type": "", + "underlyingType": "interface{}", + "argNum": 1 + }, + { + "id": "Arg_2", + "string": "%[2]s", + "type": "", + "underlyingType": "string", + "argNum": 2 + } + ], + "fuzzy": true + }, { "id": "Import log:", "message": "Import log:", From a8517ea2490cb4ca7b5fa87bd5a9300206788da3 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 13:22:51 +0200 Subject: [PATCH 17/26] funkwhale: fix progress abort on error --- internal/backends/funkwhale/funkwhale.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/backends/funkwhale/funkwhale.go b/internal/backends/funkwhale/funkwhale.go index 3619869..e32a952 100644 --- a/internal/backends/funkwhale/funkwhale.go +++ b/internal/backends/funkwhale/funkwhale.go @@ -119,7 +119,7 @@ out: for { result, err := b.client.GetFavoriteTracks(page, perPage) if err != nil { - progress <- p.Complete() + progress <- p.Abort() results <- models.LovesResult{Error: err} return } From 05f0e8d172a1863c3913f5d0db4ed4f56af2fd43 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 13:24:12 +0200 Subject: [PATCH 18/26] Change string for aborted progress bar --- internal/cli/progress.go | 2 +- internal/translations/catalog.go | 30 +++++++++---------- .../translations/locales/de/out.gotext.json | 4 +-- .../translations/locales/en/out.gotext.json | 6 ++-- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/cli/progress.go b/internal/cli/progress.go index 6b60697..88339ef 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -61,7 +61,7 @@ func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar { decor.OnComplete( decor.OnAbort( decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}), - i18n.Tr("error"), + i18n.Tr("aborted"), ), i18n.Tr("done"), ), diff --git a/internal/translations/catalog.go b/internal/translations/catalog.go index fbe5eef..20f3f35 100644 --- a/internal/translations/catalog.go +++ b/internal/translations/catalog.go @@ -83,17 +83,17 @@ var messageKeyToIndex = map[string]int{ "Visit the URL for authorization: %v": 32, "Yes": 38, "a service with this name already exists": 4, - "backend %s does not implement %s": 13, - "done": 37, - "error": 54, - "exporting": 35, - "importing": 36, - "invalid timestamp string \"%v\"": 49, - "key must only consist of A-Za-z0-9_-": 52, - "no configuration file defined, cannot write config": 51, - "no existing service configurations": 40, - "no service configuration \"%v\"": 53, - "unknown backend \"%s\"": 14, + "aborted": 54, + "backend %s does not implement %s": 13, + "done": 37, + "exporting": 35, + "importing": 36, + "invalid timestamp string \"%v\"": 49, + "key must only consist of A-Za-z0-9_-": 52, + "no configuration file defined, cannot write config": 51, + "no existing service configurations": 40, + "no service configuration \"%v\"": 53, + "unknown backend \"%s\"": 14, } var deIndex = []uint32{ // 56 elements @@ -161,10 +161,10 @@ var enIndex = []uint32{ // 56 elements 0x00000432, 0x00000455, 0x0000045d, 0x00000465, 0x0000048f, 0x000004ad, 0x000004d7, 0x00000510, 0x0000051c, 0x00000529, 0x0000054a, 0x0000056a, - 0x0000059d, 0x000005c2, 0x000005e3, 0x000005e9, + 0x0000059d, 0x000005c2, 0x000005e3, 0x000005eb, } // Size: 248 bytes -const enData string = "" + // Size: 1513 bytes +const enData string = "" + // Size: 1515 bytes "\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" + " %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" + " this name already exists\x02Saved service %[1]v using backend %[2]v\x02" + @@ -190,6 +190,6 @@ const enData string = "" + // Size: 1513 bytes "\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" + "[2]v)\x02no configuration file defined, cannot write config\x02key must " + "only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22" + - "\x02error" + "\x02aborted" - // Total table size 3745 bytes (3KiB); checksum: 5C167C91 + // Total table size 3747 bytes (3KiB); checksum: 1EAA307C diff --git a/internal/translations/locales/de/out.gotext.json b/internal/translations/locales/de/out.gotext.json index 06f5f82..810655f 100644 --- a/internal/translations/locales/de/out.gotext.json +++ b/internal/translations/locales/de/out.gotext.json @@ -379,8 +379,8 @@ "fuzzy": true }, { - "id": "error", - "message": "error", + "id": "aborted", + "message": "aborted", "translation": "" }, { diff --git a/internal/translations/locales/en/out.gotext.json b/internal/translations/locales/en/out.gotext.json index 0ef12a7..6f6825f 100644 --- a/internal/translations/locales/en/out.gotext.json +++ b/internal/translations/locales/en/out.gotext.json @@ -449,9 +449,9 @@ "fuzzy": true }, { - "id": "error", - "message": "error", - "translation": "error", + "id": "aborted", + "message": "aborted", + "translation": "aborted", "translatorComment": "Copied from source.", "fuzzy": true }, From cb6a534fa184b31bbaf9f0e89c2f41506e020f3b Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 11:25:07 +0000 Subject: [PATCH 19/26] Translated using Weblate (German) Currently translated at 100.0% (55 of 55 strings) Co-authored-by: Philipp Wolfer Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/ Translation: Scotty/app --- internal/translations/locales/de/out.gotext.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/translations/locales/de/out.gotext.json b/internal/translations/locales/de/out.gotext.json index 810655f..d114352 100644 --- a/internal/translations/locales/de/out.gotext.json +++ b/internal/translations/locales/de/out.gotext.json @@ -367,28 +367,28 @@ { "id": "exporting", "message": "exporting", - "translation": "exportiere", "translatorComment": "Copied from source.", - "fuzzy": true + "fuzzy": true, + "translation": "exportiere" }, { "id": "importing", "message": "importing", - "translation": "importiere", "translatorComment": "Copied from source.", - "fuzzy": true + "fuzzy": true, + "translation": "importiere" }, { "id": "aborted", "message": "aborted", - "translation": "" + "translation": "abgebrochen" }, { "id": "done", "message": "done", - "translation": "fertig", "translatorComment": "Copied from source.", - "fuzzy": true + "fuzzy": true, + "translation": "fertig" }, { "id": "Yes", @@ -617,4 +617,4 @@ ] } ] -} \ No newline at end of file +} From 54fffce1d942757a2c63e07f0a5d0c7b99152333 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 13:31:44 +0200 Subject: [PATCH 20/26] Update translation files --- internal/translations/catalog.go | 96 +++++------ .../locales/de/messages.gotext.json | 50 +++--- .../translations/locales/de/out.gotext.json | 17 +- .../locales/en/messages.gotext.json | 162 ++++++------------ .../translations/locales/en/out.gotext.json | 162 ++++++------------ 5 files changed, 189 insertions(+), 298 deletions(-) diff --git a/internal/translations/catalog.go b/internal/translations/catalog.go index 20f3f35..f0aaaae 100644 --- a/internal/translations/catalog.go +++ b/internal/translations/catalog.go @@ -42,12 +42,12 @@ var messageKeyToIndex = map[string]int{ "\tbackend: %v": 11, "\texport: %s": 0, "\timport: %s\n": 1, - "%v: %v": 48, + "%v: %v": 49, "Aborted": 8, "Access token": 19, "Access token received, you can use %v now.\n": 34, "Append to file": 21, - "Backend": 42, + "Backend": 43, "Check for duplicate listens on import (slower)": 24, "Client ID": 15, "Client secret": 16, @@ -57,42 +57,42 @@ var messageKeyToIndex = map[string]int{ "Error: OAuth state mismatch": 33, "Failed reading config: %v": 2, "File path": 20, - "From timestamp: %v (%v)": 44, + "From timestamp: %v (%v)": 45, "Ignore listens in incognito mode": 30, "Ignore skipped listens": 27, "Ignored duplicate listen %v: \"%v\" by %v (%v)": 25, - "Import failed, last reported timestamp was %v (%s)": 46, - "Import log:": 47, - "Imported %v of %v %s into %v.": 45, - "Latest timestamp: %v (%v)": 50, + "Import failed, last reported timestamp was %v (%s)": 47, + "Import log:": 48, + "Imported %v of %v %s into %v.": 46, + "Latest timestamp: %v (%v)": 51, "Minimum playback duration for skipped tracks (seconds)": 31, - "No": 39, + "No": 40, "Playlist title": 22, "Saved service %v using backend %v": 5, "Server URL": 17, - "Service": 41, + "Service": 42, "Service \"%v\" deleted\n": 9, "Service name": 3, "Specify a time zone for the listen timestamps": 28, "The backend %v requires authentication. Authenticate now?": 6, "Token received, you can close this window now.": 12, - "Transferring %s from %s to %s…": 43, + "Transferring %s from %s to %s…": 44, "Unique playlist identifier": 23, "Updated service %v using backend %v\n": 10, "User name": 18, "Visit the URL for authorization: %v": 32, - "Yes": 38, + "Yes": 39, "a service with this name already exists": 4, - "aborted": 54, + "aborted": 37, "backend %s does not implement %s": 13, - "done": 37, + "done": 38, "exporting": 35, "importing": 36, - "invalid timestamp string \"%v\"": 49, - "key must only consist of A-Za-z0-9_-": 52, - "no configuration file defined, cannot write config": 51, - "no existing service configurations": 40, - "no service configuration \"%v\"": 53, + "invalid timestamp string \"%v\"": 50, + "key must only consist of A-Za-z0-9_-": 53, + "no configuration file defined, cannot write config": 52, + "no existing service configurations": 41, + "no service configuration \"%v\"": 54, "unknown backend \"%s\"": 14, } @@ -108,14 +108,14 @@ var deIndex = []uint32{ // 56 elements 0x0000037e, 0x000003a4, 0x000003b4, 0x000003da, // Entry 20 - 3F 0x00000418, 0x00000443, 0x0000046d, 0x000004ad, - 0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd, - 0x000004d2, 0x000004fb, 0x00000503, 0x0000050b, - 0x00000534, 0x00000552, 0x0000057d, 0x000005ba, - 0x000005c5, 0x000005d2, 0x000005f6, 0x00000619, - 0x0000066a, 0x000006a1, 0x000006c8, 0x000006c8, + 0x000004b8, 0x000004c3, 0x000004cf, 0x000004d6, + 0x000004d9, 0x000004de, 0x00000507, 0x0000050f, + 0x00000517, 0x00000540, 0x0000055e, 0x00000589, + 0x000005c6, 0x000005d1, 0x000005de, 0x00000602, + 0x00000625, 0x00000676, 0x000006ad, 0x000006d4, } // Size: 248 bytes -const deData string = "" + // Size: 1736 bytes +const deData string = "" + // Size: 1748 bytes "\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" + " %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" + "in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" + @@ -135,15 +135,15 @@ const deData string = "" + // Size: 1736 bytes "inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" + "ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" + "rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" + - "det werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine" + - " bestehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %" + - "[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02%[1]v von" + - " %[2]v %[3]s in %[4]v importiert.\x02Import fehlgeschlagen, letzter Zeit" + - "stempel war %[1]v (%[2]s)\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Ze" + - "itstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine Konfigu" + - "rationsdatei definiert, Konfiguration kann nicht geschrieben werden\x02S" + - "chlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Servicekon" + - "figuration „%[1]v“" + "det werden.\x02exportiere\x02importiere\x02abgebrochen\x02fertig\x02Ja" + + "\x02Nein\x02keine bestehenden Servicekonfigurationen\x02Service\x02Backe" + + "nd\x02Übertrage %[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[" + + "2]v)\x02%[1]v von %[2]v %[3]s in %[4]v importiert.\x02Import fehlgeschla" + + "gen, letzter Zeitstempel war %[1]v (%[2]s)\x02Importlog:\x02%[1]v: %[2]v" + + "\x02ungültiger Zeitstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)" + + "\x02keine Konfigurationsdatei definiert, Konfiguration kann nicht geschr" + + "ieben werden\x02Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten" + + "\x02keine Servicekonfiguration „%[1]v“" var enIndex = []uint32{ // 56 elements // Entry 0 - 1F @@ -157,11 +157,11 @@ var enIndex = []uint32{ // 56 elements 0x00000307, 0x00000335, 0x00000344, 0x00000365, // Entry 20 - 3F 0x0000039c, 0x000003c3, 0x000003df, 0x00000412, - 0x0000041c, 0x00000426, 0x0000042b, 0x0000042f, - 0x00000432, 0x00000455, 0x0000045d, 0x00000465, - 0x0000048f, 0x000004ad, 0x000004d7, 0x00000510, - 0x0000051c, 0x00000529, 0x0000054a, 0x0000056a, - 0x0000059d, 0x000005c2, 0x000005e3, 0x000005eb, + 0x0000041c, 0x00000426, 0x0000042e, 0x00000433, + 0x00000437, 0x0000043a, 0x0000045d, 0x00000465, + 0x0000046d, 0x00000497, 0x000004b5, 0x000004df, + 0x00000518, 0x00000524, 0x00000531, 0x00000552, + 0x00000572, 0x000005a5, 0x000005ca, 0x000005eb, } // Size: 248 bytes const enData string = "" + // Size: 1515 bytes @@ -182,14 +182,14 @@ const enData string = "" + // Size: 1515 bytes "mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" + "yback duration for skipped tracks (seconds)\x02Visit the URL for authori" + "zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " + - "token received, you can use %[1]v now.\x02exporting\x02importing\x02done" + - "\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" + - "d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" + - "[2]v)\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02Import failed, las" + - "t reported timestamp was %[1]v (%[2]s)\x02Import log:\x02%[1]v: %[2]v" + - "\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" + - "[2]v)\x02no configuration file defined, cannot write config\x02key must " + - "only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22" + - "\x02aborted" + "token received, you can use %[1]v now.\x02exporting\x02importing\x02abor" + + "ted\x02done\x02Yes\x02No\x02no existing service configurations\x02Servic" + + "e\x02Backend\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestam" + + "p: %[1]v (%[2]v)\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02Import " + + "failed, last reported timestamp was %[1]v (%[2]s)\x02Import log:\x02%[1]" + + "v: %[2]v\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: " + + "%[1]v (%[2]v)\x02no configuration file defined, cannot write config\x02k" + + "ey must only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]" + + "v\x22" - // Total table size 3747 bytes (3KiB); checksum: 1EAA307C + // Total table size 3759 bytes (3KiB); checksum: 7B4CF967 diff --git a/internal/translations/locales/de/messages.gotext.json b/internal/translations/locales/de/messages.gotext.json index 8cbe44a..b44b7af 100644 --- a/internal/translations/locales/de/messages.gotext.json +++ b/internal/translations/locales/de/messages.gotext.json @@ -368,21 +368,23 @@ "id": "exporting", "message": "exporting", "translatorComment": "Copied from source.", - "fuzzy": true, "translation": "exportiere" }, { "id": "importing", "message": "importing", "translatorComment": "Copied from source.", - "fuzzy": true, "translation": "importiere" }, + { + "id": "aborted", + "message": "aborted", + "translation": "abgebrochen" + }, { "id": "done", "message": "done", "translatorComment": "Copied from source.", - "fuzzy": true, "translation": "fertig" }, { @@ -462,27 +464,6 @@ } ] }, - { - "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]s", - "type": "", - "underlyingType": "string", - "argNum": 2 - } - ] - }, { "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", @@ -522,6 +503,27 @@ } ] }, + { + "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", + "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", + "translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})", + "placeholders": [ + { + "id": "Arg_1", + "string": "%[1]v", + "type": "", + "underlyingType": "interface{}", + "argNum": 1 + }, + { + "id": "Arg_2", + "string": "%[2]s", + "type": "", + "underlyingType": "string", + "argNum": 2 + } + ] + }, { "id": "Import log:", "message": "Import log:", diff --git a/internal/translations/locales/de/out.gotext.json b/internal/translations/locales/de/out.gotext.json index d114352..863d9c8 100644 --- a/internal/translations/locales/de/out.gotext.json +++ b/internal/translations/locales/de/out.gotext.json @@ -367,16 +367,14 @@ { "id": "exporting", "message": "exporting", - "translatorComment": "Copied from source.", - "fuzzy": true, - "translation": "exportiere" + "translation": "exportiere", + "translatorComment": "Copied from source." }, { "id": "importing", "message": "importing", - "translatorComment": "Copied from source.", - "fuzzy": true, - "translation": "importiere" + "translation": "importiere", + "translatorComment": "Copied from source." }, { "id": "aborted", @@ -386,9 +384,8 @@ { "id": "done", "message": "done", - "translatorComment": "Copied from source.", - "fuzzy": true, - "translation": "fertig" + "translation": "fertig", + "translatorComment": "Copied from source." }, { "id": "Yes", @@ -617,4 +614,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/internal/translations/locales/en/messages.gotext.json b/internal/translations/locales/en/messages.gotext.json index ed62636..878db22 100644 --- a/internal/translations/locales/en/messages.gotext.json +++ b/internal/translations/locales/en/messages.gotext.json @@ -15,8 +15,7 @@ "argNum": 1, "expr": "strings.Join(info.ExportCapabilities, \", \")" } - ], - "fuzzy": true + ] }, { "id": "import: {ImportCapabilities__}", @@ -32,8 +31,7 @@ "argNum": 1, "expr": "strings.Join(info.ImportCapabilities, \", \")" } - ], - "fuzzy": true + ] }, { "id": "Failed reading config: {Err}", @@ -49,22 +47,19 @@ "argNum": 1, "expr": "err" } - ], - "fuzzy": true + ] }, { "id": "Service name", "message": "Service name", "translation": "Service name", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "a service with this name already exists", "message": "a service with this name already exists", "translation": "a service with this name already exists", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Saved service {Name} using backend {Backend}", @@ -88,8 +83,7 @@ "argNum": 2, "expr": "service.Backend" } - ], - "fuzzy": true + ] }, { "id": "The backend {Backend} requires authentication. Authenticate now?", @@ -105,8 +99,7 @@ "argNum": 1, "expr": "service.Backend" } - ], - "fuzzy": true + ] }, { "id": "Delete the service configuration \"{Service}\"?", @@ -122,15 +115,13 @@ "argNum": 1, "expr": "service" } - ], - "fuzzy": true + ] }, { "id": "Aborted", "message": "Aborted", "translation": "Aborted", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Service \"{Name}\" deleted", @@ -146,8 +137,7 @@ "argNum": 1, "expr": "service.Name" } - ], - "fuzzy": true + ] }, { "id": "Updated service {Name} using backend {Backend}", @@ -171,8 +161,7 @@ "argNum": 2, "expr": "service.Backend" } - ], - "fuzzy": true + ] }, { "id": "backend: {Backend}", @@ -188,15 +177,13 @@ "argNum": 1, "expr": "s.Backend" } - ], - "fuzzy": true + ] }, { "id": "Token received, you can close this window now.", "message": "Token received, you can close this window now.", "translation": "Token received, you can close this window now.", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "backend {Backend} does not implement {InterfaceName}", @@ -220,8 +207,7 @@ "argNum": 2, "expr": "interfaceName" } - ], - "fuzzy": true + ] }, { "id": "unknown backend \"{BackendName}\"", @@ -237,78 +223,67 @@ "argNum": 1, "expr": "backendName" } - ], - "fuzzy": true + ] }, { "id": "Client ID", "message": "Client ID", "translation": "Client ID", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Client secret", "message": "Client secret", "translation": "Client secret", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Server URL", "message": "Server URL", "translation": "Server URL", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "User name", "message": "User name", "translation": "User name", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Access token", "message": "Access token", "translation": "Access token", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "File path", "message": "File path", "translation": "File path", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Append to file", "message": "Append to file", "translation": "Append to file", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Playlist title", "message": "Playlist title", "translation": "Playlist title", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Unique playlist identifier", "message": "Unique playlist identifier", "translation": "Unique playlist identifier", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Check for duplicate listens on import (slower)", "message": "Check for duplicate listens on import (slower)", "translation": "Check for duplicate listens on import (slower)", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", @@ -348,50 +323,43 @@ "argNum": 4, "expr": "l.RecordingMBID" } - ], - "fuzzy": true + ] }, { "id": "Disable auto correction of submitted listens", "message": "Disable auto correction of submitted listens", "translation": "Disable auto correction of submitted listens", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Ignore skipped listens", "message": "Ignore skipped listens", "translation": "Ignore skipped listens", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Specify a time zone for the listen timestamps", "message": "Specify a time zone for the listen timestamps", "translation": "Specify a time zone for the listen timestamps", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Directory path", "message": "Directory path", "translation": "Directory path", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Ignore listens in incognito mode", "message": "Ignore listens in incognito mode", "translation": "Ignore listens in incognito mode", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)", "translation": "Minimum playback duration for skipped tracks (seconds)", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Visit the URL for authorization: {URL}", @@ -407,15 +375,13 @@ "argNum": 1, "expr": "authURL.URL" } - ], - "fuzzy": true + ] }, { "id": "Error: OAuth state mismatch", "message": "Error: OAuth state mismatch", "translation": "Error: OAuth state mismatch", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Access token received, you can use {Name} now.", @@ -431,64 +397,55 @@ "argNum": 1, "expr": "service.Name" } - ], - "fuzzy": true + ] }, { "id": "exporting", "message": "exporting", "translation": "exporting", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "importing", "message": "importing", "translation": "importing", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "done", "message": "done", "translation": "done", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Yes", "message": "Yes", "translation": "Yes", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "No", "message": "No", "translation": "No", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "no existing service configurations", "message": "no existing service configurations", "translation": "no existing service configurations", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Service", "message": "Service", "translation": "Service", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Backend", "message": "Backend", "translation": "Backend", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Transferring {Entity} from {SourceName} to {TargetName}…", @@ -520,8 +477,7 @@ "argNum": 3, "expr": "c.targetName" } - ], - "fuzzy": true + ] }, { "id": "From timestamp: {Arg_1} ({Arg_2})", @@ -543,8 +499,7 @@ "underlyingType": "interface{}", "argNum": 2 } - ], - "fuzzy": true + ] }, { "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", @@ -566,8 +521,7 @@ "underlyingType": "string", "argNum": 2 } - ], - "fuzzy": true + ] }, { "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", @@ -607,15 +561,13 @@ "argNum": 4, "expr": "c.targetName" } - ], - "fuzzy": true + ] }, { "id": "Import log:", "message": "Import log:", "translation": "Import log:", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "{Type}: {Message}", @@ -639,8 +591,7 @@ "argNum": 2, "expr": "entry.Message" } - ], - "fuzzy": true + ] }, { "id": "invalid timestamp string \"{FlagValue}\"", @@ -656,8 +607,7 @@ "argNum": 1, "expr": "flagValue" } - ], - "fuzzy": true + ] }, { "id": "Latest timestamp: {Arg_1} ({Arg_2})", @@ -679,22 +629,19 @@ "underlyingType": "interface{}", "argNum": 2 } - ], - "fuzzy": true + ] }, { "id": "no configuration file defined, cannot write config", "message": "no configuration file defined, cannot write config", "translation": "no configuration file defined, cannot write config", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "key must only consist of A-Za-z0-9_-", "message": "key must only consist of A-Za-z0-9_-", "translation": "key must only consist of A-Za-z0-9_-", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "no service configuration \"{Name}\"", @@ -710,8 +657,7 @@ "argNum": 1, "expr": "name" } - ], - "fuzzy": true + ] } ] } diff --git a/internal/translations/locales/en/out.gotext.json b/internal/translations/locales/en/out.gotext.json index 6f6825f..c2e0e84 100644 --- a/internal/translations/locales/en/out.gotext.json +++ b/internal/translations/locales/en/out.gotext.json @@ -15,8 +15,7 @@ "argNum": 1, "expr": "strings.Join(info.ExportCapabilities, \", \")" } - ], - "fuzzy": true + ] }, { "id": "import: {ImportCapabilities__}", @@ -32,8 +31,7 @@ "argNum": 1, "expr": "strings.Join(info.ImportCapabilities, \", \")" } - ], - "fuzzy": true + ] }, { "id": "Failed reading config: {Err}", @@ -49,22 +47,19 @@ "argNum": 1, "expr": "err" } - ], - "fuzzy": true + ] }, { "id": "Service name", "message": "Service name", "translation": "Service name", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "a service with this name already exists", "message": "a service with this name already exists", "translation": "a service with this name already exists", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Saved service {Name} using backend {Backend}", @@ -88,8 +83,7 @@ "argNum": 2, "expr": "service.Backend" } - ], - "fuzzy": true + ] }, { "id": "The backend {Backend} requires authentication. Authenticate now?", @@ -105,8 +99,7 @@ "argNum": 1, "expr": "service.Backend" } - ], - "fuzzy": true + ] }, { "id": "Delete the service configuration \"{Service}\"?", @@ -122,15 +115,13 @@ "argNum": 1, "expr": "service" } - ], - "fuzzy": true + ] }, { "id": "Aborted", "message": "Aborted", "translation": "Aborted", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Service \"{Name}\" deleted", @@ -146,8 +137,7 @@ "argNum": 1, "expr": "service.Name" } - ], - "fuzzy": true + ] }, { "id": "Updated service {Name} using backend {Backend}", @@ -171,8 +161,7 @@ "argNum": 2, "expr": "service.Backend" } - ], - "fuzzy": true + ] }, { "id": "backend: {Backend}", @@ -188,15 +177,13 @@ "argNum": 1, "expr": "s.Backend" } - ], - "fuzzy": true + ] }, { "id": "Token received, you can close this window now.", "message": "Token received, you can close this window now.", "translation": "Token received, you can close this window now.", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "backend {Backend} does not implement {InterfaceName}", @@ -220,8 +207,7 @@ "argNum": 2, "expr": "interfaceName" } - ], - "fuzzy": true + ] }, { "id": "unknown backend \"{BackendName}\"", @@ -237,78 +223,67 @@ "argNum": 1, "expr": "backendName" } - ], - "fuzzy": true + ] }, { "id": "Client ID", "message": "Client ID", "translation": "Client ID", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Client secret", "message": "Client secret", "translation": "Client secret", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Server URL", "message": "Server URL", "translation": "Server URL", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "User name", "message": "User name", "translation": "User name", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Access token", "message": "Access token", "translation": "Access token", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "File path", "message": "File path", "translation": "File path", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Append to file", "message": "Append to file", "translation": "Append to file", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Playlist title", "message": "Playlist title", "translation": "Playlist title", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Unique playlist identifier", "message": "Unique playlist identifier", "translation": "Unique playlist identifier", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Check for duplicate listens on import (slower)", "message": "Check for duplicate listens on import (slower)", "translation": "Check for duplicate listens on import (slower)", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", @@ -348,50 +323,43 @@ "argNum": 4, "expr": "l.RecordingMBID" } - ], - "fuzzy": true + ] }, { "id": "Disable auto correction of submitted listens", "message": "Disable auto correction of submitted listens", "translation": "Disable auto correction of submitted listens", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Ignore skipped listens", "message": "Ignore skipped listens", "translation": "Ignore skipped listens", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Specify a time zone for the listen timestamps", "message": "Specify a time zone for the listen timestamps", "translation": "Specify a time zone for the listen timestamps", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Directory path", "message": "Directory path", "translation": "Directory path", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Ignore listens in incognito mode", "message": "Ignore listens in incognito mode", "translation": "Ignore listens in incognito mode", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)", "translation": "Minimum playback duration for skipped tracks (seconds)", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Visit the URL for authorization: {URL}", @@ -407,15 +375,13 @@ "argNum": 1, "expr": "authURL.URL" } - ], - "fuzzy": true + ] }, { "id": "Error: OAuth state mismatch", "message": "Error: OAuth state mismatch", "translation": "Error: OAuth state mismatch", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Access token received, you can use {Name} now.", @@ -431,22 +397,19 @@ "argNum": 1, "expr": "service.Name" } - ], - "fuzzy": true + ] }, { "id": "exporting", "message": "exporting", "translation": "exporting", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "importing", "message": "importing", "translation": "importing", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "aborted", @@ -459,43 +422,37 @@ "id": "done", "message": "done", "translation": "done", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Yes", "message": "Yes", "translation": "Yes", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "No", "message": "No", "translation": "No", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "no existing service configurations", "message": "no existing service configurations", "translation": "no existing service configurations", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Service", "message": "Service", "translation": "Service", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Backend", "message": "Backend", "translation": "Backend", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "Transferring {Entity} from {SourceName} to {TargetName}…", @@ -527,8 +484,7 @@ "argNum": 3, "expr": "c.targetName" } - ], - "fuzzy": true + ] }, { "id": "From timestamp: {Arg_1} ({Arg_2})", @@ -550,8 +506,7 @@ "underlyingType": "interface{}", "argNum": 2 } - ], - "fuzzy": true + ] }, { "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", @@ -591,8 +546,7 @@ "argNum": 4, "expr": "c.targetName" } - ], - "fuzzy": true + ] }, { "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", @@ -614,15 +568,13 @@ "underlyingType": "string", "argNum": 2 } - ], - "fuzzy": true + ] }, { "id": "Import log:", "message": "Import log:", "translation": "Import log:", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "{Type}: {Message}", @@ -646,8 +598,7 @@ "argNum": 2, "expr": "entry.Message" } - ], - "fuzzy": true + ] }, { "id": "invalid timestamp string \"{FlagValue}\"", @@ -663,8 +614,7 @@ "argNum": 1, "expr": "flagValue" } - ], - "fuzzy": true + ] }, { "id": "Latest timestamp: {Arg_1} ({Arg_2})", @@ -686,22 +636,19 @@ "underlyingType": "interface{}", "argNum": 2 } - ], - "fuzzy": true + ] }, { "id": "no configuration file defined, cannot write config", "message": "no configuration file defined, cannot write config", "translation": "no configuration file defined, cannot write config", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "key must only consist of A-Za-z0-9_-", "message": "key must only consist of A-Za-z0-9_-", "translation": "key must only consist of A-Za-z0-9_-", - "translatorComment": "Copied from source.", - "fuzzy": true + "translatorComment": "Copied from source." }, { "id": "no service configuration \"{Name}\"", @@ -717,8 +664,7 @@ "argNum": 1, "expr": "name" } - ], - "fuzzy": true + ] } ] } \ No newline at end of file From 1f48abc2842e30861c5209271626866c1441da65 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 May 2025 15:18:14 +0200 Subject: [PATCH 21/26] Fixed timestamp displayed after import not being the updated one --- internal/cli/transfer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go index 83bad2a..0391d0d 100644 --- a/internal/cli/transfer.go +++ b/internal/cli/transfer.go @@ -124,7 +124,7 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac progress.Wait() // Update timestamp - err = c.updateTimestamp(result, timestamp) + err = c.updateTimestamp(&result, timestamp) if err != nil { return err } @@ -174,7 +174,7 @@ func (c *TransferCmd[E, I, R]) timestamp() (time.Time, error) { return time.Time{}, errors.New(i18n.Tr("invalid timestamp string \"%v\"", flagValue)) } -func (c *TransferCmd[E, I, R]) updateTimestamp(result models.ImportResult, oldTimestamp time.Time) error { +func (c *TransferCmd[E, I, R]) updateTimestamp(result *models.ImportResult, oldTimestamp time.Time) error { if oldTimestamp.After(result.LastTimestamp) { result.LastTimestamp = oldTimestamp } From b8e6ccffdb2f33aa1485f3d655ac55e5bfd61251 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 5 May 2025 11:38:29 +0200 Subject: [PATCH 22/26] Initial implementation of unified export/import progress Both export and import progress get updated over a unified channel. Most importantly this allows updating the import total from latest export results. --- internal/backends/backends_test.go | 12 ++- internal/backends/deezer/deezer.go | 41 +++++--- internal/backends/dump/dump.go | 10 +- internal/backends/export.go | 8 +- internal/backends/funkwhale/funkwhale.go | 46 ++++++--- internal/backends/import.go | 27 +++--- internal/backends/jspf/jspf.go | 34 +++++-- internal/backends/lastfm/lastfm.go | 56 +++++++---- .../backends/listenbrainz/listenbrainz.go | 53 ++++++---- internal/backends/maloja/maloja.go | 27 ++++-- .../backends/scrobblerlog/scrobblerlog.go | 19 ++-- internal/backends/spotify/spotify.go | 41 +++++--- .../backends/spotifyhistory/spotifyhistory.go | 22 +++-- internal/backends/subsonic/subsonic.go | 19 ++-- internal/cli/progress.go | 97 ++++++++++++++----- internal/cli/transfer.go | 11 +-- internal/models/interfaces.go | 10 +- internal/models/models.go | 30 ++++-- 18 files changed, 369 insertions(+), 194 deletions(-) diff --git a/internal/backends/backends_test.go b/internal/backends/backends_test.go index b6a6968..e115636 100644 --- a/internal/backends/backends_test.go +++ b/internal/backends/backends_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -18,7 +18,6 @@ Scotty. If not, see . package backends_test import ( - "reflect" "testing" "github.com/spf13/viper" @@ -33,6 +32,7 @@ import ( "go.uploadedlobster.com/scotty/internal/backends/maloja" "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" "go.uploadedlobster.com/scotty/internal/backends/spotify" + "go.uploadedlobster.com/scotty/internal/backends/spotifyhistory" "go.uploadedlobster.com/scotty/internal/backends/subsonic" "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" @@ -93,9 +93,9 @@ func TestImplementsInterfaces(t *testing.T) { expectInterface[models.LovesExport](t, &funkwhale.FunkwhaleApiBackend{}) // expectInterface[models.LovesImport](t, &funkwhale.FunkwhaleApiBackend{}) - // expectInterface[models.ListensExport](t, &jspf.JSPFBackend{}) + expectInterface[models.ListensExport](t, &jspf.JSPFBackend{}) expectInterface[models.ListensImport](t, &jspf.JSPFBackend{}) - // expectInterface[models.LovesExport](t, &jspf.JSPFBackend{}) + expectInterface[models.LovesExport](t, &jspf.JSPFBackend{}) expectInterface[models.LovesImport](t, &jspf.JSPFBackend{}) // expectInterface[models.ListensExport](t, &lastfm.LastfmApiBackend{}) @@ -115,6 +115,8 @@ func TestImplementsInterfaces(t *testing.T) { expectInterface[models.LovesExport](t, &spotify.SpotifyApiBackend{}) // expectInterface[models.LovesImport](t, &spotify.SpotifyApiBackend{}) + expectInterface[models.ListensExport](t, &spotifyhistory.SpotifyHistoryBackend{}) + expectInterface[models.ListensExport](t, &scrobblerlog.ScrobblerLogBackend{}) expectInterface[models.ListensImport](t, &scrobblerlog.ScrobblerLogBackend{}) @@ -125,6 +127,6 @@ func TestImplementsInterfaces(t *testing.T) { func expectInterface[T interface{}](t *testing.T, backend models.Backend) { ok, name := backends.ImplementsInterface[T](&backend) if !ok { - t.Errorf("%v expected to implement %v", reflect.TypeOf(backend).Name(), name) + t.Errorf("%v expected to implement %v", backend.Name(), name) } } diff --git a/internal/backends/deezer/deezer.go b/internal/backends/deezer/deezer.go index 4ba367d..2209769 100644 --- a/internal/backends/deezer/deezer.go +++ b/internal/backends/deezer/deezer.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -77,7 +77,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.Progress) { +func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { // Choose a high offset, we attempt to search the loves backwards starting // at the oldest one. offset := math.MaxInt32 @@ -88,13 +88,18 @@ func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan totalDuration := startTime.Sub(oldestTimestamp) - p := models.Progress{Total: int64(totalDuration.Seconds())} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(totalDuration.Seconds()), + }, + } out: for { result, err := b.client.UserHistory(offset, perPage) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -102,7 +107,6 @@ out: // The offset was higher then the actual number of tracks. Adjust the offset // and continue. if offset >= result.Total { - p.Total = int64(result.Total) offset = max(result.Total-perPage, 0) continue } @@ -128,7 +132,8 @@ out: } remainingTime := startTime.Sub(minTime) - p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) + p.Export.TotalItems += len(listens) + p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) progress <- p results <- models.ListensResult{Items: listens, OldestTimestamp: minTime} @@ -144,23 +149,29 @@ out: } results <- models.ListensResult{OldestTimestamp: minTime} - progress <- p.Complete() + p.Export.Complete() + progress <- p } -func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { // Choose a high offset, we attempt to search the loves backwards starting // at the oldest one. offset := math.MaxInt32 perPage := MaxItemsPerGet - p := models.Progress{Total: int64(perPage)} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(perPage), + }, + } var totalCount int out: for { result, err := b.client.UserTracks(offset, perPage) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } @@ -168,8 +179,8 @@ out: // The offset was higher then the actual number of tracks. Adjust the offset // and continue. if offset >= result.Total { - p.Total = int64(result.Total) totalCount = result.Total + p.Export.Total = int64(totalCount) offset = max(result.Total-perPage, 0) continue } @@ -186,13 +197,14 @@ out: loves = append(loves, love) } else { totalCount -= 1 - break } } sort.Sort(loves) results <- models.LovesResult{Items: loves, Total: totalCount} - p.Elapsed += int64(count) + p.Export.TotalItems = totalCount + p.Export.Total = int64(totalCount) + p.Export.Elapsed += int64(count) progress <- p if offset <= 0 { @@ -206,7 +218,8 @@ out: } } - progress <- p.Complete() + p.Export.Complete() + progress <- p } func (t Listen) AsListen() models.Listen { diff --git a/internal/backends/dump/dump.go b/internal/backends/dump/dump.go index 70be12d..add8711 100644 --- a/internal/backends/dump/dump.go +++ b/internal/backends/dump/dump.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -36,27 +36,27 @@ 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.Progress) (models.ImportResult, error) { +func (b *DumpBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { for _, listen := range export.Items { importResult.UpdateTimestamp(listen.ListenedAt) importResult.ImportCount += 1 msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)", listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMBID) importResult.Log(models.Info, msg) - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) } return importResult, nil } -func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { for _, love := range export.Items { importResult.UpdateTimestamp(love.Created) importResult.ImportCount += 1 msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)", love.Created, love.TrackName, love.ArtistName(), love.RecordingMBID) importResult.Log(models.Info, msg) - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) } return importResult, nil diff --git a/internal/backends/export.go b/internal/backends/export.go index 0346af2..c7a1f58 100644 --- a/internal/backends/export.go +++ b/internal/backends/export.go @@ -23,7 +23,7 @@ import ( type ExportProcessor[T models.ListensResult | models.LovesResult] interface { ExportBackend() models.Backend - Process(oldestTimestamp time.Time, results chan T, progress chan models.Progress) + Process(oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress) } type ListensExportProcessor struct { @@ -34,9 +34,8 @@ func (p ListensExportProcessor) ExportBackend() models.Backend { return p.Backend } -func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { +func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { defer close(results) - defer close(progress) p.Backend.ExportListens(oldestTimestamp, results, progress) } @@ -48,8 +47,7 @@ func (p LovesExportProcessor) ExportBackend() models.Backend { return p.Backend } -func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { defer close(results) - defer close(progress) p.Backend.ExportLoves(oldestTimestamp, results, progress) } diff --git a/internal/backends/funkwhale/funkwhale.go b/internal/backends/funkwhale/funkwhale.go index e32a952..cd2f28e 100644 --- a/internal/backends/funkwhale/funkwhale.go +++ b/internal/backends/funkwhale/funkwhale.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -60,19 +60,26 @@ func (b *FunkwhaleApiBackend) InitConfig(config *config.ServiceConfig) error { return nil } -func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { +func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { page := 1 perPage := MaxItemsPerGet // We need to gather the full list of listens in order to sort them listens := make(models.ListensList, 0, 2*perPage) - p := models.Progress{Total: int64(perPage)} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(perPage), + }, + } out: for { result, err := b.client.GetHistoryListenings(b.username, page, perPage) if err != nil { + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} + return } count := len(result.Results) @@ -83,7 +90,7 @@ out: for _, fwListen := range result.Results { listen := fwListen.AsListen() if listen.ListenedAt.After(oldestTimestamp) { - p.Elapsed += 1 + p.Export.Elapsed += 1 listens = append(listens, listen) } else { break out @@ -92,34 +99,42 @@ out: if result.Next == "" { // No further results - p.Total = p.Elapsed - p.Total -= int64(perPage - count) + p.Export.Total = p.Export.Elapsed + p.Export.Total -= int64(perPage - count) break out } - p.Total += int64(perPage) + p.Export.TotalItems = len(listens) + p.Export.Total += int64(perPage) progress <- p page += 1 } sort.Sort(listens) - progress <- p.Complete() + p.Export.TotalItems = len(listens) + p.Export.Complete() + progress <- p results <- models.ListensResult{Items: listens} } -func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { page := 1 perPage := MaxItemsPerGet // We need to gather the full list of listens in order to sort them loves := make(models.LovesList, 0, 2*perPage) - p := models.Progress{Total: int64(perPage)} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(perPage), + }, + } out: for { result, err := b.client.GetFavoriteTracks(page, perPage) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } @@ -132,7 +147,7 @@ out: for _, favorite := range result.Results { love := favorite.AsLove() if love.Created.After(oldestTimestamp) { - p.Elapsed += 1 + p.Export.Elapsed += 1 loves = append(loves, love) } else { break out @@ -144,13 +159,16 @@ out: break out } - p.Total += int64(perPage) + p.Export.TotalItems = len(loves) + p.Export.Total += int64(perPage) progress <- p page += 1 } sort.Sort(loves) - progress <- p.Complete() + p.Export.TotalItems = len(loves) + p.Export.Complete() + progress <- p results <- models.LovesResult{Items: loves} } diff --git a/internal/backends/import.go b/internal/backends/import.go index 407082c..d3b86ac 100644 --- a/internal/backends/import.go +++ b/internal/backends/import.go @@ -23,8 +23,8 @@ import ( type ImportProcessor[T models.ListensResult | models.LovesResult] interface { ImportBackend() models.ImportBackend - Process(results chan T, out chan models.ImportResult, progress chan models.Progress) - Import(export T, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) + Process(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) } type ListensImportProcessor struct { @@ -35,11 +35,11 @@ func (p ListensImportProcessor) ImportBackend() models.ImportBackend { return p.Backend } -func (p ListensImportProcessor) Process(results chan models.ListensResult, out chan models.ImportResult, progress chan models.Progress) { +func (p ListensImportProcessor) Process(results chan models.ListensResult, out chan models.ImportResult, progress chan models.TransferProgress) { process(p, results, out, progress) } -func (p ListensImportProcessor) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (p ListensImportProcessor) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { if export.Error != nil { return result, export.Error } @@ -64,11 +64,11 @@ func (p LovesImportProcessor) ImportBackend() models.ImportBackend { return p.Backend } -func (p LovesImportProcessor) Process(results chan models.LovesResult, out chan models.ImportResult, progress chan models.Progress) { +func (p LovesImportProcessor) Process(results chan models.LovesResult, out chan models.ImportResult, progress chan models.TransferProgress) { process(p, results, out, progress) } -func (p LovesImportProcessor) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (p LovesImportProcessor) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { if export.Error != nil { return result, export.Error } @@ -85,11 +85,10 @@ func (p LovesImportProcessor) Import(export models.LovesResult, result models.Im return importResult, nil } -func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](processor P, results chan R, out chan models.ImportResult, progress chan models.Progress) { +func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](processor P, results chan R, out chan models.ImportResult, progress chan models.TransferProgress) { defer close(out) - defer close(progress) result := models.ImportResult{} - p := models.Progress{} + p := models.TransferProgress{} if err := processor.ImportBackend().StartImport(); err != nil { out <- handleError(result, err, progress) @@ -104,7 +103,7 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( out <- handleError(result, err, progress) return } - progress <- p.FromImportResult(result) + progress <- p.FromImportResult(result, false) } if err := processor.ImportBackend().FinishImport(); err != nil { @@ -112,12 +111,14 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]]( return } - progress <- p.FromImportResult(result).Complete() + progress <- p.FromImportResult(result, true) out <- result } -func handleError(result models.ImportResult, err error, progress chan models.Progress) models.ImportResult { +func handleError(result models.ImportResult, err error, progress chan models.TransferProgress) models.ImportResult { result.Error = err - progress <- models.Progress{}.FromImportResult(result).Abort() + p := models.TransferProgress{}.FromImportResult(result, false) + p.Import.Abort() + progress <- p return result } diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index e981741..0e200f2 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -93,10 +93,15 @@ func (b *JSPFBackend) FinishImport() error { return b.writeJSPF() } -func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { +func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { err := b.readJSPF() + p := models.TransferProgress{ + Export: &models.Progress{}, + } + if err != nil { - progress <- models.Progress{}.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -109,11 +114,13 @@ func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan mode } } sort.Sort(listens) - progress <- models.Progress{Total: int64(len(listens))}.Complete() + p.Export.Total = int64(len(listens)) + p.Export.Complete() + progress <- p results <- models.ListensResult{Items: listens} } -func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { for _, listen := range export.Items { track := listenAsTrack(listen) b.playlist.Tracks = append(b.playlist.Tracks, track) @@ -121,14 +128,19 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo importResult.UpdateTimestamp(listen.ListenedAt) } - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) return importResult, nil } -func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { err := b.readJSPF() + p := models.TransferProgress{ + Export: &models.Progress{}, + } + if err != nil { - progress <- models.Progress{}.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } @@ -141,11 +153,13 @@ func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models } } sort.Sort(loves) - progress <- models.Progress{Total: int64(len(loves))}.Complete() + p.Export.Total = int64(len(loves)) + p.Export.Complete() + progress <- p results <- models.LovesResult{Items: loves} } -func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { for _, love := range export.Items { track := loveAsTrack(love) b.playlist.Tracks = append(b.playlist.Tracks, track) @@ -153,7 +167,7 @@ func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models importResult.UpdateTimestamp(love.Created) } - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) return importResult, nil } diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go index d45f793..d262ada 100644 --- a/internal/backends/lastfm/lastfm.go +++ b/internal/backends/lastfm/lastfm.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -88,13 +88,17 @@ func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error { return nil } -func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { +func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { page := MaxPage minTime := oldestTimestamp perPage := MaxItemsPerGet // We need to gather the full list of listens in order to sort them - p := models.Progress{Total: int64(page)} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(page), + }, + } out: for page > 0 { @@ -108,7 +112,8 @@ out: result, err := b.client.User.GetRecentTracks(args) if err != nil { results <- models.ListensResult{Error: err} - progress <- p.Abort() + p.Export.Abort() + progress <- p return } @@ -127,11 +132,12 @@ out: timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64) if err != nil { results <- models.ListensResult{Error: err} - progress <- p.Abort() + p.Export.Abort() + progress <- p break out } if timestamp > oldestTimestamp.Unix() { - p.Elapsed += 1 + p.Export.Elapsed += 1 listen := models.Listen{ ListenedAt: time.Unix(timestamp, 0), UserName: b.username, @@ -165,16 +171,18 @@ out: Total: result.Total, OldestTimestamp: minTime, } - p.Total = int64(result.TotalPages) - p.Elapsed = int64(result.TotalPages - page) + p.Export.Total = int64(result.TotalPages) + p.Export.Elapsed = int64(result.TotalPages - page) + p.Export.TotalItems += len(listens) progress <- p } results <- models.ListensResult{OldestTimestamp: minTime} - progress <- p.Complete() + p.Export.Complete() + progress <- p } -func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { total := len(export.Items) for i := 0; i < total; i += MaxListensPerSubmission { listens := export.Items[i:min(i+MaxListensPerSubmission, total)] @@ -244,20 +252,24 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu importResult.UpdateTimestamp(listens[count-1].ListenedAt) importResult.ImportCount += accepted - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) } return importResult, nil } -func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { // Choose a high offset, we attempt to search the loves backwards starting // at the oldest one. page := 1 perPage := MaxItemsPerGet loves := make(models.LovesList, 0, 2*MaxItemsPerGet) - p := models.Progress{Total: int64(perPage)} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(perPage), + }, + } var totalCount int out: @@ -268,12 +280,12 @@ out: "page": page, }) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } - p.Total = int64(result.Total) count := len(result.Tracks) if count == 0 { break out @@ -282,7 +294,8 @@ out: for _, track := range result.Tracks { timestamp, err := strconv.ParseInt(track.Date.Uts, 10, 64) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } @@ -308,18 +321,21 @@ out: } } - p.Elapsed += int64(count) + p.Export.Total += int64(perPage) + p.Export.TotalItems = totalCount + p.Export.Elapsed += int64(count) progress <- p page += 1 } sort.Sort(loves) + p.Export.Complete() + progress <- p results <- models.LovesResult{Items: loves, Total: totalCount} - progress <- p.Complete() } -func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { for _, love := range export.Items { err := b.client.Track.Love(lastfm.P{ "track": love.TrackName, @@ -335,7 +351,7 @@ func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult m importResult.Log(models.Error, msg) } - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) } return importResult, nil diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go index fffe0f0..61597d1 100644 --- a/internal/backends/listenbrainz/listenbrainz.go +++ b/internal/backends/listenbrainz/listenbrainz.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -72,21 +72,25 @@ 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.Progress) { +func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { startTime := time.Now() minTime := oldestTimestamp if minTime.Unix() < 1 { minTime = time.Unix(1, 0) } - totalDuration := startTime.Sub(minTime) - - p := models.Progress{Total: int64(totalDuration.Seconds())} + totalDuration := startTime.Sub(oldestTimestamp) + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(totalDuration.Seconds()), + }, + } for { result, err := b.client.GetListens(b.username, time.Now(), minTime) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -96,7 +100,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result if minTime.Unix() < result.Payload.OldestListenTimestamp { minTime = time.Unix(result.Payload.OldestListenTimestamp, 0) totalDuration = startTime.Sub(minTime) - p.Total = int64(totalDuration.Seconds()) + p.Export.Total = int64(totalDuration.Seconds()) continue } else { break @@ -119,18 +123,20 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result } sort.Sort(listens) - p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) + p.Export.TotalItems += len(listens) + p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) progress <- p results <- models.ListensResult{Items: listens, OldestTimestamp: minTime} } results <- models.ListensResult{OldestTimestamp: minTime} - progress <- p.Complete() + p.Export.Complete() + progress <- p } -func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { total := len(export.Items) - p := models.Progress{}.FromImportResult(importResult) + p := models.TransferProgress{}.FromImportResult(importResult, false) for i := 0; i < total; i += MaxListensPerRequest { listens := export.Items[i:min(i+MaxListensPerRequest, total)] count := len(listens) @@ -146,7 +152,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo for _, l := range listens { if b.checkDuplicates { isDupe, err := b.checkDuplicateListen(l) - p.Elapsed += 1 + p.Import.Elapsed += 1 progress <- p if err != nil { return importResult, err @@ -186,31 +192,36 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo importResult.UpdateTimestamp(listens[count-1].ListenedAt) } importResult.ImportCount += count - progress <- p.FromImportResult(importResult) + progress <- p.FromImportResult(importResult, false) } return importResult, nil } -func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { exportChan := make(chan models.LovesResult) - p := models.Progress{} + p := models.TransferProgress{ + Export: &models.Progress{}, + } go b.exportLoves(oldestTimestamp, exportChan) for existingLoves := range exportChan { if existingLoves.Error != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: existingLoves.Error} return } - p.Total = int64(existingLoves.Total) - p.Elapsed += int64(existingLoves.Items.Len()) + p.Export.TotalItems = existingLoves.Total + p.Export.Total = int64(existingLoves.Total) + p.Export.Elapsed += int64(len(existingLoves.Items)) progress <- p results <- existingLoves } - progress <- p.Complete() + p.Export.Complete() + progress <- p } func (b *ListenBrainzApiBackend) exportLoves(oldestTimestamp time.Time, results chan models.LovesResult) { @@ -260,7 +271,7 @@ out: } } -func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { if len(b.existingMBIDs) == 0 { existingLovesChan := make(chan models.LovesResult) go b.exportLoves(time.Unix(0, 0), existingLovesChan) @@ -330,7 +341,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe importResult.Log(models.Error, msg) } - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) } return importResult, nil diff --git a/internal/backends/maloja/maloja.go b/internal/backends/maloja/maloja.go index a22393b..8968942 100644 --- a/internal/backends/maloja/maloja.go +++ b/internal/backends/maloja/maloja.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -63,19 +63,24 @@ 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.Progress) { +func (b *MalojaApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { page := 0 perPage := MaxItemsPerGet // We need to gather the full list of listens in order to sort them listens := make(models.ListensList, 0, 2*perPage) - p := models.Progress{Total: int64(perPage)} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(perPage), + }, + } out: for { result, err := b.client.GetScrobbles(page, perPage) if err != nil { - progress <- p.Complete() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -87,25 +92,27 @@ out: for _, scrobble := range result.List { if scrobble.ListenedAt > oldestTimestamp.Unix() { - p.Elapsed += 1 + p.Export.Elapsed += 1 listens = append(listens, scrobble.AsListen()) } else { break out } } - p.Total += int64(perPage) + p.Export.TotalItems = len(listens) + p.Export.Total += int64(perPage) progress <- p page += 1 } sort.Sort(listens) - progress <- p.Complete() + p.Export.Complete() + progress <- p results <- models.ListensResult{Items: listens} } -func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { - p := models.Progress{}.FromImportResult(importResult) +func (b *MalojaApiBackend) ImportListens(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{ Title: listen.TrackName, @@ -126,7 +133,7 @@ func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResu importResult.UpdateTimestamp(listen.ListenedAt) importResult.ImportCount += 1 - progress <- p.FromImportResult(importResult) + progress <- p.FromImportResult(importResult, false) } return importResult, nil diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 7890971..db4e349 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -131,10 +131,14 @@ func (b *ScrobblerLogBackend) FinishImport() error { return b.file.Close() } -func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { +func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { file, err := os.Open(b.filePath) + p := models.TransferProgress{ + Export: &models.Progress{}, + } if err != nil { - progress <- models.Progress{}.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -143,7 +147,8 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c err = b.log.Parse(file, b.ignoreSkipped) if err != nil { - progress <- models.Progress{}.Complete() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -157,11 +162,13 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c } } sort.Sort(listens) - progress <- models.Progress{Total: int64(len(listens))}.Complete() + p.Export.Total = int64(len(listens)) + p.Export.Complete() + progress <- p results <- models.ListensResult{Items: listens} } -func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { +func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { records := make([]scrobblerlog.Record, len(export.Items)) for i, listen := range export.Items { records[i] = listenToRecord(listen) @@ -173,7 +180,7 @@ func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importR importResult.UpdateTimestamp(lastTimestamp) importResult.ImportCount += len(export.Items) - progress <- models.Progress{}.FromImportResult(importResult) + progress <- models.TransferProgress{}.FromImportResult(importResult, false) return importResult, nil } diff --git a/internal/backends/spotify/spotify.go b/internal/backends/spotify/spotify.go index be48dfe..5d45087 100644 --- a/internal/backends/spotify/spotify.go +++ b/internal/backends/spotify/spotify.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -95,18 +95,22 @@ func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error { return nil } -func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { +func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { startTime := time.Now() minTime := oldestTimestamp totalDuration := startTime.Sub(oldestTimestamp) - - p := models.Progress{Total: int64(totalDuration.Seconds())} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(totalDuration.Seconds()), + }, + } for { result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -118,7 +122,8 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha // Set minTime to the newest returned listen after, err := strconv.ParseInt(result.Cursors.After, 10, 64) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } else if after <= minTime.Unix() { @@ -146,22 +151,28 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha } sort.Sort(listens) - p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) + p.Export.TotalItems += len(listens) + p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) progress <- p results <- models.ListensResult{Items: listens, OldestTimestamp: minTime} } results <- models.ListensResult{OldestTimestamp: minTime} - progress <- p.Complete() + p.Export.Complete() + progress <- p } -func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { // Choose a high offset, we attempt to search the loves backwards starting // at the oldest one. offset := math.MaxInt32 perPage := MaxItemsPerGet - p := models.Progress{Total: int64(perPage)} + p := models.TransferProgress{ + Export: &models.Progress{ + Total: int64(perPage), + }, + } totalCount := 0 exportCount := 0 @@ -169,7 +180,8 @@ out: for { result, err := b.client.UserTracks(offset, perPage) if err != nil { - progress <- p.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } @@ -177,7 +189,7 @@ out: // The offset was higher then the actual number of tracks. Adjust the offset // and continue. if offset >= result.Total { - p.Total = int64(result.Total) + p.Export.Total = int64(result.Total) totalCount = result.Total offset = max(result.Total-perPage, 0) continue @@ -201,7 +213,7 @@ out: exportCount += len(loves) sort.Sort(loves) results <- models.LovesResult{Items: loves, Total: totalCount} - p.Elapsed += int64(count) + p.Export.Elapsed += int64(count) progress <- p if offset <= 0 { @@ -216,7 +228,8 @@ out: } results <- models.LovesResult{Total: exportCount} - progress <- p.Complete() + p.Export.Complete() + progress <- p } func (l Listen) AsListen() models.Listen { diff --git a/internal/backends/spotifyhistory/spotifyhistory.go b/internal/backends/spotifyhistory/spotifyhistory.go index 23309f3..d5c87bb 100644 --- a/internal/backends/spotifyhistory/spotifyhistory.go +++ b/internal/backends/spotifyhistory/spotifyhistory.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -72,21 +72,27 @@ func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error { return nil } -func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { +func (b *SpotifyHistoryBackend) ExportListens(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{}, + } + if err != nil { - progress <- models.Progress{}.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } slices.Sort(files) fileCount := int64(len(files)) - p := models.Progress{Total: fileCount} + p.Export.Total = fileCount for i, filePath := range files { history, err := readHistoryFile(filePath) if err != nil { - progress <- models.Progress{}.Abort() + p.Export.Abort() + progress <- p results <- models.ListensResult{Error: err} return } @@ -97,11 +103,13 @@ func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results }) sort.Sort(listens) results <- models.ListensResult{Items: listens} - p.Elapsed = int64(i) + p.Export.Elapsed = int64(i) + p.Export.TotalItems += len(listens) progress <- p } - progress <- p.Complete() + p.Export.Complete() + progress <- p } func readHistoryFile(filePath string) (StreamingHistory, error) { diff --git a/internal/backends/subsonic/subsonic.go b/internal/backends/subsonic/subsonic.go index 370765e..2098688 100644 --- a/internal/backends/subsonic/subsonic.go +++ b/internal/backends/subsonic/subsonic.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -63,25 +63,30 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error { return nil } -func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { +func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { err := b.client.Authenticate(b.password) + p := models.TransferProgress{ + Export: &models.Progress{}, + } if err != nil { - progress <- models.Progress{}.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } starred, err := b.client.GetStarred2(map[string]string{}) if err != nil { - progress <- models.Progress{}.Abort() + p.Export.Abort() + progress <- p results <- models.LovesResult{Error: err} return } loves := b.filterSongs(starred.Song, oldestTimestamp) - progress <- models.Progress{ - Total: int64(loves.Len()), - }.Complete() + p.Export.Total = int64(len(loves)) + p.Export.Complete() + progress <- p results <- models.LovesResult{Items: loves} } diff --git a/internal/cli/progress.go b/internal/cli/progress.go index 88339ef..b640815 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -28,7 +28,18 @@ import ( "go.uploadedlobster.com/scotty/internal/models" ) -func progressBar(exportProgress chan models.Progress, importProgress chan models.Progress) *mpb.Progress { +type progressBarUpdater struct { + wg *sync.WaitGroup + progress *mpb.Progress + exportBar *mpb.Bar + importBar *mpb.Bar + updateChan chan models.TransferProgress + lastExportUpdate time.Time + totalItems int + importedItems int +} + +func setupProgressBars(updateChan chan models.TransferProgress) progressBarUpdater { wg := &sync.WaitGroup{} p := mpb.New( mpb.WithWaitGroup(wg), @@ -37,15 +48,71 @@ func progressBar(exportProgress chan models.Progress, importProgress chan models mpb.WithAutoRefresh(), ) - exportBar := setupProgressBar(p, i18n.Tr("exporting")) - importBar := setupProgressBar(p, i18n.Tr("importing")) - go updateProgressBar(exportBar, wg, exportProgress) - go updateProgressBar(importBar, wg, importProgress) + u := progressBarUpdater{ + wg: wg, + progress: p, + exportBar: initProgressBar(p, i18n.Tr("exporting")), + importBar: initProgressBar(p, i18n.Tr("importing")), + updateChan: updateChan, + } - return p + go u.update() + return u } -func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar { +func (u *progressBarUpdater) wait() { + // FIXME: This should probably be closed elsewhere + close(u.updateChan) + u.progress.Wait() +} + +func (u *progressBarUpdater) update() { + u.wg.Add(1) + defer u.wg.Done() + u.lastExportUpdate = time.Now() + for progress := range u.updateChan { + if progress.Export != nil { + u.updateExportProgress(progress.Export) + } + + if progress.Import != nil { + if int64(u.totalItems) > progress.Import.Total { + progress.Import.Total = int64(u.totalItems) + } + u.updateImportProgress(progress.Import) + } + } +} + +func (u *progressBarUpdater) updateExportProgress(progress *models.Progress) { + bar := u.exportBar + u.totalItems = progress.TotalItems + + if progress.Aborted { + bar.Abort(false) + return + } + + oldIterTime := u.lastExportUpdate + u.lastExportUpdate = time.Now() + elapsedTime := u.lastExportUpdate.Sub(oldIterTime) + bar.EwmaSetCurrent(progress.Elapsed, elapsedTime) + bar.SetTotal(progress.Total, progress.Completed) +} + +func (u *progressBarUpdater) updateImportProgress(progress *models.Progress) { + bar := u.importBar + + if progress.Aborted { + bar.Abort(false) + return + } + + bar.SetCurrent(progress.Elapsed) + bar.SetTotal(progress.Total, progress.Completed) +} + +func initProgressBar(p *mpb.Progress, name string) *mpb.Bar { green := color.New(color.FgGreen).SprintFunc() return p.New(0, mpb.BarStyle(), @@ -69,19 +136,3 @@ func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar { ), ) } - -func updateProgressBar(bar *mpb.Bar, wg *sync.WaitGroup, progressChan chan models.Progress) { - wg.Add(1) - defer wg.Done() - lastIterTime := time.Now() - for progress := range progressChan { - if progress.Aborted { - bar.Abort(false) - return - } - oldIterTime := lastIterTime - lastIterTime = time.Now() - bar.EwmaSetCurrent(progress.Elapsed, lastIterTime.Sub(oldIterTime)) - bar.SetTotal(progress.Total, progress.Completed) - } -} diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go index 0391d0d..b16c590 100644 --- a/internal/cli/transfer.go +++ b/internal/cli/transfer.go @@ -109,19 +109,18 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac printTimestamp("From timestamp: %v (%v)", timestamp) // Prepare progress bars - exportProgress := make(chan models.Progress) - importProgress := make(chan models.Progress) - progress := progressBar(exportProgress, importProgress) + progressChan := make(chan models.TransferProgress) + progress := setupProgressBars(progressChan) // Export from source exportChan := make(chan R, 1000) - go exp.Process(timestamp, exportChan, exportProgress) + go exp.Process(timestamp, exportChan, progressChan) // Import into target resultChan := make(chan models.ImportResult) - go imp.Process(exportChan, resultChan, importProgress) + go imp.Process(exportChan, resultChan, progressChan) result := <-resultChan - progress.Wait() + progress.wait() // Update timestamp err = c.updateTimestamp(&result, timestamp) diff --git a/internal/models/interfaces.go b/internal/models/interfaces.go index 1c593d0..bb97dac 100644 --- a/internal/models/interfaces.go +++ b/internal/models/interfaces.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -55,7 +55,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 Progress) + ExportListens(oldestTimestamp time.Time, results chan ListensResult, progress chan TransferProgress) } // Must be implemented by services supporting the import of listens. @@ -63,7 +63,7 @@ type ListensImport interface { ImportBackend // Imports the given list of listens. - ImportListens(export ListensResult, importResult ImportResult, progress chan Progress) (ImportResult, error) + ImportListens(export ListensResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error) } // Must be implemented by services supporting the export of loves. @@ -73,7 +73,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 Progress) + ExportLoves(oldestTimestamp time.Time, results chan LovesResult, progress chan TransferProgress) } // Must be implemented by services supporting the import of loves. @@ -81,5 +81,5 @@ type LovesImport interface { ImportBackend // Imports the given list of loves. - ImportLoves(export LovesResult, importResult ImportResult, progress chan Progress) (ImportResult, error) + ImportLoves(export LovesResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error) } diff --git a/internal/models/models.go b/internal/models/models.go index b8f9121..081266d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -209,11 +209,25 @@ func (i *ImportResult) Log(t LogEntryType, msg string) { }) } +type TransferProgress struct { + Export *Progress + Import *Progress +} + +func (p TransferProgress) FromImportResult(result ImportResult, completed bool) TransferProgress { + importProgress := Progress{ + Completed: completed, + }.FromImportResult(result) + p.Import = &importProgress + return p +} + type Progress struct { - Total int64 - Elapsed int64 - Completed bool - Aborted bool + TotalItems int + Total int64 + Elapsed int64 + Completed bool + Aborted bool } func (p Progress) FromImportResult(result ImportResult) Progress { @@ -222,13 +236,11 @@ func (p Progress) FromImportResult(result ImportResult) Progress { return p } -func (p Progress) Complete() Progress { +func (p *Progress) Complete() { p.Elapsed = p.Total p.Completed = true - return p } -func (p Progress) Abort() Progress { +func (p *Progress) Abort() { p.Aborted = true - return p } From 17cee9cb8bf852a4c11d41dec75c384475e3ddb8 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 5 May 2025 17:39:47 +0200 Subject: [PATCH 23/26] For import progress show actually processed and total count --- internal/cli/progress.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/cli/progress.go b/internal/cli/progress.go index b640815..6696226 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -51,8 +51,8 @@ func setupProgressBars(updateChan chan models.TransferProgress) progressBarUpdat u := progressBarUpdater{ wg: wg, progress: p, - exportBar: initProgressBar(p, i18n.Tr("exporting")), - importBar: initProgressBar(p, i18n.Tr("importing")), + exportBar: initExportProgressBar(p, i18n.Tr("exporting")), + importBar: initImportProgressBar(p, i18n.Tr("importing")), updateChan: updateChan, } @@ -112,8 +112,18 @@ func (u *progressBarUpdater) updateImportProgress(progress *models.Progress) { bar.SetTotal(progress.Total, progress.Completed) } -func initProgressBar(p *mpb.Progress, name string) *mpb.Bar { +func initExportProgressBar(p *mpb.Progress, name string) *mpb.Bar { + return initProgressBar(p, name, + decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth})) +} + +func initImportProgressBar(p *mpb.Progress, name string) *mpb.Bar { + return initProgressBar(p, name, decor.Counters(0, "%d / %d")) +} + +func initProgressBar(p *mpb.Progress, name string, progressDecorator decor.Decorator) *mpb.Bar { green := color.New(color.FgGreen).SprintFunc() + red := color.New(color.FgHiRed, color.Bold).SprintFunc() return p.New(0, mpb.BarStyle(), mpb.PrependDecorators( @@ -127,8 +137,8 @@ func initProgressBar(p *mpb.Progress, name string) *mpb.Bar { mpb.AppendDecorators( decor.OnComplete( decor.OnAbort( - decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}), - i18n.Tr("aborted"), + progressDecorator, + red(i18n.Tr("aborted")), ), i18n.Tr("done"), ), From a87c42059f48e750aa11adb28af586f341a9be7e Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 5 May 2025 17:49:44 +0200 Subject: [PATCH 24/26] Use a WaitGroup to wait for both export and import goroutine to finish --- internal/backends/export.go | 11 ++++++++--- internal/backends/import.go | 16 ++++++++++------ internal/cli/progress.go | 3 +-- internal/cli/transfer.go | 10 +++++++--- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/internal/backends/export.go b/internal/backends/export.go index c7a1f58..54daafb 100644 --- a/internal/backends/export.go +++ b/internal/backends/export.go @@ -16,6 +16,7 @@ Scotty. If not, see . package backends import ( + "sync" "time" "go.uploadedlobster.com/scotty/internal/models" @@ -23,7 +24,7 @@ import ( type ExportProcessor[T models.ListensResult | models.LovesResult] interface { ExportBackend() models.Backend - Process(oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress) + Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress) } type ListensExportProcessor struct { @@ -34,7 +35,9 @@ func (p ListensExportProcessor) ExportBackend() models.Backend { return p.Backend } -func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { +func (p ListensExportProcessor) Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { + wg.Add(1) + defer wg.Done() defer close(results) p.Backend.ExportListens(oldestTimestamp, results, progress) } @@ -47,7 +50,9 @@ func (p LovesExportProcessor) ExportBackend() models.Backend { return p.Backend } -func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { +func (p LovesExportProcessor) Process(wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { + wg.Add(1) + defer wg.Done() defer close(results) p.Backend.ExportLoves(oldestTimestamp, results, progress) } diff --git a/internal/backends/import.go b/internal/backends/import.go index d3b86ac..0a2e341 100644 --- a/internal/backends/import.go +++ b/internal/backends/import.go @@ -18,12 +18,14 @@ Scotty. If not, see . package backends import ( + "sync" + "go.uploadedlobster.com/scotty/internal/models" ) type ImportProcessor[T models.ListensResult | models.LovesResult] interface { ImportBackend() models.ImportBackend - Process(results chan T, out chan models.ImportResult, progress chan models.TransferProgress) + Process(wg *sync.WaitGroup, results chan T, out chan models.ImportResult, progress chan models.TransferProgress) Import(export T, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) } @@ -35,8 +37,8 @@ func (p ListensImportProcessor) ImportBackend() models.ImportBackend { return p.Backend } -func (p ListensImportProcessor) Process(results chan models.ListensResult, out chan models.ImportResult, progress chan models.TransferProgress) { - process(p, results, out, progress) +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) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { @@ -64,8 +66,8 @@ func (p LovesImportProcessor) ImportBackend() models.ImportBackend { return p.Backend } -func (p LovesImportProcessor) Process(results chan models.LovesResult, out chan models.ImportResult, progress chan models.TransferProgress) { - process(p, results, out, progress) +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) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { @@ -85,7 +87,9 @@ func (p LovesImportProcessor) Import(export models.LovesResult, result models.Im return importResult, nil } -func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](processor P, results chan R, out chan models.ImportResult, progress chan models.TransferProgress) { +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) { + wg.Add(1) + defer wg.Done() defer close(out) result := models.ImportResult{} p := models.TransferProgress{} diff --git a/internal/cli/progress.go b/internal/cli/progress.go index 6696226..e93ec18 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -60,8 +60,7 @@ func setupProgressBars(updateChan chan models.TransferProgress) progressBarUpdat return u } -func (u *progressBarUpdater) wait() { - // FIXME: This should probably be closed elsewhere +func (u *progressBarUpdater) close() { close(u.updateChan) u.progress.Wait() } diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go index b16c590..62dd079 100644 --- a/internal/cli/transfer.go +++ b/internal/cli/transfer.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "strconv" + "sync" "time" "github.com/spf13/cobra" @@ -112,15 +113,18 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac progressChan := make(chan models.TransferProgress) progress := setupProgressBars(progressChan) + wg := &sync.WaitGroup{} + // Export from source exportChan := make(chan R, 1000) - go exp.Process(timestamp, exportChan, progressChan) + go exp.Process(wg, timestamp, exportChan, progressChan) // Import into target resultChan := make(chan models.ImportResult) - go imp.Process(exportChan, resultChan, progressChan) + go imp.Process(wg, exportChan, resultChan, progressChan) result := <-resultChan - progress.wait() + wg.Wait() + progress.close() // Update timestamp err = c.updateTimestamp(&result, timestamp) From a42b5d784df19c77dba9352e9b932d24487174b4 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 8 May 2025 07:40:12 +0200 Subject: [PATCH 25/26] Added short doc string to ratelimit package --- pkg/ratelimit/httpheader.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/ratelimit/httpheader.go b/pkg/ratelimit/httpheader.go index 3f2552c..dba5e30 100644 --- a/pkg/ratelimit/httpheader.go +++ b/pkg/ratelimit/httpheader.go @@ -13,6 +13,7 @@ You should have received a copy of the GNU General Public License along with Scotty. If not, see . */ +// Helper functions to set up rate limiting with resty. package ratelimit import ( From 97600d8190f80d0125a83849ad88e533433e8ef3 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Fri, 9 May 2025 07:38:28 +0200 Subject: [PATCH 26/26] Update dependencies --- go.mod | 20 ++++++++++---------- go.sum | 52 ++++++++++++++++++++++++++-------------------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 8ed329b..47c7e88 100644 --- a/go.mod +++ b/go.mod @@ -23,11 +23,11 @@ require ( github.com/vbauerster/mpb/v8 v8.10.0 go.uploadedlobster.com/mbtypes v0.4.0 go.uploadedlobster.com/musicbrainzws2 v0.14.0 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 - golang.org/x/oauth2 v0.29.0 - golang.org/x/text v0.24.0 + golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 + golang.org/x/oauth2 v0.30.0 + golang.org/x/text v0.25.0 gorm.io/datatypes v1.2.5 - gorm.io/gorm v1.26.0 + gorm.io/gorm v1.26.1 ) require ( @@ -58,15 +58,15 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/image v0.26.0 // indirect + golang.org/x/image v0.27.0 // indirect golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/tools v0.32.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/tools v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/mysql v1.5.7 // indirect - modernc.org/libc v1.65.0 // indirect + modernc.org/libc v1.65.2 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.10.0 // indirect modernc.org/sqlite v1.37.0 // indirect diff --git a/go.sum b/go.sum index 4d3cb1b..426e8c0 100644 --- a/go.sum +++ b/go.sum @@ -136,13 +136,13 @@ go.uploadedlobster.com/musicbrainzws2 v0.14.0 h1:YaEtxNwLSNT1gzFipQ4XlaThNfXjBpz go.uploadedlobster.com/musicbrainzws2 v0.14.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= -golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= -golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= @@ -151,15 +151,15 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -169,8 +169,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -179,16 +179,16 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= @@ -206,18 +206,18 @@ gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2e gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g= gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs= -gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= -modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= -modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= +gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.27.1 h1:emhLB4uoOmkZUnTDFcMI3AbkmU/Evjuerit9Taqe6Ss= +modernc.org/ccgo/v4 v4.27.1/go.mod h1:543Q0qQhJWekKVS5P6yL5fO6liNhla9Lbm2/B3rEKDE= modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= -modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +modernc.org/libc v1.65.2 h1:drWL1QO9fKXr3kXDN8y+4lKyBr8bA3mtUBQpftq3IJw= +modernc.org/libc v1.65.2/go.mod h1:VI3V2S5mNka4deJErQ0jsMXe7jgxojE2fOB/mWoHlbc= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=