Moved scrobblerlog parsing to separate package

This commit is contained in:
Philipp Wolfer 2025-04-29 08:36:34 +02:00
parent 69665bc286
commit aeb3a56982
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
3 changed files with 74 additions and 56 deletions

View file

@ -25,6 +25,7 @@ import (
"go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
) )
type ScrobblerLogBackend struct { type ScrobblerLogBackend struct {
@ -32,7 +33,7 @@ type ScrobblerLogBackend struct {
includeSkipped bool includeSkipped bool
append bool append bool
file *os.File file *os.File
log ScrobblerLog log scrobblerlog.ScrobblerLog
} }
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" } func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
@ -58,8 +59,8 @@ func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Ba
b.filePath = config.GetString("file-path") b.filePath = config.GetString("file-path")
b.includeSkipped = config.GetBool("include-skipped", false) b.includeSkipped = config.GetBool("include-skipped", false)
b.append = config.GetBool("append", true) b.append = config.GetBool("append", true)
b.log = ScrobblerLog{ b.log = scrobblerlog.ScrobblerLog{
Timezone: "UNKNOWN", TZ: scrobblerlog.TZ_UTC,
Client: "Rockbox unknown $Revision$", Client: "Rockbox unknown $Revision$",
} }
return b return b
@ -88,7 +89,7 @@ func (b *ScrobblerLogBackend) StartImport() error {
} else { } else {
// Verify existing file is a scrobbler log // Verify existing file is a scrobbler log
reader := bufio.NewReader(file) reader := bufio.NewReader(file)
if err = ReadHeader(reader, &b.log); err != nil { if err = b.log.ReadHeader(reader); err != nil {
file.Close() file.Close()
return err return err
} }
@ -99,7 +100,7 @@ func (b *ScrobblerLogBackend) StartImport() error {
} }
if !b.append { if !b.append {
if err = WriteHeader(file, &b.log); err != nil { if err = b.log.WriteHeader(file); err != nil {
file.Close() file.Close()
return err return err
} }
@ -124,21 +125,21 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c
defer file.Close() defer file.Close()
log, err := Parse(file, b.includeSkipped) err = b.log.Parse(file, b.includeSkipped)
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Complete()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
listens := log.Listens.NewerThan(oldestTimestamp) listens := b.log.Listens.NewerThan(oldestTimestamp)
sort.Sort(listens) sort.Sort(listens)
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 := Write(b.file, export.Items) lastTimestamp, err := b.log.Append(b.file, export.Items)
if err != nil { if err != nil {
return importResult, err return importResult, err
} }

View file

@ -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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. 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 package scrobblerlog
import ( import (
@ -34,22 +40,31 @@ import (
"go.uploadedlobster.com/scotty/internal/models" "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 { type ScrobblerLog struct {
Timezone string TZ TZInfo
Client string Client string
Listens models.ListensList Listens models.ListensList
location *time.Location location *time.Location
} }
func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { func (l *ScrobblerLog) Parse(data io.Reader, includeSkipped bool) error {
result := ScrobblerLog{ l.Listens = make(models.ListensList, 0)
Listens: make(models.ListensList, 0),
}
reader := bufio.NewReader(data) reader := bufio.NewReader(data)
err := ReadHeader(reader, &result) err := l.ReadHeader(reader)
if err != nil { if err != nil {
return result, err return err
} }
tsvReader := csv.NewReader(reader) tsvReader := csv.NewReader(reader)
@ -64,7 +79,7 @@ func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) {
if err == io.EOF { if err == io.EOF {
break break
} else if err != nil { } else if err != nil {
return result, err return err
} }
// fmt.Printf("row: %v\n", row) // 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 // We consider only the last field (recording MBID) optional
if len(row) < 7 { if len(row) < 7 {
line, _ := tsvReader.FieldPos(0) 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] rating := row[5]
@ -80,18 +95,18 @@ func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) {
continue continue
} }
listen, err := result.rowToListen(row) listen, err := l.rowToListen(row)
if err != nil { 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 := csv.NewWriter(data)
tsvWriter.Comma = '\t' tsvWriter.Comma = '\t'
@ -122,7 +137,7 @@ func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time,
return return
} }
func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error { func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error {
// Skip header // Skip header
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
line, _, err := reader.ReadLine() 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. // the log knows the time, but not the timezone.
timezone, found := strings.CutPrefix(text, "#TZ/") timezone, found := strings.CutPrefix(text, "#TZ/")
if found { if found {
log.Timezone = timezone l.TZ = TZInfo(timezone)
log.location = locationFromTimezone(log.Timezone) l.location = locationFromTimezone(l.TZ)
continue continue
} }
client, found := strings.CutPrefix(text, "#CLIENT/") client, found := strings.CutPrefix(text, "#CLIENT/")
if found { if found {
log.Client = client l.Client = client
continue continue
} }
} }
@ -161,11 +176,11 @@ func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error {
return nil return nil
} }
func WriteHeader(writer io.Writer, log *ScrobblerLog) error { func (l *ScrobblerLog) WriteHeader(writer io.Writer) error {
headers := []string{ headers := []string{
"#AUDIOSCROBBLER/1.1\n", "#AUDIOSCROBBLER/1.1\n",
"#TZ/" + log.Timezone + "\n", "#TZ/" + string(l.TZ) + "\n",
"#CLIENT/" + log.Client + "\n", "#CLIENT/" + l.Client + "\n",
} }
for _, line := range headers { for _, line := range headers {
_, err := writer.Write([]byte(line)) _, 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. // 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 // Often this is set to "UNKNOWN" in the log file, in which case it defaults
// to UTC. // to UTC.
func locationFromTimezone(timezone string) *time.Location { func locationFromTimezone(timezone TZInfo) *time.Location {
location, err := time.LoadLocation(timezone) location, err := time.LoadLocation(string(timezone))
if err != nil { if err != nil {
return time.UTC return time.UTC
} }

View file

@ -31,8 +31,8 @@ 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/backends/scrobblerlog"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
) )
var testScrobblerLog = `#AUDIOSCROBBLER/1.1 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) { func TestParser(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
data := bytes.NewBufferString(testScrobblerLog) data := bytes.NewBufferString(testScrobblerLog)
result, err := scrobblerlog.Parse(data, true) result := scrobblerlog.ScrobblerLog{}
err := result.Parse(data, true)
require.NoError(t, err) require.NoError(t, err)
assert.Equal("UNKNOWN", result.Timezone) 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.Listens, 5)
listen1 := result.Listens[0] listen1 := result.Listens[0]
@ -70,7 +71,8 @@ func TestParser(t *testing.T) {
func TestParserExcludeSkipped(t *testing.T) { func TestParserExcludeSkipped(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
data := bytes.NewBufferString(testScrobblerLog) data := bytes.NewBufferString(testScrobblerLog)
result, err := scrobblerlog.Parse(data, false) result := scrobblerlog.ScrobblerLog{}
err := result.Parse(data, false)
require.NoError(t, err) require.NoError(t, err)
assert.Len(result.Listens, 4) assert.Len(result.Listens, 4)
listen4 := result.Listens[3] listen4 := result.Listens[3]
@ -78,14 +80,15 @@ func TestParserExcludeSkipped(t *testing.T) {
assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMBID) 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) assert := assert.New(t)
data := make([]byte, 0, 10) data := make([]byte, 0, 10)
buffer := bytes.NewBuffer(data) buffer := bytes.NewBuffer(data)
log := scrobblerlog.ScrobblerLog{ log := scrobblerlog.ScrobblerLog{
Timezone: "Unknown", TZ: scrobblerlog.TZ_UNKNOWN,
Client: "Rockbox foo $Revision$", Client: "Rockbox foo $Revision$",
Listens: []models.Listen{ }
listens := []models.Listen{
{ {
ListenedAt: time.Unix(1699572072, 0), ListenedAt: time.Unix(1699572072, 0),
Track: models.Track{ Track: models.Track{
@ -98,17 +101,16 @@ func TestWrite(t *testing.T) {
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"}, AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"},
}, },
}, },
},
} }
err := scrobblerlog.WriteHeader(buffer, &log) err := log.WriteHeader(buffer)
require.NoError(t, err) require.NoError(t, err)
lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens) lastTimestamp, err := log.Append(buffer, listens)
require.NoError(t, err) require.NoError(t, err)
result := buffer.String() result := buffer.String()
lines := strings.Split(result, "\n") lines := strings.Split(result, "\n")
assert.Equal(5, len(lines)) assert.Equal(5, len(lines))
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0]) 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("#CLIENT/Rockbox foo $Revision$", lines[2])
assert.Equal( assert.Equal(
"Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff", "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) data := bytes.NewBufferString(testScrobblerLog)
reader := bufio.NewReader(data) reader := bufio.NewReader(data)
log := scrobblerlog.ScrobblerLog{} log := scrobblerlog.ScrobblerLog{}
err := scrobblerlog.ReadHeader(reader, &log) err := log.ReadHeader(reader)
assert.NoError(t, err) 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.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
assert.Empty(t, log.Listens) assert.Empty(t, log.Listens)
} }