Files
playlink/internal/engine/tagedit.go
T
cdnutterandClaude Opus 5.5 d8ac25a55d 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>
2026-09-29 15:30:19 -07:00

586 lines
16 KiB
Go

package engine
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// Tag editing works on either library. A file used by only one library is
// rewritten to a new file that atomically replaces it (the old file goes to
// quarantine). A file hardlinked elsewhere (typically shared by master and
// slave) is updated in place so the link survives and every linked path sees
// the new tags; a verified backup copy goes to quarantine first. Choosing
// "separate" instead gives only this path its own edited copy.
const (
SideMaster = "master"
SideSlave = "slave"
tagModeReplace = "replace"
tagModeInPlace = "inplace"
maxTagFiles = 500
maxTagValue = 2000
)
var ErrNoTagTools = errors.New("tag editing needs ffmpeg and ffprobe, which were not found")
// SetTagTools enables tag editing.
func (e *Engine) SetTagTools(t tags.Tools) { e.tagTools = t }
func (e *Engine) TagEditing() bool { return e.tagTools.FFmpeg != "" && e.tagTools.FFprobe != "" }
func sideRoot(l *libs, side string) (fsops.Root, error) {
switch side {
case SideMaster:
return l.master, nil
case SideSlave:
return l.slave, nil
}
return fsops.Root{}, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
// FileEntry is one row of the library browser.
type FileEntry struct {
Name string `json:"name"`
Rel string `json:"rel"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
Links int `json:"links,omitempty"` // >1: the file is shared (hardlinked)
Editable bool `json:"editable,omitempty"`
}
// ListFiles lists a folder of either library: subfolders and audio files.
func (e *Engine) ListFiles(ctx context.Context, pairID int64, side, rel string) ([]FileEntry, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
dir := root.Path
if rel != "" {
if dir, err = root.Resolve(rel); err != nil {
return nil, err
}
}
ents, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
out := []FileEntry{}
for _, en := range ents {
name := en.Name()
abs := filepath.Join(dir, name)
if strings.HasPrefix(name, ".") || en.Type()&fs.ModeSymlink != 0 || abs == l.quarantine {
continue
}
child := name
if rel != "" {
child = filepath.Join(rel, name)
}
if en.IsDir() {
out = append(out, FileEntry{Name: name, Rel: child, Dir: true})
continue
}
if !l.hasExt(name) {
continue
}
in, err := fsops.Lstat(abs)
if err != nil || !in.Mode.IsRegular() {
continue
}
_, editable := tags.FormatOf(name)
out = append(out, FileEntry{Name: name, Rel: child, Size: in.Size, Links: int(in.Nlink), Editable: editable})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Dir != out[j].Dir {
return out[i].Dir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
// TagFile is the editable view of one file's tags.
type TagFile struct {
Rel string `json:"rel"`
Editable bool `json:"editable"`
Reason string `json:"reason,omitempty"`
Fields map[string]string `json:"fields"`
Other map[string]string `json:"other"`
Links int `json:"links"`
Size int64 `json:"size"`
}
func cleanRels(rels []string) ([]string, error) {
if len(rels) == 0 {
return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk)
}
if len(rels) > maxTagFiles {
return nil, fmt.Errorf("%w: select at most %d files at a time", ErrInvalidBulk, maxTagFiles)
}
seen := map[string]bool{}
var out []string
for _, r := range rels {
c, err := fsops.CleanRel(r)
if err != nil {
return nil, err
}
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out, nil
}
// ReadTags reads the tags of files in one library.
func (e *Engine) ReadTags(ctx context.Context, pairID int64, side string, rels []string) ([]TagFile, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
rels, err := cleanRels(rels)
if err != nil {
return nil, err
}
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
out := make([]TagFile, 0, len(rels))
for _, rel := range rels {
tf := TagFile{Rel: rel, Fields: map[string]string{}, Other: map[string]string{}}
abs, err := root.Resolve(rel)
if err == nil {
var in fsops.Info
if in, err = fsops.RegularFile(abs); err == nil {
tf.Links, tf.Size = int(in.Nlink), in.Size
var f tags.File
if f, err = e.tagTools.Read(ctx, abs); err == nil {
tf.Editable, tf.Fields, tf.Other = true, f.Fields, f.Other
}
}
}
if err != nil {
tf.Reason = err.Error()
}
out = append(out, tf)
}
return out, nil
}
// TagEdit is one tag edit request.
type TagEdit struct {
Side string `json:"side"`
Paths []string `json:"paths"`
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 {
known := map[string]bool{}
for _, id := range tags.FieldIDs {
known[id] = true
}
for id, v := range set {
if !known[id] {
return fmt.Errorf("%w: unknown tag field %q", ErrInvalidBulk, id)
}
if len(v) > maxTagValue || !utf8.ValidString(v) || strings.ContainsRune(v, 0) {
return fmt.Errorf("%w: invalid value for %s", ErrInvalidBulk, id)
}
}
return nil
}
// StartTagEdit applies a tag edit to each selected file in a background job.
func (e *Engine) StartTagEdit(pairID int64, req TagEdit) (*Job, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if req.Side != SideMaster && req.Side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
// 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
}
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
}
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, 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]
Cover bool `json:"cover,omitempty"` // new art embedded
}
// 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)
abs, err := root.Resolve(rel)
if err != nil {
return err
}
info, err := fsops.RegularFile(abs)
if err != nil {
return err
}
f, err := e.tagTools.Read(ctx, abs)
if err != nil {
return err
}
changes := map[string]string{}
d := tagDetail{Side: side, Mode: tagModeReplace, Changes: map[string][2]string{}}
for id, v := range set {
if f.Fields[id] != v {
changes[id] = v
d.Changes[id] = [2]string{f.Fields[id], v}
}
}
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
}
plan, err := f.Plan(changes)
if err != nil {
return err
}
if info.Nlink > 1 && !separate {
d.Mode = tagModeInPlace
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActTags, QuarantineSide: side, Trigger: "tags"}
if side == SideMaster {
o.RelMaster = rel
} else {
o.RelSlave = rel
}
o.Detail, _ = json.Marshal(d)
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
j.count("planned")
return nil
}
var b [8]byte
_, _ = rand.Read(b[:])
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, cover); err != nil {
return err
}
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.
if now, err := fsops.RegularFile(abs); err != nil || !now.SameInode(info) || !now.Unchanged(info.Size, info.MtimeNs) {
return fmt.Errorf("%s %w", rel, fsops.ErrChanged)
}
q := quarantinePath(l, o.ID, side, rel)
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
return err
}
if d.Mode == tagModeReplace {
// Keep the old file (as a hardlink, no extra space) and swap in
// the new one atomically, with the original's permissions.
if err := fsops.LinkNew(abs, info, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
if err := os.Chmod(tmp, info.Mode.Perm()); err != nil {
dropQuarantine(l, q, info)
e.clearQuarantine(ctx, o.ID)
return err
}
if err := os.Rename(tmp, abs); err != nil {
dropQuarantine(l, q, info)
e.clearQuarantine(ctx, o.ID)
return err
}
return fsops.SyncDir(filepath.Dir(abs))
}
// In place: a verified backup copy first, then overwrite the shared
// file's contents; restore from the backup if anything goes wrong.
if err := fsops.CopyNew(abs, q, info.Mode); err != nil {
return fmt.Errorf("backup: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
if err := fsops.OverwriteInPlace(abs, tmp); err != nil {
if rerr := fsops.OverwriteInPlace(abs, q); rerr != nil {
return fmt.Errorf("%v; restoring the original also failed (%v): the original is at %s", err, rerr, q)
}
_ = os.Remove(q)
e.clearQuarantine(ctx, o.ID)
return fmt.Errorf("could not write the new tags, the original was restored: %w", err)
}
return nil
}()
if err = e.finish(ctx, &o, err); err != nil {
return err
}
j.count("edited")
e.refreshTagged(ctx, l, side, rel)
return nil
}
func (e *Engine) setQuarantine(ctx context.Context, opID int64, q string) {
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, opID)
}
// refreshTagged updates the matches that involve an edited file.
func (e *Engine) refreshTagged(ctx context.Context, l *libs, side, rel string) {
if side == SideSlave {
e.refresh(ctx, l, rel)
return
}
ms, _ := e.st.MatchesByMaster(ctx, l.pair.ID, rel)
for _, m := range ms {
e.refresh(ctx, l, m.RelSlave, m)
}
}
func tagOpPath(l *libs, o store.Operation) (string, tagDetail, error) {
var d tagDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return "", d, fmt.Errorf("unreadable operation details: %w", err)
}
root, err := sideRoot(l, d.Side)
if err != nil {
return "", d, err
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
abs, err := root.Resolve(rel)
return abs, d, err
}
// undoTags puts the file back the way it was before the edit.
func (e *Engine) undoTags(ctx context.Context, l *libs, o store.Operation) error {
abs, d, err := tagOpPath(l, o)
if err != nil {
return err
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
cur, err := fsops.RegularFile(abs)
if err != nil {
return err
}
if d.Mode == tagModeReplace {
if cur.SameInode(qi) {
return errors.New("the file is already the original")
}
// The original inode comes back, rejoining any links it had.
if err := fsops.ReplaceWithLink(o.QuarantinePath, qi, abs); err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
} else {
if err := fsops.OverwriteInPlace(abs, o.QuarantinePath); err != nil {
return err
}
if err := os.Remove(o.QuarantinePath); err == nil {
pruneEmpty(filepath.Dir(o.QuarantinePath), l.quarantine)
}
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
e.refreshTagged(ctx, l, d.Side, rel)
return nil
}
// recoverTags handles a tag edit cut short by a crash. An in-place edit may
// have left a partially written file, so the backup is written back. A
// replace edit either happened (atomic rename) or did not; an unused
// quarantine link is dropped.
func (e *Engine) recoverTags(ctx context.Context, o store.Operation) string {
l, err := e.loadPair(ctx, o.PairID)
if err != nil || o.QuarantinePath == "" {
return "interrupted before any change"
}
abs, d, err := tagOpPath(l, o)
if err != nil {
return err.Error()
}
// The half-written new file is disposable.
if ents, err := os.ReadDir(filepath.Dir(abs)); err == nil {
for _, en := range ents {
if strings.HasPrefix(en.Name(), fsops.TmpPrefix) && en.Type().IsRegular() {
_ = os.Remove(filepath.Join(filepath.Dir(abs), en.Name()))
}
}
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return "interrupted; " + err.Error()
}
if d.Mode == tagModeInPlace {
if err := fsops.OverwriteInPlace(abs, o.QuarantinePath); err != nil {
return fmt.Sprintf("interrupted; could not restore the original (%v), it is kept at %s", err, o.QuarantinePath)
}
_ = os.Remove(o.QuarantinePath)
e.clearQuarantine(ctx, o.ID)
return "interrupted by shutdown; the original tags were restored"
}
if cur, err := fsops.Lstat(abs); err == nil && cur.SameInode(qi) {
dropQuarantine(l, o.QuarantinePath, qi) // rename never happened
e.clearQuarantine(ctx, o.ID)
return "interrupted by shutdown before the file was replaced; nothing changed"
}
return "interrupted by shutdown after the new tags were written"
}