mirror of
https://github.com/navidrome/navidrome.git
synced 2025-06-08 19:32:16 +03:00
GetCoverArt generates a tiled (2x2) image for playlists
This commit is contained in:
parent
501386b11f
commit
949331ed24
@ -69,6 +69,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
|||||||
artReader, err = newAlbumArtworkReader(ctx, a, artID)
|
artReader, err = newAlbumArtworkReader(ctx, a, artID)
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||||
|
case model.KindPlaylistArtwork:
|
||||||
|
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||||
default:
|
default:
|
||||||
artReader, err = newPlaceholderReader(ctx, artID)
|
artReader, err = newPlaceholderReader(ctx, artID)
|
||||||
}
|
}
|
||||||
|
@ -30,15 +30,16 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
|
|||||||
album: *al,
|
album: *al,
|
||||||
}
|
}
|
||||||
a.cacheKey.artID = artID
|
a.cacheKey.artID = artID
|
||||||
a.cacheKey.lastUpdate = a.LastUpdated()
|
if al.UpdatedAt.After(mf.UpdatedAt) {
|
||||||
|
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||||
|
} else {
|
||||||
|
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||||
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *mediafileArtworkReader) LastUpdated() time.Time {
|
func (a *mediafileArtworkReader) LastUpdated() time.Time {
|
||||||
if a.album.UpdatedAt.After(a.mediafile.UpdatedAt) {
|
return a.lastUpdate
|
||||||
return a.album.UpdatedAt
|
|
||||||
}
|
|
||||||
return a.mediafile.UpdatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||||
|
149
core/artwork/reader_playlist.go
Normal file
149
core/artwork/reader_playlist.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type playlistArtworkReader struct {
|
||||||
|
cacheKey
|
||||||
|
a *artwork
|
||||||
|
pl model.Playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileSize = 600
|
||||||
|
|
||||||
|
func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) {
|
||||||
|
pl, err := artwork.ds.Playlist(ctx).Get(artID.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := &playlistArtworkReader{
|
||||||
|
a: artwork,
|
||||||
|
pl: *pl,
|
||||||
|
}
|
||||||
|
a.cacheKey.artID = artID
|
||||||
|
a.cacheKey.lastUpdate = pl.UpdatedAt
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *playlistArtworkReader) LastUpdated() time.Time {
|
||||||
|
return a.lastUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||||
|
var ff []sourceFunc
|
||||||
|
pl, err := a.a.ds.Playlist(ctx).GetWithTracks(a.pl.ID)
|
||||||
|
if err == nil {
|
||||||
|
ff = append(ff, a.fromGeneratedTile(ctx, pl.Tracks))
|
||||||
|
}
|
||||||
|
ff = append(ff, fromPlaceholder())
|
||||||
|
r, source := extractImage(ctx, a.artID, ff...)
|
||||||
|
return r, source, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *playlistArtworkReader) fromGeneratedTile(ctx context.Context, tracks model.PlaylistTracks) sourceFunc {
|
||||||
|
return func() (io.ReadCloser, string, error) {
|
||||||
|
tiles, err := a.loadTiles(ctx, tracks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
r, err := a.createTiledImage(ctx, tiles)
|
||||||
|
return r, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactIDs(tracks model.PlaylistTracks) []model.ArtworkID {
|
||||||
|
slices.SortFunc(tracks, func(a, b model.PlaylistTrack) bool { return a.AlbumID < b.AlbumID })
|
||||||
|
tracks = slices.CompactFunc(tracks, func(a, b model.PlaylistTrack) bool { return a.AlbumID == b.AlbumID })
|
||||||
|
ids := make([]model.ArtworkID, len(tracks))
|
||||||
|
for i, t := range tracks {
|
||||||
|
ids[i] = t.AlbumCoverArtID()
|
||||||
|
}
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
rand.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *playlistArtworkReader) loadTiles(ctx context.Context, t model.PlaylistTracks) ([]image.Image, error) {
|
||||||
|
ids := compactIDs(t)
|
||||||
|
|
||||||
|
var tiles []image.Image
|
||||||
|
for len(tiles) < 4 {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id := ids[len(ids)-1]
|
||||||
|
ids = ids[0 : len(ids)-1]
|
||||||
|
r, _, err := fromAlbum(ctx, a.a, id)()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tile, err := a.createTile(ctx, r)
|
||||||
|
if err == nil {
|
||||||
|
tiles = append(tiles, tile)
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
}
|
||||||
|
switch len(tiles) {
|
||||||
|
case 0:
|
||||||
|
return nil, errors.New("could not find any eligible cover")
|
||||||
|
case 2:
|
||||||
|
tiles = append(tiles, tiles[1], tiles[0])
|
||||||
|
case 3:
|
||||||
|
tiles = append(tiles, tiles[0])
|
||||||
|
}
|
||||||
|
return tiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) {
|
||||||
|
img, _, err := image.Decode(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
var rgba draw.Image
|
||||||
|
var err error
|
||||||
|
if len(tiles) == 4 {
|
||||||
|
rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}})
|
||||||
|
draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src)
|
||||||
|
draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src)
|
||||||
|
draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src)
|
||||||
|
draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src)
|
||||||
|
err = png.Encode(buf, rgba)
|
||||||
|
} else {
|
||||||
|
err = png.Encode(buf, tiles[0])
|
||||||
|
}
|
||||||
|
return io.NopCloser(buf), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func rect(pos int) image.Rectangle {
|
||||||
|
r := image.Rectangle{}
|
||||||
|
switch pos {
|
||||||
|
case 1:
|
||||||
|
r.Min.X = tileSize / 2
|
||||||
|
case 2:
|
||||||
|
r.Min.Y = tileSize / 2
|
||||||
|
case 3:
|
||||||
|
r.Min.X = tileSize / 2
|
||||||
|
r.Min.Y = tileSize / 2
|
||||||
|
}
|
||||||
|
r.Max.X = r.Min.X + tileSize/2
|
||||||
|
r.Max.Y = r.Min.Y + tileSize/2
|
||||||
|
return r
|
||||||
|
}
|
@ -54,7 +54,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
|||||||
r := io.TeeReader(orig, buf)
|
r := io.TeeReader(orig, buf)
|
||||||
defer orig.Close()
|
defer orig.Close()
|
||||||
|
|
||||||
resized, origSize, err := resizeImage(r, a.size)
|
resized, origSize, err := resizeImageIntoReader(r, a.size)
|
||||||
log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size)
|
log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, err)
|
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, err)
|
||||||
@ -74,12 +74,7 @@ func asImageReader(r io.Reader) (io.Reader, string, error) {
|
|||||||
return br, http.DetectContentType(buf), nil
|
return br, http.DetectContentType(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
func resizeImage(r io.Reader, size int) (image.Image, int, error) {
|
||||||
r, format, err := asImageReader(reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
img, _, err := image.Decode(r)
|
img, _, err := image.Decode(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@ -94,6 +89,20 @@ func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
|||||||
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return m, number.Max(bounds.Max.X, bounds.Max.Y), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeImageIntoReader(reader io.Reader, size int) (io.Reader, int, error) {
|
||||||
|
r, format, err := asImageReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m, origSize, err := resizeImage(r, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
if format == "image/png" {
|
if format == "image/png" {
|
||||||
@ -101,5 +110,5 @@ func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
|||||||
} else {
|
} else {
|
||||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||||
}
|
}
|
||||||
return buf, number.Max(bounds.Max.X, bounds.Max.Y), err
|
return buf, origSize, err
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,9 @@ type sourceFunc func() (r io.ReadCloser, path string, err error)
|
|||||||
func (f sourceFunc) String() string {
|
func (f sourceFunc) String() string {
|
||||||
name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
|
name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
|
||||||
name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.")
|
name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.")
|
||||||
name = strings.TrimPrefix(name, "(*artwork).")
|
if _, after, found := strings.Cut(name, ")."); found {
|
||||||
|
name = after
|
||||||
|
}
|
||||||
name = strings.TrimSuffix(name, ".func1")
|
name = strings.TrimSuffix(name, ".func1")
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user