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))