diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 092117c38..f0db7a774 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/agents/lastfm" "github.com/navidrome/navidrome/core/agents/listenbrainz" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/db" @@ -45,9 +46,9 @@ func CreateNativeAPIRouter() *nativeapi.Router { func CreateSubsonicAPIRouter() *subsonic.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - fileCache := core.GetImageCache() + fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - artwork := core.NewArtwork(dataStore, fileCache, fFmpeg) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg) transcodingCache := core.GetTranscodingCache() mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) archiver := core.NewArchiver(mediaStreamer, dataStore) @@ -58,7 +59,7 @@ func CreateSubsonicAPIRouter() *subsonic.Router { broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) playTracker := scrobbler.GetPlayTracker(dataStore, broker) - router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker) return router } @@ -80,10 +81,10 @@ func createScanner() scanner.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) playlists := core.NewPlaylists(dataStore) - fileCache := core.GetImageCache() + fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - artwork := core.NewArtwork(dataStore, fileCache, fFmpeg) - cacheWarmer := core.NewArtworkCacheWarmer(artwork) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork) broker := events.GetBroker() scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker) return scannerScanner diff --git a/core/artwork.go b/core/artwork/artwork.go similarity index 64% rename from core/artwork.go rename to core/artwork/artwork.go index 72cb9df16..ba47348e8 100644 --- a/core/artwork.go +++ b/core/artwork/artwork.go @@ -1,4 +1,4 @@ -package core +package artwork import ( "bufio" @@ -12,21 +12,15 @@ import ( "image/png" "io" "net/http" - "os" - "path/filepath" - "reflect" - "runtime" "strings" "time" - "github.com/dhowden/tag" "github.com/disintegration/imaging" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" _ "golang.org/x/image/webp" @@ -59,24 +53,15 @@ func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, } } - key := &artworkKey{a: a, artID: artID, size: size} + item := &artItem{a: a, artID: artID, size: size} - r, err := a.cache.Get(ctx, key) + r, err := a.cache.Get(ctx, item) if err != nil && !errors.Is(err, context.Canceled) { log.Error(ctx, "Error accessing image cache", "id", id, "size", size, err) } return r, err } -type fromFunc func() (io.ReadCloser, string, error) - -func (f fromFunc) String() string { - name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() - name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core.") - name = strings.TrimSuffix(name, ".func1") - return name -} - func (a *artwork) get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, path string, err error) { // If requested a resized image, get the original (possibly from cache) if size > 0 { @@ -110,7 +95,7 @@ func (a *artwork) extractAlbumImage(ctx context.Context, artID model.ArtworkID) log.Error(ctx, "Could not retrieve album", "id", artID.ID, err) return nil, "" } - var ff = a.fromCoverArtPriority(ctx, conf.Server.CoverArtPriority, *al) + var ff = fromCoverArtPriority(ctx, a.ffmpeg, conf.Server.CoverArtPriority, *al) ff = append(ff, fromPlaceholder()) return extractImage(ctx, artID, ff...) } @@ -126,9 +111,9 @@ func (a *artwork) extractMediaFileImage(ctx context.Context, artID model.Artwork return nil, "" } - var ff []fromFunc + var ff []sourceFunc if mf.CoverArtID().Kind == model.KindMediaFileArtwork { - ff = []fromFunc{ + ff = []sourceFunc{ fromTag(mf.Path), fromFFmpegTag(ctx, a.ffmpeg, mf.Path), } @@ -152,38 +137,28 @@ func (a *artwork) resizedFromOriginal(ctx context.Context, artID model.ArtworkID return resized, nil } -func extractImage(ctx context.Context, artID model.ArtworkID, extractFuncs ...fromFunc) (io.ReadCloser, string) { +func extractImage(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string) { for _, f := range extractFuncs { if ctx.Err() != nil { return nil, "" } r, path, err := f() if r != nil { - log.Trace(ctx, "Found artwork", "artID", artID, "path", path, "origin", f) + log.Trace(ctx, "Found artwork", "artID", artID, "path", path, "source", f) return r, path } - log.Trace(ctx, "Tried to extract artwork", "artID", artID, "origin", f, err) + log.Trace(ctx, "Tried to extract artwork", "artID", artID, "source", f, err) } log.Error(ctx, "extractImage should never reach this point!", "artID", artID, "path") return nil, "" } -func (a *artwork) fromAlbum(ctx context.Context, id model.ArtworkID) fromFunc { - return func() (io.ReadCloser, string, error) { - r, path, err := a.get(ctx, id, 0) - if err != nil { - return nil, "", err - } - return r, path, nil - } -} - -func (a *artwork) fromCoverArtPriority(ctx context.Context, priority string, al model.Album) []fromFunc { - var ff []fromFunc +func fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string, al model.Album) []sourceFunc { + var ff []sourceFunc for _, pattern := range strings.Split(strings.ToLower(priority), ",") { pattern = strings.TrimSpace(pattern) if pattern == "embedded" { - ff = append(ff, fromTag(al.EmbedArtPath), fromFFmpegTag(ctx, a.ffmpeg, al.EmbedArtPath)) + ff = append(ff, fromTag(al.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, al.EmbedArtPath)) continue } if al.ImageFiles != "" { @@ -193,79 +168,6 @@ func (a *artwork) fromCoverArtPriority(ctx context.Context, priority string, al return ff } -func fromExternalFile(ctx context.Context, files string, pattern string) fromFunc { - return func() (io.ReadCloser, string, error) { - for _, file := range filepath.SplitList(files) { - _, name := filepath.Split(file) - match, err := filepath.Match(pattern, strings.ToLower(name)) - if err != nil { - log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file) - continue - } - if !match { - continue - } - f, err := os.Open(file) - if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", file, err) - continue - } - return f, file, err - } - return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) - } -} - -func fromTag(path string) fromFunc { - return func() (io.ReadCloser, string, error) { - if path == "" { - return nil, "", nil - } - 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, "", fmt.Errorf("no embedded image found in %s", path) - } - return io.NopCloser(bytes.NewReader(picture.Data)), path, nil - } -} - -func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) fromFunc { - return func() (io.ReadCloser, string, error) { - if path == "" { - return nil, "", nil - } - r, err := ffmpeg.ExtractImage(ctx, path) - if err != nil { - return nil, "", err - } - defer r.Close() - buf := new(bytes.Buffer) - _, err = io.Copy(buf, r) - if err != nil { - return nil, "", err - } - return io.NopCloser(buf), path, nil - } -} - -func fromPlaceholder() fromFunc { - return func() (io.ReadCloser, string, error) { - r, _ := resources.FS().Open(consts.PlaceholderAlbumArt) - return r, consts.PlaceholderAlbumArt, nil - } -} - func asImageReader(r io.Reader) (io.Reader, string, error) { br := bufio.NewReader(r) buf, err := br.Peek(512) @@ -305,26 +207,26 @@ func resizeImage(reader io.Reader, size int) (io.Reader, error) { return buf, err } -type ArtworkCache struct { +type imageCache struct { cache.FileCache } -type artworkKey struct { +type artItem struct { a *artwork artID model.ArtworkID size int } -func (k *artworkKey) Key() string { +func (k *artItem) Key() string { return fmt.Sprintf("%s.%d.%d", k.artID, k.size, conf.Server.CoverJpegQuality) } func GetImageCache() cache.FileCache { - return singleton.GetInstance(func() *ArtworkCache { - return &ArtworkCache{ + return singleton.GetInstance(func() *imageCache { + return &imageCache{ FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { - info := arg.(*artworkKey) + info := arg.(*artItem) r, _, err := info.a.get(ctx, info.artID, info.size) return r, err }), diff --git a/core/artwork_cache_warmer.go b/core/artwork/artwork_cache_warmer.go similarity index 71% rename from core/artwork_cache_warmer.go rename to core/artwork/artwork_cache_warmer.go index ac01e3e47..f1e12449a 100644 --- a/core/artwork_cache_warmer.go +++ b/core/artwork/artwork_cache_warmer.go @@ -1,4 +1,4 @@ -package core +package artwork import ( "context" @@ -11,17 +11,17 @@ import ( "github.com/navidrome/navidrome/utils/pl" ) -type ArtworkCacheWarmer interface { +type CacheWarmer interface { PreCache(artID model.ArtworkID) } -func NewArtworkCacheWarmer(artwork Artwork) ArtworkCacheWarmer { +func NewCacheWarmer(artwork Artwork) CacheWarmer { // If image cache is disabled, return a NOOP implementation if conf.Server.ImageCacheSize == "0" { return &noopCacheWarmer{} } - a := &artworkCacheWarmer{ + a := &cacheWarmer{ artwork: artwork, input: make(chan string), } @@ -29,23 +29,23 @@ func NewArtworkCacheWarmer(artwork Artwork) ArtworkCacheWarmer { return a } -type artworkCacheWarmer struct { +type cacheWarmer struct { artwork Artwork input chan string } -func (a *artworkCacheWarmer) PreCache(artID model.ArtworkID) { +func (a *cacheWarmer) PreCache(artID model.ArtworkID) { a.input <- artID.String() } -func (a *artworkCacheWarmer) run(ctx context.Context) { +func (a *cacheWarmer) run(ctx context.Context) { errs := pl.Sink(ctx, 2, a.input, a.doCacheImage) for err := range errs { log.Warn(ctx, "Error warming cache", err) } } -func (a *artworkCacheWarmer) doCacheImage(ctx context.Context, id string) error { +func (a *cacheWarmer) doCacheImage(ctx context.Context, id string) error { r, err := a.artwork.Get(ctx, id, 0) if err != nil { return fmt.Errorf("error cacheing id='%s': %w", id, err) diff --git a/core/artwork_internal_test.go b/core/artwork/artwork_internal_test.go similarity index 99% rename from core/artwork_internal_test.go rename to core/artwork/artwork_internal_test.go index 3488c81a6..9a6b4b1e7 100644 --- a/core/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -1,4 +1,4 @@ -package core +package artwork import ( "context" diff --git a/core/artwork/sources.go b/core/artwork/sources.go new file mode 100644 index 000000000..8fcb1d2c6 --- /dev/null +++ b/core/artwork/sources.go @@ -0,0 +1,113 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + + "github.com/dhowden/tag" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" +) + +type sourceFunc func() (io.ReadCloser, string, error) + +func (f sourceFunc) String() string { + name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() + name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core.") + name = strings.TrimPrefix(name, "(*artwork).") + name = strings.TrimSuffix(name, ".func1") + return name +} + +func (a *artwork) fromAlbum(ctx context.Context, id model.ArtworkID) sourceFunc { + return func() (io.ReadCloser, string, error) { + r, path, err := a.get(ctx, id, 0) + if err != nil { + return nil, "", err + } + return r, path, nil + } +} + +func fromExternalFile(ctx context.Context, files string, pattern string) sourceFunc { + return func() (io.ReadCloser, string, error) { + for _, file := range filepath.SplitList(files) { + _, name := filepath.Split(file) + match, err := filepath.Match(pattern, strings.ToLower(name)) + if err != nil { + log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file) + continue + } + if !match { + continue + } + f, err := os.Open(file) + if err != nil { + log.Warn(ctx, "Could not open cover art file", "file", file, err) + continue + } + return f, file, err + } + return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) + } +} + +func fromTag(path string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if path == "" { + return nil, "", nil + } + 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, "", fmt.Errorf("no embedded image found in %s", path) + } + return io.NopCloser(bytes.NewReader(picture.Data)), path, nil + } +} + +func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if path == "" { + return nil, "", nil + } + r, err := ffmpeg.ExtractImage(ctx, path) + if err != nil { + return nil, "", err + } + defer r.Close() + buf := new(bytes.Buffer) + _, err = io.Copy(buf, r) + if err != nil { + return nil, "", err + } + return io.NopCloser(buf), path, nil + } +} + +func fromPlaceholder() sourceFunc { + return func() (io.ReadCloser, string, error) { + r, _ := resources.FS().Open(consts.PlaceholderAlbumArt) + return r, consts.PlaceholderAlbumArt, nil + } +} diff --git a/core/wire_providers.go b/core/wire_providers.go index cd0586e22..0a4c365f1 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -3,15 +3,16 @@ package core import ( "github.com/google/wire" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/scrobbler" ) var Set = wire.NewSet( - NewArtwork, + artwork.NewArtwork, NewMediaStreamer, GetTranscodingCache, - GetImageCache, + artwork.GetImageCache, NewArchiver, NewExternalMetadata, NewPlayers, @@ -20,5 +21,5 @@ var Set = wire.NewSet( scrobbler.GetPlayTracker, NewShare, NewPlaylists, - NewArtworkCacheWarmer, + artwork.NewCacheWarmer, ) diff --git a/scanner/refresher.go b/scanner/refresher.go index a3f7821a5..2f286dad0 100644 --- a/scanner/refresher.go +++ b/scanner/refresher.go @@ -8,7 +8,7 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -25,10 +25,10 @@ type refresher struct { album map[string]struct{} artist map[string]struct{} dirMap dirMap - cacheWarmer core.ArtworkCacheWarmer + cacheWarmer artwork.CacheWarmer } -func newRefresher(ds model.DataStore, cw core.ArtworkCacheWarmer, dirMap dirMap) *refresher { +func newRefresher(ds model.DataStore, cw artwork.CacheWarmer, dirMap dirMap) *refresher { return &refresher{ ds: ds, album: map[string]struct{}{}, diff --git a/scanner/scanner.go b/scanner/scanner.go index 65dc0c55c..0b6561517 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -9,6 +9,7 @@ import ( "time" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" @@ -46,7 +47,7 @@ type scanner struct { ds model.DataStore pls core.Playlists broker events.Broker - cacheWarmer core.ArtworkCacheWarmer + cacheWarmer artwork.CacheWarmer } type scanStatus struct { @@ -56,7 +57,7 @@ type scanStatus struct { lastUpdate time.Time } -func New(ds model.DataStore, playlists core.Playlists, cacheWarmer core.ArtworkCacheWarmer, broker events.Broker) Scanner { +func New(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer, broker events.Broker) Scanner { s := &scanner{ ds: ds, pls: playlists, diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go index 134a17e50..9e17f3ea4 100644 --- a/scanner/tag_scanner.go +++ b/scanner/tag_scanner.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -27,10 +28,10 @@ type TagScanner struct { plsSync *playlistImporter cnt *counters mapper *mediaFileMapper - cacheWarmer core.ArtworkCacheWarmer + cacheWarmer artwork.CacheWarmer } -func NewTagScanner(rootFolder string, ds model.DataStore, playlists core.Playlists, cacheWarmer core.ArtworkCacheWarmer) FolderScanner { +func NewTagScanner(rootFolder string, ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer) FolderScanner { s := &TagScanner{ rootFolder: rootFolder, plsSync: newPlaylistImporter(ds, playlists, rootFolder), diff --git a/server/subsonic/api.go b/server/subsonic/api.go index e71180635..9ab2c5f0b 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -30,7 +31,7 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, type Router struct { http.Handler ds model.DataStore - artwork core.Artwork + artwork artwork.Artwork streamer core.MediaStreamer archiver core.Archiver players core.Players @@ -41,7 +42,7 @@ type Router struct { scrobbler scrobbler.PlayTracker } -func New(ds model.DataStore, artwork core.Artwork, streamer core.MediaStreamer, archiver core.Archiver, +func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, players core.Players, externalMetadata core.ExternalMetadata, scanner scanner.Scanner, broker events.Broker, playlists core.Playlists, scrobbler scrobbler.PlayTracker) *Router { r := &Router{