scrobblerlog: Allow configuring fallback time zone

Fixes #6
This commit is contained in:
Philipp Wolfer 2025-04-29 10:05:40 +02:00
parent 0f4b04c641
commit ed191d2f15
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
5 changed files with 68 additions and 22 deletions

View file

@ -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)

View file

@ -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$",

View file

@ -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"`)
}

View file

@ -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
}

View file

@ -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)