git/intake/main
internal/artwork/local.go
314 lines8 KB last change raw · history
package artwork

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"

	"intake/internal/audio"
)

var imageExts = map[string]bool{
	".jpg": true, ".jpeg": true, ".png": true, ".webp": true,
	".gif": true, ".bmp": true, ".tif": true, ".tiff": true,
}

var folderNames = []string{
	"cover", "folder", "front", "album", "albumart", "albumartsmall",
	"case", "artwork", "thumb", "scan",
}

func embeddedCandidates(ctx context.Context, tracks []audio.Track) []Candidate {
	if len(tracks) == 0 {
		return nil
	}
	dir, err := os.MkdirTemp("", "intake-embedded-art-")
	if err != nil {
		return nil
	}

	type entry struct {
		candidate Candidate
		count     int
	}
	seen := map[string]*entry{}
	var order []string

	for _, t := range tracks {
		if t.ArtCount == 0 || !strings.EqualFold(t.Ext, ".flac") {
			continue
		}
		data, mime, err := extractEmbedded(ctx, t.Path)
		if err != nil || len(data) == 0 {
			continue
		}
		key := fmt.Sprintf("%x", sha1sum(data))
		if e, ok := seen[key]; ok {
			e.count++
			continue
		}
		ext := ".jpg"
		switch strings.ToLower(mime) {
		case "image/png":
			ext = ".png"
		case "image/webp":
			ext = ".webp"
		case "image/gif":
			ext = ".gif"
		}
		path := filepath.Join(dir, "embedded-"+key[:12]+ext)
		if err := os.WriteFile(path, data, 0o644); err != nil {
			continue
		}
		c := Candidate{
			Source:        SourceEmbedded,
			Label:         "embedded in the files",
			Path:          path,
			Bytes:         int64(len(data)),
			Preferred:     true,
			EmbeddedCount: 1,
			MatchScore:    3,
		}
		if !fillDimensions(ctx, &c) {
			continue
		}
		seen[key] = &entry{candidate: c, count: 1}
		order = append(order, key)
	}

	out := make([]Candidate, 0, len(order))
	for _, key := range order {
		e := seen[key]
		e.candidate.EmbeddedCount = e.count
		e.candidate.Label = fmt.Sprintf("embedded in %d file(s)", e.count)
		out = append(out, e.candidate)
	}
	return out
}

func extractEmbedded(ctx context.Context, path string) ([]byte, string, error) {
	cmd := exec.CommandContext(ctx, "metaflac", "--export-picture-to=-", path)
	var out, errb bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errb
	if err := cmd.Run(); err != nil {
		return nil, "", fmt.Errorf("metaflac export-picture: %s", strings.TrimSpace(errb.String()))
	}
	if out.Len() == 0 {
		return nil, "", fmt.Errorf("no picture block")
	}
	mime := sniffMIME(out.Bytes())
	return out.Bytes(), mime, nil
}

func sniffMIME(data []byte) string {
	switch {
	case len(data) > 3 && data[0] == 0xFF && data[1] == 0xD8:
		return "image/jpeg"
	case len(data) > 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n":
		return "image/png"
	case len(data) > 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
		return "image/webp"
	case len(data) > 6 && string(data[0:6]) == "GIF89a":
		return "image/gif"
	case len(data) > 6 && string(data[0:6]) == "GIF87a":
		return "image/gif"
	}
	return "application/octet-stream"
}

// folderCandidates finds cover art beside the album, and one level below it.
//
// Art beside the tracks wins outright. Only when there is none does it look in
// subdirectories, because that is how scans usually arrive: a "Scans" or
// "Artwork" folder holding front.jpg. That used to be invisible, so an album
// whose sleeve was sitting right there was filed with a generated placeholder
// and the real art stayed in staging for ever.
func folderCandidates(ctx context.Context, dir, albumRoot string) []Candidate {
	if out := imagesIn(ctx, dir, ""); len(out) > 0 {
		sortFolderCandidates(out, filepath.Base(dir))
		return out
	}

	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil
	}
	var out []Candidate
	for _, e := range entries {
		if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
			continue
		}
		sub := filepath.Join(dir, e.Name())
		found := imagesIn(ctx, sub, e.Name())
		if len(found) == 0 {
			continue
		}
		// Folders whose name says what they hold come first, so "Scans/front.jpg"
		// is preferred to "extras/back.jpg".
		sortFolderCandidates(found, e.Name())
		out = append(out, found...)
	}
	if len(out) > 0 {
		sortFolderCandidates(out, filepath.Base(dir))
	}
	return out
}

// imagesIn collects the images directly inside one directory. prefix names the
// subdirectory they came from, so the list can say where a candidate was found.
func imagesIn(ctx context.Context, dir, prefix string) []Candidate {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil
	}
	var out []Candidate
	for _, e := range entries {
		if e.IsDir() || !imageExts[strings.ToLower(filepath.Ext(e.Name()))] {
			continue
		}
		if strings.HasPrefix(e.Name(), ".") {
			continue
		}
		path := filepath.Join(dir, e.Name())
		info, err := e.Info()
		if err != nil {
			continue
		}
		label := e.Name()
		if prefix != "" {
			label = prefix + string(filepath.Separator) + e.Name()
		}
		c := Candidate{
			Source:     SourceFolder,
			Label:      label,
			Path:       path,
			Bytes:      info.Size(),
			MatchScore: 3,
		}
		if !fillDimensions(ctx, &c) {
			continue
		}
		out = append(out, c)
	}
	return out
}

func sortFolderCandidates(list []Candidate, _ string) {
	rank := func(name string) int {
		base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))
		for i, want := range folderNames {
			if base == want {
				return i
			}
		}
		for i, want := range folderNames {
			if strings.Contains(base, want) {
				return len(folderNames) + i
			}
		}
		return len(folderNames) * 2
	}
	for i := range list {
		for j := i + 1; j < len(list); j++ {
			ri, rj := rank(list[i].Label), rank(list[j].Label)
			if rj < ri || (rj == ri && list[j].Width*list[j].Height > list[i].Width*list[i].Height) {
				list[i], list[j] = list[j], list[i]
			}
		}
	}
}

func localCandidates(ctx context.Context, folder string, album AlbumInfo) []Candidate {
	entries, err := os.ReadDir(folder)
	if err != nil {
		return nil
	}
	wantArtist := strings.ToLower(album.Artist)
	wantAlbum := strings.ToLower(album.Album)
	var out []Candidate
	for _, e := range entries {
		if e.IsDir() || !imageExts[strings.ToLower(filepath.Ext(e.Name()))] {
			continue
		}
		name := strings.ToLower(e.Name())
		if wantAlbum != "" && !strings.Contains(name, sanitizeForMatch(wantAlbum)) {
			continue
		}
		if wantArtist != "" && !strings.Contains(name, sanitizeForMatch(wantArtist)) {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		c := Candidate{
			Source:     SourceLocalFolder,
			Label:      e.Name(),
			Path:       filepath.Join(folder, e.Name()),
			Bytes:      info.Size(),
			MatchScore: 3,
		}
		if !fillDimensions(ctx, &c) {
			continue
		}
		out = append(out, c)
	}
	return out
}

func sanitizeForMatch(s string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(s) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			b.WriteRune(r)
		case r == ' ' || r == '-' || r == '_':
			b.WriteRune('-')
		}
	}
	return strings.Trim(b.String(), "-")
}

// fillDimensions records an image's size, and reports whether the file is an
// image that can be used at all.
//
// This is the only thing standing between a junk file and a broken album: a text
// file called cover.jpg used to be offered as a candidate, chosen because it was
// the only folder image, and then fail the album when embedding it produced
// "unable to guess MIME type". Something magick cannot identify cannot be a
// cover.
func fillDimensions(ctx context.Context, c *Candidate) bool {
	if info, err := os.Stat(c.Path); err == nil {
		c.Bytes = info.Size()
	}
	if _, err := exec.LookPath("magick"); err != nil {
		// Without it nothing can be measured, so the file is given the benefit
		// of the doubt and the optimiser reports the real problem instead.
		return true
	}
	cmd := exec.CommandContext(ctx, "magick", "identify", "-format", "%w %h", c.Path)
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &bytes.Buffer{}
	if err := cmd.Run(); err != nil {
		return false
	}
	fields := strings.Fields(out.String())
	if len(fields) != 2 {
		return false
	}
	w, err1 := strconv.Atoi(fields[0])
	h, err2 := strconv.Atoi(fields[1])
	if err1 != nil || err2 != nil {
		return false
	}
	c.Width, c.Height = w, h
	return true
}