diff --git a/server/assets/pages/home.tmpl b/server/assets/pages/home.tmpl
index 040826d..0fa3ccc 100644
--- a/server/assets/pages/home.tmpl
+++ b/server/assets/pages/home.tmpl
@@ -50,6 +50,36 @@
{{ end }}
+
+
+ ListenBrainz
+
+
+
gonic can scrobble to ListenBrainz and compatible sites.
+
+
+
current status
+ {{ if .User.ListenBrainzSession }}
+ linked
+
|
+
+ {{ else }}
+
unlinked
+
+
+ {{ end }}
+
+
{{ if .User.IsAdmin }}
{{/* admin panel to manage all users */}}
diff --git a/server/assets/static/main.css b/server/assets/static/main.css
index c7d3423..d93915f 100644
--- a/server/assets/static/main.css
+++ b/server/assets/static/main.css
@@ -37,11 +37,9 @@ button,
textarea {
border-radius: 0;
box-sizing: border-box;
- margin: 0;
+ margin: calc(var(--size)*0.33) 0 0 0;;
+ border: 1px solid #ccc;
padding: 0;
- border: none;
- outline: 1px solid #ccc;
- height: var(--size);
vertical-align: middle;
}
@@ -173,3 +171,8 @@ a:hover {
.angry {
background-color: #f4433669;
}
+
+#listenbrainz-pref {
+ width: 100%;
+ margin: calc(var(--size)*0.5) 0;
+}
diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go
index c023a08..9e0f982 100644
--- a/server/ctrladmin/handlers.go
+++ b/server/ctrladmin/handlers.go
@@ -161,6 +161,27 @@ func (c *Controller) ServeUnlinkLastFMDo(r *http.Request) *Response {
return &Response{redirect: "/admin/home"}
}
+func (c *Controller) ServeLinkListenBrainzDo(r *http.Request) *Response {
+ token := r.FormValue("token")
+ if token == "" {
+ return &Response{
+ err: "please provide a token",
+ code: 400,
+ }
+ }
+ user := r.Context().Value(CtxUser).(*db.User)
+ user.ListenBrainzSession = token
+ c.DB.Save(&user)
+ return &Response{redirect: "/admin/home"}
+}
+
+func (c *Controller) ServeUnlinkListenBrainzDo(r *http.Request) *Response {
+ user := r.Context().Value(CtxUser).(*db.User)
+ user.ListenBrainzSession = ""
+ c.DB.Save(&user)
+ return &Response{redirect: "/admin/home"}
+}
+
func (c *Controller) ServeChangeUsername(r *http.Request) *Response {
username := r.URL.Query().Get("user")
if username == "" {
diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go
index e5805c2..044e71b 100644
--- a/server/ctrlsubsonic/handlers_common.go
+++ b/server/ctrlsubsonic/handlers_common.go
@@ -44,9 +44,6 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response {
}
// fetch user to get lastfm session
user := r.Context().Value(CtxUser).(*db.User)
- if user.LastFMSession == "" {
- return spec.NewError(0, "you don't have a last.fm session")
- }
// fetch track for getting info to send to last.fm function
track := &db.Track{}
c.DB.
diff --git a/server/db/db.go b/server/db/db.go
index ebf32ae..5a51550 100644
--- a/server/db/db.go
+++ b/server/db/db.go
@@ -78,6 +78,7 @@ func New(path string) (*DB, error) {
migrateUpdateTranscodePrefIDX(),
migrateAddAlbumIDX(),
migrateMultiGenre(),
+ migrateListenBrainz(),
))
if err = migr.Migrate(); err != nil {
return nil, fmt.Errorf("migrating to latest version: %w", err)
diff --git a/server/db/migrations.go b/server/db/migrations.go
index f2dfa25..d1cbe38 100644
--- a/server/db/migrations.go
+++ b/server/db/migrations.go
@@ -210,3 +210,18 @@ func migrateMultiGenre() gormigrate.Migration {
},
}
}
+
+func migrateListenBrainz() gormigrate.Migration {
+ return gormigrate.Migration{
+ ID: "202101081149",
+ Migrate: func(tx *gorm.DB) error {
+ step := tx.AutoMigrate(
+ User{},
+ )
+ if err := step.Error; err != nil {
+ return fmt.Errorf("step auto migrate: %w", err)
+ }
+ return nil
+ },
+ }
+}
diff --git a/server/db/model.go b/server/db/model.go
index 92b92fc..9f66f70 100644
--- a/server/db/model.go
+++ b/server/db/model.go
@@ -134,12 +134,13 @@ func (t *Track) GenreStrings() []string {
}
type User struct {
- ID int `gorm:"primary_key"`
- CreatedAt time.Time
- Name string `gorm:"not null; unique_index" sql:"default: null"`
- Password string `gorm:"not null" sql:"default: null"`
- LastFMSession string `sql:"default: null"`
- IsAdmin bool `sql:"default: null"`
+ ID int `gorm:"primary_key"`
+ CreatedAt time.Time
+ Name string `gorm:"not null; unique_index" sql:"default: null"`
+ Password string `gorm:"not null" sql:"default: null"`
+ LastFMSession string `sql:"default: null"`
+ ListenBrainzSession string `sql:"default: null"`
+ IsAdmin bool `sql:"default: null"`
}
type Setting struct {
diff --git a/server/lastfm/lastfm.go b/server/lastfm/lastfm.go
index c9172ac..70caa76 100644
--- a/server/lastfm/lastfm.go
+++ b/server/lastfm/lastfm.go
@@ -1,8 +1,10 @@
package lastfm
import (
+ "bytes"
"crypto/md5"
"encoding/hex"
+ "encoding/json"
"encoding/xml"
"errors"
"fmt"
@@ -16,11 +18,12 @@ import (
const (
lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/"
- lbBaseURL = "https://api.listenbrainz.org"
+ lbBaseURL = "https://api.listenbrainz.org"
)
var (
- ErrLastFM = errors.New("last.fm error")
+ ErrLastFM = errors.New("last.fm error")
+ ErrListenBrainz = errors.New("listenbrainz error")
)
// TODO: remove this package's dependency on models/db
@@ -84,11 +87,10 @@ type LastfmScrobbler struct { //nolint
DB *db.DB
}
-func (lfm *LastfmScrobbler) Scrobble(reqUser interface{}, opts ScrobbleOptions) error {
+func (lfm *LastfmScrobbler) Scrobble(user *db.User, opts ScrobbleOptions) error {
apiKey := lfm.DB.GetSetting("lastfm_api_key")
secret := lfm.DB.GetSetting("lastfm_secret")
// fetch user to get lastfm session
- user := reqUser.(*db.User)
if user.LastFMSession == "" {
return fmt.Errorf("you don't have a last.fm session: %w", ErrLastFM)
}
@@ -113,6 +115,10 @@ func (lfm *LastfmScrobbler) Scrobble(reqUser interface{}, opts ScrobbleOptions)
return err
}
+func (lfm *LastfmScrobbler) Enabled(user *db.User) bool {
+ return user.LastFMSession != ""
+}
+
func ArtistGetInfo(apiKey string, artist *db.Artist) (Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
@@ -124,3 +130,48 @@ func ArtistGetInfo(apiKey string, artist *db.Artist) (Artist, error) {
}
return resp.Artist, nil
}
+
+type ListenBrainzScrobbler struct {
+ DB *db.DB
+}
+
+func (lb *ListenBrainzScrobbler) Scrobble(user *db.User, opts ScrobbleOptions) error {
+ listenType := "single"
+ if !opts.Submission {
+ listenType = "playing_now"
+ }
+ scrobble := ListenBrainzScrobble{
+ ListenType: listenType,
+ Payload: []ListenBrainzPayload{{
+ ListenedAt: opts.StampMili / 1e3,
+ TrackMetadata: ListenBrainzTrackMetadata{
+ AdditionalInfo: ListenBrainzAdditionalInfo{
+ TrackNumber: opts.Track.TagTrackNumber,
+ },
+ ArtistName: opts.Track.TagTrackArtist,
+ TrackName: opts.Track.TagTitle,
+ ReleaseName: opts.Track.Album.TagTitle,
+ },
+ }},
+ }
+ payloadBuf := bytes.Buffer{}
+ if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil {
+ return err
+ }
+ req, _ := http.NewRequest("POST", lbBaseURL+"/1/submit-listens", &payloadBuf)
+ req.Header.Add("Authorization", "Token "+user.ListenBrainzSession)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ if res.StatusCode == http.StatusUnauthorized {
+ return fmt.Errorf("unathorized error scrobbling to listenbrainz %w",
+ ErrListenBrainz)
+ }
+ res.Body.Close()
+ return nil
+}
+
+func (lb *ListenBrainzScrobbler) Enabled(user *db.User) bool {
+ return user.ListenBrainzSession != ""
+}
diff --git a/server/lastfm/models.go b/server/lastfm/models.go
index 6279689..f54abcd 100644
--- a/server/lastfm/models.go
+++ b/server/lastfm/models.go
@@ -2,10 +2,13 @@ package lastfm
import (
"encoding/xml"
+
+ "go.senan.xyz/gonic/server/db"
)
type Scrobbler interface {
- Scrobble(interface{}, ScrobbleOptions) error
+ Scrobble(*db.User, ScrobbleOptions) error
+ Enabled(*db.User) bool
}
type LastFM struct {
@@ -60,3 +63,24 @@ type ArtistBio struct {
Summary string `xml:"summary"`
Content string `xml:"content"`
}
+
+type ListenBrainzAdditionalInfo struct {
+ TrackNumber int `json:"tracknumber"`
+}
+
+type ListenBrainzTrackMetadata struct {
+ AdditionalInfo ListenBrainzAdditionalInfo `json:"additional_info"`
+ ArtistName string `json:"artist_name"`
+ TrackName string `json:"track_name"`
+ ReleaseName string `json:"release_name"`
+}
+
+type ListenBrainzPayload struct {
+ ListenedAt int `json:"listened_at"`
+ TrackMetadata ListenBrainzTrackMetadata `json:"track_metadata"`
+}
+
+type ListenBrainzScrobble struct {
+ ListenType string `json:"listen_type"`
+ Payload []ListenBrainzPayload `json:"payload"`
+}
diff --git a/server/server.go b/server/server.go
index 8e1fff6..23d3a2f 100644
--- a/server/server.go
+++ b/server/server.go
@@ -64,8 +64,10 @@ func New(opts Options) *Server {
//
ctrlAdmin := ctrladmin.New(base, sessDB)
lastfmScrobbler := &lastfm.LastfmScrobbler{DB: opts.DB}
+ listenbrainzScrobbler := &lastfm.ListenBrainzScrobbler{DB: opts.DB}
scrobblers := []lastfm.Scrobbler{
lastfmScrobbler,
+ listenbrainzScrobbler,
}
ctrlSubsonic := &ctrlsubsonic.Controller{
Controller: base,
@@ -127,6 +129,8 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) {
routUser.Handle("/change_own_password_do", ctrl.H(ctrl.ServeChangeOwnPasswordDo))
routUser.Handle("/link_lastfm_do", ctrl.H(ctrl.ServeLinkLastFMDo))
routUser.Handle("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo))
+ routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo))
+ routUser.Handle("/unlink_listenbrainz_do", ctrl.H(ctrl.ServeUnlinkListenBrainzDo))
routUser.Handle("/upload_playlist_do", ctrl.H(ctrl.ServeUploadPlaylistDo))
routUser.Handle("/delete_playlist_do", ctrl.H(ctrl.ServeDeletePlaylistDo))
routUser.Handle("/create_transcode_pref_do", ctrl.H(ctrl.ServeCreateTranscodePrefDo))