Skip to content

Commit c374577

Browse files
committed
feat(subsonic): cache and use lastfm responses for covers, bios, top songs
1 parent 2b9052c commit c374577

File tree

7 files changed

+238
-91
lines changed

7 files changed

+238
-91
lines changed

artistinfocache/artistinfocache.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//nolint:revive
2+
package artistinfocache
3+
4+
import (
5+
"context"
6+
"errors"
7+
"fmt"
8+
"log"
9+
"time"
10+
11+
"github.com/jinzhu/gorm"
12+
"go.senan.xyz/gonic/db"
13+
"go.senan.xyz/gonic/scrobble/lastfm"
14+
)
15+
16+
const keepFor = 30 * time.Hour * 24
17+
18+
type ArtistInfoCache struct {
19+
db *db.DB
20+
lastfmClient *lastfm.Client
21+
}
22+
23+
func New(db *db.DB, lastfmClient *lastfm.Client) *ArtistInfoCache {
24+
return &ArtistInfoCache{db: db, lastfmClient: lastfmClient}
25+
}
26+
27+
func (a *ArtistInfoCache) GetOrLookup(ctx context.Context, apiKey string, artistID int) (*db.ArtistInfo, error) {
28+
var artist db.Artist
29+
if err := a.db.Find(&artist, "id=?", artistID).Error; err != nil {
30+
return nil, fmt.Errorf("find artist in db: %w", err)
31+
}
32+
33+
var artistInfo db.ArtistInfo
34+
if err := a.db.Find(&artistInfo, "id=?", artistID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
35+
return nil, fmt.Errorf("find artist info in db: %w", err)
36+
}
37+
38+
if artistInfo.ID == 0 || artistInfo.Biography == "" /* prev not found maybe */ || time.Since(artistInfo.UpdatedAt) > keepFor {
39+
return a.Lookup(ctx, apiKey, &artist)
40+
}
41+
42+
return &artistInfo, nil
43+
}
44+
45+
func (a *ArtistInfoCache) Get(ctx context.Context, artistID int) (*db.ArtistInfo, error) {
46+
var artistInfo db.ArtistInfo
47+
if err := a.db.Find(&artistInfo, "id=?", artistID).Error; err != nil {
48+
return nil, fmt.Errorf("find artist info in db: %w", err)
49+
}
50+
return &artistInfo, nil
51+
}
52+
53+
func (a *ArtistInfoCache) Lookup(ctx context.Context, apiKey string, artist *db.Artist) (*db.ArtistInfo, error) {
54+
var artistInfo db.ArtistInfo
55+
artistInfo.ID = artist.ID
56+
57+
if err := a.db.FirstOrCreate(&artistInfo, "id=?", artistInfo.ID).Error; err != nil {
58+
return nil, fmt.Errorf("first or create artist info: %w", err)
59+
}
60+
61+
info, err := a.lastfmClient.ArtistGetInfo(apiKey, artist.Name)
62+
if err != nil {
63+
return nil, fmt.Errorf("get upstream info: %w", err)
64+
}
65+
66+
artistInfo.ID = artist.ID
67+
artistInfo.Biography = info.Bio.Summary
68+
artistInfo.MusicBrainzID = info.MBID
69+
artistInfo.LastFMURL = info.URL
70+
71+
var similar []string
72+
for _, sim := range info.Similar.Artists {
73+
similar = append(similar, sim.Name)
74+
}
75+
artistInfo.SetSimilarArtists(similar)
76+
77+
url, _ := a.lastfmClient.StealArtistImage(info.URL)
78+
artistInfo.ImageURL = url
79+
80+
topTracksResponse, err := a.lastfmClient.ArtistGetTopTracks(apiKey, artist.Name)
81+
if err != nil {
82+
return nil, fmt.Errorf("get top tracks: %w", err)
83+
}
84+
var topTracks []string
85+
for _, tr := range topTracksResponse.Tracks {
86+
topTracks = append(topTracks, tr.Name)
87+
}
88+
artistInfo.SetTopTracks(topTracks)
89+
90+
if err := a.db.Save(&artistInfo).Error; err != nil {
91+
return nil, fmt.Errorf("save upstream info: %w", err)
92+
}
93+
94+
return &artistInfo, nil
95+
}
96+
97+
func (a *ArtistInfoCache) Refresh(apiKey string, interval time.Duration) error {
98+
ticker := time.NewTicker(interval)
99+
for range ticker.C {
100+
q := a.db.
101+
Where("artist_infos.id IS NULL OR artist_infos.updated_at<?", time.Now().Add(-keepFor)).
102+
Joins("LEFT JOIN artist_infos ON artist_infos.id=artists.id")
103+
104+
var artist db.Artist
105+
if err := q.Find(&artist).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
106+
log.Printf("error finding non cached artist: %v", err)
107+
continue
108+
}
109+
if artist.ID == 0 {
110+
continue
111+
}
112+
113+
if _, err := a.Lookup(context.Background(), apiKey, &artist); err != nil {
114+
log.Printf("error looking up non cached artist %s: %v", artist.Name, err)
115+
continue
116+
}
117+
118+
log.Printf("cached artist info for %q", artist.Name)
119+
}
120+
121+
return nil
122+
}

cmd/gonic/gonic.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/sentriz/gormstore"
2727

2828
"go.senan.xyz/gonic"
29+
"go.senan.xyz/gonic/artistinfocache"
2930
"go.senan.xyz/gonic/db"
3031
"go.senan.xyz/gonic/jukebox"
3132
"go.senan.xyz/gonic/playlist"
@@ -205,6 +206,8 @@ func main() {
205206
sessDB.SessionOpts.HttpOnly = true
206207
sessDB.SessionOpts.SameSite = http.SameSiteLaxMode
207208

209+
artistInfoCache := artistinfocache.New(dbc, lastfmClient)
210+
208211
ctrlBase := &ctrlbase.Controller{
209212
DB: dbc,
210213
PlaylistStore: playlistStore,
@@ -216,12 +219,13 @@ func main() {
216219
log.Panicf("error creating admin controller: %v\n", err)
217220
}
218221
ctrlSubsonic := &ctrlsubsonic.Controller{
219-
Controller: ctrlBase,
220-
MusicPaths: musicPaths,
221-
PodcastsPath: *confPodcastPath,
222-
CacheAudioPath: cacheDirAudio,
223-
CacheCoverPath: cacheDirCovers,
224-
LastFMClient: lastfmClient,
222+
Controller: ctrlBase,
223+
MusicPaths: musicPaths,
224+
PodcastsPath: *confPodcastPath,
225+
CacheAudioPath: cacheDirAudio,
226+
CacheCoverPath: cacheDirCovers,
227+
LastFMClient: lastfmClient,
228+
ArtistInfoCache: artistInfoCache,
225229
Scrobblers: []scrobble.Scrobbler{
226230
lastfm.NewScrobbler(dbc, lastfmClient),
227231
listenbrainz.NewScrobbler(),
@@ -345,6 +349,14 @@ func main() {
345349
})
346350
}
347351

352+
lastfmAPIKey, _ := dbc.GetSetting("lastfm_api_key")
353+
if lastfmAPIKey != "" {
354+
g.Add(func() error {
355+
log.Printf("starting job 'refresh artist info'\n")
356+
return artistInfoCache.Refresh(lastfmAPIKey, 5*time.Second)
357+
}, nil)
358+
}
359+
348360
if *confScanAtStart {
349361
if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil {
350362
log.Panicf("error scanning at start: %v\n", err)

db/migrations.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
5858
construct(ctx, "202305301718", migratePlayCountToLength),
5959
construct(ctx, "202307281628", migrateAlbumArtistsMany2Many),
6060
construct(ctx, "202309070009", migrateDeleteArtistCoverField),
61+
construct(ctx, "202309131743", migrateArtistInfo),
6162
}
6263

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

606607
return nil
607608
}
609+
610+
func migrateArtistInfo(tx *gorm.DB, _ MigrationContext) error {
611+
return tx.AutoMigrate(
612+
ArtistInfo{},
613+
).
614+
Error
615+
}

db/model.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package db
77
// https://www.db-fiddle.com/f/wJ7z8L7mu6ZKaYmWk1xr1p/5
88

99
import (
10+
"fmt"
1011
"path"
1112
"path/filepath"
1213
"sort"
@@ -32,7 +33,7 @@ func splitIDs(in, sep string) []specid.ID {
3233
return ret
3334
}
3435

35-
func joinIds(in []specid.ID, sep string) string {
36+
func join[T fmt.Stringer](in []T, sep string) string {
3637
if in == nil {
3738
return ""
3839
}
@@ -270,7 +271,7 @@ func (p *PlayQueue) GetItems() []specid.ID {
270271
}
271272

272273
func (p *PlayQueue) SetItems(items []specid.ID) {
273-
p.Items = joinIds(items, ",")
274+
p.Items = join(items, ",")
274275
}
275276

276277
type TranscodePreference struct {
@@ -441,3 +442,21 @@ type InternetRadioStation struct {
441442
func (ir *InternetRadioStation) SID() *specid.ID {
442443
return &specid.ID{Type: specid.InternetRadioStation, Value: ir.ID}
443444
}
445+
446+
type ArtistInfo struct {
447+
ID int `gorm:"primary_key" sql:"type:int REFERENCES artists(id) ON DELETE CASCADE"`
448+
CreatedAt time.Time
449+
UpdatedAt time.Time `gorm:"index"`
450+
Biography string
451+
MusicBrainzID string
452+
LastFMURL string
453+
ImageURL string
454+
SimilarArtists string
455+
TopTracks string
456+
}
457+
458+
func (p *ArtistInfo) GetSimilarArtists() []string { return strings.Split(p.SimilarArtists, ";") }
459+
func (p *ArtistInfo) SetSimilarArtists(items []string) { p.SimilarArtists = strings.Join(items, ";") }
460+
461+
func (p *ArtistInfo) GetTopTracks() []string { return strings.Split(p.TopTracks, ";") }
462+
func (p *ArtistInfo) SetTopTracks(items []string) { p.TopTracks = strings.Join(items, ";") }

server/ctrlsubsonic/ctrl.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"log"
1010
"net/http"
1111

12+
"go.senan.xyz/gonic/artistinfocache"
1213
"go.senan.xyz/gonic/jukebox"
1314
"go.senan.xyz/gonic/podcasts"
1415
"go.senan.xyz/gonic/scrobble"
@@ -41,15 +42,16 @@ func PathsOf(paths []MusicPath) []string {
4142

4243
type Controller struct {
4344
*ctrlbase.Controller
44-
MusicPaths []MusicPath
45-
PodcastsPath string
46-
CacheAudioPath string
47-
CacheCoverPath string
48-
Jukebox *jukebox.Jukebox
49-
Scrobblers []scrobble.Scrobbler
50-
Podcasts *podcasts.Podcasts
51-
Transcoder transcode.Transcoder
52-
LastFMClient *lastfm.Client
45+
MusicPaths []MusicPath
46+
PodcastsPath string
47+
CacheAudioPath string
48+
CacheCoverPath string
49+
Jukebox *jukebox.Jukebox
50+
Scrobblers []scrobble.Scrobbler
51+
Podcasts *podcasts.Podcasts
52+
Transcoder transcode.Transcoder
53+
LastFMClient *lastfm.Client
54+
ArtistInfoCache *artistinfocache.ArtistInfoCache
5355
}
5456

5557
type metaResponse struct {

server/ctrlsubsonic/handlers_by_tags.go

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -322,41 +322,38 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
322322
if apiKey == "" {
323323
return sub
324324
}
325-
info, err := c.LastFMClient.ArtistGetInfo(apiKey, artist.Name)
325+
326+
info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID)
326327
if err != nil {
327328
return spec.NewError(0, "fetching artist info: %v", err)
328329
}
329330

330-
sub.ArtistInfoTwo.Biography = info.Bio.Summary
331-
sub.ArtistInfoTwo.MusicBrainzID = info.MBID
332-
sub.ArtistInfoTwo.LastFMURL = info.URL
331+
sub.ArtistInfoTwo.Biography = info.Biography
332+
sub.ArtistInfoTwo.MusicBrainzID = info.MusicBrainzID
333+
sub.ArtistInfoTwo.LastFMURL = info.LastFMURL
333334

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

338-
if url, _ := c.LastFMClient.StealArtistImage(info.URL); url != "" {
339-
sub.ArtistInfoTwo.SmallImageURL = url
340-
sub.ArtistInfoTwo.MediumImageURL = url
341-
sub.ArtistInfoTwo.LargeImageURL = url
342-
sub.ArtistInfoTwo.ArtistImageURL = url
339+
if info.ImageURL != "" {
340+
sub.ArtistInfoTwo.SmallImageURL = info.ImageURL
341+
sub.ArtistInfoTwo.MediumImageURL = info.ImageURL
342+
sub.ArtistInfoTwo.LargeImageURL = info.ImageURL
343+
sub.ArtistInfoTwo.ArtistImageURL = info.ImageURL
343344
}
344345

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

352-
for i, similarInfo := range similarArtists.Artists {
349+
for i, similarName := range info.GetSimilarArtists() {
353350
if i == count {
354351
break
355352
}
356353
var artist db.Artist
357354
err = c.DB.
358355
Select("artists.*, count(albums.id) album_count").
359-
Where("name=?", similarInfo.Name).
356+
Where("name=?", similarName).
360357
Joins("LEFT JOIN album_artists ON album_artists.artist_id=artists.id").
361358
Joins("LEFT JOIN albums ON albums.id=album_artists.album_id").
362359
Group("artists.id").
@@ -372,7 +369,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
372369
}
373370
sub.ArtistInfoTwo.SimilarArtist = append(sub.ArtistInfoTwo.SimilarArtist, &spec.SimilarArtist{
374371
ID: artistID,
375-
Name: similarInfo.Name,
372+
Name: similarName,
376373
CoverArt: artistID,
377374
AlbumCount: artist.AlbumCount,
378375
})
@@ -544,7 +541,7 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response {
544541
if apiKey == "" {
545542
return spec.NewResponse()
546543
}
547-
topTracks, err := c.LastFMClient.ArtistGetTopTracks(apiKey, artist.Name)
544+
info, err := c.ArtistInfoCache.GetOrLookup(r.Context(), apiKey, artist.ID)
548545
if err != nil {
549546
return spec.NewError(0, "fetching artist top tracks: %v", err)
550547
}
@@ -554,15 +551,11 @@ func (c *Controller) ServeGetTopSongs(r *http.Request) *spec.Response {
554551
Tracks: make([]*spec.TrackChild, 0),
555552
}
556553

557-
if len(topTracks.Tracks) == 0 {
554+
topTrackNames := info.GetTopTracks()
555+
if len(topTrackNames) == 0 {
558556
return sub
559557
}
560558

561-
topTrackNames := make([]string, len(topTracks.Tracks))
562-
for i, t := range topTracks.Tracks {
563-
topTrackNames[i] = t.Name
564-
}
565-
566559
var tracks []*db.Track
567560
err = c.DB.
568561
Preload("Album").

0 commit comments

Comments
 (0)