Skip to content

Commit

Permalink
feat(subsonic): cache and use lastfm responses for covers, bios, top …
Browse files Browse the repository at this point in the history
…songs
  • Loading branch information
sentriz committed Sep 13, 2023
1 parent 2b9052c commit c374577
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 91 deletions.
122 changes: 122 additions & 0 deletions artistinfocache/artistinfocache.go
@@ -0,0 +1,122 @@
//nolint:revive
package artistinfocache

import (
"context"
"errors"
"fmt"
"log"
"time"

"github.com/jinzhu/gorm"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/scrobble/lastfm"
)

const keepFor = 30 * time.Hour * 24

type ArtistInfoCache struct {
db *db.DB
lastfmClient *lastfm.Client
}

func New(db *db.DB, lastfmClient *lastfm.Client) *ArtistInfoCache {
return &ArtistInfoCache{db: db, lastfmClient: lastfmClient}
}

func (a *ArtistInfoCache) GetOrLookup(ctx context.Context, apiKey string, artistID int) (*db.ArtistInfo, error) {
var artist db.Artist
if err := a.db.Find(&artist, "id=?", artistID).Error; err != nil {
return nil, fmt.Errorf("find artist in db: %w", err)
}

var artistInfo db.ArtistInfo
if err := a.db.Find(&artistInfo, "id=?", artistID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("find artist info in db: %w", err)
}

if artistInfo.ID == 0 || artistInfo.Biography == "" /* prev not found maybe */ || time.Since(artistInfo.UpdatedAt) > keepFor {
return a.Lookup(ctx, apiKey, &artist)
}

return &artistInfo, nil
}

func (a *ArtistInfoCache) Get(ctx context.Context, artistID int) (*db.ArtistInfo, error) {
var artistInfo db.ArtistInfo
if err := a.db.Find(&artistInfo, "id=?", artistID).Error; err != nil {
return nil, fmt.Errorf("find artist info in db: %w", err)
}
return &artistInfo, nil
}

func (a *ArtistInfoCache) Lookup(ctx context.Context, apiKey string, artist *db.Artist) (*db.ArtistInfo, error) {
var artistInfo db.ArtistInfo
artistInfo.ID = artist.ID

if err := a.db.FirstOrCreate(&artistInfo, "id=?", artistInfo.ID).Error; err != nil {
return nil, fmt.Errorf("first or create artist info: %w", err)
}

info, err := a.lastfmClient.ArtistGetInfo(apiKey, artist.Name)
if err != nil {
return nil, fmt.Errorf("get upstream info: %w", err)
}

artistInfo.ID = artist.ID
artistInfo.Biography = info.Bio.Summary
artistInfo.MusicBrainzID = info.MBID
artistInfo.LastFMURL = info.URL

var similar []string
for _, sim := range info.Similar.Artists {
similar = append(similar, sim.Name)
}
artistInfo.SetSimilarArtists(similar)

url, _ := a.lastfmClient.StealArtistImage(info.URL)
artistInfo.ImageURL = url

topTracksResponse, err := a.lastfmClient.ArtistGetTopTracks(apiKey, artist.Name)
if err != nil {
return nil, fmt.Errorf("get top tracks: %w", err)
}
var topTracks []string
for _, tr := range topTracksResponse.Tracks {
topTracks = append(topTracks, tr.Name)
}
artistInfo.SetTopTracks(topTracks)

if err := a.db.Save(&artistInfo).Error; err != nil {
return nil, fmt.Errorf("save upstream info: %w", err)
}

return &artistInfo, nil
}

func (a *ArtistInfoCache) Refresh(apiKey string, interval time.Duration) error {
ticker := time.NewTicker(interval)
for range ticker.C {
q := a.db.
Where("artist_infos.id IS NULL OR artist_infos.updated_at<?", time.Now().Add(-keepFor)).
Joins("LEFT JOIN artist_infos ON artist_infos.id=artists.id")

var artist db.Artist
if err := q.Find(&artist).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("error finding non cached artist: %v", err)
continue
}
if artist.ID == 0 {
continue
}

if _, err := a.Lookup(context.Background(), apiKey, &artist); err != nil {
log.Printf("error looking up non cached artist %s: %v", artist.Name, err)
continue
}

log.Printf("cached artist info for %q", artist.Name)
}

return nil
}
24 changes: 18 additions & 6 deletions cmd/gonic/gonic.go
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/sentriz/gormstore"

"go.senan.xyz/gonic"
"go.senan.xyz/gonic/artistinfocache"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/jukebox"
"go.senan.xyz/gonic/playlist"
Expand Down Expand Up @@ -205,6 +206,8 @@ func main() {
sessDB.SessionOpts.HttpOnly = true
sessDB.SessionOpts.SameSite = http.SameSiteLaxMode

artistInfoCache := artistinfocache.New(dbc, lastfmClient)

ctrlBase := &ctrlbase.Controller{
DB: dbc,
PlaylistStore: playlistStore,
Expand All @@ -216,12 +219,13 @@ func main() {
log.Panicf("error creating admin controller: %v\n", err)
}
ctrlSubsonic := &ctrlsubsonic.Controller{
Controller: ctrlBase,
MusicPaths: musicPaths,
PodcastsPath: *confPodcastPath,
CacheAudioPath: cacheDirAudio,
CacheCoverPath: cacheDirCovers,
LastFMClient: lastfmClient,
Controller: ctrlBase,
MusicPaths: musicPaths,
PodcastsPath: *confPodcastPath,
CacheAudioPath: cacheDirAudio,
CacheCoverPath: cacheDirCovers,
LastFMClient: lastfmClient,
ArtistInfoCache: artistInfoCache,
Scrobblers: []scrobble.Scrobbler{
lastfm.NewScrobbler(dbc, lastfmClient),
listenbrainz.NewScrobbler(),
Expand Down Expand Up @@ -345,6 +349,14 @@ func main() {
})
}

lastfmAPIKey, _ := dbc.GetSetting("lastfm_api_key")
if lastfmAPIKey != "" {
g.Add(func() error {
log.Printf("starting job 'refresh artist info'\n")
return artistInfoCache.Refresh(lastfmAPIKey, 5*time.Second)
}, nil)
}

if *confScanAtStart {
if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil {
log.Panicf("error scanning at start: %v\n", err)
Expand Down
8 changes: 8 additions & 0 deletions db/migrations.go
Expand Up @@ -58,6 +58,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
construct(ctx, "202305301718", migratePlayCountToLength),
construct(ctx, "202307281628", migrateAlbumArtistsMany2Many),
construct(ctx, "202309070009", migrateDeleteArtistCoverField),
construct(ctx, "202309131743", migrateArtistInfo),
}

return gormigrate.
Expand Down Expand Up @@ -605,3 +606,10 @@ func migrateDeleteArtistCoverField(tx *gorm.DB, _ MigrationContext) error {

return nil
}

func migrateArtistInfo(tx *gorm.DB, _ MigrationContext) error {
return tx.AutoMigrate(
ArtistInfo{},
).
Error
}
23 changes: 21 additions & 2 deletions db/model.go
Expand Up @@ -7,6 +7,7 @@ package db
// https://www.db-fiddle.com/f/wJ7z8L7mu6ZKaYmWk1xr1p/5

import (
"fmt"
"path"
"path/filepath"
"sort"
Expand All @@ -32,7 +33,7 @@ func splitIDs(in, sep string) []specid.ID {
return ret
}

func joinIds(in []specid.ID, sep string) string {
func join[T fmt.Stringer](in []T, sep string) string {
if in == nil {
return ""
}
Expand Down Expand Up @@ -270,7 +271,7 @@ func (p *PlayQueue) GetItems() []specid.ID {
}

func (p *PlayQueue) SetItems(items []specid.ID) {
p.Items = joinIds(items, ",")
p.Items = join(items, ",")
}

type TranscodePreference struct {
Expand Down Expand Up @@ -441,3 +442,21 @@ type InternetRadioStation struct {
func (ir *InternetRadioStation) SID() *specid.ID {
return &specid.ID{Type: specid.InternetRadioStation, Value: ir.ID}
}

type ArtistInfo struct {
ID int `gorm:"primary_key" sql:"type:int REFERENCES artists(id) ON DELETE CASCADE"`
CreatedAt time.Time
UpdatedAt time.Time `gorm:"index"`
Biography string
MusicBrainzID string
LastFMURL string
ImageURL string
SimilarArtists string
TopTracks string
}

func (p *ArtistInfo) GetSimilarArtists() []string { return strings.Split(p.SimilarArtists, ";") }
func (p *ArtistInfo) SetSimilarArtists(items []string) { p.SimilarArtists = strings.Join(items, ";") }

func (p *ArtistInfo) GetTopTracks() []string { return strings.Split(p.TopTracks, ";") }
func (p *ArtistInfo) SetTopTracks(items []string) { p.TopTracks = strings.Join(items, ";") }
20 changes: 11 additions & 9 deletions server/ctrlsubsonic/ctrl.go
Expand Up @@ -9,6 +9,7 @@ import (
"log"
"net/http"

"go.senan.xyz/gonic/artistinfocache"
"go.senan.xyz/gonic/jukebox"
"go.senan.xyz/gonic/podcasts"
"go.senan.xyz/gonic/scrobble"
Expand Down Expand Up @@ -41,15 +42,16 @@ func PathsOf(paths []MusicPath) []string {

type Controller struct {
*ctrlbase.Controller
MusicPaths []MusicPath
PodcastsPath string
CacheAudioPath string
CacheCoverPath string
Jukebox *jukebox.Jukebox
Scrobblers []scrobble.Scrobbler
Podcasts *podcasts.Podcasts
Transcoder transcode.Transcoder
LastFMClient *lastfm.Client
MusicPaths []MusicPath
PodcastsPath string
CacheAudioPath string
CacheCoverPath string
Jukebox *jukebox.Jukebox
Scrobblers []scrobble.Scrobbler
Podcasts *podcasts.Podcasts
Transcoder transcode.Transcoder
LastFMClient *lastfm.Client
ArtistInfoCache *artistinfocache.ArtistInfoCache
}

type metaResponse struct {
Expand Down
39 changes: 16 additions & 23 deletions server/ctrlsubsonic/handlers_by_tags.go
Expand Up @@ -322,41 +322,38 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
if apiKey == "" {
return sub
}
info, err := c.LastFMClient.ArtistGetInfo(apiKey, artist.Name)

info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID)
if err != nil {
return spec.NewError(0, "fetching artist info: %v", err)
}

sub.ArtistInfoTwo.Biography = info.Bio.Summary
sub.ArtistInfoTwo.MusicBrainzID = info.MBID
sub.ArtistInfoTwo.LastFMURL = info.URL
sub.ArtistInfoTwo.Biography = info.Biography
sub.ArtistInfoTwo.MusicBrainzID = info.MusicBrainzID
sub.ArtistInfoTwo.LastFMURL = info.LastFMURL

sub.ArtistInfoTwo.SmallImageURL = c.genArtistCoverURL(r, &artist, 64)
sub.ArtistInfoTwo.MediumImageURL = c.genArtistCoverURL(r, &artist, 126)
sub.ArtistInfoTwo.LargeImageURL = c.genArtistCoverURL(r, &artist, 256)

if url, _ := c.LastFMClient.StealArtistImage(info.URL); url != "" {
sub.ArtistInfoTwo.SmallImageURL = url
sub.ArtistInfoTwo.MediumImageURL = url
sub.ArtistInfoTwo.LargeImageURL = url
sub.ArtistInfoTwo.ArtistImageURL = url
if info.ImageURL != "" {
sub.ArtistInfoTwo.SmallImageURL = info.ImageURL
sub.ArtistInfoTwo.MediumImageURL = info.ImageURL
sub.ArtistInfoTwo.LargeImageURL = info.ImageURL
sub.ArtistInfoTwo.ArtistImageURL = info.ImageURL
}

count := params.GetOrInt("count", 20)
inclNotPresent := params.GetOrBool("includeNotPresent", false)
similarArtists, err := c.LastFMClient.ArtistGetSimilar(apiKey, artist.Name)
if err != nil {
return spec.NewError(0, "fetching artist similar: %v", err)
}

for i, similarInfo := range similarArtists.Artists {
for i, similarName := range info.GetSimilarArtists() {
if i == count {
break
}
var artist db.Artist
err = c.DB.
Select("artists.*, count(albums.id) album_count").
Where("name=?", similarInfo.Name).
Where("name=?", similarName).
Joins("LEFT JOIN album_artists ON album_artists.artist_id=artists.id").
Joins("LEFT JOIN albums ON albums.id=album_artists.album_id").
Group("artists.id").
Expand All @@ -372,7 +369,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
}
sub.ArtistInfoTwo.SimilarArtist = append(sub.ArtistInfoTwo.SimilarArtist, &spec.SimilarArtist{
ID: artistID,
Name: similarInfo.Name,
Name: similarName,
CoverArt: artistID,
AlbumCount: artist.AlbumCount,
})
Expand Down Expand Up @@ -544,7 +541,7 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response {
if apiKey == "" {
return spec.NewResponse()
}
topTracks, err := c.LastFMClient.ArtistGetTopTracks(apiKey, artist.Name)
info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID)
if err != nil {
return spec.NewError(0, "fetching artist top tracks: %v", err)
}
Expand All @@ -554,15 +551,11 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response {
Tracks: make([]*spec.TrackChild, 0),
}

if len(topTracks.Tracks) == 0 {
topTrackNames := info.GetTopTracks()
if len(topTrackNames) == 0 {
return sub
}

topTrackNames := make([]string, len(topTracks.Tracks))
for i, t := range topTracks.Tracks {
topTrackNames[i] = t.Name
}

var tracks []*db.Track
err = c.DB.
Preload("Album").
Expand Down

0 comments on commit c374577

Please sign in to comment.