Add support for scrobbling to listenbrainz
This commit is contained in:
committed by
Senan Kelly
parent
f4ff7e70f2
commit
b9998f7ee6
@@ -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">|</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 */}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user