diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index eeabfa5b9..4b417cfb3 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -69,6 +69,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s artReader, err = newAlbumArtworkReader(ctx, a, artID) case model.KindMediaFileArtwork: artReader, err = newMediafileArtworkReader(ctx, a, artID) + case model.KindPlaylistArtwork: + artReader, err = newPlaylistArtworkReader(ctx, a, artID) default: artReader, err = newPlaceholderReader(ctx, artID) } diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index 13b35fcc0..a7c134fc5 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -30,15 +30,16 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode album: *al, } 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 } func (a *mediafileArtworkReader) LastUpdated() time.Time { - if a.album.UpdatedAt.After(a.mediafile.UpdatedAt) { - return a.album.UpdatedAt - } - return a.mediafile.UpdatedAt + return a.lastUpdate } func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go new file mode 100644 index 000000000..844bcf9b4 --- /dev/null +++ b/core/artwork/reader_playlist.go @@ -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 +} diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index d7594b12c..09e263018 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -54,7 +54,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin r := io.TeeReader(orig, buf) 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) if err != nil { 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 } -func resizeImage(reader io.Reader, size int) (io.Reader, int, error) { - r, format, err := asImageReader(reader) - if err != nil { - return nil, 0, err - } - +func resizeImage(r io.Reader, size int) (image.Image, int, error) { img, _, err := image.Decode(r) if err != nil { 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) } + 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.Reset() if format == "image/png" { @@ -101,5 +110,5 @@ func resizeImage(reader io.Reader, size int) (io.Reader, int, error) { } else { 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 } diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 328f0b994..0afa6b373 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -40,7 +40,9 @@ type sourceFunc func() (r io.ReadCloser, path string, err error) func (f sourceFunc) String() string { name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() 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") return name }