Add album art search, auto-match and a whole-album tag editor

Search iTunes, Deezer, MusicBrainz, DuckDuckGo and Google for square covers,
embed them and/or save cover.jpg, auto-match covers for albums, and edit an
album's shared tags and track list in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 15:30:19 -07:00
1 parent 5fb195f0d6
commit d8ac25a55d
21 files changed
+2719 -38

No files matched your search

+117 -17
View File
@@ -194,16 +194,29 @@ func (e *Engine) ReadTags(ctx context.Context, pairID int64, side string, rels [
type TagEdit struct {
Side string `json:"side"`
Paths []string `json:"paths"`
Set map[string]string `json:"set"` // field -> new value ("" removes the tag)
Set map[string]string `json:"set"` // field -> new value ("" removes the tag), for every file
// Files adds per-song changes (an album's track titles and numbers).
Files []FileEdit `json:"files,omitempty"`
// Art applies album art to the edited files and/or their album folder.
Art *ArtApply `json:"art,omitempty"`
// Separate gives a hardlinked file its own edited copy instead of
// updating it in place for every linked path.
Separate bool `json:"separate"`
}
type FileEdit struct {
Path string `json:"path"`
Set map[string]string `json:"set"`
}
// ArtApply is a prepared cover (see art.Store) and where it goes.
type ArtApply struct {
Token string `json:"token"`
Embed bool `json:"embed"` // into each song (MP3, FLAC, M4A)
FolderFile bool `json:"folder_file"` // as cover.jpg in the album folder
}
func validTagChanges(set map[string]string) error {
if len(set) == 0 {
return fmt.Errorf("%w: no tags changed", ErrInvalidBulk)
}
known := map[string]bool{}
for _, id := range tags.FieldIDs {
known[id] = true
@@ -227,39 +240,118 @@ func (e *Engine) StartTagEdit(pairID int64, req TagEdit) (*Job, error) {
if req.Side != SideMaster && req.Side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
rels, err := cleanRels(req.Paths)
if err != nil {
return nil, err
// Every file gets the shared changes plus its own.
perFile := map[string]map[string]string{}
var order []string
add := func(p string, set map[string]string) error {
c, err := fsops.CleanRel(p)
if err != nil {
return err
}
if _, ok := perFile[c]; !ok {
perFile[c] = map[string]string{}
order = append(order, c)
}
for k, v := range set {
perFile[c][k] = v
}
return nil
}
if err := validTagChanges(req.Set); err != nil {
return nil, err
for _, p := range req.Paths {
if err := add(p, req.Set); err != nil {
return nil, err
}
}
for _, f := range req.Files {
if err := add(f.Path, req.Set); err != nil {
return nil, err
}
if err := add(f.Path, f.Set); err != nil {
return nil, err
}
}
if len(order) == 0 {
return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk)
}
if len(order) > maxTagFiles {
return nil, fmt.Errorf("%w: select at most %d files at a time", ErrInvalidBulk, maxTagFiles)
}
changed := false
for _, set := range perFile {
if len(set) > 0 {
if err := validTagChanges(set); err != nil {
return nil, err
}
changed = true
}
}
var cover string
if req.Art != nil && (req.Art.Embed || req.Art.FolderFile) {
p, err := e.preparedArt(req.Art.Token)
if err != nil {
return nil, err
}
cover = p
}
if !changed && cover == "" {
return nil, fmt.Errorf("%w: nothing changed", ErrInvalidBulk)
}
return e.startJob(pairID, "edit-tags", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
j.phase("Editing tags", int64(len(rels)))
for _, rel := range rels {
embed := ""
if cover != "" && req.Art.Embed {
embed = cover
}
j.phase("Editing tags", int64(len(order)))
for _, rel := range order {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.editTagsOne(ctx, j, l, req.Side, rel, req.Set, req.Separate); err != nil {
if err := e.editTagsOne(ctx, j, l, req.Side, rel, perFile[rel], embed, req.Separate); err != nil {
j.skip(rel + ": " + err.Error())
}
j.tick()
}
if cover != "" && req.Art.FolderFile {
for _, folder := range albumFolders(order) {
if err := e.writeFolderCover(ctx, l, req.Side, folder, cover); err != nil {
j.skip(folder + " cover.jpg: " + err.Error())
} else {
j.count("folder art")
}
}
}
return nil
})
}
// albumFolders lists the distinct album folders of some songs (disc folders
// folded into their album).
func albumFolders(rels []string) []string {
seen := map[string]bool{}
var out []string
for _, r := range rels {
f := AlbumFolder(r)
if f != "" && !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
type tagDetail struct {
Side string `json:"side"`
Mode string `json:"mode"`
Changes map[string][2]string `json:"changes"` // field -> [old, new]
Changes map[string][2]string `json:"changes"` // field -> [old, new]
Cover bool `json:"cover,omitempty"` // new art embedded
}
func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel string, set map[string]string, separate bool) error {
// editTagsOne applies tag changes and/or a new embedded cover to one file.
func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel string, set map[string]string, cover string, separate bool) error {
e.mu.Lock()
defer e.mu.Unlock()
root, _ := sideRoot(l, side)
@@ -283,7 +375,15 @@ func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel str
d.Changes[id] = [2]string{f.Fields[id], v}
}
}
if len(changes) == 0 {
if cover != "" && !f.Format.EmbedsCover() {
cover = "" // Ogg-based files: the folder image carries their art
if len(changes) == 0 {
j.count("art in folder only")
return nil
}
}
d.Cover = cover != ""
if len(changes) == 0 && cover == "" {
j.count("unchanged")
return nil
}
@@ -314,10 +414,10 @@ func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel str
tmp := filepath.Join(filepath.Dir(abs), fsops.TmpPrefix+hex.EncodeToString(b[:])+filepath.Ext(abs))
defer os.Remove(tmp)
err = func() error {
if err := e.tagTools.Write(ctx, abs, tmp, f, plan); err != nil {
if err := e.tagTools.Write(ctx, abs, tmp, f, plan, cover); err != nil {
return err
}
if err := e.tagTools.Verify(ctx, abs, tmp, changes); err != nil {
if err := e.tagTools.Verify(ctx, abs, tmp, changes, cover != ""); err != nil {
return err
}
// Nothing may have touched the original while the new file was made.