mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
186 lines
5.7 KiB
Go
186 lines
5.7 KiB
Go
package artwork
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"go.senan.xyz/taglib"
|
|
)
|
|
|
|
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
|
|
for _, f := range extractFuncs {
|
|
if ctx.Err() != nil {
|
|
return nil, "", ctx.Err()
|
|
}
|
|
start := time.Now()
|
|
r, path, err := f()
|
|
if r != nil {
|
|
msg := fmt.Sprintf("Found %s artwork", artID.Kind)
|
|
log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start))
|
|
return r, path, nil
|
|
}
|
|
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
|
}
|
|
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
|
|
}
|
|
|
|
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.")
|
|
if _, after, found := strings.Cut(name, ")."); found {
|
|
name = after
|
|
}
|
|
name = strings.TrimSuffix(name, ".func1")
|
|
return name
|
|
}
|
|
|
|
func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
|
|
return func() (io.ReadCloser, string, error) {
|
|
for _, file := range 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 := libFS.Open(file)
|
|
if err != nil {
|
|
log.Warn(ctx, "Could not open cover art file", "file", file, err)
|
|
continue
|
|
}
|
|
return f, file, nil
|
|
}
|
|
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
|
|
}
|
|
}
|
|
|
|
// These regexes are used to match the picture type in the file, in the order they are listed.
|
|
var picTypeRegexes = []*regexp.Regexp{
|
|
regexp.MustCompile(`(?i).*cover.*front.*|.*front.*cover.*`),
|
|
regexp.MustCompile(`(?i).*front.*`),
|
|
regexp.MustCompile(`(?i).*cover.*`),
|
|
}
|
|
|
|
func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
|
|
return func() (io.ReadCloser, string, error) {
|
|
if relPath == "" {
|
|
return nil, "", nil
|
|
}
|
|
f, err := libFS.Open(relPath)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
rs, ok := f.(io.ReadSeeker)
|
|
if !ok {
|
|
f.Close()
|
|
return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath)
|
|
}
|
|
tf, err := taglib.OpenStream(rs,
|
|
taglib.WithReadStyle(taglib.ReadStyleFast),
|
|
taglib.WithFilename(relPath),
|
|
)
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, "", err
|
|
}
|
|
// Close in LIFO order: tf first (it holds rs internally), then f.
|
|
defer f.Close()
|
|
defer tf.Close()
|
|
|
|
images := tf.Properties().Images
|
|
if len(images) == 0 {
|
|
return nil, "", fmt.Errorf("no embedded image found in %s", relPath)
|
|
}
|
|
|
|
imageIndex := findBestImageIndex(ctx, images, relPath)
|
|
data, err := tf.Image(imageIndex)
|
|
if err != nil || len(data) == 0 {
|
|
return nil, "", fmt.Errorf("could not load embedded image from %s", relPath)
|
|
}
|
|
return io.NopCloser(bytes.NewReader(data)), relPath, nil
|
|
}
|
|
}
|
|
|
|
func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path string) int {
|
|
for _, regex := range picTypeRegexes {
|
|
for i, img := range images {
|
|
if regex.MatchString(img.Type) {
|
|
log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path)
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
|
|
return 0
|
|
}
|
|
|
|
// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess
|
|
// and cannot read from arbitrary fs.FS implementations; piping via stdin is a
|
|
// non-trivial refactor with stream/seek implications.
|
|
//
|
|
// TODO(artwork-musicfs): when the storage backing the library is not local
|
|
// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source
|
|
// func to return (nil, "", nil) so callers fall through cleanly.
|
|
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
|
|
}
|
|
// Validate that the stream actually contains image data by reading the first byte.
|
|
// ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the
|
|
// file has no video/image stream (e.g., an MP3 without embedded art).
|
|
buf := make([]byte, 1)
|
|
n, err := r.Read(buf)
|
|
if n == 0 || err != nil {
|
|
r.Close()
|
|
return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err)
|
|
}
|
|
return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil
|
|
}
|
|
}
|
|
|
|
// readCloser combines a Reader and a Closer into an io.ReadCloser.
|
|
type readCloser struct {
|
|
io.Reader
|
|
io.Closer
|
|
}
|
|
|
|
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
|
hc := http.Client{Timeout: 5 * time.Second}
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
|
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
|
resp, err := hc.Do(req) //nolint:gosec
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status)
|
|
}
|
|
return resp.Body, imageUrl.String(), nil
|
|
}
|