🏡 index : quine-global/quintodrome.git

package lastfm

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"sync"

	"github.com/andybalholm/cascadia"
	"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/cache"
	"github.com/navidrome/navidrome/utils/httpclient"
	"golang.org/x/net/html"
)

const (
	lastFMAgentName    = "lastfm"
	sessionKeyProperty = "LastFMSessionKey"
)

var ignoredContent = []string{
	// Empty Artist/Album
	`<a href="https://www.last.fm/music/`,
}

var lastFMReadMoreRegex = regexp.MustCompile(`\s*<a href="https://www\.last\.fm/music/[^"]*">Read more on Last\.fm</a>\.?`)

func cleanContent(content string) string {
	return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
}

type lastfmAgent struct {
	ds           model.DataStore
	sessionKeys  *agents.SessionKeys
	apiKey       string
	secret       string
	languages    []string
	client       *client
	httpClient   httpDoer
	getInfoMutex sync.Mutex
}

func lastFMConstructor(ds model.DataStore) *lastfmAgent {
	if !conf.Server.LastFM.Enabled || conf.Server.LastFM.ApiKey == "" || conf.Server.LastFM.Secret == "" {
		return nil
	}
	l := &lastfmAgent{
		ds:          ds,
		languages:   conf.Server.LastFM.Languages,
		apiKey:      conf.Server.LastFM.ApiKey,
		secret:      conf.Server.LastFM.Secret,
		sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
	}
	hc := httpclient.New(consts.DefaultHttpClientTimeOut)
	chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
	l.httpClient = chc
	l.client = newClient(l.apiKey, l.secret, chc)
	return l
}

func (l *lastfmAgent) AgentName() string {
	return lastFMAgentName
}

var imageRegex = regexp.MustCompile(`u\/(\d+)`)

// isValidContent checks if content is non-empty and not in the ignored list
func isValidContent(content string) bool {
	content = strings.TrimSpace(content)
	if content == "" {
		return false
	}
	for _, ign := range ignoredContent {
		if strings.HasPrefix(content, ign) {
			return false
		}
	}
	return true
}

func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
	var a *Album
	var resp agents.AlbumInfo
	for _, lang := range l.languages {
		var err error
		a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
		if err != nil {
			return nil, err
		}
		resp.Name = a.Name
		resp.MBID = a.MBID
		resp.URL = a.URL
		if isValidContent(a.Description.Summary) {
			resp.Description = cleanContent(a.Description.Summary)
			return &resp, nil
		}
		log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
	}
	// This condition should not be hit (languages default to ["en"]), but just in case
	if a == nil {
		return nil, agents.ErrNotFound
	}
	return &resp, nil
}

func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
	a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0])
	if err != nil {
		return nil, err
	}

	// Last.fm can return duplicate sizes.
	seenSizes := map[int]bool{}
	images := make([]agents.ExternalImage, 0)

	// 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
		}
		if _, exists := seenSizes[numericSize]; !exists {
			images = append(images, agents.ExternalImage{
				Size: numericSize,
				URL:  img.URL,
			})
			seenSizes[numericSize] = true
		}
	}
	return images, nil
}

func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
	a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
	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, l.languages[0])
	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) {
	for _, lang := range l.languages {
		a, err := l.callArtistGetInfo(ctx, name, lang)
		if err != nil {
			return "", err
		}
		if isValidContent(a.Bio.Summary) {
			return cleanContent(a.Bio.Summary), nil
		}
		log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
	}
	return "", agents.ErrNotFound
}

func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
	resp, err := l.callArtistGetSimilar(ctx, name, 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, 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) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
	resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
	if err != nil {
		return nil, err
	}
	if len(resp) == 0 {
		return nil, agents.ErrNotFound
	}
	res := make([]agents.Song, 0, len(resp))
	for _, t := range resp {
		res = append(res, agents.Song{
			Name:    t.Name,
			MBID:    t.MBID,
			Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}},
		})
	}
	return res, nil
}

var (
	artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
	artistIgnoredImage   = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name

	// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
	// methods, which the page block does not affect.
	errNoArtistPage = errors.New("no artist image in Last.fm page")
)

func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
	log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
	a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
	if err != nil {
		return nil, fmt.Errorf("get artist info: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
	if err != nil {
		return nil, fmt.Errorf("create artist image request: %w", err)
	}
	resp, err := l.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("get artist url: %w", err)
	}
	defer resp.Body.Close()

	node, err := html.Parse(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("parse html: %w", err)
	}

	var res []agents.ExternalImage
	n := cascadia.Query(node, artistOpenGraphQuery)
	if n == nil {
		// A real artist page always has og:image; its absence means a bot challenge or a redesign.
		log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
		return nil, errNoArtistPage
	}
	for _, attr := range n.Attr {
		if attr.Key != "content" {
			continue
		}
		if strings.Contains(attr.Val, artistIgnoredImage) {
			log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
			return res, nil
		}

		res = []agents.ExternalImage{
			{URL: attr.Val},
		}
	}
	return res, nil
}

// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable —
// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed.
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
	a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
	if err != nil {
		if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
			// A not-found is a definitive absence, not a fault: return the shared sentinel so the
			// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
			log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
			return nil, agents.ErrNotFound
		}
		log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
		return nil, err
	}
	return a, nil
}

func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
	l.getInfoMutex.Lock()
	defer l.getInfoMutex.Unlock()

	a, err := l.client.artistGetInfo(ctx, name, lang)
	if err != nil {
		if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
			// A not-found is a definitive absence, not a fault: return the shared sentinel so it
			// doesn't trip the artwork worker's breaker, and log at Debug instead of Error.
			log.Debug(ctx, "Artist not found in Last.fm", "artist", name)
			return nil, agents.ErrNotFound
		}
		log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
		return nil, err
	}
	return a, nil
}

func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, limit int) ([]Artist, error) {
	s, err := l.client.artistGetSimilar(ctx, name, limit)
	if err != nil {
		log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, err)
		return nil, err
	}
	return s.Artists, nil
}

func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName string, count int) ([]Track, error) {
	t, err := l.client.artistGetTopTracks(ctx, artistName, count)
	if err != nil {
		log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, err)
		return nil, err
	}
	return t.Track, nil
}

func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
	s, err := l.client.trackGetSimilar(ctx, name, artist, count)
	if err != nil {
		log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
		return nil, err
	}
	return s.Track, nil
}

func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
	if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
		return track.Participants[role][0].Name
	}
	return displayName
}

func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
	sk, err := l.sessionKeys.Get(ctx, userId)
	if err != nil || sk == "" {
		return scrobbler.ErrNotAuthorized
	}

	err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
		artist:      l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
		track:       track.Title,
		album:       track.Album,
		trackNumber: track.TrackNumber,
		mbid:        track.MbzRecordingID,
		duration:    int(track.Duration),
		albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
	})
	if err != nil {
		log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
		return errors.Join(err, 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 errors.Join(err, 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:      l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
		track:       s.Title,
		album:       s.Album,
		trackNumber: s.TrackNumber,
		mbid:        s.MbzRecordingID,
		duration:    int(s.Duration),
		albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, 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 errors.Join(err, scrobbler.ErrRetryLater)
	}
	// 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client.
	if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) {
		return errors.Join(err, scrobbler.ErrRetryLater)
	}
	return errors.Join(err, scrobbler.ErrUnrecoverable)
}

func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
	sk, err := l.sessionKeys.Get(ctx, userId)
	return err == nil && sk != ""
}

func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
	return nil
}

func init() {
	conf.AddHook(func() {
		agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
			// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
			// See https://go.dev/doc/faq#nil_error
			a := lastFMConstructor(ds)
			if a != nil {
				return a
			}
			return nil
		})
		scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
			// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
			// See https://go.dev/doc/faq#nil_error
			a := lastFMConstructor(ds)
			if a != nil {
				return a
			}
			return nil
		})
	})
}