From 69665bc28680b09601ee7db10af211452d842415 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 28 Apr 2025 08:54:17 +0200 Subject: [PATCH 01/14] scrobblerlog: consider timezone from parsed file --- internal/backends/scrobblerlog/parser.go | 42 ++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/internal/backends/scrobblerlog/parser.go b/internal/backends/scrobblerlog/parser.go index 1ef08f7..eeb603b 100644 --- a/internal/backends/scrobblerlog/parser.go +++ b/internal/backends/scrobblerlog/parser.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 @@ -38,6 +38,7 @@ type ScrobblerLog struct { Timezone string Client string Listens models.ListensList + location *time.Location } func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { @@ -79,8 +80,7 @@ func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { continue } - client := strings.Split(result.Client, " ")[0] - listen, err := rowToListen(row, client) + listen, err := result.rowToListen(row) if err != nil { return result, err } @@ -138,14 +138,19 @@ func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error { err = fmt.Errorf("not a scrobbler log file") } + // The timezone can be set to "UTC" or "UNKNOWN", if the device writing + // the log knows the time, but not the timezone. timezone, found := strings.CutPrefix(text, "#TZ/") if found { log.Timezone = timezone + log.location = locationFromTimezone(log.Timezone) + continue } client, found := strings.CutPrefix(text, "#CLIENT/") if found { log.Client = client + continue } } @@ -171,7 +176,7 @@ func WriteHeader(writer io.Writer, log *ScrobblerLog) error { return nil } -func rowToListen(row []string, client string) (models.Listen, error) { +func (l ScrobblerLog) rowToListen(row []string) (models.Listen, error) { var listen models.Listen trackNumber, err := strconv.Atoi(row[3]) if err != nil { @@ -183,11 +188,12 @@ func rowToListen(row []string, client string) (models.Listen, error) { return listen, err } - timestamp, err := strconv.Atoi(row[6]) + timestamp, err := strconv.ParseInt(row[6], 10, 64) if err != nil { return listen, err } + client := strings.Split(l.Client, " ")[0] listen = models.Listen{ Track: models.Track{ ArtistNames: []string{row[0]}, @@ -200,7 +206,7 @@ func rowToListen(row []string, client string) (models.Listen, error) { "media_player": client, }, }, - ListenedAt: time.Unix(int64(timestamp), 0), + ListenedAt: timeFromLocalTimestamp(timestamp, l.location), } if len(row) > 7 { @@ -209,3 +215,27 @@ func rowToListen(row []string, client string) (models.Listen, error) { return listen, nil } + +// Convert the timezone string from the header to a time.Location. +// Often this is set to "UNKNOWN" in the log file, in which case it defaults +// to UTC. +func locationFromTimezone(timezone string) *time.Location { + location, err := time.LoadLocation(timezone) + if err != nil { + return time.UTC + } + return location +} + +// Convert a Unix timestamp to a time.Time object, but treat the timestamp +// as being in the given location's timezone instead of UTC. +func timeFromLocalTimestamp(timestamp int64, location *time.Location) time.Time { + t := time.Unix(timestamp, 0) + + // The time is now in UTC. Get the offset to the requested timezone. + _, offset := t.In(location).Zone() + if offset != 0 { + t = t.Add(time.Duration(offset) * time.Second) + } + return t +} From aeb3a56982d5d2f9d46c3654c880ca829df0d322 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 08:36:34 +0200 Subject: [PATCH 02/14] Moved scrobblerlog parsing to separate package --- .../backends/scrobblerlog/scrobblerlog.go | 19 +++--- .../backends => pkg}/scrobblerlog/parser.go | 61 ++++++++++++------- .../scrobblerlog/parser_test.go | 50 +++++++-------- 3 files changed, 74 insertions(+), 56 deletions(-) rename {internal/backends => pkg}/scrobblerlog/parser.go (78%) rename {internal/backends => pkg}/scrobblerlog/parser_test.go (80%) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 84cae88..bb05086 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -25,6 +25,7 @@ import ( "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" + "go.uploadedlobster.com/scotty/pkg/scrobblerlog" ) type ScrobblerLogBackend struct { @@ -32,7 +33,7 @@ type ScrobblerLogBackend struct { includeSkipped bool append bool file *os.File - log ScrobblerLog + log scrobblerlog.ScrobblerLog } func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" } @@ -58,9 +59,9 @@ func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Ba b.filePath = config.GetString("file-path") b.includeSkipped = config.GetBool("include-skipped", false) b.append = config.GetBool("append", true) - b.log = ScrobblerLog{ - Timezone: "UNKNOWN", - Client: "Rockbox unknown $Revision$", + b.log = scrobblerlog.ScrobblerLog{ + TZ: scrobblerlog.TZ_UTC, + Client: "Rockbox unknown $Revision$", } return b } @@ -88,7 +89,7 @@ func (b *ScrobblerLogBackend) StartImport() error { } else { // Verify existing file is a scrobbler log reader := bufio.NewReader(file) - if err = ReadHeader(reader, &b.log); err != nil { + if err = b.log.ReadHeader(reader); err != nil { file.Close() return err } @@ -99,7 +100,7 @@ func (b *ScrobblerLogBackend) StartImport() error { } if !b.append { - if err = WriteHeader(file, &b.log); err != nil { + if err = b.log.WriteHeader(file); err != nil { file.Close() return err } @@ -124,21 +125,21 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c defer file.Close() - log, err := Parse(file, b.includeSkipped) + err = b.log.Parse(file, b.includeSkipped) if err != nil { progress <- models.Progress{}.Complete() results <- models.ListensResult{Error: err} return } - listens := log.Listens.NewerThan(oldestTimestamp) + listens := b.log.Listens.NewerThan(oldestTimestamp) sort.Sort(listens) progress <- models.Progress{Elapsed: int64(len(listens))}.Complete() results <- models.ListensResult{Items: listens} } func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { - lastTimestamp, err := Write(b.file, export.Items) + lastTimestamp, err := b.log.Append(b.file, export.Items) if err != nil { return importResult, err } diff --git a/internal/backends/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go similarity index 78% rename from internal/backends/scrobblerlog/parser.go rename to pkg/scrobblerlog/parser.go index eeb603b..a200d05 100644 --- a/internal/backends/scrobblerlog/parser.go +++ b/pkg/scrobblerlog/parser.go @@ -19,6 +19,12 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + +// Package to parse and writer .scrobbler.log files as written by Rockbox. +// +// See +// - https://www.rockbox.org/wiki/LastFMLog +// - https://git.rockbox.org/cgit/rockbox.git/tree/apps/plugins/lastfm_scrobbler.c package scrobblerlog import ( @@ -34,22 +40,31 @@ import ( "go.uploadedlobster.com/scotty/internal/models" ) +// TZInfo is the timezone information in the header of the scrobbler log file. +// It can be "UTC" or "UNKNOWN", if the device writing the scrobbler log file +// knows the time, but not the timezone. +type TZInfo string + +const ( + TZ_UNKNOWN TZInfo = "UNKNOWN" + TZ_UTC TZInfo = "UTC" +) + +// Represents a scrobbler log file. type ScrobblerLog struct { - Timezone string + TZ TZInfo Client string Listens models.ListensList location *time.Location } -func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { - result := ScrobblerLog{ - Listens: make(models.ListensList, 0), - } +func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error { + l.Listens = make(models.ListensList, 0) reader := bufio.NewReader(data) - err := ReadHeader(reader, &result) + err := l.ReadHeader(reader) if err != nil { - return result, err + return err } tsvReader := csv.NewReader(reader) @@ -64,7 +79,7 @@ func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { if err == io.EOF { break } else if err != nil { - return result, err + return err } // fmt.Printf("row: %v\n", row) @@ -72,7 +87,7 @@ func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { // We consider only the last field (recording MBID) optional if len(row) < 7 { line, _ := tsvReader.FieldPos(0) - return result, fmt.Errorf("invalid record in scrobblerlog line %v", line) + return fmt.Errorf("invalid record in scrobblerlog line %v", line) } rating := row[5] @@ -80,18 +95,18 @@ func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { continue } - listen, err := result.rowToListen(row) + listen, err := l.rowToListen(row) if err != nil { - return result, err + return err } - result.Listens = append(result.Listens, listen) + l.Listens = append(l.Listens, listen) } - return result, nil + return nil } -func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time, err error) { +func (l *ScrobblerLog) Append(data io.Writer, listens models.ListensList) (lastTimestamp time.Time, err error) { tsvWriter := csv.NewWriter(data) tsvWriter.Comma = '\t' @@ -122,7 +137,7 @@ func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time, return } -func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error { +func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error { // Skip header for i := 0; i < 3; i++ { line, _, err := reader.ReadLine() @@ -142,14 +157,14 @@ func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error { // the log knows the time, but not the timezone. timezone, found := strings.CutPrefix(text, "#TZ/") if found { - log.Timezone = timezone - log.location = locationFromTimezone(log.Timezone) + l.TZ = TZInfo(timezone) + l.location = locationFromTimezone(l.TZ) continue } client, found := strings.CutPrefix(text, "#CLIENT/") if found { - log.Client = client + l.Client = client continue } } @@ -161,11 +176,11 @@ func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error { return nil } -func WriteHeader(writer io.Writer, log *ScrobblerLog) error { +func (l *ScrobblerLog) WriteHeader(writer io.Writer) error { headers := []string{ "#AUDIOSCROBBLER/1.1\n", - "#TZ/" + log.Timezone + "\n", - "#CLIENT/" + log.Client + "\n", + "#TZ/" + string(l.TZ) + "\n", + "#CLIENT/" + l.Client + "\n", } for _, line := range headers { _, err := writer.Write([]byte(line)) @@ -219,8 +234,8 @@ func (l ScrobblerLog) rowToListen(row []string) (models.Listen, error) { // Convert the timezone string from the header to a time.Location. // Often this is set to "UNKNOWN" in the log file, in which case it defaults // to UTC. -func locationFromTimezone(timezone string) *time.Location { - location, err := time.LoadLocation(timezone) +func locationFromTimezone(timezone TZInfo) *time.Location { + location, err := time.LoadLocation(string(timezone)) if err != nil { return time.UTC } diff --git a/internal/backends/scrobblerlog/parser_test.go b/pkg/scrobblerlog/parser_test.go similarity index 80% rename from internal/backends/scrobblerlog/parser_test.go rename to pkg/scrobblerlog/parser_test.go index 480481f..b70f408 100644 --- a/internal/backends/scrobblerlog/parser_test.go +++ b/pkg/scrobblerlog/parser_test.go @@ -31,8 +31,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" "go.uploadedlobster.com/scotty/internal/models" + "go.uploadedlobster.com/scotty/pkg/scrobblerlog" ) var testScrobblerLog = `#AUDIOSCROBBLER/1.1 @@ -48,9 +48,10 @@ Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262bea func TestParser(t *testing.T) { assert := assert.New(t) data := bytes.NewBufferString(testScrobblerLog) - result, err := scrobblerlog.Parse(data, true) + result := scrobblerlog.ScrobblerLog{} + err := result.Parse(data, true) require.NoError(t, err) - assert.Equal("UNKNOWN", result.Timezone) + assert.Equal(scrobblerlog.TZ_UNKNOWN, result.TZ) assert.Equal("Rockbox sansaclipplus $Revision$", result.Client) assert.Len(result.Listens, 5) listen1 := result.Listens[0] @@ -70,7 +71,8 @@ func TestParser(t *testing.T) { func TestParserExcludeSkipped(t *testing.T) { assert := assert.New(t) data := bytes.NewBufferString(testScrobblerLog) - result, err := scrobblerlog.Parse(data, false) + result := scrobblerlog.ScrobblerLog{} + err := result.Parse(data, false) require.NoError(t, err) assert.Len(result.Listens, 4) listen4 := result.Listens[3] @@ -78,37 +80,37 @@ func TestParserExcludeSkipped(t *testing.T) { assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMBID) } -func TestWrite(t *testing.T) { +func TestAppend(t *testing.T) { assert := assert.New(t) data := make([]byte, 0, 10) buffer := bytes.NewBuffer(data) log := scrobblerlog.ScrobblerLog{ - Timezone: "Unknown", - Client: "Rockbox foo $Revision$", - Listens: []models.Listen{ - { - ListenedAt: time.Unix(1699572072, 0), - Track: models.Track{ - ArtistNames: []string{"Prinzhorn Dance School"}, - ReleaseName: "Home Economics", - TrackName: "Reign", - TrackNumber: 1, - Duration: 271 * time.Second, - RecordingMBID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), - AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"}, - }, + TZ: scrobblerlog.TZ_UNKNOWN, + Client: "Rockbox foo $Revision$", + } + listens := []models.Listen{ + { + ListenedAt: time.Unix(1699572072, 0), + Track: models.Track{ + ArtistNames: []string{"Prinzhorn Dance School"}, + ReleaseName: "Home Economics", + TrackName: "Reign", + TrackNumber: 1, + Duration: 271 * time.Second, + RecordingMBID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), + AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"}, }, }, } - err := scrobblerlog.WriteHeader(buffer, &log) + err := log.WriteHeader(buffer) require.NoError(t, err) - lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens) + lastTimestamp, err := log.Append(buffer, listens) require.NoError(t, err) result := buffer.String() lines := strings.Split(result, "\n") assert.Equal(5, len(lines)) assert.Equal("#AUDIOSCROBBLER/1.1", lines[0]) - assert.Equal("#TZ/Unknown", lines[1]) + assert.Equal("#TZ/UNKNOWN", lines[1]) assert.Equal("#CLIENT/Rockbox foo $Revision$", lines[2]) assert.Equal( "Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff", @@ -121,9 +123,9 @@ func TestReadHeader(t *testing.T) { data := bytes.NewBufferString(testScrobblerLog) reader := bufio.NewReader(data) log := scrobblerlog.ScrobblerLog{} - err := scrobblerlog.ReadHeader(reader, &log) + err := log.ReadHeader(reader) assert.NoError(t, err) - assert.Equal(t, log.Timezone, "UNKNOWN") + assert.Equal(t, log.TZ, scrobblerlog.TZ_UNKNOWN) assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$") assert.Empty(t, log.Listens) } From aad542850a139177a8e451d1f32a976ed59db9a7 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 09:18:57 +0200 Subject: [PATCH 03/14] scrobblerlog: Use specific Record type This makes the interface more generic and easier to reuse in other projects. --- .../backends/scrobblerlog/scrobblerlog.go | 54 ++++++++++- pkg/scrobblerlog/parser.go | 93 ++++++++++--------- pkg/scrobblerlog/parser_test.go | 61 ++++++------ 3 files changed, 132 insertions(+), 76 deletions(-) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index bb05086..bf5afac 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -20,6 +20,7 @@ import ( "bufio" "os" "sort" + "strings" "time" "go.uploadedlobster.com/scotty/internal/config" @@ -132,14 +133,22 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c return } - listens := b.log.Listens.NewerThan(oldestTimestamp) - sort.Sort(listens) + 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)) + } + sort.Sort(listens.NewerThan(oldestTimestamp)) progress <- models.Progress{Elapsed: int64(len(listens))}.Complete() results <- models.ListensResult{Items: listens} } func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { - lastTimestamp, err := b.log.Append(b.file, export.Items) + records := make([]scrobblerlog.Record, len(export.Items)) + for i, listen := range export.Items { + records[i] = listenToRecord(listen) + } + lastTimestamp, err := b.log.Append(b.file, records) if err != nil { return importResult, err } @@ -150,3 +159,42 @@ func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importR return importResult, nil } + +func recordToListen(record scrobblerlog.Record, client string) models.Listen { + return models.Listen{ + ListenedAt: record.Timestamp, + Track: models.Track{ + ArtistNames: []string{record.ArtistName}, + ReleaseName: record.AlbumName, + TrackName: record.TrackName, + TrackNumber: record.TrackNumber, + Duration: record.Duration, + RecordingMBID: record.MusicBrainzRecordingID, + AdditionalInfo: models.AdditionalInfo{ + "rockbox_rating": record.Rating, + "media_player": client, + }, + }, + } +} + +func listenToRecord(listen models.Listen) scrobblerlog.Record { + var rating scrobblerlog.Rating + rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string) + if !ok || rockboxRating == "" { + rating = scrobblerlog.RATING_LISTENED + } else { + rating = scrobblerlog.Rating(rating) + } + + return scrobblerlog.Record{ + ArtistName: listen.ArtistName(), + AlbumName: listen.ReleaseName, + TrackName: listen.TrackName, + TrackNumber: listen.TrackNumber, + Duration: listen.Duration, + Rating: rating, + Timestamp: listen.ListenedAt, + MusicBrainzRecordingID: listen.RecordingMBID, + } +} diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go index a200d05..8f9b88a 100644 --- a/pkg/scrobblerlog/parser.go +++ b/pkg/scrobblerlog/parser.go @@ -37,7 +37,6 @@ import ( "time" "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/models" ) // TZInfo is the timezone information in the header of the scrobbler log file. @@ -50,16 +49,36 @@ const ( TZ_UTC TZInfo = "UTC" ) +// L if listened at least 50% or S if skipped +type Rating string + +const ( + RATING_LISTENED Rating = "L" + RATING_SKIPPED Rating = "S" +) + +// A single entry of a track in the scrobbler log file. +type Record struct { + ArtistName string + AlbumName string + TrackName string + TrackNumber int + Duration time.Duration + Rating Rating + Timestamp time.Time + MusicBrainzRecordingID mbtypes.MBID +} + // Represents a scrobbler log file. type ScrobblerLog struct { TZ TZInfo Client string - Listens models.ListensList + Records []Record location *time.Location } func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error { - l.Listens = make(models.ListensList, 0) + l.Records = make([]Record, 0) reader := bufio.NewReader(data) err := l.ReadHeader(reader) @@ -95,41 +114,37 @@ func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error { continue } - listen, err := l.rowToListen(row) + record, err := l.rowToRecord(row) if err != nil { return err } - l.Listens = append(l.Listens, listen) + l.Records = append(l.Records, record) } return nil } -func (l *ScrobblerLog) Append(data io.Writer, listens models.ListensList) (lastTimestamp time.Time, err error) { +func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp time.Time, err error) { tsvWriter := csv.NewWriter(data) tsvWriter.Comma = '\t' - for _, listen := range listens { - if listen.ListenedAt.Unix() > lastTimestamp.Unix() { - lastTimestamp = listen.ListenedAt + for _, record := range records { + if record.Timestamp.After(lastTimestamp) { + lastTimestamp = record.Timestamp } // A row is: // artistName releaseName trackName trackNumber duration rating timestamp recordingMBID - rating, ok := listen.AdditionalInfo["rockbox_rating"].(string) - if !ok || rating == "" { - rating = "L" - } err = tsvWriter.Write([]string{ - listen.ArtistName(), - listen.ReleaseName, - listen.TrackName, - strconv.Itoa(listen.TrackNumber), - strconv.Itoa(int(listen.Duration.Seconds())), - rating, - strconv.Itoa(int(listen.ListenedAt.Unix())), - string(listen.RecordingMBID), + record.ArtistName, + record.AlbumName, + record.TrackName, + strconv.Itoa(record.TrackNumber), + strconv.Itoa(int(record.Duration.Seconds())), + string(record.Rating), + strconv.FormatInt(record.Timestamp.Unix(), 10), + string(record.MusicBrainzRecordingID), }) } @@ -191,44 +206,38 @@ func (l *ScrobblerLog) WriteHeader(writer io.Writer) error { return nil } -func (l ScrobblerLog) rowToListen(row []string) (models.Listen, error) { - var listen models.Listen +func (l ScrobblerLog) rowToRecord(row []string) (Record, error) { + var record Record trackNumber, err := strconv.Atoi(row[3]) if err != nil { - return listen, err + return record, err } duration, err := strconv.Atoi(row[4]) if err != nil { - return listen, err + return record, err } timestamp, err := strconv.ParseInt(row[6], 10, 64) if err != nil { - return listen, err + return record, err } - client := strings.Split(l.Client, " ")[0] - listen = models.Listen{ - Track: models.Track{ - ArtistNames: []string{row[0]}, - ReleaseName: row[1], - TrackName: row[2], - TrackNumber: trackNumber, - Duration: time.Duration(duration * int(time.Second)), - AdditionalInfo: models.AdditionalInfo{ - "rockbox_rating": row[5], - "media_player": client, - }, - }, - ListenedAt: timeFromLocalTimestamp(timestamp, l.location), + record = Record{ + ArtistName: row[0], + AlbumName: row[1], + TrackName: row[2], + TrackNumber: trackNumber, + Duration: time.Duration(duration) * time.Second, + Rating: Rating(row[5]), + Timestamp: timeFromLocalTimestamp(timestamp, l.location), } if len(row) > 7 { - listen.Track.RecordingMBID = mbtypes.MBID(row[7]) + record.MusicBrainzRecordingID = mbtypes.MBID(row[7]) } - return listen, nil + return record, nil } // Convert the timezone string from the header to a time.Location. diff --git a/pkg/scrobblerlog/parser_test.go b/pkg/scrobblerlog/parser_test.go index b70f408..9b4513f 100644 --- a/pkg/scrobblerlog/parser_test.go +++ b/pkg/scrobblerlog/parser_test.go @@ -31,7 +31,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/pkg/scrobblerlog" ) @@ -53,19 +52,20 @@ func TestParser(t *testing.T) { require.NoError(t, err) assert.Equal(scrobblerlog.TZ_UNKNOWN, result.TZ) assert.Equal("Rockbox sansaclipplus $Revision$", result.Client) - assert.Len(result.Listens, 5) - listen1 := result.Listens[0] - assert.Equal("Özcan Deniz", listen1.ArtistName()) - assert.Equal("Ses ve Ayrilik", listen1.ReleaseName) - assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName) - assert.Equal(5, listen1.TrackNumber) - assert.Equal(time.Duration(306*time.Second), listen1.Duration) - assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"]) - assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt) - assert.Equal(mbtypes.MBID(""), listen1.RecordingMBID) - listen4 := result.Listens[3] - assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"]) - assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMBID) + assert.Len(result.Records, 5) + record1 := result.Records[0] + assert.Equal("Özcan Deniz", record1.ArtistName) + assert.Equal("Ses ve Ayrilik", record1.AlbumName) + assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName) + assert.Equal(5, record1.TrackNumber) + assert.Equal(time.Duration(306*time.Second), record1.Duration) + assert.Equal(scrobblerlog.RATING_LISTENED, record1.Rating) + assert.Equal(time.Unix(1260342084, 0), record1.Timestamp) + assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID) + record4 := result.Records[3] + assert.Equal(scrobblerlog.RATING_SKIPPED, record4.Rating) + assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), + record4.MusicBrainzRecordingID) } func TestParserExcludeSkipped(t *testing.T) { @@ -74,10 +74,11 @@ func TestParserExcludeSkipped(t *testing.T) { result := scrobblerlog.ScrobblerLog{} err := result.Parse(data, false) require.NoError(t, err) - assert.Len(result.Listens, 4) - listen4 := result.Listens[3] - assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"]) - assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMBID) + assert.Len(result.Records, 4) + record4 := result.Records[3] + assert.Equal(scrobblerlog.RATING_LISTENED, record4.Rating) + assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), + record4.MusicBrainzRecordingID) } func TestAppend(t *testing.T) { @@ -88,23 +89,21 @@ func TestAppend(t *testing.T) { TZ: scrobblerlog.TZ_UNKNOWN, Client: "Rockbox foo $Revision$", } - listens := []models.Listen{ + records := []scrobblerlog.Record{ { - ListenedAt: time.Unix(1699572072, 0), - Track: models.Track{ - ArtistNames: []string{"Prinzhorn Dance School"}, - ReleaseName: "Home Economics", - TrackName: "Reign", - TrackNumber: 1, - Duration: 271 * time.Second, - RecordingMBID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), - AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"}, - }, + ArtistName: "Prinzhorn Dance School", + AlbumName: "Home Economics", + TrackName: "Reign", + TrackNumber: 1, + Duration: 271 * time.Second, + Rating: scrobblerlog.RATING_LISTENED, + Timestamp: time.Unix(1699572072, 0), + MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), }, } err := log.WriteHeader(buffer) require.NoError(t, err) - lastTimestamp, err := log.Append(buffer, listens) + lastTimestamp, err := log.Append(buffer, records) require.NoError(t, err) result := buffer.String() lines := strings.Split(result, "\n") @@ -127,5 +126,5 @@ func TestReadHeader(t *testing.T) { assert.NoError(t, err) assert.Equal(t, log.TZ, scrobblerlog.TZ_UNKNOWN) assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$") - assert.Empty(t, log.Listens) + assert.Empty(t, log.Records) } From 0f4b04c641c531cd0d9a2f42e30b2a32701b0bf5 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 10:03:28 +0200 Subject: [PATCH 04/14] Renamed Backend.FromConfig to Backend.InitConfig and added error handling --- internal/backends/backends.go | 6 +++++- internal/backends/deezer/deezer.go | 4 ++-- internal/backends/deezer/deezer_test.go | 7 ++++--- internal/backends/dump/dump.go | 4 ++-- internal/backends/funkwhale/funkwhale.go | 4 ++-- internal/backends/funkwhale/funkwhale_test.go | 7 ++++--- internal/backends/jspf/jspf.go | 4 ++-- internal/backends/jspf/jspf_test.go | 7 ++++--- internal/backends/lastfm/lastfm.go | 4 ++-- internal/backends/listenbrainz/listenbrainz.go | 4 ++-- internal/backends/listenbrainz/listenbrainz_test.go | 7 ++++--- internal/backends/maloja/maloja.go | 4 ++-- internal/backends/maloja/maloja_test.go | 7 ++++--- internal/backends/scrobblerlog/scrobblerlog.go | 4 ++-- internal/backends/scrobblerlog/scrobblerlog_test.go | 7 ++++--- internal/backends/spotify/spotify.go | 4 ++-- internal/backends/spotify/spotify_test.go | 7 ++++--- internal/backends/spotifyhistory/spotifyhistory.go | 4 ++-- internal/backends/subsonic/subsonic.go | 4 ++-- internal/backends/subsonic/subsonic_test.go | 7 ++++--- internal/models/interfaces.go | 2 +- 21 files changed, 60 insertions(+), 48 deletions(-) diff --git a/internal/backends/backends.go b/internal/backends/backends.go index e4cbbc9..a9c3292 100644 --- a/internal/backends/backends.go +++ b/internal/backends/backends.go @@ -123,7 +123,11 @@ func backendWithConfig(config config.ServiceConfig) (models.Backend, error) { if err != nil { return nil, err } - return backend.FromConfig(&config), nil + err = backend.InitConfig(&config) + if err != nil { + return nil, err + } + return backend, nil } func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) { diff --git a/internal/backends/deezer/deezer.go b/internal/backends/deezer/deezer.go index 3131c3e..e7d9762 100644 --- a/internal/backends/deezer/deezer.go +++ b/internal/backends/deezer/deezer.go @@ -49,10 +49,10 @@ func (b *DeezerApiBackend) Options() []models.BackendOption { }} } -func (b *DeezerApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error { b.clientId = config.GetString("client-id") b.clientSecret = config.GetString("client-secret") - return b + return nil } func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { diff --git a/internal/backends/deezer/deezer_test.go b/internal/backends/deezer/deezer_test.go index 9550c0e..19776f4 100644 --- a/internal/backends/deezer/deezer_test.go +++ b/internal/backends/deezer/deezer_test.go @@ -35,13 +35,14 @@ var ( testTrack []byte ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("client-id", "someclientid") c.Set("client-secret", "someclientsecret") service := config.NewServiceConfig("test", c) - backend := (&deezer.DeezerApiBackend{}).FromConfig(&service) - assert.IsType(t, &deezer.DeezerApiBackend{}, backend) + backend := deezer.DeezerApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestListenAsListen(t *testing.T) { diff --git a/internal/backends/dump/dump.go b/internal/backends/dump/dump.go index 728a774..70be12d 100644 --- a/internal/backends/dump/dump.go +++ b/internal/backends/dump/dump.go @@ -29,8 +29,8 @@ func (b *DumpBackend) Name() string { return "dump" } func (b *DumpBackend) Options() []models.BackendOption { return nil } -func (b *DumpBackend) FromConfig(config *config.ServiceConfig) models.Backend { - return b +func (b *DumpBackend) InitConfig(config *config.ServiceConfig) error { + return nil } func (b *DumpBackend) StartImport() error { return nil } diff --git a/internal/backends/funkwhale/funkwhale.go b/internal/backends/funkwhale/funkwhale.go index 48c3d8f..99bf43d 100644 --- a/internal/backends/funkwhale/funkwhale.go +++ b/internal/backends/funkwhale/funkwhale.go @@ -51,13 +51,13 @@ func (b *FunkwhaleApiBackend) Options() []models.BackendOption { }} } -func (b *FunkwhaleApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *FunkwhaleApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = NewClient( config.GetString("server-url"), config.GetString("token"), ) b.username = config.GetString("username") - return b + return nil } func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { diff --git a/internal/backends/funkwhale/funkwhale_test.go b/internal/backends/funkwhale/funkwhale_test.go index d8654d8..93ab97b 100644 --- a/internal/backends/funkwhale/funkwhale_test.go +++ b/internal/backends/funkwhale/funkwhale_test.go @@ -27,12 +27,13 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(&service) - assert.IsType(t, &funkwhale.FunkwhaleApiBackend{}, backend) + backend := funkwhale.FunkwhaleApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestFunkwhaleListeningAsListen(t *testing.T) { diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index bfa3892..152c810 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -60,7 +60,7 @@ func (b *JSPFBackend) Options() []models.BackendOption { }} } -func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") b.append = config.GetBool("append", true) b.playlist = jspf.Playlist{ @@ -75,7 +75,7 @@ func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend { }, }, } - return b + return nil } func (b *JSPFBackend) StartImport() error { diff --git a/internal/backends/jspf/jspf_test.go b/internal/backends/jspf/jspf_test.go index 31b5370..bf4f99d 100644 --- a/internal/backends/jspf/jspf_test.go +++ b/internal/backends/jspf/jspf_test.go @@ -26,13 +26,14 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("file-path", "/foo/bar.jspf") c.Set("title", "My Playlist") c.Set("username", "outsidecontext") c.Set("identifier", "http://example.com/playlist1") service := config.NewServiceConfig("test", c) - backend := (&jspf.JSPFBackend{}).FromConfig(&service) - assert.IsType(t, &jspf.JSPFBackend{}, backend) + backend := jspf.JSPFBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go index ba660de..2d4a9d5 100644 --- a/internal/backends/lastfm/lastfm.go +++ b/internal/backends/lastfm/lastfm.go @@ -61,12 +61,12 @@ func (b *LastfmApiBackend) Options() []models.BackendOption { }} } -func (b *LastfmApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error { clientId := config.GetString("client-id") clientSecret := config.GetString("client-secret") b.client = lastfm.New(clientId, clientSecret) b.username = config.GetString("username") - return b + return nil } func (b *LastfmApiBackend) StartImport() error { return nil } diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go index d13c869..49755c6 100644 --- a/internal/backends/listenbrainz/listenbrainz.go +++ b/internal/backends/listenbrainz/listenbrainz.go @@ -56,13 +56,13 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption { }} } -func (b *ListenBrainzApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = NewClient(config.GetString("token")) b.mbClient = *musicbrainzws2.NewClient(version.AppName, version.AppVersion) b.client.MaxResults = MaxItemsPerGet b.username = config.GetString("username") b.checkDuplicates = config.GetBool("check-duplicate-listens", false) - return b + return nil } func (b *ListenBrainzApiBackend) StartImport() error { return nil } diff --git a/internal/backends/listenbrainz/listenbrainz_test.go b/internal/backends/listenbrainz/listenbrainz_test.go index 93428d7..bf2e4d3 100644 --- a/internal/backends/listenbrainz/listenbrainz_test.go +++ b/internal/backends/listenbrainz/listenbrainz_test.go @@ -28,12 +28,13 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(&service) - assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend) + backend := listenbrainz.ListenBrainzApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestListenBrainzListenAsListen(t *testing.T) { diff --git a/internal/backends/maloja/maloja.go b/internal/backends/maloja/maloja.go index 135bef3..e9e3348 100644 --- a/internal/backends/maloja/maloja.go +++ b/internal/backends/maloja/maloja.go @@ -51,13 +51,13 @@ func (b *MalojaApiBackend) Options() []models.BackendOption { }} } -func (b *MalojaApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *MalojaApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = NewClient( config.GetString("server-url"), config.GetString("token"), ) b.nofix = config.GetBool("nofix", false) - return b + return nil } func (b *MalojaApiBackend) StartImport() error { return nil } diff --git a/internal/backends/maloja/maloja_test.go b/internal/backends/maloja/maloja_test.go index 52be58c..4a1f318 100644 --- a/internal/backends/maloja/maloja_test.go +++ b/internal/backends/maloja/maloja_test.go @@ -26,12 +26,13 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&maloja.MalojaApiBackend{}).FromConfig(&service) - assert.IsType(t, &maloja.MalojaApiBackend{}, backend) + backend := maloja.MalojaApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestScrobbleAsListen(t *testing.T) { diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index bf5afac..1fdfaff 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -56,7 +56,7 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption { }} } -func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") b.includeSkipped = config.GetBool("include-skipped", false) b.append = config.GetBool("append", true) @@ -64,7 +64,7 @@ func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Ba TZ: scrobblerlog.TZ_UTC, Client: "Rockbox unknown $Revision$", } - return b + return nil } func (b *ScrobblerLogBackend) StartImport() error { diff --git a/internal/backends/scrobblerlog/scrobblerlog_test.go b/internal/backends/scrobblerlog/scrobblerlog_test.go index 04e76c1..7a8ab14 100644 --- a/internal/backends/scrobblerlog/scrobblerlog_test.go +++ b/internal/backends/scrobblerlog/scrobblerlog_test.go @@ -25,10 +25,11 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(&service) - assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend) + backend := scrobblerlog.ScrobblerLogBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } diff --git a/internal/backends/spotify/spotify.go b/internal/backends/spotify/spotify.go index a4e3c87..ae2fc25 100644 --- a/internal/backends/spotify/spotify.go +++ b/internal/backends/spotify/spotify.go @@ -52,10 +52,10 @@ func (b *SpotifyApiBackend) Options() []models.BackendOption { }} } -func (b *SpotifyApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error { b.clientId = config.GetString("client-id") b.clientSecret = config.GetString("client-secret") - return b + return nil } func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { diff --git a/internal/backends/spotify/spotify_test.go b/internal/backends/spotify/spotify_test.go index 1aa7e87..8949128 100644 --- a/internal/backends/spotify/spotify_test.go +++ b/internal/backends/spotify/spotify_test.go @@ -38,13 +38,14 @@ var ( testTrack []byte ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("client-id", "someclientid") c.Set("client-secret", "someclientsecret") service := config.NewServiceConfig("test", c) - backend := (&spotify.SpotifyApiBackend{}).FromConfig(&service) - assert.IsType(t, &spotify.SpotifyApiBackend{}, backend) + backend := spotify.SpotifyApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestSpotifyListenAsListen(t *testing.T) { diff --git a/internal/backends/spotifyhistory/spotifyhistory.go b/internal/backends/spotifyhistory/spotifyhistory.go index 40323a4..1c986be 100644 --- a/internal/backends/spotifyhistory/spotifyhistory.go +++ b/internal/backends/spotifyhistory/spotifyhistory.go @@ -64,12 +64,12 @@ func (b *SpotifyHistoryBackend) Options() []models.BackendOption { }} } -func (b *SpotifyHistoryBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error { b.dirPath = config.GetString("dir-path") b.ignoreIncognito = config.GetBool("ignore-incognito", true) b.ignoreSkipped = config.GetBool("ignore-skipped", false) b.skippedMinSeconds = config.GetInt("ignore-min-duration-seconds", 30) - return b + return nil } func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { diff --git a/internal/backends/subsonic/subsonic.go b/internal/backends/subsonic/subsonic.go index 59d4719..1c26bfd 100644 --- a/internal/backends/subsonic/subsonic.go +++ b/internal/backends/subsonic/subsonic.go @@ -52,7 +52,7 @@ func (b *SubsonicApiBackend) Options() []models.BackendOption { }} } -func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = subsonic.Client{ Client: &http.Client{}, BaseUrl: config.GetString("server-url"), @@ -60,7 +60,7 @@ func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Bac ClientName: version.AppName, } b.password = config.GetString("token") - return b + return nil } func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { diff --git a/internal/backends/subsonic/subsonic_test.go b/internal/backends/subsonic/subsonic_test.go index f6508c5..638c116 100644 --- a/internal/backends/subsonic/subsonic_test.go +++ b/internal/backends/subsonic/subsonic_test.go @@ -27,13 +27,14 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("server-url", "https://subsonic.example.com") c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&subsonic.SubsonicApiBackend{}).FromConfig(&service) - assert.IsType(t, &subsonic.SubsonicApiBackend{}, backend) + backend := subsonic.SubsonicApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestSongToLove(t *testing.T) { diff --git a/internal/models/interfaces.go b/internal/models/interfaces.go index cc19d8d..1c593d0 100644 --- a/internal/models/interfaces.go +++ b/internal/models/interfaces.go @@ -30,7 +30,7 @@ type Backend interface { Name() string // Initialize the backend from a config. - FromConfig(config *config.ServiceConfig) Backend + InitConfig(config *config.ServiceConfig) error // Return configuration options Options() []BackendOption From ed191d2f15131dea901964227b9c485d85497a3b Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 10:05:40 +0200 Subject: [PATCH 05/14] scrobblerlog: Allow configuring fallback time zone Fixes #6 --- config.example.toml | 7 +++ .../backends/scrobblerlog/scrobblerlog.go | 14 ++++++ .../scrobblerlog/scrobblerlog_test.go | 10 +++++ pkg/scrobblerlog/parser.go | 44 +++++++++---------- pkg/scrobblerlog/parser_test.go | 15 +++++++ 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/config.example.toml b/config.example.toml index 6a5eb88..bdccb16 100644 --- a/config.example.toml +++ b/config.example.toml @@ -61,6 +61,13 @@ include-skipped = true # If true (default), new listens will be appended to the existing file. Set to # false to overwrite the file and create a new scrobbler log on every run. append = true +# Specify the time zone of the listens in the scrobbler log. While the log files +# are supposed to contain Unix timestamps, which are always in UTC, the player +# writing the log might not be time zone aware. This can cause the timestamps +# to be in a different time zone. Use the time-zone setting to specify a +# different time zone, e.g. "Europe/Berlin" or "America/New_York". +# The default is UTC. +time-zone = "UTC" [service.jspf] # Write listens and loves to JSPF playlist files (https://xspf.org/jspf) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 1fdfaff..22c8577 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -18,6 +18,7 @@ package scrobblerlog import ( "bufio" + "fmt" "os" "sort" "strings" @@ -34,6 +35,7 @@ type ScrobblerLogBackend struct { includeSkipped bool append bool file *os.File + timezone *time.Location log scrobblerlog.ScrobblerLog } @@ -53,6 +55,10 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption { Label: i18n.Tr("Append to file"), Type: models.Bool, Default: "true", + }, { + Name: "time-zone", + Label: i18n.Tr("Specify a time zone for the listen timestamps"), + Type: models.String, }} } @@ -60,6 +66,14 @@ func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") b.includeSkipped = config.GetBool("include-skipped", false) b.append = config.GetBool("append", true) + timezone := config.GetString("time-zone") + if 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.TZ_UTC, Client: "Rockbox unknown $Revision$", diff --git a/internal/backends/scrobblerlog/scrobblerlog_test.go b/internal/backends/scrobblerlog/scrobblerlog_test.go index 7a8ab14..962aebf 100644 --- a/internal/backends/scrobblerlog/scrobblerlog_test.go +++ b/internal/backends/scrobblerlog/scrobblerlog_test.go @@ -33,3 +33,13 @@ func TestInitConfig(t *testing.T) { err := backend.InitConfig(&service) assert.NoError(t, err) } + +func TestInitConfigInvalidTimezone(t *testing.T) { + c := viper.New() + configuredTimezone := "Invalid/Timezone" + c.Set("time-zone", configuredTimezone) + service := config.NewServiceConfig("test", c) + backend := scrobblerlog.ScrobblerLogBackend{} + err := backend.InitConfig(&service) + assert.ErrorContains(t, err, `Invalid time-zone "Invalid/Timezone"`) +} diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go index 8f9b88a..9e33754 100644 --- a/pkg/scrobblerlog/parser.go +++ b/pkg/scrobblerlog/parser.go @@ -71,10 +71,12 @@ type Record struct { // Represents a scrobbler log file. type ScrobblerLog struct { - TZ TZInfo - Client string - Records []Record - location *time.Location + TZ TZInfo + Client string + Records []Record + // Timezone to be used for timestamps in the log file, + // if TZ is set to [TZ_UNKNOWN]. + FallbackTimezone *time.Location } func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error { @@ -173,7 +175,6 @@ func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error { timezone, found := strings.CutPrefix(text, "#TZ/") if found { l.TZ = TZInfo(timezone) - l.location = locationFromTimezone(l.TZ) continue } @@ -223,6 +224,11 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) { return record, err } + var timezone *time.Location = nil + if l.TZ == TZ_UNKNOWN { + timezone = l.FallbackTimezone + } + record = Record{ ArtistName: row[0], AlbumName: row[1], @@ -230,7 +236,7 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) { TrackNumber: trackNumber, Duration: time.Duration(duration) * time.Second, Rating: Rating(row[5]), - Timestamp: timeFromLocalTimestamp(timestamp, l.location), + Timestamp: timeFromLocalTimestamp(timestamp, timezone), } if len(row) > 7 { @@ -240,26 +246,20 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) { return record, nil } -// Convert the timezone string from the header to a time.Location. -// Often this is set to "UNKNOWN" in the log file, in which case it defaults -// to UTC. -func locationFromTimezone(timezone TZInfo) *time.Location { - location, err := time.LoadLocation(string(timezone)) - if err != nil { - return time.UTC - } - return location -} - -// Convert a Unix timestamp to a time.Time object, but treat the timestamp +// Convert a Unix timestamp to a [time.Time] object, but treat the timestamp // as being in the given location's timezone instead of UTC. +// If location is nil, the timestamp is returned as UTC. func timeFromLocalTimestamp(timestamp int64, location *time.Location) time.Time { t := time.Unix(timestamp, 0) - // The time is now in UTC. Get the offset to the requested timezone. - _, offset := t.In(location).Zone() - if offset != 0 { - t = t.Add(time.Duration(offset) * time.Second) + // The time is now in UTC. Get the offset to the requested timezone + // and shift the time accordingly. + if location != nil { + _, offset := t.In(location).Zone() + if offset != 0 { + t = t.Add(time.Duration(offset) * time.Second) + } } + return t } diff --git a/pkg/scrobblerlog/parser_test.go b/pkg/scrobblerlog/parser_test.go index 9b4513f..fe2f3ec 100644 --- a/pkg/scrobblerlog/parser_test.go +++ b/pkg/scrobblerlog/parser_test.go @@ -81,6 +81,21 @@ func TestParserExcludeSkipped(t *testing.T) { record4.MusicBrainzRecordingID) } +func TestParserFallbackTimezone(t *testing.T) { + assert := assert.New(t) + data := bytes.NewBufferString(testScrobblerLog) + result := scrobblerlog.ScrobblerLog{ + FallbackTimezone: time.FixedZone("UTC+2", 7200), + } + err := result.Parse(data, false) + require.NoError(t, err) + record1 := result.Records[0] + assert.Equal( + time.Unix(1260342084, 0).Add(2*time.Hour), + record1.Timestamp, + ) +} + func TestAppend(t *testing.T) { assert := assert.New(t) data := make([]byte, 0, 10) From b104c2bc428a9b1d74fc30f85e0fde212f228b72 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 10:10:32 +0200 Subject: [PATCH 06/14] scrobblerlog: fixed listen export progress --- internal/backends/scrobblerlog/scrobblerlog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 22c8577..55c3517 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -153,7 +153,7 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c listens = append(listens, recordToListen(record, client)) } sort.Sort(listens.NewerThan(oldestTimestamp)) - progress <- models.Progress{Elapsed: int64(len(listens))}.Complete() + progress <- models.Progress{Total: int64(len(listens))}.Complete() results <- models.ListensResult{Items: listens} } From 159f486cdca54ff98f7e5be4ea0c7152c20854ac Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 10:32:59 +0200 Subject: [PATCH 07/14] Upgrade musicbrainzws2 --- go.mod | 2 +- go.sum | 4 ++-- internal/backends/listenbrainz/listenbrainz.go | 6 +++++- internal/version/version.go | 1 + 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 22a3154..ef1286c 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d github.com/vbauerster/mpb/v8 v8.9.3 go.uploadedlobster.com/mbtypes v0.4.0 - go.uploadedlobster.com/musicbrainzws2 v0.13.1 + 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 diff --git a/go.sum b/go.sum index 1ee05c8..8ade87a 100644 --- a/go.sum +++ b/go.sum @@ -132,8 +132,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s= go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM= -go.uploadedlobster.com/musicbrainzws2 v0.13.1 h1:34GKI7l9eTCyh9ozNOHmlwAAUTDK9WVRsFZK5trxcwQ= -go.uploadedlobster.com/musicbrainzws2 v0.13.1/go.mod h1:TVln70Fzp/++fw0/jCP1xXwgilVwDkzTwRbV8GwUYLA= +go.uploadedlobster.com/musicbrainzws2 v0.14.0 h1:YaEtxNwLSNT1gzFipQ4XlaThNfXjBpzzb4I6WhIeUwg= +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= diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go index 49755c6..d0074b1 100644 --- a/internal/backends/listenbrainz/listenbrainz.go +++ b/internal/backends/listenbrainz/listenbrainz.go @@ -58,7 +58,11 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption { func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = NewClient(config.GetString("token")) - b.mbClient = *musicbrainzws2.NewClient(version.AppName, version.AppVersion) + b.mbClient = *musicbrainzws2.NewClient(musicbrainzws2.AppInfo{ + Name: version.AppName, + Version: version.AppVersion, + URL: version.AppURL, + }) b.client.MaxResults = MaxItemsPerGet b.username = config.GetString("username") b.checkDuplicates = config.GetBool("check-duplicate-listens", false) diff --git a/internal/version/version.go b/internal/version/version.go index 3f02fe2..818bec1 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -18,6 +18,7 @@ package version const ( AppName = "scotty" AppVersion = "0.4.1" + AppURL = "https://git.sr.ht/~phw/scotty/" ) func UserAgent() string { From 47486ff659bdf084cc7211c25e7f2633c9bcfc91 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 11:05:37 +0200 Subject: [PATCH 08/14] Update weblate configuration --- .build.yml | 2 +- .weblate | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .weblate diff --git a/.build.yml b/.build.yml index 8be4e81..32f13ec 100644 --- a/.build.yml +++ b/.build.yml @@ -5,7 +5,7 @@ packages: - hut - weblate-wlc secrets: - - 2a17e258-3e99-4093-9527-832c350d9c53 + - eafb7dc6-c02d-4b26-a960-61b968a4f454 oauth: pages.sr.ht/PAGES:RW tasks: - weblate-update: | diff --git a/.weblate b/.weblate new file mode 100644 index 0000000..9c9511e --- /dev/null +++ b/.weblate @@ -0,0 +1,3 @@ +[weblate] +url = https://translate.uploadedlobster.com/api/ +translation = scotty/app From c817480809516756aa7709d8a1221f546926cea3 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 11:12:28 +0200 Subject: [PATCH 09/14] Updated Weblate CI secret and fixed build --- .build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.build.yml b/.build.yml index 32f13ec..d1064df 100644 --- a/.build.yml +++ b/.build.yml @@ -5,10 +5,11 @@ packages: - hut - weblate-wlc secrets: - - eafb7dc6-c02d-4b26-a960-61b968a4f454 + - 0e2ad815-6c46-4cea-878e-70fc33f71e77 oauth: pages.sr.ht/PAGES:RW tasks: - weblate-update: | + cd scotty wlc --format text pull scotty - test: | cd scotty From 597914e6db39ea6e63ad48d1bd76d3ed30f09ffd Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 11:15:49 +0200 Subject: [PATCH 10/14] Announce new releases to Go Module Index --- .build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.build.yml b/.build.yml index d1064df..a5d2238 100644 --- a/.build.yml +++ b/.build.yml @@ -29,5 +29,15 @@ tasks: - publish-redirect: | # Update redirect on https://go.uploadedlobster.com/scotty ./scotty/pages/publish.sh + # Skip releasing if this is not a tagged release + - only-tags: | + cd scotty + GIT_REF=$(git describe --always) + [[ "$GIT_REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || complete-build + - announce-release: | + # Announce new release to Go Module Index + cd scotty + VERSION=$(git describe --exact-match) + curl "https://proxy.golang.org/go.uploadedlobster.com/scotty/@v/${VERSION}.info" artifacts: - scotty/dist/artifacts.tar From e135ea5fa90f46d3d17394b274870baee7510c0c Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 11:43:42 +0200 Subject: [PATCH 11/14] Update goreleaser config file format --- .goreleaser.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 06b612a..48c88c8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,7 +6,7 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj -version: 1 +version: 2 before: hooks: @@ -28,7 +28,7 @@ universal_binaries: - replace: true archives: - - format: tar.gz + - formats: ['tar.gz'] # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}-{{ .Version }}_ @@ -42,7 +42,7 @@ archives: # use zip for windows archives format_overrides: - goos: windows - format: zip + formats: ['zip'] files: - COPYING - README.md From 82858315fa9598aff00136ab088cdc14c1e74aab Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 11:44:04 +0200 Subject: [PATCH 12/14] Disable Linux 386 builds Compilaton fails with latest gorm --- .goreleaser.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 48c88c8..1a1e0ba 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -21,6 +21,8 @@ builds: - windows - darwin ignore: + - goos: linux + goarch: "386" - goos: windows goarch: "386" From 1516a3a9d6eb5d5c834c73c9d72fdfecdc03c379 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 12:57:28 +0200 Subject: [PATCH 13/14] scrobblerlog: renamed setting include-skipped to ignore-skipped This makes the setting consistent with the similar setting for spotify --- config.example.toml | 10 ++++---- .../backends/scrobblerlog/scrobblerlog.go | 23 ++++++++++--------- pkg/scrobblerlog/parser.go | 11 ++++----- pkg/scrobblerlog/parser_test.go | 6 ++--- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/config.example.toml b/config.example.toml index bdccb16..6b81bac 100644 --- a/config.example.toml +++ b/config.example.toml @@ -56,8 +56,8 @@ backend = "scrobbler-log" # The file path to the .scrobbler.log file. Relative paths are resolved against # the current working directory when running scotty. file-path = "./.scrobbler.log" -# If true, reading listens from the file also returns listens marked as "skipped" -include-skipped = true +# If true (default), ignore listens marked as skipped. +ignore-skipped = true # If true (default), new listens will be appended to the existing file. Set to # false to overwrite the file and create a new scrobbler log on every run. append = true @@ -105,9 +105,9 @@ dir-path = "./my_spotify_data_extended/Spotify Extended Streaming Histor ignore-incognito = true # If true, ignore listens marked as skipped. Default is false. ignore-skipped = false -# Only consider skipped listens with a playback duration longer than this number -# of seconds. Default is 30 seconds. If ignore-skipped is set to false this -# setting has no effect. +# Only consider skipped listens with a playback duration longer than or equal to +# this number of seconds. Default is 30 seconds. If ignore-skipped is enabled +# this setting has no effect. ignore-min-duration-seconds = 30 [service.deezer] diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 55c3517..26d417a 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -31,12 +31,12 @@ import ( ) type ScrobblerLogBackend struct { - filePath string - includeSkipped bool - append bool - file *os.File - timezone *time.Location - log scrobblerlog.ScrobblerLog + filePath string + ignoreSkipped bool + append bool + file *os.File + timezone *time.Location + log scrobblerlog.ScrobblerLog } func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" } @@ -47,9 +47,10 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption { Label: i18n.Tr("File path"), Type: models.String, }, { - Name: "include-skipped", - Label: i18n.Tr("Include skipped listens"), - Type: models.Bool, + Name: "ignore-skipped", + Label: i18n.Tr("Ignore skipped listens"), + Type: models.Bool, + Default: "true", }, { Name: "append", Label: i18n.Tr("Append to file"), @@ -64,7 +65,7 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption { func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") - b.includeSkipped = config.GetBool("include-skipped", false) + b.ignoreSkipped = config.GetBool("ignore-skipped", true) b.append = config.GetBool("append", true) timezone := config.GetString("time-zone") if timezone != "" { @@ -140,7 +141,7 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c defer file.Close() - err = b.log.Parse(file, b.includeSkipped) + err = b.log.Parse(file, b.ignoreSkipped) if err != nil { progress <- models.Progress{}.Complete() results <- models.ListensResult{Error: err} diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go index 9e33754..892f6e8 100644 --- a/pkg/scrobblerlog/parser.go +++ b/pkg/scrobblerlog/parser.go @@ -79,7 +79,7 @@ type ScrobblerLog struct { FallbackTimezone *time.Location } -func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error { +func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { l.Records = make([]Record, 0) reader := bufio.NewReader(data) @@ -111,16 +111,15 @@ func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error { return fmt.Errorf("invalid record in scrobblerlog line %v", line) } - rating := row[5] - if !includeSkipped && rating == "S" { - continue - } - record, err := l.rowToRecord(row) if err != nil { return err } + if ignoreSkipped && record.Rating == RATING_SKIPPED { + continue + } + l.Records = append(l.Records, record) } diff --git a/pkg/scrobblerlog/parser_test.go b/pkg/scrobblerlog/parser_test.go index fe2f3ec..7fd57c3 100644 --- a/pkg/scrobblerlog/parser_test.go +++ b/pkg/scrobblerlog/parser_test.go @@ -48,7 +48,7 @@ func TestParser(t *testing.T) { assert := assert.New(t) data := bytes.NewBufferString(testScrobblerLog) result := scrobblerlog.ScrobblerLog{} - err := result.Parse(data, true) + err := result.Parse(data, false) require.NoError(t, err) assert.Equal(scrobblerlog.TZ_UNKNOWN, result.TZ) assert.Equal("Rockbox sansaclipplus $Revision$", result.Client) @@ -68,11 +68,11 @@ func TestParser(t *testing.T) { record4.MusicBrainzRecordingID) } -func TestParserExcludeSkipped(t *testing.T) { +func TestParserIgnoreSkipped(t *testing.T) { assert := assert.New(t) data := bytes.NewBufferString(testScrobblerLog) result := scrobblerlog.ScrobblerLog{} - err := result.Parse(data, false) + err := result.Parse(data, true) require.NoError(t, err) assert.Len(result.Records, 4) record4 := result.Records[3] From 39b31fc664dab411a033d2908930cb5eaa390d8b Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 13:01:54 +0200 Subject: [PATCH 14/14] Update changelog --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 11251cd..cda0d79 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,12 @@ - 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: fixed progress for listen export +- 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. ## 0.4.1 - 2024-09-16