mirror of
https://git.sr.ht/~phw/scotty
synced 2025-05-31 10:58:35 +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"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -91,53 +92,36 @@ type ScrobblerLog struct {
|
|||
// The reader must provide a valid scrobbler log file with a valid header.
|
||||
// This function implicitly calls [ScrobblerLog.ReadHeader].
|
||||
func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error {
|
||||
l.Records = make([]Record, 0)
|
||||
|
||||
reader := bufio.NewReader(data)
|
||||
err := l.readHeader(reader)
|
||||
tsvReader, err := l.initReader(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tsvReader := csv.NewReader(reader)
|
||||
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)
|
||||
for _, err := range l.iterRecords(tsvReader, ignoreSkipped) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ignoreSkipped && record.Rating == RatingSkipped {
|
||||
continue
|
||||
}
|
||||
|
||||
l.Records = append(l.Records, record)
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Skip header
|
||||
for i := 0; i < 3; i++ {
|
||||
|
@ -215,37 +230,64 @@ func (l *ScrobblerLog) readHeader(reader *bufio.Reader) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
func (l *ScrobblerLog) iterRecords(reader *csv.Reader, ignoreSkipped bool) iter.Seq2[Record, error] {
|
||||
return func(yield func(Record, error) bool) {
|
||||
l.Records = make([]Record, 0)
|
||||
for {
|
||||
record, err := l.parseRow(reader)
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
yield(Record{}, err)
|
||||
break
|
||||
}
|
||||
|
||||
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) {
|
||||
var record Record
|
||||
func (l *ScrobblerLog) parseRow(reader *csv.Reader) (*Record, error) {
|
||||
// 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])
|
||||
if err != nil {
|
||||
return record, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
duration, err := strconv.Atoi(row[4])
|
||||
if err != nil {
|
||||
return record, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timestamp, err := strconv.ParseInt(row[6], 10, 64)
|
||||
if err != nil {
|
||||
return record, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var timezone *time.Location = nil
|
||||
|
@ -253,7 +295,7 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
|
|||
timezone = l.FallbackTimezone
|
||||
}
|
||||
|
||||
record = Record{
|
||||
record := Record{
|
||||
ArtistName: row[0],
|
||||
AlbumName: row[1],
|
||||
TrackName: row[2],
|
||||
|
@ -267,7 +309,7 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
|
|||
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
|
||||
|
|
|
@ -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
|
||||
`
|
||||
|
||||
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)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
result := scrobblerlog.ScrobblerLog{}
|
||||
|
@ -68,7 +75,7 @@ func TestParser(t *testing.T) {
|
|||
record4.MusicBrainzRecordingID)
|
||||
}
|
||||
|
||||
func TestParserIgnoreSkipped(t *testing.T) {
|
||||
func TestParseIgnoreSkipped(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
result := scrobblerlog.ScrobblerLog{}
|
||||
|
@ -81,7 +88,7 @@ func TestParserIgnoreSkipped(t *testing.T) {
|
|||
record4.MusicBrainzRecordingID)
|
||||
}
|
||||
|
||||
func TestParserFallbackTimezone(t *testing.T) {
|
||||
func TestParseFallbackTimezone(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
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) {
|
||||
assert := assert.New(t)
|
||||
data := make([]byte, 0, 10)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue