mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-04 13:07:36 +03:00
Remove current artwork implementation
This commit is contained in:
parent
0130c6dc13
commit
c430401ea9
11 changed files with 164 additions and 304 deletions
177
core/artwork.go
177
core/artwork.go
|
@ -1,29 +1,18 @@
|
||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
|
||||||
"image/png"
|
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dhowden/tag"
|
|
||||||
"github.com/disintegration/imaging"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
"github.com/navidrome/navidrome/utils"
|
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
)
|
)
|
||||||
|
@ -43,6 +32,10 @@ type artwork struct {
|
||||||
cache cache.FileCache
|
cache cache.FileCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, error) {
|
||||||
|
return resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||||
|
}
|
||||||
|
|
||||||
type imageInfo struct {
|
type imageInfo struct {
|
||||||
a *artwork
|
a *artwork
|
||||||
id string
|
id string
|
||||||
|
@ -55,160 +48,6 @@ func (ci *imageInfo) Key() string {
|
||||||
return fmt.Sprintf("%s.%d.%s.%d", ci.path, ci.size, ci.lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
return fmt.Sprintf("%s.%d.%s.%d", ci.path, ci.size, ci.lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, error) {
|
|
||||||
path, lastUpdate, err := a.getImagePath(ctx, id)
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !conf.Server.DevFastAccessCoverArt {
|
|
||||||
if stat, err := os.Stat(path); err == nil {
|
|
||||||
lastUpdate = stat.ModTime()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info := &imageInfo{
|
|
||||||
a: a,
|
|
||||||
id: id,
|
|
||||||
path: path,
|
|
||||||
size: size,
|
|
||||||
lastUpdate: lastUpdate,
|
|
||||||
}
|
|
||||||
|
|
||||||
r, err := a.cache.Get(ctx, info)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error accessing image cache", "path", path, "size", size, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return r, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *artwork) getImagePath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
|
|
||||||
// If id is an album cover ID
|
|
||||||
if strings.HasPrefix(id, "al-") {
|
|
||||||
log.Trace(ctx, "Looking for album art", "id", id)
|
|
||||||
id = strings.TrimPrefix(id, "al-")
|
|
||||||
var al *model.Album
|
|
||||||
al, err = a.ds.Album(ctx).Get(id)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if al.CoverArtId == "" {
|
|
||||||
err = model.ErrNotFound
|
|
||||||
}
|
|
||||||
return al.CoverArtPath, al.UpdatedAt, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace(ctx, "Looking for media file art", "id", id)
|
|
||||||
|
|
||||||
// Check if id is a mediaFile id
|
|
||||||
var mf *model.MediaFile
|
|
||||||
mf, err = a.ds.MediaFile(ctx).Get(id)
|
|
||||||
|
|
||||||
// If it is not, may be an albumId
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
|
||||||
return a.getImagePath(ctx, "al-"+id)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it is a mediaFile, and it has cover art, return it (if feature is disabled, skip)
|
|
||||||
if !conf.Server.DevFastAccessCoverArt && mf.HasCoverArt {
|
|
||||||
return mf.Path, mf.UpdatedAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the mediaFile does not have a coverArt, fallback to the album cover
|
|
||||||
log.Trace(ctx, "Media file does not contain art. Falling back to album art", "id", id, "albumId", "al-"+mf.AlbumID)
|
|
||||||
return a.getImagePath(ctx, "al-"+mf.AlbumID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *artwork) getArtwork(ctx context.Context, id string, path string, size int) (reader io.ReadCloser, err error) {
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
|
|
||||||
reader, err = resources.FS().Open(consts.PlaceholderAlbumArt)
|
|
||||||
|
|
||||||
if size != 0 && err == nil {
|
|
||||||
var r io.ReadCloser
|
|
||||||
r, err = resources.FS().Open(consts.PlaceholderAlbumArt)
|
|
||||||
reader, err = resizeImage(r, size, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if path == "" {
|
|
||||||
return nil, errors.New("empty path given for artwork")
|
|
||||||
}
|
|
||||||
|
|
||||||
if size == 0 {
|
|
||||||
// If requested original size, just read from the file
|
|
||||||
if utils.IsAudioFile(path) {
|
|
||||||
reader, err = readFromTag(path)
|
|
||||||
} else {
|
|
||||||
reader, err = readFromFile(path)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If requested a resized image, get the original (possibly from cache) and resize it
|
|
||||||
var r io.ReadCloser
|
|
||||||
r, err = a.Get(ctx, id, 0)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer r.Close()
|
|
||||||
reader, err = resizeImage(r, size, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func resizeImage(reader io.Reader, size int, usePng bool) (io.ReadCloser, error) {
|
|
||||||
img, _, err := image.Decode(reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve the aspect ratio of the image.
|
|
||||||
var m *image.NRGBA
|
|
||||||
bounds := img.Bounds()
|
|
||||||
if bounds.Max.X > bounds.Max.Y {
|
|
||||||
m = imaging.Resize(img, size, 0, imaging.Lanczos)
|
|
||||||
} else {
|
|
||||||
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
if usePng {
|
|
||||||
err = png.Encode(buf, m)
|
|
||||||
} else {
|
|
||||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
|
||||||
}
|
|
||||||
return io.NopCloser(buf), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFromTag(path string) (io.ReadCloser, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
m, err := tag.ReadFrom(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
picture := m.Picture()
|
|
||||||
if picture == nil {
|
|
||||||
return nil, errors.New("file does not contain embedded art")
|
|
||||||
}
|
|
||||||
return io.NopCloser(bytes.NewReader(picture.Data)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFromFile(path string) (io.ReadCloser, error) {
|
|
||||||
return os.Open(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
onceImageCache sync.Once
|
onceImageCache sync.Once
|
||||||
instanceImageCache ArtworkCache
|
instanceImageCache ArtworkCache
|
||||||
|
@ -218,13 +57,7 @@ func GetImageCache() ArtworkCache {
|
||||||
onceImageCache.Do(func() {
|
onceImageCache.Do(func() {
|
||||||
instanceImageCache = cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
instanceImageCache = cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||||
info := arg.(*imageInfo)
|
return nil, nil
|
||||||
reader, err := info.a.getArtwork(ctx, info.id, info.path, info.size)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error loading artwork art", "path", info.path, "size", info.size, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reader, nil
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return instanceImageCache
|
return instanceImageCache
|
||||||
|
|
|
@ -21,9 +21,9 @@ var _ = Describe("Artwork", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
{ID: "222", CoverArtId: "123", CoverArtPath: "tests/fixtures/test.mp3"},
|
{ID: "222", EmbedArtId: "123", EmbedArtPath: "tests/fixtures/test.mp3"},
|
||||||
{ID: "333", CoverArtId: ""},
|
{ID: "333", EmbedArtId: ""},
|
||||||
{ID: "444", CoverArtId: "444", CoverArtPath: "tests/fixtures/cover.jpg"},
|
{ID: "444", EmbedArtId: "444", EmbedArtPath: "tests/fixtures/cover.jpg"},
|
||||||
})
|
})
|
||||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
{ID: "123", AlbumID: "222", Path: "tests/fixtures/test.mp3", HasCoverArt: true},
|
{ID: "123", AlbumID: "222", Path: "tests/fixtures/test.mp3", HasCoverArt: true},
|
||||||
|
|
|
@ -7,8 +7,8 @@ type Album struct {
|
||||||
|
|
||||||
ID string `structs:"id" json:"id" orm:"column(id)"`
|
ID string `structs:"id" json:"id" orm:"column(id)"`
|
||||||
Name string `structs:"name" json:"name"`
|
Name string `structs:"name" json:"name"`
|
||||||
CoverArtPath string `structs:"cover_art_path" json:"coverArtPath"`
|
EmbedArtPath string `structs:"cover_art_path" json:"coverArtPath"`
|
||||||
CoverArtId string `structs:"cover_art_id" json:"coverArtId"`
|
EmbedArtId string `structs:"cover_art_id" json:"coverArtId"`
|
||||||
ArtistID string `structs:"artist_id" json:"artistId" orm:"column(artist_id)"`
|
ArtistID string `structs:"artist_id" json:"artistId" orm:"column(artist_id)"`
|
||||||
Artist string `structs:"artist" json:"artist"`
|
Artist string `structs:"artist" json:"artist"`
|
||||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"column(album_artist_id)"`
|
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"column(album_artist_id)"`
|
||||||
|
@ -39,6 +39,10 @@ type Album struct {
|
||||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a Album) CoverArtID() ArtworkID {
|
||||||
|
return artworkIDFromAlbum(a)
|
||||||
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Albums []Album
|
Albums []Album
|
||||||
DiscID struct {
|
DiscID struct {
|
||||||
|
|
61
model/artwork_id.go
Normal file
61
model/artwork_id.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Kind struct{ prefix string }
|
||||||
|
|
||||||
|
var (
|
||||||
|
KindMediaFileArtwork = Kind{"mf"}
|
||||||
|
KindAlbumArtwork = Kind{"al"}
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArtworkID struct {
|
||||||
|
Kind Kind
|
||||||
|
ID string
|
||||||
|
LastAccess time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id ArtworkID) String() string {
|
||||||
|
return fmt.Sprintf("%s-%s-%x", id.Kind.prefix, id.ID, id.LastAccess.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseArtworkID(id string) (ArtworkID, error) {
|
||||||
|
parts := strings.Split(id, "-")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return ArtworkID{}, errors.New("invalid artwork id")
|
||||||
|
}
|
||||||
|
lastUpdate, err := strconv.ParseInt(parts[2], 16, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ArtworkID{}, err
|
||||||
|
}
|
||||||
|
if parts[0] != KindAlbumArtwork.prefix && parts[0] != KindMediaFileArtwork.prefix {
|
||||||
|
return ArtworkID{}, errors.New("invalid artwork kind")
|
||||||
|
}
|
||||||
|
return ArtworkID{
|
||||||
|
Kind: Kind{parts[0]},
|
||||||
|
ID: parts[1],
|
||||||
|
LastAccess: time.Unix(lastUpdate, 0),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func artworkIDFromAlbum(al Album) ArtworkID {
|
||||||
|
return ArtworkID{
|
||||||
|
Kind: KindAlbumArtwork,
|
||||||
|
ID: al.ID,
|
||||||
|
LastAccess: al.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func artworkIDFromMediaFile(mf MediaFile) ArtworkID {
|
||||||
|
return ArtworkID{
|
||||||
|
Kind: KindMediaFileArtwork,
|
||||||
|
ID: mf.ID,
|
||||||
|
LastAccess: mf.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
34
model/artwork_id_test.go
Normal file
34
model/artwork_id_test.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("ParseArtworkID()", func() {
|
||||||
|
It("parses album artwork ids", func() {
|
||||||
|
id, err := model.ParseArtworkID("al-1234-ff")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(id.Kind).To(Equal(model.KindAlbumArtwork))
|
||||||
|
Expect(id.ID).To(Equal("1234"))
|
||||||
|
Expect(id.LastAccess).To(Equal(time.Unix(255, 0)))
|
||||||
|
})
|
||||||
|
It("parses media file artwork ids", func() {
|
||||||
|
id, err := model.ParseArtworkID("mf-a6f8d2b1-ffff")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(id.Kind).To(Equal(model.KindMediaFileArtwork))
|
||||||
|
Expect(id.ID).To(Equal("a6f8d2b1"))
|
||||||
|
Expect(id.LastAccess).To(Equal(time.Unix(65535, 0)))
|
||||||
|
})
|
||||||
|
It("fails to parse malformed ids", func() {
|
||||||
|
_, err := model.ParseArtworkID("a6f8d2b1")
|
||||||
|
Expect(err).To(MatchError("invalid artwork id"))
|
||||||
|
})
|
||||||
|
It("fails to parse ids with invalid kind", func() {
|
||||||
|
_, err := model.ParseArtworkID("xx-a6f8d2b1-ff")
|
||||||
|
Expect(err).To(MatchError("invalid artwork kind"))
|
||||||
|
})
|
||||||
|
})
|
|
@ -2,7 +2,6 @@ package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -65,10 +64,19 @@ type MediaFile struct {
|
||||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime)
|
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mf *MediaFile) ContentType() string {
|
func (mf MediaFile) ContentType() string {
|
||||||
return mime.TypeByExtension("." + mf.Suffix)
|
return mime.TypeByExtension("." + mf.Suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mf MediaFile) CoverArtID() ArtworkID {
|
||||||
|
// If it is a mediaFile, and it has cover art, return it (if feature is disabled, skip)
|
||||||
|
if mf.HasCoverArt && !conf.Server.DevFastAccessCoverArt {
|
||||||
|
return artworkIDFromMediaFile(mf)
|
||||||
|
}
|
||||||
|
// if the mediaFile does not have a coverArt, fallback to the album cover
|
||||||
|
return artworkIDFromAlbum(Album{ID: mf.AlbumID, UpdatedAt: mf.UpdatedAt})
|
||||||
|
}
|
||||||
|
|
||||||
type MediaFiles []MediaFile
|
type MediaFiles []MediaFile
|
||||||
|
|
||||||
func (mfs MediaFiles) Dirs() []string {
|
func (mfs MediaFiles) Dirs() []string {
|
||||||
|
@ -88,7 +96,6 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||||
var songArtistIds []string
|
var songArtistIds []string
|
||||||
var mbzAlbumIds []string
|
var mbzAlbumIds []string
|
||||||
var comments []string
|
var comments []string
|
||||||
var firstPath string
|
|
||||||
for _, m := range mfs {
|
for _, m := range mfs {
|
||||||
// We assume these attributes are all the same for all songs on an album
|
// We assume these attributes are all the same for all songs on an album
|
||||||
a.ID = m.AlbumID
|
a.ID = m.AlbumID
|
||||||
|
@ -128,12 +135,9 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||||
m.Album, m.AlbumArtist, m.Artist,
|
m.Album, m.AlbumArtist, m.Artist,
|
||||||
m.SortAlbumName, m.SortAlbumArtistName, m.SortArtistName,
|
m.SortAlbumName, m.SortAlbumArtistName, m.SortArtistName,
|
||||||
m.DiscSubtitle)
|
m.DiscSubtitle)
|
||||||
if m.HasCoverArt {
|
if m.HasCoverArt && a.EmbedArtId == "" {
|
||||||
a.CoverArtId = m.ID
|
a.EmbedArtId = m.ID
|
||||||
a.CoverArtPath = m.Path
|
a.EmbedArtPath = m.Path
|
||||||
}
|
|
||||||
if firstPath == "" {
|
|
||||||
firstPath = m.Path
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
comments = slices.Compact(comments)
|
comments = slices.Compact(comments)
|
||||||
|
@ -150,13 +154,6 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||||
a.AllArtistIDs = strings.Join(slices.Compact(songArtistIds), " ")
|
a.AllArtistIDs = strings.Join(slices.Compact(songArtistIds), " ")
|
||||||
a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds)
|
a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds)
|
||||||
|
|
||||||
if a.CoverArtPath == "" || !strings.HasPrefix(conf.Server.CoverArtPriority, "embedded") {
|
|
||||||
if path := getCoverFromPath(firstPath, a.CoverArtPath); path != "" {
|
|
||||||
a.CoverArtId = "al-" + a.ID
|
|
||||||
a.CoverArtPath = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,44 +191,6 @@ func fixAlbumArtist(a Album, albumArtistIds []string) Album {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCoverFromPath accepts a path to a file, and returns a path to an eligible cover image from the
|
|
||||||
// file's directory (as configured with CoverArtPriority). If no cover file is found, among
|
|
||||||
// available choices, or an error occurs, an empty string is returned. If HasEmbeddedCover is true,
|
|
||||||
// and 'embedded' is matched among eligible choices, GetCoverFromPath will return early with an
|
|
||||||
// empty path.
|
|
||||||
// TODO: Move to scanner (or at least out of here)
|
|
||||||
func getCoverFromPath(mediaPath string, embeddedPath string) string {
|
|
||||||
n, err := os.Open(filepath.Dir(mediaPath))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
defer n.Close()
|
|
||||||
names, err := n.Readdirnames(-1)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range strings.Split(conf.Server.CoverArtPriority, ",") {
|
|
||||||
pat := strings.ToLower(strings.TrimSpace(p))
|
|
||||||
if pat == "embedded" {
|
|
||||||
if embeddedPath != "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range names {
|
|
||||||
match, _ := filepath.Match(pat, strings.ToLower(name))
|
|
||||||
if match && utils.IsImageFile(name) {
|
|
||||||
return filepath.Join(filepath.Dir(mediaPath), name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaFileRepository interface {
|
type MediaFileRepository interface {
|
||||||
CountAll(options ...QueryOptions) (int64, error)
|
CountAll(options ...QueryOptions) (int64, error)
|
||||||
Exists(id string) (bool, error)
|
Exists(id string) (bool, error)
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
@ -55,57 +51,3 @@ var _ = Describe("fixAlbumArtist", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("getCoverFromPath", func() {
|
|
||||||
var testFolder, testPath, embeddedPath string
|
|
||||||
BeforeEach(func() {
|
|
||||||
testFolder, _ = os.MkdirTemp("", "album_persistence_tests")
|
|
||||||
if err := os.MkdirAll(testFolder, 0777); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if _, err := os.Create(filepath.Join(testFolder, "Cover.jpeg")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if _, err := os.Create(filepath.Join(testFolder, "FRONT.PNG")); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
testPath = filepath.Join(testFolder, "somefile.test")
|
|
||||||
embeddedPath = filepath.Join(testFolder, "somefile.mp3")
|
|
||||||
})
|
|
||||||
AfterEach(func() {
|
|
||||||
_ = os.RemoveAll(testFolder)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns audio file for embedded cover", func() {
|
|
||||||
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
|
|
||||||
Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal(""))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns external file when no embedded cover exists", func() {
|
|
||||||
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
|
|
||||||
Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "Cover.jpeg")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns embedded cover even if not first choice", func() {
|
|
||||||
conf.Server.CoverArtPriority = "something.png, embedded, cover.*, front.*"
|
|
||||||
Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal(""))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns first correct match case-insensitively", func() {
|
|
||||||
conf.Server.CoverArtPriority = "embedded, cover.jpg, front.svg, front.png"
|
|
||||||
Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "FRONT.PNG")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns match for embedded pattern", func() {
|
|
||||||
conf.Server.CoverArtPriority = "embedded, cover.jp?g, front.png"
|
|
||||||
Expect(getCoverFromPath(testPath, "")).To(Equal(filepath.Join(testFolder, "Cover.jpeg")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty string if no match was found", func() {
|
|
||||||
conf.Server.CoverArtPriority = "embedded, cover.jpg, front.apng"
|
|
||||||
Expect(getCoverFromPath(testPath, "")).To(Equal(""))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset configuration to default.
|
|
||||||
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
|
|
||||||
})
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ package model_test
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
. "github.com/navidrome/navidrome/model"
|
. "github.com/navidrome/navidrome/model"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
@ -26,7 +28,7 @@ var _ = Describe("MediaFiles", func() {
|
||||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||||
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||||
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true,
|
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music/file.mp3",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -49,7 +51,8 @@ var _ = Describe("MediaFiles", func() {
|
||||||
Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment"))
|
Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment"))
|
||||||
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
||||||
Expect(album.Compilation).To(BeTrue())
|
Expect(album.Compilation).To(BeTrue())
|
||||||
Expect(album.CoverArtId).To(Equal("2"))
|
Expect(album.EmbedArtId).To(Equal("2"))
|
||||||
|
Expect(album.EmbedArtPath).To(Equal("/music/file.mp3"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Context("Aggregated attributes", func() {
|
Context("Aggregated attributes", func() {
|
||||||
|
@ -220,6 +223,34 @@ var _ = Describe("MediaFiles", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var _ = Describe("MediaFile", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.DevFastAccessCoverArt = false
|
||||||
|
})
|
||||||
|
Describe(".CoverArtId()", func() {
|
||||||
|
It("returns its own id if it HasCoverArt", func() {
|
||||||
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||||
|
id := mf.CoverArtID()
|
||||||
|
Expect(id.Kind).To(Equal(KindMediaFileArtwork))
|
||||||
|
Expect(id.ID).To(Equal(mf.ID))
|
||||||
|
})
|
||||||
|
It("returns its album id if HasCoverArt is false", func() {
|
||||||
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
|
||||||
|
id := mf.CoverArtID()
|
||||||
|
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||||
|
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||||
|
})
|
||||||
|
It("returns its album id if DevFastAccessCoverArt is enabled", func() {
|
||||||
|
conf.Server.DevFastAccessCoverArt = true
|
||||||
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||||
|
id := mf.CoverArtID()
|
||||||
|
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||||
|
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
func t(v string) time.Time {
|
func t(v string) time.Time {
|
||||||
var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"}
|
var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"}
|
||||||
for _, f := range timeFormats {
|
for _, f := range timeFormats {
|
||||||
|
|
|
@ -46,9 +46,9 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the"}
|
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtId: "1", EmbedArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the"}
|
||||||
albumAbbeyRoad = model.Album{ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the"}
|
albumAbbeyRoad = model.Album{ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtId: "2", EmbedArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the"}
|
||||||
albumRadioactivity = model.Album{ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity"}
|
albumRadioactivity = model.Album{ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, EmbedArtId: "3", EmbedArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity"}
|
||||||
testAlbums = model.Albums{
|
testAlbums = model.Albums{
|
||||||
albumSgtPeppers,
|
albumSgtPeppers,
|
||||||
albumAbbeyRoad,
|
albumAbbeyRoad,
|
||||||
|
|
|
@ -360,7 +360,7 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album)
|
||||||
}
|
}
|
||||||
dir.UserRating = album.Rating
|
dir.UserRating = album.Rating
|
||||||
dir.SongCount = album.SongCount
|
dir.SongCount = album.SongCount
|
||||||
dir.CoverArt = album.CoverArtId
|
dir.CoverArt = album.CoverArtID().String()
|
||||||
if album.Starred {
|
if album.Starred {
|
||||||
dir.Starred = &album.StarredAt
|
dir.Starred = &album.StarredAt
|
||||||
}
|
}
|
||||||
|
@ -380,7 +380,7 @@ func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model
|
||||||
dir.Name = album.Name
|
dir.Name = album.Name
|
||||||
dir.Artist = album.AlbumArtist
|
dir.Artist = album.AlbumArtist
|
||||||
dir.ArtistId = album.AlbumArtistID
|
dir.ArtistId = album.AlbumArtistID
|
||||||
dir.CoverArt = album.CoverArtId
|
dir.CoverArt = album.CoverArtID().String()
|
||||||
dir.SongCount = album.SongCount
|
dir.SongCount = album.SongCount
|
||||||
dir.Duration = int(album.Duration)
|
dir.Duration = int(album.Duration)
|
||||||
dir.PlayCount = album.PlayCount
|
dir.PlayCount = album.PlayCount
|
||||||
|
|
|
@ -80,7 +80,7 @@ func toArtists(ctx context.Context, artists model.Artists) []responses.Artist {
|
||||||
return as
|
return as
|
||||||
}
|
}
|
||||||
|
|
||||||
func toArtist(ctx context.Context, a model.Artist) responses.Artist {
|
func toArtist(_ context.Context, a model.Artist) responses.Artist {
|
||||||
artist := responses.Artist{
|
artist := responses.Artist{
|
||||||
Id: a.ID,
|
Id: a.ID,
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
|
@ -147,11 +147,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||||
child.Size = mf.Size
|
child.Size = mf.Size
|
||||||
child.Suffix = mf.Suffix
|
child.Suffix = mf.Suffix
|
||||||
child.BitRate = mf.BitRate
|
child.BitRate = mf.BitRate
|
||||||
if mf.HasCoverArt {
|
child.CoverArt = mf.CoverArtID().String()
|
||||||
child.CoverArt = mf.ID
|
|
||||||
} else {
|
|
||||||
child.CoverArt = "al-" + mf.AlbumID
|
|
||||||
}
|
|
||||||
child.ContentType = mf.ContentType()
|
child.ContentType = mf.ContentType()
|
||||||
player, ok := request.PlayerFrom(ctx)
|
player, ok := request.PlayerFrom(ctx)
|
||||||
if ok && player.ReportRealPath {
|
if ok && player.ReportRealPath {
|
||||||
|
@ -202,7 +198,7 @@ func childrenFromMediaFiles(ctx context.Context, mfs model.MediaFiles) []respons
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|
||||||
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
func childFromAlbum(_ context.Context, al model.Album) responses.Child {
|
||||||
child := responses.Child{}
|
child := responses.Child{}
|
||||||
child.Id = al.ID
|
child.Id = al.ID
|
||||||
child.IsDir = true
|
child.IsDir = true
|
||||||
|
@ -212,7 +208,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||||
child.Artist = al.AlbumArtist
|
child.Artist = al.AlbumArtist
|
||||||
child.Year = al.MaxYear
|
child.Year = al.MaxYear
|
||||||
child.Genre = al.Genre
|
child.Genre = al.Genre
|
||||||
child.CoverArt = al.CoverArtId
|
child.CoverArt = al.CoverArtID().String()
|
||||||
child.Created = &al.CreatedAt
|
child.Created = &al.CreatedAt
|
||||||
child.Parent = al.AlbumArtistID
|
child.Parent = al.AlbumArtistID
|
||||||
child.ArtistId = al.AlbumArtistID
|
child.ArtistId = al.AlbumArtistID
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue