From 49480621b580a4676109e2012d6949ce478b8c8c Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sat, 9 Oct 2021 18:16:15 -0700 Subject: scrobbler: initial log record type Define the type for a log record and add an helper function to create a new record. --- internal/scrobbler/record.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 internal/scrobbler/record.go (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/record.go b/internal/scrobbler/record.go new file mode 100644 index 0000000..927ed27 --- /dev/null +++ b/internal/scrobbler/record.go @@ -0,0 +1,34 @@ +package scrobbler + +import ( + "strconv" + "time" + + "github.com/fhs/gompd/v2/mpd" + "github.com/google/uuid" +) + +type Record struct { + Id uuid.UUID + Title string + Album string + Artist string + Duration time.Duration +} + +func NewRecord(attrs mpd.Attrs) (*Record, error) { + record := Record{ + Id: uuid.New(), + Title: attrs["Title"], + Album: attrs["Album"], + Artist: attrs["Artist"], + } + + dur, err := strconv.ParseFloat(attrs["duration"], 32) + if err != nil { + return nil, err + } + + record.Duration = time.Second * time.Duration(dur) + return &record, nil +} -- cgit v1.2.3 From 6440b7f28190eff567fa411ead2adbd80d2d870e Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 10 Oct 2021 11:44:47 -0700 Subject: scrobbler: add functions to create and run it Add a new function to create a scrobbler. The function takes care of creating the mpd client. Add a function to run the scrobbler, which takes care of creating a new record when needed. This will simplify the interface for the caller, as all they really care about is: create the scrobbler, close it when we're done, and collect songs information while we listen to our music. --- internal/scrobbler/scrobbler.go | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 internal/scrobbler/scrobbler.go (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/scrobbler.go b/internal/scrobbler/scrobbler.go new file mode 100644 index 0000000..061b909 --- /dev/null +++ b/internal/scrobbler/scrobbler.go @@ -0,0 +1,66 @@ +package scrobbler + +import ( + "log" + + "golang.fcuny.net/mpd-stats/internal/mpd" +) + +type Scrobbler struct { + player *mpd.Player +} + +func NewScrobbler(net string, addr string) (*Scrobbler, error) { + var s Scrobbler + + p, err := mpd.NewPlayer(net, addr) + if err != nil { + return nil, err + } + + s.player = p + return &s, nil +} + +func (s *Scrobbler) Close() error { + return s.player.Close() +} + +func (s *Scrobbler) Run() error { + var ( + currentRecord *Record + previousRecord *Record + ) + + for { + e := <-s.player.Watcher.Event + if e != "" { + attrs, err := s.player.Client.CurrentSong() + if err != nil { + log.Fatalf("could not get current song: %v", err) + } + + if currentRecord == nil { + currentRecord, err = NewRecord(attrs) + if err != nil { + log.Fatalf("could not create a log: %v", err) + } + log.Printf("we're playing %s/%s/%s [%s]\n", currentRecord.Artist, currentRecord.Album, currentRecord.Title, currentRecord.Duration) + previousRecord = currentRecord + continue + } + + if currentRecord.Title != attrs["Title"] || currentRecord.Artist != attrs["Artist"] || currentRecord.Album != attrs["Album"] { + currentRecord, err = NewRecord(attrs) + if err != nil { + log.Fatalf("could not create a log: %v", err) + } + } + + if currentRecord.Id != previousRecord.Id { + log.Printf("we're playing %s/%s/%s [%s]\n", currentRecord.Artist, currentRecord.Album, currentRecord.Title, currentRecord.Duration) + previousRecord = currentRecord + } + } + } +} -- cgit v1.2.3 From 07342c7dd5ac6c959629968af20a82232393d9d9 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 10 Oct 2021 12:58:56 -0700 Subject: scrobbler: add interface to the sqlite3 database We want to persist the records in a database, so we can extract statistics and an history. The module for the database is straightforward: it opens the database if it exists and return an handler to it. If the database does not exists, we create it and we create the only table we need (records). --- internal/scrobbler/db.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 internal/scrobbler/db.go (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/db.go b/internal/scrobbler/db.go new file mode 100644 index 0000000..a788b4c --- /dev/null +++ b/internal/scrobbler/db.go @@ -0,0 +1,54 @@ +package scrobbler + +import ( + "database/sql" + "fmt" + "os" + + _ "github.com/mattn/go-sqlite3" +) + +func initdb(dbpath string) error { + if _, err := os.Stat(dbpath); err == nil { + return fmt.Errorf("%s already exists", dbpath) + } + + db, err := sql.Open("sqlite3", dbpath) + if err != nil { + return err + } + defer db.Close() + + sqlStmt := `create table records (id text primary key, + title text, + artist text, + album text, + duration int, + time timestamp + );` + + _, err = db.Exec(sqlStmt) + if err != nil { + return err + } + + return nil +} + +func opendatabase(dbpath string) (*sql.DB, error) { + var err error + _, err = os.Stat(dbpath) + + if err != nil { + if err := initdb(dbpath); err != nil { + return nil, err + } + } + + db, err := sql.Open("sqlite3", dbpath) + if err != nil { + return nil, fmt.Errorf("unable to open database: %s", err) + } + + return db, nil +} -- cgit v1.2.3 From ff915c334278d0e4d8acd8698f375022979a2c96 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 10 Oct 2021 13:00:38 -0700 Subject: scrobbler: add timestamp to the record When we create a new record, let's capture when this was created. --- internal/scrobbler/record.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/record.go b/internal/scrobbler/record.go index 927ed27..b9f95a0 100644 --- a/internal/scrobbler/record.go +++ b/internal/scrobbler/record.go @@ -9,19 +9,21 @@ import ( ) type Record struct { - Id uuid.UUID - Title string - Album string - Artist string - Duration time.Duration + Id uuid.UUID + Title string + Album string + Artist string + Duration time.Duration + Timestamp time.Time } func NewRecord(attrs mpd.Attrs) (*Record, error) { record := Record{ - Id: uuid.New(), - Title: attrs["Title"], - Album: attrs["Album"], - Artist: attrs["Artist"], + Id: uuid.New(), + Title: attrs["Title"], + Album: attrs["Album"], + Artist: attrs["Artist"], + Timestamp: time.Now(), } dur, err := strconv.ParseFloat(attrs["duration"], 32) -- cgit v1.2.3 From c95f72a953e6a9068c1e7b89f530fa05f10c4bde Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 10 Oct 2021 13:01:21 -0700 Subject: mpd-stats: pass database path to the scrobbler When creating a scrobbler, we provide the path to the database. The scrobbler then get a handler to the database. When a new record is created, we persist it to the database using the `save` function. --- internal/scrobbler/scrobbler.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/scrobbler.go b/internal/scrobbler/scrobbler.go index 061b909..a31569e 100644 --- a/internal/scrobbler/scrobbler.go +++ b/internal/scrobbler/scrobbler.go @@ -1,6 +1,7 @@ package scrobbler import ( + "database/sql" "log" "golang.fcuny.net/mpd-stats/internal/mpd" @@ -8,17 +9,25 @@ import ( type Scrobbler struct { player *mpd.Player + db *sql.DB } -func NewScrobbler(net string, addr string) (*Scrobbler, error) { - var s Scrobbler - +func NewScrobbler(net string, addr string, dbpath string) (*Scrobbler, error) { p, err := mpd.NewPlayer(net, addr) if err != nil { return nil, err } - s.player = p + db, err := opendatabase(dbpath) + if err != nil { + return nil, err + } + + s := Scrobbler{ + player: p, + db: db, + } + return &s, nil } @@ -47,6 +56,7 @@ func (s *Scrobbler) Run() error { } log.Printf("we're playing %s/%s/%s [%s]\n", currentRecord.Artist, currentRecord.Album, currentRecord.Title, currentRecord.Duration) previousRecord = currentRecord + s.save(currentRecord) continue } @@ -60,7 +70,20 @@ func (s *Scrobbler) Run() error { if currentRecord.Id != previousRecord.Id { log.Printf("we're playing %s/%s/%s [%s]\n", currentRecord.Artist, currentRecord.Album, currentRecord.Title, currentRecord.Duration) previousRecord = currentRecord + s.save(currentRecord) } } } } + +func (s *Scrobbler) save(record *Record) error { + _, err := s.db.Exec("insert into records(id, title, artist, album, duration, time) values(?, ?, ?, ?, ?, ?)", + record.Id, + record.Title, + record.Artist, + record.Album, + int(record.Duration.Seconds()), + record.Timestamp, + ) + return err +} -- cgit v1.2.3 From e86984a78aa0c95bca878c8d24f143b1e515a3f0 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 10 Oct 2021 13:22:14 -0700 Subject: record: add some basic testing --- internal/scrobbler/record.go | 6 +++++ internal/scrobbler/record_test.go | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 internal/scrobbler/record_test.go (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/record.go b/internal/scrobbler/record.go index b9f95a0..e252fd3 100644 --- a/internal/scrobbler/record.go +++ b/internal/scrobbler/record.go @@ -34,3 +34,9 @@ func NewRecord(attrs mpd.Attrs) (*Record, error) { record.Duration = time.Second * time.Duration(dur) return &record, nil } + +func (r *Record) EqualAttrs(attrs mpd.Attrs) bool { + return r.Title == attrs["Title"] && + r.Album == attrs["Album"] && + r.Artist == attrs["Artist"] +} diff --git a/internal/scrobbler/record_test.go b/internal/scrobbler/record_test.go new file mode 100644 index 0000000..3bf8554 --- /dev/null +++ b/internal/scrobbler/record_test.go @@ -0,0 +1,53 @@ +package scrobbler + +import ( + "testing" + + "github.com/fhs/gompd/v2/mpd" +) + +func TestNewRecord(t *testing.T) { + song := mpd.Attrs{ + "Artist": "Nine Inch Nails", + "Album": "The Downward Spiral", + "Title": "Reptile", + "duration": "411.00", + } + + record, err := NewRecord(song) + if err != nil { + t.Errorf("NewRecord returned an error: %s", err) + } + if record == nil { + t.Errorf("NewRecord returned nil record") + } +} + +func TestRecordEqualAttrs(t *testing.T) { + s1 := mpd.Attrs{ + "Artist": "Nine Inch Nails", + "Album": "The Downward Spiral", + "Title": "Reptile", + "duration": "411.00", + } + + s2 := mpd.Attrs{ + "Artist": "Nine Inch Nails", + "Album": "The Downward Spiral", + "Title": "Closer", + "duration": "373.00", + } + + r, err := NewRecord(s1) + if err != nil { + t.Errorf("NewRecord returned an error: %s", err) + } + + if !r.EqualAttrs(s1) { + t.Errorf("EqualAttrs expected true got false") + } + + if r.EqualAttrs(s2) { + t.Errorf("EqualAttrs expected false got true") + } +} -- cgit v1.2.3 From e8059bd9a8ea7c1bba29ca947c7c7fa7eedee8cb Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 10 Oct 2021 13:24:29 -0700 Subject: scrobbler: use helper function EqualAttrs To compare the current attributes with the current record, we can use the helper `EqualAttrs` which will tell us if we need to create a new record or not. --- internal/scrobbler/scrobbler.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/scrobbler.go b/internal/scrobbler/scrobbler.go index a31569e..e16458c 100644 --- a/internal/scrobbler/scrobbler.go +++ b/internal/scrobbler/scrobbler.go @@ -54,13 +54,12 @@ func (s *Scrobbler) Run() error { if err != nil { log.Fatalf("could not create a log: %v", err) } - log.Printf("we're playing %s/%s/%s [%s]\n", currentRecord.Artist, currentRecord.Album, currentRecord.Title, currentRecord.Duration) previousRecord = currentRecord s.save(currentRecord) continue } - if currentRecord.Title != attrs["Title"] || currentRecord.Artist != attrs["Artist"] || currentRecord.Album != attrs["Album"] { + if !currentRecord.EqualAttrs(attrs) { currentRecord, err = NewRecord(attrs) if err != nil { log.Fatalf("could not create a log: %v", err) @@ -68,7 +67,6 @@ func (s *Scrobbler) Run() error { } if currentRecord.Id != previousRecord.Id { - log.Printf("we're playing %s/%s/%s [%s]\n", currentRecord.Artist, currentRecord.Album, currentRecord.Title, currentRecord.Duration) previousRecord = currentRecord s.save(currentRecord) } -- cgit v1.2.3 From a4fe1e3ef4e06e19f146a0a6498cdb550ef8b4f3 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sun, 10 Oct 2021 17:52:19 -0700 Subject: scrobbler: record how long a song was played Add a column `playtime` to the records table to keep track of how long a song was played. With this information, in the future, we will be able to sum up how long we listen to music, but also which songs were skipped. --- internal/scrobbler/db.go | 1 + internal/scrobbler/scrobbler.go | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/db.go b/internal/scrobbler/db.go index a788b4c..5f80aa4 100644 --- a/internal/scrobbler/db.go +++ b/internal/scrobbler/db.go @@ -24,6 +24,7 @@ func initdb(dbpath string) error { artist text, album text, duration int, + playtime int, time timestamp );` diff --git a/internal/scrobbler/scrobbler.go b/internal/scrobbler/scrobbler.go index e16458c..df8e46a 100644 --- a/internal/scrobbler/scrobbler.go +++ b/internal/scrobbler/scrobbler.go @@ -3,6 +3,7 @@ package scrobbler import ( "database/sql" "log" + "time" "golang.fcuny.net/mpd-stats/internal/mpd" ) @@ -67,6 +68,9 @@ func (s *Scrobbler) Run() error { } if currentRecord.Id != previousRecord.Id { + if err := s.update(previousRecord); err != nil { + log.Printf("failed to update record %s: %s", previousRecord.Id, err) + } previousRecord = currentRecord s.save(currentRecord) } @@ -75,7 +79,7 @@ func (s *Scrobbler) Run() error { } func (s *Scrobbler) save(record *Record) error { - _, err := s.db.Exec("insert into records(id, title, artist, album, duration, time) values(?, ?, ?, ?, ?, ?)", + _, err := s.db.Exec("insert into records(id, title, artist, album, duration, playtime, time) values(?, ?, ?, ?, ?, 0, ?)", record.Id, record.Title, record.Artist, @@ -85,3 +89,13 @@ func (s *Scrobbler) save(record *Record) error { ) return err } + +func (s *Scrobbler) update(record *Record) error { + tnow := time.Now() + playtime := tnow.Sub(record.Timestamp).Seconds() + _, err := s.db.Exec("update records set playtime = ? where id = ?", + int(playtime), + record.Id, + ) + return err +} -- cgit v1.2.3 From ed67fc99451452b7df32f6609f8e9798089e243f Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Mon, 11 Oct 2021 19:34:59 -0700 Subject: scrobbler: read mpd status before processing song If the status of the player is "stop", we don't have a new song to handle. In this case, if there's a current song, let's update the status and clear our state. Closes #1. --- internal/scrobbler/scrobbler.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) (limited to 'internal/scrobbler') diff --git a/internal/scrobbler/scrobbler.go b/internal/scrobbler/scrobbler.go index df8e46a..f0f9d0e 100644 --- a/internal/scrobbler/scrobbler.go +++ b/internal/scrobbler/scrobbler.go @@ -45,25 +45,42 @@ func (s *Scrobbler) Run() error { for { e := <-s.player.Watcher.Event if e != "" { + status, err := s.player.Client.Status() + if err != nil { + log.Printf("could not read the status: %v", err) + } + + if status["state"] == "stop" { + if currentRecord != nil { + if err := s.update(currentRecord); err != nil { + log.Printf("failed to update record %s: %s", currentRecord.Id, err) + } + currentRecord = nil + } + continue + } + attrs, err := s.player.Client.CurrentSong() if err != nil { - log.Fatalf("could not get current song: %v", err) + log.Printf("could not get current song: %v", err) } if currentRecord == nil { currentRecord, err = NewRecord(attrs) if err != nil { - log.Fatalf("could not create a log: %v", err) + log.Printf("could not create a log: %v", err) } previousRecord = currentRecord - s.save(currentRecord) + if err := s.save(currentRecord); err != nil { + log.Printf("failed to insert record %s: %s", currentRecord.Id, err) + } continue } if !currentRecord.EqualAttrs(attrs) { currentRecord, err = NewRecord(attrs) if err != nil { - log.Fatalf("could not create a log: %v", err) + log.Printf("could not create a log: %v", err) } } -- cgit v1.2.3