mirror of
https://github.com/gabehf/Koito.git
synced 2026-04-22 12:01:52 -07:00
Three bugs causing all artist images to be the same Last.fm placeholder: 1. Last.fm stopped serving real artist images years ago and returns a generic placeholder (hash 2a96cbd8b46e442fc41c2b86b821562f) for every artist. Added filter in selectBestImage to reject URLs containing this known placeholder hash. 2. Provider order had Last.fm before Deezer for artist images. Since Last.fm "succeeded" with the placeholder, Deezer was never reached. Swapped order: Deezer now checked before Last.fm. 3. FetchMissingArtistImages had inverted if/else — aliases were used on error, bare name on success. Fixed condition to err == nil.
393 lines
10 KiB
Go
393 lines
10 KiB
Go
package catalog
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gabehf/koito/internal/cfg"
|
|
"github.com/gabehf/koito/internal/db"
|
|
"github.com/gabehf/koito/internal/images"
|
|
"github.com/gabehf/koito/internal/logger"
|
|
"github.com/gabehf/koito/internal/utils"
|
|
"github.com/google/uuid"
|
|
"github.com/h2non/bimg"
|
|
)
|
|
|
|
type ImageSize string
|
|
|
|
const (
|
|
ImageSizeSmall ImageSize = "small"
|
|
ImageSizeMedium ImageSize = "medium"
|
|
ImageSizeLarge ImageSize = "large"
|
|
// imageSizeXL ImageSize = "xl"
|
|
ImageSizeFull ImageSize = "full"
|
|
|
|
ImageCacheDir = "image_cache"
|
|
)
|
|
|
|
func ImageSourceSize() (size ImageSize) {
|
|
if cfg.FullImageCacheEnabled() {
|
|
size = ImageSizeFull
|
|
} else {
|
|
size = ImageSizeLarge
|
|
}
|
|
return
|
|
}
|
|
|
|
func ParseImageSize(size string) (ImageSize, error) {
|
|
switch strings.ToLower(size) {
|
|
case "small":
|
|
return ImageSizeSmall, nil
|
|
case "medium":
|
|
return ImageSizeMedium, nil
|
|
case "large":
|
|
return ImageSizeLarge, nil
|
|
// case "xl":
|
|
// return imageSizeXL, nil
|
|
case "full":
|
|
return ImageSizeFull, nil
|
|
default:
|
|
return "", fmt.Errorf("unknown image size: %s", size)
|
|
}
|
|
}
|
|
func GetImageSize(size ImageSize) int {
|
|
var px int
|
|
switch size {
|
|
case "small":
|
|
px = 48
|
|
case "medium":
|
|
px = 256
|
|
case "large":
|
|
px = 500
|
|
case "xl":
|
|
px = 1000
|
|
}
|
|
return px
|
|
}
|
|
|
|
func SourceImageDir() string {
|
|
if cfg.FullImageCacheEnabled() {
|
|
return path.Join(cfg.ConfigDir(), ImageCacheDir, "full")
|
|
} else {
|
|
return path.Join(cfg.ConfigDir(), ImageCacheDir, "large")
|
|
}
|
|
}
|
|
|
|
// DownloadAndCacheImage downloads an image from the given URL, then calls CompressAndSaveImage.
|
|
func DownloadAndCacheImage(ctx context.Context, id uuid.UUID, url string, size ImageSize) error {
|
|
l := logger.FromContext(ctx)
|
|
err := images.ValidateImageURL(url)
|
|
if err != nil {
|
|
return fmt.Errorf("DownloadAndCacheImage: %w", err)
|
|
}
|
|
l.Debug().Msgf("Downloading image for ID %s", id)
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return fmt.Errorf("DownloadAndCacheImage: http.Get: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("DownloadAndCacheImage: failed to download image, status: %s", resp.Status)
|
|
}
|
|
|
|
err = CompressAndSaveImage(ctx, id.String(), size, resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("DownloadAndCacheImage: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Compresses an image to the specified size, then saves it to the correct cache folder.
|
|
func CompressAndSaveImage(ctx context.Context, filename string, size ImageSize, body io.Reader) error {
|
|
l := logger.FromContext(ctx)
|
|
|
|
if size == ImageSizeFull {
|
|
err := saveImage(filename, size, body)
|
|
if err != nil {
|
|
return fmt.Errorf("CompressAndSaveImage: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
l.Debug().Msg("Creating resized image")
|
|
compressed, err := compressImage(size, body)
|
|
if err != nil {
|
|
return fmt.Errorf("CompressAndSaveImage: %w", err)
|
|
}
|
|
|
|
err = saveImage(filename, size, compressed)
|
|
if err != nil {
|
|
return fmt.Errorf("CompressAndSaveImage: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SaveImage saves an image to the image_cache/{size} folder
|
|
func saveImage(filename string, size ImageSize, data io.Reader) error {
|
|
configDir := cfg.ConfigDir()
|
|
cacheDir := filepath.Join(configDir, ImageCacheDir)
|
|
|
|
// Ensure the cache directory exists
|
|
err := os.MkdirAll(filepath.Join(cacheDir, string(size)), 0744)
|
|
if err != nil {
|
|
return fmt.Errorf("saveImage: failed to create full image cache directory: %w", err)
|
|
}
|
|
|
|
// Create a file in the cache directory
|
|
imagePath := filepath.Join(cacheDir, string(size), filename)
|
|
file, err := os.Create(imagePath)
|
|
if err != nil {
|
|
return fmt.Errorf("saveImage: failed to create image file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Save the image to the file
|
|
_, err = io.Copy(file, data)
|
|
if err != nil {
|
|
return fmt.Errorf("saveImage: failed to save image: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func compressImage(size ImageSize, data io.Reader) (io.Reader, error) {
|
|
imgBytes, err := io.ReadAll(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("compressImage: io.ReadAll: %w", err)
|
|
}
|
|
px := GetImageSize(size)
|
|
// Resize with bimg
|
|
imgBytes, err = bimg.NewImage(imgBytes).Process(bimg.Options{
|
|
Width: px,
|
|
Height: px,
|
|
Crop: true,
|
|
Quality: 85,
|
|
StripMetadata: true,
|
|
Type: bimg.WEBP,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("compressImage: bimg.NewImage: %w", err)
|
|
}
|
|
if len(imgBytes) == 0 {
|
|
return nil, fmt.Errorf("compressImage: failed to compress image: %w", err)
|
|
}
|
|
return bytes.NewReader(imgBytes), nil
|
|
}
|
|
|
|
func DeleteImage(filename uuid.UUID) error {
|
|
configDir := cfg.ConfigDir()
|
|
cacheDir := filepath.Join(configDir, ImageCacheDir)
|
|
|
|
// err := os.Remove(path.Join(cacheDir, "xl", filename.String()))
|
|
// if err != nil && !os.IsNotExist(err) {
|
|
// return err
|
|
// }
|
|
err := os.Remove(path.Join(cacheDir, "full", filename.String()))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("DeleteImage: %w", err)
|
|
}
|
|
err = os.Remove(path.Join(cacheDir, "large", filename.String()))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("DeleteImage: %w", err)
|
|
}
|
|
err = os.Remove(path.Join(cacheDir, "medium", filename.String()))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("DeleteImage: %w", err)
|
|
}
|
|
err = os.Remove(path.Join(cacheDir, "small", filename.String()))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("DeleteImage: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Finds any images in all image_cache folders and deletes them if they are not associated with
|
|
// an album or artist.
|
|
func PruneOrphanedImages(ctx context.Context, store db.DB) error {
|
|
l := logger.FromContext(ctx)
|
|
|
|
configDir := cfg.ConfigDir()
|
|
cacheDir := filepath.Join(configDir, ImageCacheDir)
|
|
|
|
count := 0
|
|
// go through every folder to find orphaned images
|
|
// store already processed images to speed up pruining
|
|
memo := make(map[string]bool)
|
|
for _, dir := range []string{"large", "medium", "small", "full"} {
|
|
c, err := pruneDirImgs(ctx, store, path.Join(cacheDir, dir), memo)
|
|
if err != nil {
|
|
return fmt.Errorf("PruneOrphanedImages: %w", err)
|
|
}
|
|
count += c
|
|
}
|
|
l.Info().Msgf("Purged %d images", count)
|
|
return nil
|
|
}
|
|
|
|
// returns the number of pruned images
|
|
func pruneDirImgs(ctx context.Context, store db.DB, path string, memo map[string]bool) (int, error) {
|
|
l := logger.FromContext(ctx)
|
|
count := 0
|
|
files, err := os.ReadDir(path)
|
|
if err != nil {
|
|
l.Info().Msgf("Failed to read from directory %s; skipping for prune", path)
|
|
files = []os.DirEntry{}
|
|
}
|
|
for _, file := range files {
|
|
fn := file.Name()
|
|
imageid, err := uuid.Parse(fn)
|
|
if err != nil {
|
|
l.Debug().Msgf("Filename does not appear to be UUID: %s", fn)
|
|
continue
|
|
}
|
|
exists, err := store.ImageHasAssociation(ctx, imageid)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pruneDirImages: %w", err)
|
|
} else if exists {
|
|
continue
|
|
}
|
|
// image does not have association
|
|
l.Debug().Msgf("Deleting image: %s", imageid)
|
|
err = DeleteImage(imageid)
|
|
if err != nil {
|
|
l.Err(err).Msg("Error purging orphaned images")
|
|
}
|
|
if memo != nil {
|
|
memo[fn] = true
|
|
}
|
|
count++
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func FetchMissingArtistImages(ctx context.Context, store db.DB) error {
|
|
l := logger.FromContext(ctx)
|
|
l.Info().Msg("FetchMissingArtistImages: Starting backfill of missing artist images")
|
|
|
|
var from int32 = 0
|
|
|
|
for {
|
|
l.Debug().Int32("ID", from).Msg("Fetching artist images to backfill from ID")
|
|
artists, err := store.ArtistsWithoutImages(ctx, from)
|
|
if err != nil {
|
|
return fmt.Errorf("FetchMissingArtistImages: failed to fetch artists for image backfill: %w", err)
|
|
}
|
|
|
|
if len(artists) == 0 {
|
|
if from == 0 {
|
|
l.Info().Msg("FetchMissingArtistImages: No artists with missing images found")
|
|
} else {
|
|
l.Info().Msg("FetchMissingArtistImages: Finished fetching missing artist images")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for _, artist := range artists {
|
|
from = artist.ID
|
|
|
|
l.Debug().
|
|
Str("title", artist.Name).
|
|
Msg("FetchMissingArtistImages: Attempting to fetch missing artist image")
|
|
|
|
var aliases []string
|
|
if aliasrow, err := store.GetAllArtistAliases(ctx, artist.ID); err == nil {
|
|
aliases = utils.FlattenAliases(aliasrow)
|
|
} else {
|
|
aliases = []string{artist.Name}
|
|
}
|
|
|
|
var imgid uuid.UUID
|
|
imgUrl, imgErr := images.GetArtistImage(ctx, images.ArtistImageOpts{
|
|
Aliases: aliases,
|
|
})
|
|
if imgErr == nil && imgUrl != "" {
|
|
imgid = uuid.New()
|
|
err = store.UpdateArtist(ctx, db.UpdateArtistOpts{
|
|
ID: artist.ID,
|
|
Image: imgid,
|
|
ImageSrc: imgUrl,
|
|
})
|
|
if err != nil {
|
|
l.Err(err).
|
|
Str("title", artist.Name).
|
|
Msg("FetchMissingArtistImages: Failed to update artist with image in database")
|
|
continue
|
|
}
|
|
l.Info().
|
|
Str("name", artist.Name).
|
|
Msg("FetchMissingArtistImages: Successfully fetched missing artist image")
|
|
} else {
|
|
l.Err(err).
|
|
Str("name", artist.Name).
|
|
Msg("FetchMissingArtistImages: Failed to fetch artist image")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func FetchMissingAlbumImages(ctx context.Context, store db.DB) error {
|
|
l := logger.FromContext(ctx)
|
|
l.Info().Msg("FetchMissingAlbumImages: Starting backfill of missing album images")
|
|
|
|
var from int32 = 0
|
|
|
|
for {
|
|
l.Debug().Int32("ID", from).Msg("Fetching album images to backfill from ID")
|
|
albums, err := store.AlbumsWithoutImages(ctx, from)
|
|
if err != nil {
|
|
return fmt.Errorf("FetchMissingAlbumImages: failed to fetch albums for image backfill: %w", err)
|
|
}
|
|
|
|
if len(albums) == 0 {
|
|
if from == 0 {
|
|
l.Info().Msg("FetchMissingAlbumImages: No albums with missing images found")
|
|
} else {
|
|
l.Info().Msg("FetchMissingAlbumImages: Finished fetching missing album images")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for _, album := range albums {
|
|
from = album.ID
|
|
|
|
l.Debug().
|
|
Str("title", album.Title).
|
|
Msg("FetchMissingAlbumImages: Attempting to fetch missing album image")
|
|
|
|
var imgid uuid.UUID
|
|
imgUrl, imgErr := images.GetAlbumImage(ctx, images.AlbumImageOpts{
|
|
Artists: utils.FlattenSimpleArtistNames(album.Artists),
|
|
Album: album.Title,
|
|
ReleaseMbzID: album.MbzID,
|
|
})
|
|
if imgErr == nil && imgUrl != "" {
|
|
imgid = uuid.New()
|
|
err = store.UpdateAlbum(ctx, db.UpdateAlbumOpts{
|
|
ID: album.ID,
|
|
Image: imgid,
|
|
ImageSrc: imgUrl,
|
|
})
|
|
if err != nil {
|
|
l.Err(err).
|
|
Str("title", album.Title).
|
|
Msg("FetchMissingAlbumImages: Failed to update album with image in database")
|
|
continue
|
|
}
|
|
l.Info().
|
|
Str("name", album.Title).
|
|
Msg("FetchMissingAlbumImages: Successfully fetched missing album image")
|
|
} else {
|
|
l.Err(err).
|
|
Str("name", album.Title).
|
|
Msg("FetchMissingAlbumImages: Failed to fetch album image")
|
|
}
|
|
}
|
|
}
|
|
}
|