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

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

View file

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