From aeb3a56982d5d2f9d46c3654c880ca829df0d322 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 08:36:34 +0200 Subject: [PATCH] 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) }