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
}