diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 9e0f982..38f7228 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -9,8 +9,8 @@ import ( "go.senan.xyz/gonic/server/db" "go.senan.xyz/gonic/server/encode" - "go.senan.xyz/gonic/server/lastfm" "go.senan.xyz/gonic/server/scanner" + "go.senan.xyz/gonic/server/scrobble/lastfm" ) func firstExisting(or string, strings ...string) string { diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index a9ff25b..ee12a1d 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -13,7 +13,7 @@ import ( "go.senan.xyz/gonic/server/ctrlsubsonic/params" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" "go.senan.xyz/gonic/server/jukebox" - "go.senan.xyz/gonic/server/lastfm" + "go.senan.xyz/gonic/server/scrobble" ) type CtxKey int @@ -29,7 +29,7 @@ type Controller struct { CachePath string CoverCachePath string Jukebox *jukebox.Jukebox - Scrobblers []lastfm.Scrobbler + Scrobblers []scrobble.Scrobbler } type metaResponse struct { diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 38bfd3b..b55dc94 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -11,7 +11,7 @@ import ( "go.senan.xyz/gonic/server/ctrlsubsonic/spec" "go.senan.xyz/gonic/server/ctrlsubsonic/specid" "go.senan.xyz/gonic/server/db" - "go.senan.xyz/gonic/server/lastfm" + "go.senan.xyz/gonic/server/scrobble/lastfm" ) func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response { diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 044e71b..0092fd5 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -12,7 +12,6 @@ import ( "go.senan.xyz/gonic/server/ctrlsubsonic/spec" "go.senan.xyz/gonic/server/ctrlsubsonic/specid" "go.senan.xyz/gonic/server/db" - "go.senan.xyz/gonic/server/lastfm" "go.senan.xyz/gonic/server/scanner" ) @@ -50,20 +49,13 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response { Preload("Album"). Preload("Artist"). First(track, id.Value) - // scrobble with above info - opts := lastfm.ScrobbleOptions{ - Track: track, - // clients will provide time in miliseconds, so use that or - // instead convert UnixNano to miliseconds - StampMili: params.GetOrInt("time", int(time.Now().UnixNano()/1e6)), - Submission: params.GetOrBool("submission", true), - } + // clients will provide time in miliseconds, so use that or + // instead convert UnixNano to miliseconds + optStampMili := params.GetOrInt("time", int(time.Now().UnixNano()/1e6)) + optSubmission := params.GetOrBool("submission", true) scrobbleErrs := []error{} for _, scrobbler := range c.Scrobblers { - if !scrobbler.Enabled(user) { - continue - } - err = scrobbler.Scrobble(user, opts) + err = scrobbler.Scrobble(user, track, optStampMili, optSubmission) scrobbleErrs = append(scrobbleErrs, err) } if len(scrobbleErrs) != 0 { diff --git a/server/lastfm/models.go b/server/lastfm/models.go deleted file mode 100644 index f54abcd..0000000 --- a/server/lastfm/models.go +++ /dev/null @@ -1,86 +0,0 @@ -package lastfm - -import ( - "encoding/xml" - - "go.senan.xyz/gonic/server/db" -) - -type Scrobbler interface { - Scrobble(*db.User, ScrobbleOptions) error - Enabled(*db.User) bool -} - -type LastFM struct { - XMLName xml.Name `xml:"lfm"` - Status string `xml:"status,attr"` - Session Session `xml:"session"` - Error Error `xml:"error"` - Artist Artist `xml:"artist"` -} - -type Session struct { - Name string `xml:"name"` - Key string `xml:"key"` - Subscriber uint `xml:"subscriber"` -} - -type Error struct { - Code uint `xml:"code,attr"` - Value string `xml:",chardata"` -} - -type Artist struct { - XMLName xml.Name `xml:"artist"` - Name string `xml:"name"` - MBID string `xml:"mbid"` - URL string `xml:"url"` - Image []struct { - Text string `xml:",chardata"` - Size string `xml:"size,attr"` - } `xml:"image"` - Streamable string `xml:"streamable"` - Stats struct { - Listeners string `xml:"listeners"` - Plays string `xml:"plays"` - } `xml:"stats"` - Similar struct { - Artists []Artist `xml:"artist"` - } `xml:"similar"` - Tags struct { - Tag []ArtistTag `xml:"tag"` - } `xml:"tags"` - Bio ArtistBio `xml:"bio"` -} - -type ArtistTag struct { - Name string `xml:"name"` - URL string `xml:"url"` -} - -type ArtistBio struct { - Published string `xml:"published"` - 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/lastfm/lastfm.go b/server/scrobble/lastfm/lastfm.go similarity index 50% rename from server/lastfm/lastfm.go rename to server/scrobble/lastfm/lastfm.go index 70caa76..f51e884 100644 --- a/server/lastfm/lastfm.go +++ b/server/scrobble/lastfm/lastfm.go @@ -1,10 +1,8 @@ package lastfm import ( - "bytes" "crypto/md5" "encoding/hex" - "encoding/json" "encoding/xml" "errors" "fmt" @@ -14,19 +12,69 @@ import ( "strconv" "go.senan.xyz/gonic/server/db" + "go.senan.xyz/gonic/server/scrobble" ) const ( - lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/" - lbBaseURL = "https://api.listenbrainz.org" + baseURL = "https://ws.audioscrobbler.com/2.0/" ) var ( - ErrLastFM = errors.New("last.fm error") - ErrListenBrainz = errors.New("listenbrainz error") + ErrLastFM = errors.New("last.fm error") ) -// TODO: remove this package's dependency on models/db +type LastFM struct { + XMLName xml.Name `xml:"lfm"` + Status string `xml:"status,attr"` + Session Session `xml:"session"` + Error Error `xml:"error"` + Artist Artist `xml:"artist"` +} + +type Session struct { + Name string `xml:"name"` + Key string `xml:"key"` + Subscriber uint `xml:"subscriber"` +} + +type Error struct { + Code uint `xml:"code,attr"` + Value string `xml:",chardata"` +} + +type Artist struct { + XMLName xml.Name `xml:"artist"` + Name string `xml:"name"` + MBID string `xml:"mbid"` + URL string `xml:"url"` + Image []struct { + Text string `xml:",chardata"` + Size string `xml:"size,attr"` + } `xml:"image"` + Streamable string `xml:"streamable"` + Stats struct { + Listeners string `xml:"listeners"` + Plays string `xml:"plays"` + } `xml:"stats"` + Similar struct { + Artists []Artist `xml:"artist"` + } `xml:"similar"` + Tags struct { + Tag []ArtistTag `xml:"tag"` + } `xml:"tags"` + Bio ArtistBio `xml:"bio"` +} + +type ArtistTag struct { + Name string `xml:"name"` + URL string `xml:"url"` +} + +type ArtistBio struct { + Published string `xml:"published"` + Summary string `xml:"summary"` + Content string `xml:"content"` +} func getParamSignature(params url.Values, secret string) string { // the parameters must be in order before hashing @@ -46,7 +94,7 @@ func getParamSignature(params url.Values, secret string) string { } func makeRequest(method string, params url.Values) (LastFM, error) { - req, _ := http.NewRequest(method, lastfmBaseURL, nil) + req, _ := http.NewRequest(method, baseURL, nil) req.URL.RawQuery = params.Encode() resp, err := http.DefaultClient.Do(req) if err != nil { @@ -64,6 +112,18 @@ func makeRequest(method string, params url.Values) (LastFM, error) { return lastfm, nil } +func ArtistGetInfo(apiKey string, artist *db.Artist) (Artist, error) { + params := url.Values{} + params.Add("method", "artist.getInfo") + params.Add("api_key", apiKey) + params.Add("artist", artist.Name) + resp, err := makeRequest("GET", params) + if err != nil { + return Artist{}, fmt.Errorf("making artist GET: %w", err) + } + return resp.Artist, nil +} + func GetSession(apiKey, secret, token string) (string, error) { params := url.Values{} params.Add("method", "auth.getSession") @@ -77,101 +137,39 @@ func GetSession(apiKey, secret, token string) (string, error) { return resp.Session.Key, nil } -type ScrobbleOptions struct { - Track *db.Track - StampMili int - Submission bool -} - -type LastfmScrobbler struct { //nolint +type Scrobbler struct { DB *db.DB } -func (lfm *LastfmScrobbler) Scrobble(user *db.User, opts ScrobbleOptions) error { - apiKey := lfm.DB.GetSetting("lastfm_api_key") - secret := lfm.DB.GetSetting("lastfm_secret") +func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stampMili int, submission bool) error { + if user.LastFMSession == "" { + return nil + } + apiKey := s.DB.GetSetting("lastfm_api_key") + secret := s.DB.GetSetting("lastfm_secret") // fetch user to get lastfm session if user.LastFMSession == "" { return fmt.Errorf("you don't have a last.fm session: %w", ErrLastFM) } params := url.Values{} - if opts.Submission { + if submission { params.Add("method", "track.Scrobble") // last.fm wants the timestamp in seconds - params.Add("timestamp", strconv.Itoa(opts.StampMili/1e3)) + params.Add("timestamp", strconv.Itoa(stampMili/1e3)) } else { params.Add("method", "track.updateNowPlaying") } params.Add("api_key", apiKey) params.Add("sk", user.LastFMSession) - params.Add("artist", opts.Track.TagTrackArtist) - params.Add("track", opts.Track.TagTitle) - params.Add("trackNumber", strconv.Itoa(opts.Track.TagTrackNumber)) - params.Add("album", opts.Track.Album.TagTitle) - params.Add("mbid", opts.Track.TagBrainzID) - params.Add("albumArtist", opts.Track.Artist.Name) + params.Add("artist", track.TagTrackArtist) + params.Add("track", track.TagTitle) + params.Add("trackNumber", strconv.Itoa(track.TagTrackNumber)) + params.Add("album", track.Album.TagTitle) + params.Add("mbid", track.TagBrainzID) + params.Add("albumArtist", track.Artist.Name) params.Add("api_sig", getParamSignature(params, secret)) _, err := makeRequest("POST", params) 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") - params.Add("api_key", apiKey) - params.Add("artist", artist.Name) - resp, err := makeRequest("GET", params) - if err != nil { - return Artist{}, fmt.Errorf("making artist GET: %w", err) - } - 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 != "" -} +var _ scrobble.Scrobbler = (*Scrobbler)(nil) diff --git a/server/lastfm/lastfm_test.go b/server/scrobble/lastfm/lastfm_test.go similarity index 100% rename from server/lastfm/lastfm_test.go rename to server/scrobble/lastfm/lastfm_test.go diff --git a/server/scrobble/listenbrainz/listenbrainz.go b/server/scrobble/listenbrainz/listenbrainz.go new file mode 100644 index 0000000..66e1097 --- /dev/null +++ b/server/scrobble/listenbrainz/listenbrainz.go @@ -0,0 +1,90 @@ +package listenbrainz + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + + "go.senan.xyz/gonic/server/db" + "go.senan.xyz/gonic/server/scrobble" +) + +const ( + baseURL = "https://api.listenbrainz.org" + submitPath = "/1/submit-listens" + + listenTypeSingle = "single" + listenTypePlayingNow = "playing_now" +) + +var ( + ErrListenBrainz = errors.New("listenbrainz error") +) + +type AdditionalInfo struct { + TrackNumber int `json:"tracknumber"` +} + +type TrackMetadata struct { + AdditionalInfo AdditionalInfo `json:"additional_info"` + ArtistName string `json:"artist_name"` + TrackName string `json:"track_name"` + ReleaseName string `json:"release_name"` +} + +type Payload struct { + ListenedAt int `json:"listened_at"` + TrackMetadata TrackMetadata `json:"track_metadata"` +} + +type Scrobble struct { + ListenType string `json:"listen_type"` + Payload []Payload `json:"payload"` +} + +type Scrobbler struct{} + +func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stampMili int, submission bool) error { + if user.ListenBrainzSession == "" { + return nil + } + payload := Payload{ + ListenedAt: stampMili / 1e3, + TrackMetadata: TrackMetadata{ + AdditionalInfo: AdditionalInfo{ + TrackNumber: track.TagTrackNumber, + }, + ArtistName: track.TagTrackArtist, + TrackName: track.TagTitle, + ReleaseName: track.Album.TagTitle, + }, + } + scrobble := Scrobble{ + ListenType: listenTypeSingle, + Payload: []Payload{payload}, + } + if !submission { + scrobble.ListenType = listenTypePlayingNow + } + payloadBuf := bytes.Buffer{} + if err := json.NewEncoder(&payloadBuf).Encode(scrobble); err != nil { + return err + } + submitURL := fmt.Sprintf("%s%s", baseURL, submitPath) + authHeader := fmt.Sprintf("Token %s", user.ListenBrainzSession) + req, _ := http.NewRequest("POST", submitURL, &payloadBuf) + req.Header.Add("Authorization", authHeader) + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("http post: %w", err) + } + if res.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("unathorized error scrobbling to listenbrainz %w", ErrListenBrainz) + } + res.Body.Close() + return nil +} + +var _ scrobble.Scrobbler = (*Scrobbler)(nil) diff --git a/server/scrobble/scrobble.go b/server/scrobble/scrobble.go new file mode 100644 index 0000000..431b374 --- /dev/null +++ b/server/scrobble/scrobble.go @@ -0,0 +1,9 @@ +package scrobble + +import ( + "go.senan.xyz/gonic/server/db" +) + +type Scrobbler interface { + Scrobble(user *db.User, track *db.Track, stampMili int, submission bool) error +} diff --git a/server/server.go b/server/server.go index 23d3a2f..710d5ac 100644 --- a/server/server.go +++ b/server/server.go @@ -17,8 +17,10 @@ import ( "go.senan.xyz/gonic/server/ctrlsubsonic" "go.senan.xyz/gonic/server/db" "go.senan.xyz/gonic/server/jukebox" - "go.senan.xyz/gonic/server/lastfm" "go.senan.xyz/gonic/server/scanner" + "go.senan.xyz/gonic/server/scrobble" + "go.senan.xyz/gonic/server/scrobble/lastfm" + "go.senan.xyz/gonic/server/scrobble/listenbrainz" ) type Options struct { @@ -63,11 +65,9 @@ func New(opts Options) *Server { sessDB.SessionOpts.SameSite = http.SameSiteLaxMode // ctrlAdmin := ctrladmin.New(base, sessDB) - lastfmScrobbler := &lastfm.LastfmScrobbler{DB: opts.DB} - listenbrainzScrobbler := &lastfm.ListenBrainzScrobbler{DB: opts.DB} - scrobblers := []lastfm.Scrobbler{ - lastfmScrobbler, - listenbrainzScrobbler, + scrobblers := []scrobble.Scrobbler{ + &lastfm.Scrobbler{DB: opts.DB}, + &listenbrainz.Scrobbler{}, } ctrlSubsonic := &ctrlsubsonic.Controller{ Controller: base,