From ed191d2f15131dea901964227b9c485d85497a3b Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 10:05:40 +0200 Subject: [PATCH] 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)