From aad542850a139177a8e451d1f32a976ed59db9a7 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 29 Apr 2025 09:18:57 +0200 Subject: [PATCH] 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) }