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 }