package lastfm import ( "context" "errors" "net/http" "regexp" "strconv" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" ) const ( lastFMAgentName = "lastfm" sessionKeyProperty = "LastFMSessionKey" ) type lastfmAgent struct { ds model.DataStore sessionKeys *agents.SessionKeys apiKey string secret string lang string client *client } func lastFMConstructor(ds model.DataStore) *lastfmAgent { l := &lastfmAgent{ ds: ds, lang: conf.Server.LastFM.Language, apiKey: conf.Server.LastFM.ApiKey, secret: conf.Server.LastFM.Secret, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } hc := &http.Client{ Timeout: consts.DefaultHttpClientTimeOut, } chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.client = newClient(l.apiKey, l.secret, l.lang, chc) return l } func (l *lastfmAgent) AgentName() string { return lastFMAgentName } var imageRegex = regexp.MustCompile(`u\/(\d+)`) func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { a, err := l.callAlbumGetInfo(ctx, name, artist, mbid) if err != nil { return nil, err } response := agents.AlbumInfo{ Name: a.Name, MBID: a.MBID, Description: a.Description.Summary, URL: a.URL, Images: make([]agents.ExternalImage, 0), } // Last.fm can return duplicate sizes. seenSizes := map[int]bool{} // This assumes that Last.fm returns images with size small, medium, and large. // This is true as of December 29, 2022 for _, img := range a.Image { size := imageRegex.FindStringSubmatch(img.URL) // Last.fm can return images without URL if len(size) == 0 || len(size[0]) < 4 { log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size) continue } numericSize, err := strconv.Atoi(size[0][2:]) if err != nil { log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err) return nil, err } else { if _, exists := seenSizes[numericSize]; !exists { response.Images = append(response.Images, agents.ExternalImage{ Size: numericSize, URL: img.URL, }) seenSizes[numericSize] = true } } } return &response, nil } func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { a, err := l.callArtistGetInfo(ctx, name, "") if err != nil { return "", err } if a.MBID == "" { return "", agents.ErrNotFound } return a.MBID, nil } func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { a, err := l.callArtistGetInfo(ctx, name, mbid) if err != nil { return "", err } if a.URL == "" { return "", agents.ErrNotFound } return a.URL, nil } func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { a, err := l.callArtistGetInfo(ctx, name, mbid) if err != nil { return "", err } if a.Bio.Summary == "" { return "", agents.ErrNotFound } return a.Bio.Summary, nil } func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { resp, err := l.callArtistGetSimilar(ctx, name, mbid, limit) if err != nil { return nil, err } if len(resp) == 0 { return nil, agents.ErrNotFound } var res []agents.Artist for _, a := range resp { res = append(res, agents.Artist{ Name: a.Name, MBID: a.MBID, }) } return res, nil } func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { resp, err := l.callArtistGetTopTracks(ctx, artistName, mbid, count) if err != nil { return nil, err } if len(resp) == 0 { return nil, agents.ErrNotFound } var res []agents.Song for _, t := range resp { res = append(res, agents.Song{ Name: t.Name, MBID: t.MBID, }) } return res, nil } func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) { a, err := l.client.albumGetInfo(ctx, name, artist, mbid) var lfErr *lastFMError isLastFMError := errors.As(err, &lfErr) if mbid != "" && (isLastFMError && lfErr.Code == 6) { log.Warn(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) return l.callAlbumGetInfo(ctx, name, artist, "") } if err != nil { if isLastFMError && lfErr.Code == 6 { log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err) } else { log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err) } return nil, err } return a, nil } func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) { a, err := l.client.artistGetInfo(ctx, name, mbid) var lfErr *lastFMError isLastFMError := errors.As(err, &lfErr) if mbid != "" && ((err == nil && a.Name == "[unknown]") || (isLastFMError && lfErr.Code == 6)) { log.Warn(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid) return l.callArtistGetInfo(ctx, name, "") } if err != nil { log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid, err) return nil, err } return a, nil } func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbid string, limit int) ([]Artist, error) { s, err := l.client.artistGetSimilar(ctx, name, mbid, limit) var lfErr *lastFMError isLastFMError := errors.As(err, &lfErr) if mbid != "" && ((err == nil && s.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) { log.Warn(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid) return l.callArtistGetSimilar(ctx, name, "", limit) } if err != nil { log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid, err) return nil, err } return s.Artists, nil } func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mbid string, count int) ([]Track, error) { t, err := l.client.artistGetTopTracks(ctx, artistName, mbid, count) var lfErr *lastFMError isLastFMError := errors.As(err, &lfErr) if mbid != "" && ((err == nil && t.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) { log.Warn(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid) return l.callArtistGetTopTracks(ctx, artistName, "", count) } if err != nil { log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid, err) return nil, err } return t.Track, nil } func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { sk, err := l.sessionKeys.Get(ctx, userId) if err != nil || sk == "" { return scrobbler.ErrNotAuthorized } err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{ artist: track.Artist, track: track.Title, album: track.Album, trackNumber: track.TrackNumber, mbid: track.MbzTrackID, duration: int(track.Duration), albumArtist: track.AlbumArtist, }) if err != nil { log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err) return scrobbler.ErrUnrecoverable } return nil } func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { sk, err := l.sessionKeys.Get(ctx, userId) if err != nil || sk == "" { return scrobbler.ErrNotAuthorized } if s.Duration <= 30 { log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration) return nil } err = l.client.scrobble(ctx, sk, ScrobbleInfo{ artist: s.Artist, track: s.Title, album: s.Album, trackNumber: s.TrackNumber, mbid: s.MbzTrackID, duration: int(s.Duration), albumArtist: s.AlbumArtist, timestamp: s.TimeStamp, }) if err == nil { return nil } var lfErr *lastFMError isLastFMError := errors.As(err, &lfErr) if !isLastFMError { log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) return scrobbler.ErrRetryLater } if lfErr.Code == 11 || lfErr.Code == 16 { return scrobbler.ErrRetryLater } return scrobbler.ErrUnrecoverable } func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { sk, err := l.sessionKeys.Get(ctx, userId) return err == nil && sk != "" } func init() { conf.AddHook(func() { if conf.Server.LastFM.Enabled { agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface { return lastFMConstructor(ds) }) scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler { return lastFMConstructor(ds) }) } }) }