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:
1 parent
5fb195f0d6
commit
d8ac25a55d
21 files changed
+2719
-38
No files matched your search
+117
-17
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user