scrobblerlog: Use specific Record type

This makes the interface more generic and easier to reuse in other
projects.
This commit is contained in:
Philipp Wolfer 2025-04-29 09:18:57 +02:00
parent aeb3a56982
commit aad542850a
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
3 changed files with 132 additions and 76 deletions

View file

@ -20,6 +20,7 @@ import (
"bufio" "bufio"
"os" "os"
"sort" "sort"
"strings"
"time" "time"
"go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/config"
@ -132,14 +133,22 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c
return return
} }
listens := b.log.Listens.NewerThan(oldestTimestamp) listens := make(models.ListensList, 0, len(b.log.Records))
sort.Sort(listens) 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() progress <- models.Progress{Elapsed: int64(len(listens))}.Complete()
results <- models.ListensResult{Items: listens} results <- models.ListensResult{Items: listens}
} }
func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { 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 { if err != nil {
return importResult, err return importResult, err
} }
@ -150,3 +159,42 @@ func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importR
return importResult, nil 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,
}
}

View file

@ -37,7 +37,6 @@ import (
"time" "time"
"go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/models"
) )
// TZInfo is the timezone information in the header of the scrobbler log file. // TZInfo is the timezone information in the header of the scrobbler log file.
@ -50,16 +49,36 @@ const (
TZ_UTC TZInfo = "UTC" 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. // Represents a scrobbler log file.
type ScrobblerLog struct { type ScrobblerLog struct {
TZ TZInfo TZ TZInfo
Client string Client string
Listens models.ListensList Records []Record
location *time.Location location *time.Location
} }
func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error { 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) reader := bufio.NewReader(data)
err := l.ReadHeader(reader) err := l.ReadHeader(reader)
@ -95,41 +114,37 @@ func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error {
continue continue
} }
listen, err := l.rowToListen(row) record, err := l.rowToRecord(row)
if err != nil { if err != nil {
return err return err
} }
l.Listens = append(l.Listens, listen) l.Records = append(l.Records, record)
} }
return nil 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 := csv.NewWriter(data)
tsvWriter.Comma = '\t' tsvWriter.Comma = '\t'
for _, listen := range listens { for _, record := range records {
if listen.ListenedAt.Unix() > lastTimestamp.Unix() { if record.Timestamp.After(lastTimestamp) {
lastTimestamp = listen.ListenedAt lastTimestamp = record.Timestamp
} }
// A row is: // A row is:
// artistName releaseName trackName trackNumber duration rating timestamp recordingMBID // artistName releaseName trackName trackNumber duration rating timestamp recordingMBID
rating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
if !ok || rating == "" {
rating = "L"
}
err = tsvWriter.Write([]string{ err = tsvWriter.Write([]string{
listen.ArtistName(), record.ArtistName,
listen.ReleaseName, record.AlbumName,
listen.TrackName, record.TrackName,
strconv.Itoa(listen.TrackNumber), strconv.Itoa(record.TrackNumber),
strconv.Itoa(int(listen.Duration.Seconds())), strconv.Itoa(int(record.Duration.Seconds())),
rating, string(record.Rating),
strconv.Itoa(int(listen.ListenedAt.Unix())), strconv.FormatInt(record.Timestamp.Unix(), 10),
string(listen.RecordingMBID), string(record.MusicBrainzRecordingID),
}) })
} }
@ -191,44 +206,38 @@ func (l *ScrobblerLog) WriteHeader(writer io.Writer) error {
return nil return nil
} }
func (l ScrobblerLog) rowToListen(row []string) (models.Listen, error) { func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
var listen models.Listen var record Record
trackNumber, err := strconv.Atoi(row[3]) trackNumber, err := strconv.Atoi(row[3])
if err != nil { if err != nil {
return listen, err return record, err
} }
duration, err := strconv.Atoi(row[4]) duration, err := strconv.Atoi(row[4])
if err != nil { if err != nil {
return listen, err return record, err
} }
timestamp, err := strconv.ParseInt(row[6], 10, 64) timestamp, err := strconv.ParseInt(row[6], 10, 64)
if err != nil { if err != nil {
return listen, err return record, err
} }
client := strings.Split(l.Client, " ")[0] record = Record{
listen = models.Listen{ ArtistName: row[0],
Track: models.Track{ AlbumName: row[1],
ArtistNames: []string{row[0]},
ReleaseName: row[1],
TrackName: row[2], TrackName: row[2],
TrackNumber: trackNumber, TrackNumber: trackNumber,
Duration: time.Duration(duration * int(time.Second)), Duration: time.Duration(duration) * time.Second,
AdditionalInfo: models.AdditionalInfo{ Rating: Rating(row[5]),
"rockbox_rating": row[5], Timestamp: timeFromLocalTimestamp(timestamp, l.location),
"media_player": client,
},
},
ListenedAt: timeFromLocalTimestamp(timestamp, l.location),
} }
if len(row) > 7 { 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. // Convert the timezone string from the header to a time.Location.

View file

@ -31,7 +31,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/pkg/scrobblerlog" "go.uploadedlobster.com/scotty/pkg/scrobblerlog"
) )
@ -53,19 +52,20 @@ func TestParser(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(scrobblerlog.TZ_UNKNOWN, result.TZ) assert.Equal(scrobblerlog.TZ_UNKNOWN, result.TZ)
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client) assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
assert.Len(result.Listens, 5) assert.Len(result.Records, 5)
listen1 := result.Listens[0] record1 := result.Records[0]
assert.Equal("Özcan Deniz", listen1.ArtistName()) assert.Equal("Özcan Deniz", record1.ArtistName)
assert.Equal("Ses ve Ayrilik", listen1.ReleaseName) assert.Equal("Ses ve Ayrilik", record1.AlbumName)
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName) assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName)
assert.Equal(5, listen1.TrackNumber) assert.Equal(5, record1.TrackNumber)
assert.Equal(time.Duration(306*time.Second), listen1.Duration) assert.Equal(time.Duration(306*time.Second), record1.Duration)
assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"]) assert.Equal(scrobblerlog.RATING_LISTENED, record1.Rating)
assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt) assert.Equal(time.Unix(1260342084, 0), record1.Timestamp)
assert.Equal(mbtypes.MBID(""), listen1.RecordingMBID) assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID)
listen4 := result.Listens[3] record4 := result.Records[3]
assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"]) assert.Equal(scrobblerlog.RATING_SKIPPED, record4.Rating)
assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMBID) assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"),
record4.MusicBrainzRecordingID)
} }
func TestParserExcludeSkipped(t *testing.T) { func TestParserExcludeSkipped(t *testing.T) {
@ -74,10 +74,11 @@ func TestParserExcludeSkipped(t *testing.T) {
result := scrobblerlog.ScrobblerLog{} result := scrobblerlog.ScrobblerLog{}
err := result.Parse(data, false) err := result.Parse(data, false)
require.NoError(t, err) require.NoError(t, err)
assert.Len(result.Listens, 4) assert.Len(result.Records, 4)
listen4 := result.Listens[3] record4 := result.Records[3]
assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"]) assert.Equal(scrobblerlog.RATING_LISTENED, record4.Rating)
assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMBID) assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"),
record4.MusicBrainzRecordingID)
} }
func TestAppend(t *testing.T) { func TestAppend(t *testing.T) {
@ -88,23 +89,21 @@ func TestAppend(t *testing.T) {
TZ: scrobblerlog.TZ_UNKNOWN, TZ: scrobblerlog.TZ_UNKNOWN,
Client: "Rockbox foo $Revision$", Client: "Rockbox foo $Revision$",
} }
listens := []models.Listen{ records := []scrobblerlog.Record{
{ {
ListenedAt: time.Unix(1699572072, 0), ArtistName: "Prinzhorn Dance School",
Track: models.Track{ AlbumName: "Home Economics",
ArtistNames: []string{"Prinzhorn Dance School"},
ReleaseName: "Home Economics",
TrackName: "Reign", TrackName: "Reign",
TrackNumber: 1, TrackNumber: 1,
Duration: 271 * time.Second, Duration: 271 * time.Second,
RecordingMBID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), Rating: scrobblerlog.RATING_LISTENED,
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"}, Timestamp: time.Unix(1699572072, 0),
}, MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
}, },
} }
err := log.WriteHeader(buffer) err := log.WriteHeader(buffer)
require.NoError(t, err) require.NoError(t, err)
lastTimestamp, err := log.Append(buffer, listens) lastTimestamp, err := log.Append(buffer, records)
require.NoError(t, err) require.NoError(t, err)
result := buffer.String() result := buffer.String()
lines := strings.Split(result, "\n") lines := strings.Split(result, "\n")
@ -127,5 +126,5 @@ func TestReadHeader(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, log.TZ, scrobblerlog.TZ_UNKNOWN) assert.Equal(t, log.TZ, scrobblerlog.TZ_UNKNOWN)
assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$") assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
assert.Empty(t, log.Listens) assert.Empty(t, log.Records)
} }