A new Tags tab browses either library; songs can be edited one at a time
or several at once (e.g. a whole album, where per-song fields are locked
and differing values are kept unless changed). Review's compare panel
links to the editor for either side.
Tags are read with ffprobe and written with ffmpeg (MP3/ID3, FLAC, M4A,
OGG, Opus). An existing tag is always written under the name the file
already uses, so edits replace values instead of duplicating them (e.g.
raw Vorbis ALBUMARTIST/TRACKNUMBER), totals follow the file's style
("3/12" or a separate TRACKTOTAL), stale alias tags (YEAR next to DATE)
are removed, other tags and cover art are kept, and no encoder tag is
added. Every written file is verified: values must read back and the
decoded audio must be bit-identical.
Hardlinks are respected: a file used by one library is replaced
atomically (old file to quarantine); a file shared with the other
library is updated in place so the link survives, after a verified
backup copy goes to quarantine, with an option to edit only one
library's copy instead. Edits are operations (old -> new values shown in
History) that can be undone; an interrupted in-place edit is rolled back
on startup. Dry run applies. Migration 6 stores operation details.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
486 lines
14 KiB
Go
486 lines
14 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)
|
|
// Separate gives a hardlinked file its own edited copy instead of
|
|
// updating it in place for every linked path.
|
|
Separate bool `json:"separate"`
|
|
}
|
|
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
rels, err := cleanRels(req.Paths)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validTagChanges(req.Set); err != nil {
|
|
return nil, err
|
|
}
|
|
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 {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if err := e.editTagsOne(ctx, j, l, req.Side, rel, req.Set, req.Separate); err != nil {
|
|
j.skip(rel + ": " + err.Error())
|
|
}
|
|
j.tick()
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
type tagDetail struct {
|
|
Side string `json:"side"`
|
|
Mode string `json:"mode"`
|
|
Changes map[string][2]string `json:"changes"` // field -> [old, new]
|
|
}
|
|
|
|
func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel string, set map[string]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 len(changes) == 0 {
|
|
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); err != nil {
|
|
return err
|
|
}
|
|
if err := e.tagTools.Verify(ctx, abs, tmp, changes); 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"
|
|
}
|