Add support for scrobbling to listenbrainz

This commit is contained in:
Alex McGrath
2021-01-08 13:10:34 +00:00
committed by Senan Kelly
parent f4ff7e70f2
commit b9998f7ee6
10 changed files with 165 additions and 18 deletions

View File

@@ -50,6 +50,36 @@
{{ end }}
</div>
</div>
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-brain"></i> ListenBrainz
</div>
<div class="box-description text-light">
<p>gonic can scrobble to <a href="https://listenbrainz.org/" target="_blank">ListenBrainz</a> and compatible sites.</p>
</div>
<div class="text-right">
<span class="text-light">current status</span>
{{ if .User.ListenBrainzSession }}
linked
<span class="text-light">&#124;</span>
<form action="{{ path "/admin/unlink_listenbrainz_do" }}" method="post">
<input type="submit" value="unlink">
</form>
{{ else }}
<span class="angry">unlinked</span>
<form id="listenbrainz-pref-set" action="{{ path "/admin/link_listenbrainz_do" }}" method="post"></form>
<table id="listenbrainz-pref">
<tr>
<td><label for="listenbrainz-token">Token:</label></td>
<td><input form="listenbrainz-pref-set" id="listenbrainz-token" type="text" name="token" value="{{ default "" .User.ListenBrainzSession }}"></td>
</tr>
<tr>
<td colspan="2"><input form="listenbrainz-pref-set" type="submit" value="save"></td>
</tr>
</table>
{{ end }}
</div>
</div>
<div class="padded box">
{{ if .User.IsAdmin }}
{{/* admin panel to manage all users */}}

View File

@@ -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;
}

View File

@@ -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 == "" {

View File

@@ -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.

View File

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

View File

@@ -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
},
}
}

View File

@@ -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 {

View File

@@ -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 != ""
}

View File

@@ -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"`
}

View File

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