diff options
| -rw-r--r-- | cmd/mpd-scrobbler/README.org | 22 | ||||
| -rw-r--r-- | cmd/mpd-scrobbler/main.go | 57 | ||||
| -rw-r--r-- | internal/mpd/mpd.go | 48 | ||||
| -rw-r--r-- | internal/scrobbler/db.go | 55 | ||||
| -rw-r--r-- | internal/scrobbler/record.go | 42 | ||||
| -rw-r--r-- | internal/scrobbler/record_test.go | 53 | ||||
| -rw-r--r-- | internal/scrobbler/scrobbler.go | 118 | ||||
| -rw-r--r-- | systemd/mpd-scrobbler.service | 43 |
8 files changed, 438 insertions, 0 deletions
diff --git a/cmd/mpd-scrobbler/README.org b/cmd/mpd-scrobbler/README.org new file mode 100644 index 0000000..8c0a7d9 --- /dev/null +++ b/cmd/mpd-scrobbler/README.org @@ -0,0 +1,22 @@ +#+TITLE: mpd-stats + +Log played songs to extract statistics. This is similar to what libre.fm used to do, but locally. + +* Logging +Collect logs from mpd. A log record is composed of the following fields: +- id: UUID +- song's name: the name of the song +- song's album: the name of the album +- song's artist: the name of the artist +- song's duration: the duration of the song +- date: date the song was played + +The logs are recorded in a database (sqlite3 to start). +* Install +The Makefile assumes the system is running Linux and systemd. + +Run =make install=. This will: +- install the binary in your =GOPATH= (using =go install=) +- install a systemd unit file under =$HOME/.config/systemd/user= +- reload systemd unit files +- start the service diff --git a/cmd/mpd-scrobbler/main.go b/cmd/mpd-scrobbler/main.go new file mode 100644 index 0000000..c2693a4 --- /dev/null +++ b/cmd/mpd-scrobbler/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "golang.fcuny.net/mpd-stats/internal/scrobbler" +) + +func main() { + var ( + mpdHost = flag.String("host", "localhost", "The MPD server to connect to (default: localhost)") + mpdPort = flag.Int("port", 6600, "The TCP port of the MPD server to connect to (default: 6600)") + ) + flag.Parse() + + net := "tcp" + addr := fmt.Sprintf("%s:%d", *mpdHost, *mpdPort) + + dbpath, err := getDbPath() + if err != nil { + log.Fatalf("failed to get the path to the database: %v", err) + } + + s, err := scrobbler.NewScrobbler(net, addr, dbpath) + if err != nil { + log.Fatalf("failed to create a client: %v", err) + } + + defer func() { + if err := s.Close(); err != nil { + log.Fatalf("failed to close the scrobbler: %v", err) + } + }() + + s.Run() +} + +func getDbPath() (string, error) { + xch := os.Getenv("XDG_CONFIG_HOME") + if xch == "" { + home := os.Getenv("HOME") + xch = filepath.Join(home, ".config") + } + + scrobblerHome := filepath.Join(xch, "mpd-scrobbler") + if _, err := os.Stat(scrobblerHome); os.IsNotExist(err) { + if err := os.Mkdir(scrobblerHome, 0755); err != nil { + return "", err + } + } + + return filepath.Join(scrobblerHome, "scrobbler.sql"), nil +} diff --git a/internal/mpd/mpd.go b/internal/mpd/mpd.go new file mode 100644 index 0000000..5b57ee4 --- /dev/null +++ b/internal/mpd/mpd.go @@ -0,0 +1,48 @@ +package mpd + +import ( + "log" + "time" + + "github.com/fhs/gompd/v2/mpd" +) + +type Player struct { + Watcher *mpd.Watcher + Client *mpd.Client +} + +func NewPlayer(net string, addr string) (*Player, error) { + var ( + p Player + err error + ) + + p.Watcher, err = mpd.NewWatcher(net, addr, "", "player") + if err != nil { + log.Fatalf("failed to create a watcher: %v", err) + } + + p.Client, err = mpd.Dial(net, addr) + if err != nil { + log.Fatalf("failed to start mpd client: %v", err) + } + + go func() { + for range time.Tick(30 * time.Second) { + p.Client.Ping() + } + }() + + return &p, nil +} + +func (p *Player) Close() error { + if err := p.Watcher.Close(); err != nil { + return err + } + if err := p.Client.Close(); err != nil { + return err + } + return nil +} diff --git a/internal/scrobbler/db.go b/internal/scrobbler/db.go new file mode 100644 index 0000000..5f80aa4 --- /dev/null +++ b/internal/scrobbler/db.go @@ -0,0 +1,55 @@ +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, + playtime 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 +} diff --git a/internal/scrobbler/record.go b/internal/scrobbler/record.go new file mode 100644 index 0000000..e252fd3 --- /dev/null +++ b/internal/scrobbler/record.go @@ -0,0 +1,42 @@ +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 + Timestamp time.Time +} + +func NewRecord(attrs mpd.Attrs) (*Record, error) { + record := Record{ + Id: uuid.New(), + Title: attrs["Title"], + Album: attrs["Album"], + Artist: attrs["Artist"], + Timestamp: time.Now(), + } + + dur, err := strconv.ParseFloat(attrs["duration"], 32) + if err != nil { + return nil, err + } + + 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") + } +} diff --git a/internal/scrobbler/scrobbler.go b/internal/scrobbler/scrobbler.go new file mode 100644 index 0000000..f0f9d0e --- /dev/null +++ b/internal/scrobbler/scrobbler.go @@ -0,0 +1,118 @@ +package scrobbler + +import ( + "database/sql" + "log" + "time" + + "golang.fcuny.net/mpd-stats/internal/mpd" +) + +type Scrobbler struct { + player *mpd.Player + db *sql.DB +} + +func NewScrobbler(net string, addr string, dbpath string) (*Scrobbler, error) { + p, err := mpd.NewPlayer(net, addr) + if err != nil { + return nil, err + } + + db, err := opendatabase(dbpath) + if err != nil { + return nil, err + } + + s := Scrobbler{ + player: p, + db: db, + } + + 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 != "" { + 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.Printf("could not get current song: %v", err) + } + + if currentRecord == nil { + currentRecord, err = NewRecord(attrs) + if err != nil { + log.Printf("could not create a log: %v", err) + } + previousRecord = 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.Printf("could not create a log: %v", err) + } + } + + 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) + } + } + } +} + +func (s *Scrobbler) save(record *Record) error { + _, err := s.db.Exec("insert into records(id, title, artist, album, duration, playtime, time) values(?, ?, ?, ?, ?, 0, ?)", + record.Id, + record.Title, + record.Artist, + record.Album, + int(record.Duration.Seconds()), + record.Timestamp, + ) + 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 +} diff --git a/systemd/mpd-scrobbler.service b/systemd/mpd-scrobbler.service new file mode 100644 index 0000000..7990208 --- /dev/null +++ b/systemd/mpd-scrobbler.service @@ -0,0 +1,43 @@ +[Unit] +Description=mpd scrobbler +Documentation=https://git.fcuny.net/fcuny/mpd-stats +ConditionFileIsExecutable=%h/workspace/go/bin/mpd-scrobbler + +[Service] +ExecStart=%h/workspace/go/bin/mpd-scrobbler +Restart=on-failure + +PrivateTmp=yes +ProtectSystem=strict +NoNewPrivileges=yes +ProtectHome=yes + +# Prohibit access to any kind of namespacing: +RestrictNamespaces=yes + +# Make cgroup file system hierarchy inaccessible: +ProtectControlGroups=yes + +# Deny access to other user’s information in /proc: +ProtectProc=invisible + +# Only allow access to /proc pid files, no other files: +ProcSubset=pid + +# This daemon must not create any new files, but set the umask to 077 just in case. +UMask=077 + +# Filter dangerous system calls. The following is listed as safe basic choice +# in systemd.exec(5): +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged +SystemCallFilter=~@resources +SystemCallErrorNumber=EPERM + +# Deny kernel execution domain changing: +LockPersonality=yes + +# Deny memory mappings that are writable and executable: +MemoryDenyWriteExecute=yes + |
