feat(subsonic): cache and use lastfm responses for covers, bios, top songs

This commit is contained in:
sentriz
2023-09-13 20:35:38 +01:00
parent 2b9052ca87
commit c374577328
7 changed files with 236 additions and 89 deletions

View File

@@ -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"
@@ -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 {

View File

@@ -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").
@@ -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,
})
@@ -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)
}
@@ -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").

View File

@@ -2,8 +2,10 @@ package ctrlsubsonic
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -13,6 +15,7 @@ import (
"github.com/disintegration/imaging"
"github.com/jinzhu/gorm"
"go.senan.xyz/gonic/artistinfocache"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
@@ -107,115 +110,101 @@ const (
var (
errCoverNotFound = errors.New("could not find a cover with that id")
errCoverEmpty = errors.New("no cover found for that folder")
errCoverEmpty = errors.New("no cover found")
)
// TODO: can we use specidpaths.Locate here?
func coverGetPath(dbc *db.DB, podcastPath string, id specid.ID) (string, error) {
func coverFor(dbc *db.DB, artistInfoCache *artistinfocache.ArtistInfoCache, podcastPath string, id specid.ID) (io.ReadCloser, error) {
switch id.Type {
case specid.Album:
return coverGetPathAlbum(dbc, id.Value)
return coverForAlbum(dbc, id.Value)
case specid.Artist:
return coverGetPathArtist(dbc, id.Value)
return coverForArtist(artistInfoCache, id.Value)
case specid.Podcast:
return coverGetPathPodcast(dbc, podcastPath, id.Value)
return coverForPodcast(dbc, podcastPath, id.Value)
case specid.PodcastEpisode:
return coverGetPathPodcastEpisode(dbc, podcastPath, id.Value)
default:
return "", errCoverNotFound
return nil, errCoverNotFound
}
}
func coverGetPathAlbum(dbc *db.DB, id int) (string, error) {
func coverForAlbum(dbc *db.DB, id int) (*os.File, error) {
folder := &db.Album{}
err := dbc.DB.
Select("id, root_dir, left_path, right_path, cover").
First(folder, id).
Error
if err != nil {
return "", fmt.Errorf("select album: %w", err)
return nil, fmt.Errorf("select album: %w", err)
}
if folder.Cover == "" {
return "", errCoverEmpty
return nil, errCoverEmpty
}
return path.Join(
folder.RootDir,
folder.LeftPath,
folder.RightPath,
folder.Cover,
), nil
return os.Open(path.Join(folder.RootDir, folder.LeftPath, folder.RightPath, folder.Cover))
}
func coverGetPathArtist(dbc *db.DB, id int) (string, error) {
folder := &db.Album{}
err := dbc.DB.
Select("albums.id, albums.root_dir, albums.left_path, albums.right_path, albums.cover").
Joins("JOIN album_artists ON album_artists.album_id=albums.id").
Where("album_artists.artist_id=?", id).
Group("albums.id").
Find(folder).
Error
func coverForArtist(artistInfoCache *artistinfocache.ArtistInfoCache, id int) (io.ReadCloser, error) {
info, err := artistInfoCache.Get(context.Background(), id)
if err != nil {
return "", fmt.Errorf("select guessed artist folder: %w", err)
return nil, fmt.Errorf("get artist info from cache: %w", err)
}
if folder.Cover == "" {
return "", errCoverEmpty
if info.ImageURL == "" {
return nil, fmt.Errorf("%w: cache miss", errCoverEmpty)
}
return path.Join(
folder.RootDir,
folder.LeftPath,
folder.RightPath,
folder.Cover,
), nil
resp, err := http.Get(info.ImageURL)
if err != nil {
return nil, fmt.Errorf("req image from lastfm: %w", err)
}
return resp.Body, nil
}
func coverGetPathPodcast(dbc *db.DB, podcastPath string, id int) (string, error) {
func coverForPodcast(dbc *db.DB, podcastPath string, id int) (*os.File, error) {
podcast := &db.Podcast{}
err := dbc.
First(podcast, id).
Error
if err != nil {
return "", fmt.Errorf("select podcast: %w", err)
return nil, fmt.Errorf("select podcast: %w", err)
}
if podcast.ImagePath == "" {
return "", errCoverEmpty
return nil, errCoverEmpty
}
return path.Join(podcastPath, podcast.ImagePath), nil
return os.Open(path.Join(podcastPath, podcast.ImagePath))
}
func coverGetPathPodcastEpisode(dbc *db.DB, podcastPath string, id int) (string, error) {
func coverGetPathPodcastEpisode(dbc *db.DB, podcastPath string, id int) (*os.File, error) {
episode := &db.PodcastEpisode{}
err := dbc.
First(episode, id).
Error
if err != nil {
return "", fmt.Errorf("select episode: %w", err)
return nil, fmt.Errorf("select episode: %w", err)
}
podcast := &db.Podcast{}
err = dbc.
First(podcast, episode.PodcastID).
Error
if err != nil {
return "", fmt.Errorf("select podcast: %w", err)
return nil, fmt.Errorf("select podcast: %w", err)
}
if podcast.ImagePath == "" {
return "", errCoverEmpty
return nil, errCoverEmpty
}
return path.Join(podcastPath, podcast.ImagePath), nil
return os.Open(path.Join(podcastPath, podcast.ImagePath))
}
func coverScaleAndSave(absPath, cachePath string, size int) error {
src, err := imaging.Open(absPath)
func coverScaleAndSave(reader io.Reader, cachePath string, size int) error {
src, err := imaging.Decode(reader)
if err != nil {
return fmt.Errorf("resizing `%s`: %w", absPath, err)
return fmt.Errorf("resizing: %w", err)
}
width := size
if width > src.Bounds().Dx() {
// don't upscale images
width = src.Bounds().Dx()
}
err = imaging.Save(imaging.Resize(src, width, 0, imaging.Lanczos), cachePath)
if err != nil {
if err = imaging.Save(imaging.Resize(src, width, 0, imaging.Lanczos), cachePath); err != nil {
return fmt.Errorf("caching `%s`: %w", cachePath, err)
}
return nil
@@ -235,11 +224,13 @@ func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *s
_, err = os.Stat(cachePath)
switch {
case os.IsNotExist(err):
coverPath, err := coverGetPath(c.DB, c.PodcastsPath, id)
reader, err := coverFor(c.DB, c.ArtistInfoCache, c.PodcastsPath, id)
if err != nil {
return spec.NewError(10, "couldn't find cover `%s`: %v", id, err)
}
if err := coverScaleAndSave(coverPath, cachePath, size); err != nil {
defer reader.Close()
if err := coverScaleAndSave(reader, cachePath, size); err != nil {
log.Printf("error scaling cover: %v", err)
return nil
}