mirror of
https://git.sr.ht/~phw/scotty
synced 2025-06-01 19:38:34 +02:00
Implemented ScrobblerLog.ParseIter
This commit is contained in:
parent
15755458e9
commit
3b9d07e6b5
2 changed files with 129 additions and 57 deletions
|
@ -39,6 +39,7 @@ import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"iter"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -91,53 +92,36 @@ type ScrobblerLog struct {
|
||||||
// The reader must provide a valid scrobbler log file with a valid header.
|
// The reader must provide a valid scrobbler log file with a valid header.
|
||||||
// This function implicitly calls [ScrobblerLog.ReadHeader].
|
// This function implicitly calls [ScrobblerLog.ReadHeader].
|
||||||
func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error {
|
func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error {
|
||||||
l.Records = make([]Record, 0)
|
tsvReader, err := l.initReader(data)
|
||||||
|
|
||||||
reader := bufio.NewReader(data)
|
|
||||||
err := l.readHeader(reader)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tsvReader := csv.NewReader(reader)
|
for _, err := range l.iterRecords(tsvReader, ignoreSkipped) {
|
||||||
tsvReader.Comma = '\t'
|
|
||||||
// Row length is often flexible
|
|
||||||
tsvReader.FieldsPerRecord = -1
|
|
||||||
|
|
||||||
for {
|
|
||||||
// A row is:
|
|
||||||
// artistName releaseName trackName trackNumber duration rating timestamp recordingMBID
|
|
||||||
row, err := tsvReader.Read()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// fmt.Printf("row: %v\n", row)
|
|
||||||
|
|
||||||
// We consider only the last field (recording MBID) optional
|
|
||||||
// This was added in the 1.1 file format.
|
|
||||||
if len(row) < 7 {
|
|
||||||
line, _ := tsvReader.FieldPos(0)
|
|
||||||
return fmt.Errorf("invalid record in scrobblerlog line %v", line)
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := l.rowToRecord(row)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ignoreSkipped && record.Rating == RatingSkipped {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Records = append(l.Records, record)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parses a scrobbler log file from the given reader and returns an iterator over all records.
|
||||||
|
//
|
||||||
|
// The reader must provide a valid scrobbler log file with a valid header.
|
||||||
|
// This function implicitly calls [ScrobblerLog.ReadHeader].
|
||||||
|
func (l *ScrobblerLog) ParseIter(data io.Reader, ignoreSkipped bool) iter.Seq2[Record, error] {
|
||||||
|
|
||||||
|
tsvReader, err := l.initReader(data)
|
||||||
|
if err != nil {
|
||||||
|
return func(yield func(Record, error) bool) {
|
||||||
|
yield(Record{}, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.iterRecords(tsvReader, ignoreSkipped)
|
||||||
|
}
|
||||||
|
|
||||||
// Append writes the given records to the writer.
|
// Append writes the given records to the writer.
|
||||||
//
|
//
|
||||||
// The writer should be for an existing scrobbler log file or
|
// The writer should be for an existing scrobbler log file or
|
||||||
|
@ -177,6 +161,37 @@ func (l *ScrobblerLog) ReadHeader(reader io.Reader) error {
|
||||||
return l.readHeader(bufio.NewReader(reader))
|
return l.readHeader(bufio.NewReader(reader))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes the header of a scrobbler log file to the given writer.
|
||||||
|
func (l *ScrobblerLog) WriteHeader(writer io.Writer) error {
|
||||||
|
headers := []string{
|
||||||
|
"#AUDIOSCROBBLER/1.1\n",
|
||||||
|
"#TZ/" + string(l.TZ) + "\n",
|
||||||
|
"#CLIENT/" + l.Client + "\n",
|
||||||
|
}
|
||||||
|
for _, line := range headers {
|
||||||
|
_, err := writer.Write([]byte(line))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ScrobblerLog) initReader(data io.Reader) (*csv.Reader, error) {
|
||||||
|
reader := bufio.NewReader(data)
|
||||||
|
err := l.readHeader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tsvReader := csv.NewReader(reader)
|
||||||
|
tsvReader.Comma = '\t'
|
||||||
|
// Row length is often flexible
|
||||||
|
tsvReader.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
return tsvReader, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (l *ScrobblerLog) readHeader(reader *bufio.Reader) 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++ {
|
||||||
|
@ -215,37 +230,64 @@ func (l *ScrobblerLog) readHeader(reader *bufio.Reader) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes the header of a scrobbler log file to the given writer.
|
func (l *ScrobblerLog) iterRecords(reader *csv.Reader, ignoreSkipped bool) iter.Seq2[Record, error] {
|
||||||
func (l *ScrobblerLog) WriteHeader(writer io.Writer) error {
|
return func(yield func(Record, error) bool) {
|
||||||
headers := []string{
|
l.Records = make([]Record, 0)
|
||||||
"#AUDIOSCROBBLER/1.1\n",
|
for {
|
||||||
"#TZ/" + string(l.TZ) + "\n",
|
record, err := l.parseRow(reader)
|
||||||
"#CLIENT/" + l.Client + "\n",
|
if err == io.EOF {
|
||||||
}
|
break
|
||||||
for _, line := range headers {
|
} else if err != nil {
|
||||||
_, err := writer.Write([]byte(line))
|
yield(Record{}, err)
|
||||||
if err != nil {
|
break
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
if ignoreSkipped && record.Rating == RatingSkipped {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Records = append(l.Records, *record)
|
||||||
|
if !yield(*record, nil) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
|
func (l *ScrobblerLog) parseRow(reader *csv.Reader) (*Record, error) {
|
||||||
var record Record
|
// A row is:
|
||||||
|
// artistName releaseName trackName trackNumber duration rating timestamp recordingMBID
|
||||||
|
row, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Printf("row: %v\n", row)
|
||||||
|
|
||||||
|
// We consider only the last field (recording MBID) optional
|
||||||
|
// This was added in the 1.1 file format.
|
||||||
|
if len(row) < 7 {
|
||||||
|
line, _ := reader.FieldPos(0)
|
||||||
|
return nil, fmt.Errorf("invalid record in scrobblerlog line %v", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.rowToRecord(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l ScrobblerLog) rowToRecord(row []string) (*Record, error) {
|
||||||
trackNumber, err := strconv.Atoi(row[3])
|
trackNumber, err := strconv.Atoi(row[3])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return record, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
duration, err := strconv.Atoi(row[4])
|
duration, err := strconv.Atoi(row[4])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return record, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp, err := strconv.ParseInt(row[6], 10, 64)
|
timestamp, err := strconv.ParseInt(row[6], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return record, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var timezone *time.Location = nil
|
var timezone *time.Location = nil
|
||||||
|
@ -253,7 +295,7 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
|
||||||
timezone = l.FallbackTimezone
|
timezone = l.FallbackTimezone
|
||||||
}
|
}
|
||||||
|
|
||||||
record = Record{
|
record := Record{
|
||||||
ArtistName: row[0],
|
ArtistName: row[0],
|
||||||
AlbumName: row[1],
|
AlbumName: row[1],
|
||||||
TrackName: row[2],
|
TrackName: row[2],
|
||||||
|
@ -267,7 +309,7 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
|
||||||
record.MusicBrainzRecordingID = mbtypes.MBID(row[7])
|
record.MusicBrainzRecordingID = mbtypes.MBID(row[7])
|
||||||
}
|
}
|
||||||
|
|
||||||
return record, nil
|
return &record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert a Unix timestamp to a [time.Time] object, but treat the timestamp
|
// Convert a Unix timestamp to a [time.Time] object, but treat the timestamp
|
||||||
|
|
|
@ -44,7 +44,14 @@ Kraftwerk Trans-Europe Express The Hall of Mirrors 2 474 S 1260358000 385ba9e9-6
|
||||||
Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262beaf-19f8-4534-b9ed-7eef9ca8e83f
|
Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262beaf-19f8-4534-b9ed-7eef9ca8e83f
|
||||||
`
|
`
|
||||||
|
|
||||||
func TestParser(t *testing.T) {
|
var testScrobblerLogInvalid = `#AUDIOSCROBBLER/1.1
|
||||||
|
#TZ/UNKNOWN
|
||||||
|
#CLIENT/Rockbox sansaclipplus $Revision$
|
||||||
|
Özcan Deniz Ses ve Ayrilik Sevdanin rengi (sipacik) byMrTurkey 5 306 L 1260342084
|
||||||
|
Özcan Deniz Hediye 2@V@7 Bir Dudaktan 1 210 L
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
result := scrobblerlog.ScrobblerLog{}
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
@ -68,7 +75,7 @@ func TestParser(t *testing.T) {
|
||||||
record4.MusicBrainzRecordingID)
|
record4.MusicBrainzRecordingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParserIgnoreSkipped(t *testing.T) {
|
func TestParseIgnoreSkipped(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
result := scrobblerlog.ScrobblerLog{}
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
@ -81,7 +88,7 @@ func TestParserIgnoreSkipped(t *testing.T) {
|
||||||
record4.MusicBrainzRecordingID)
|
record4.MusicBrainzRecordingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParserFallbackTimezone(t *testing.T) {
|
func TestParseFallbackTimezone(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
result := scrobblerlog.ScrobblerLog{
|
result := scrobblerlog.ScrobblerLog{
|
||||||
|
@ -96,6 +103,29 @@ func TestParserFallbackTimezone(t *testing.T) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseInvalid(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
data := bytes.NewBufferString(testScrobblerLogInvalid)
|
||||||
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
err := result.Parse(data, true)
|
||||||
|
assert.ErrorContains(err, "invalid record in scrobblerlog line 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIter(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
records := make([]scrobblerlog.Record, 0)
|
||||||
|
for record, err := range result.ParseIter(data, false) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(records, 5)
|
||||||
|
record1 := result.Records[0]
|
||||||
|
assert.Equal("Ses ve Ayrilik", record1.AlbumName)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppend(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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue