Add a built-in tag editor for both libraries

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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 12:14:19 -07:00
1 parent 1da0132d5e
commit 3eb02e2c8d
17 files changed
+1842 -33

No files matched your search

+3
View File
@@ -16,6 +16,7 @@ import (
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// DefaultExtensions are the audio file types considered by default.
@@ -41,6 +42,8 @@ type Engine struct {
// the same files.
mu sync.Mutex
tagTools tags.Tools
jobsMu sync.Mutex
jobs map[int64]*Job
nextID int64
+16
View File
@@ -625,6 +625,16 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
if err != nil {
return err
}
if o.Action == store.ActTags {
if err := e.undoTags(ctx, l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("tag edit undone", "op", o.ID)
return nil
}
if o.Action == store.ActRename {
if err := undoRename(l, o); err != nil {
return err
@@ -848,6 +858,12 @@ func (e *Engine) Recover(ctx context.Context) {
return
}
for _, o := range ops {
if o.Action == store.ActTags {
msg := e.recoverTags(ctx, o)
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, msg)
e.log.Warn("recovered interrupted tag edit", "op", o.ID, "result", msg)
continue
}
if o.Action == store.ActRename {
if p, err := e.st.Pair(ctx, o.PairID); err == nil {
recoverRename(p, o, e.log)
+485
View File
@@ -0,0 +1,485 @@
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"
}
+191
View File
@@ -0,0 +1,191 @@
package engine
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
func tagEnv(t *testing.T, dryRun bool) *env {
t.Helper()
ffmpeg, err1 := exec.LookPath("ffmpeg")
ffprobe, err2 := exec.LookPath("ffprobe")
if err1 != nil || err2 != nil {
t.Skip("ffmpeg/ffprobe not installed")
}
ev := setup(t, dryRun)
ev.e.SetTagTools(tags.Tools{FFmpeg: ffmpeg, FFprobe: ffprobe})
return ev
}
func mkSong(t *testing.T, path, title string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
if out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1",
"-metadata", "title="+title, "-metadata", "artist=Avenged Sevenfold", path).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
}
func (ev *env) title(t *testing.T, p string) string {
t.Helper()
f, err := ev.e.tagTools.Read(ctx, p)
if err != nil {
t.Fatal(err)
}
return f.Fields[tags.Title]
}
func (ev *env) editTags(t *testing.T, req TagEdit) *Job {
t.Helper()
if _, err := ev.e.StartTagEdit(ev.pair.ID, req); err != nil {
t.Fatal(err)
}
return ev.wait()
}
func lastOp(t *testing.T, ev *env) store.Operation {
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 1})
if len(ops) == 0 {
t.Fatal("no operation recorded")
}
return ops[0]
}
// A file used by one library only is replaced by a new file; undo brings the
// original back.
func TestEditTagsReplace(t *testing.T) {
ev := tagEnv(t, false)
p := filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01 Nightmare.mp3")
mkSong(t, p, "Nightmar")
before, _ := os.ReadFile(p)
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3"}, Set: map[string]string{tags.Title: "Nightmare", tags.Genre: "Metal"}})
if j.Results["edited"] != 1 {
t.Fatalf("edit failed: %+v %v", j.Results, j.Skipped)
}
if ev.title(t, p) != "Nightmare" {
t.Fatal("title not written")
}
o := lastOp(t, ev)
var d tagDetail
json.Unmarshal(o.Detail, &d)
if d.Mode != tagModeReplace || d.Changes[tags.Title] != [2]string{"Nightmar", "Nightmare"} || o.QuarantinePath == "" {
t.Fatalf("unexpected op: %+v %+v", o, d)
}
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
if after, _ := os.ReadFile(p); string(after) != string(before) {
t.Fatal("undo should restore the original bytes")
}
}
// A file shared by master and slave is edited in place: the link survives
// and both libraries see the new tags. Undo restores the old tags for both.
func TestEditTagsSharedInPlace(t *testing.T) {
ev := tagEnv(t, false)
rel := "Avenged Sevenfold/Nightmare/01 Nightmare.flac"
m, s := filepath.Join(ev.master, rel), filepath.Join(ev.slave, rel)
mkSong(t, m, "Old")
os.MkdirAll(filepath.Dir(s), 0o755)
if err := os.Link(m, s); err != nil {
t.Fatal(err)
}
ev.scan()
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{rel}, Set: map[string]string{tags.Title: "Nightmare"}})
if j.Results["edited"] != 1 {
t.Fatalf("edit failed: %+v %v", j.Results, j.Skipped)
}
if !sameInode(t, m, s) || ev.title(t, m) != "Nightmare" {
t.Fatal("the shared file must stay linked and show the new tags in both libraries")
}
if got := ev.match(rel); got.Status != store.StatusLinked {
t.Fatalf("match should still be linked, got %s", got.Status)
}
o := lastOp(t, ev)
if b, err := os.ReadFile(o.QuarantinePath); err != nil || len(b) == 0 {
t.Fatal("a backup copy should be in quarantine")
}
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
if !sameInode(t, m, s) || ev.title(t, m) != "Old" {
t.Fatal("undo should restore the old tags and keep the link")
}
}
// "Only this library" gives the slave its own edited copy.
func TestEditTagsSeparateCopy(t *testing.T) {
ev := tagEnv(t, false)
rel := "Band/Album/01 Song.mp3"
m, s := filepath.Join(ev.master, rel), filepath.Join(ev.slave, rel)
mkSong(t, m, "Song")
os.MkdirAll(filepath.Dir(s), 0o755)
os.Link(m, s)
ev.scan()
ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{rel}, Set: map[string]string{tags.Title: "Song (My Tag)"}, Separate: true})
if sameInode(t, m, s) || ev.title(t, m) != "Song" || ev.title(t, s) != "Song (My Tag)" {
t.Fatal("only the slave copy should change, as a separate file")
}
if got := ev.match(rel); got.Status != store.StatusDifferent {
t.Fatalf("the pair is no longer linked, got %s", got.Status)
}
if err := ev.e.Undo(ctx, lastOp(t, ev).ID); err != nil {
t.Fatal(err)
}
if !sameInode(t, m, s) {
t.Fatal("undo should rejoin the original link")
}
}
func TestEditTagsDryRunAndUnchanged(t *testing.T) {
ev := tagEnv(t, true)
p := filepath.Join(ev.slave, "A/B/x.mp3")
mkSong(t, p, "Same")
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{tags.Title: "Changed"}})
if j.Results["planned"] != 1 || ev.title(t, p) != "Same" {
t.Fatalf("dry run must not write: %+v", j.Results)
}
j = ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{tags.Title: "Same"}})
if j.Results["unchanged"] != 1 {
t.Fatalf("identical values should be skipped: %+v", j.Results)
}
if _, err := ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{"../escape.mp3"}, Set: map[string]string{tags.Title: "x"}}); err == nil {
t.Fatal("paths outside the library must be refused")
}
if _, err := ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{"lyrics": "x"}}); err == nil {
t.Fatal("unknown fields must be refused")
}
}
// A crash during an in-place write leaves a half-written file; recovery
// writes the backup back.
func TestRecoverInterruptedInPlaceEdit(t *testing.T) {
ev := tagEnv(t, false)
rel := "A/B/song.flac"
p := filepath.Join(ev.slave, rel)
mkSong(t, p, "Original")
orig, _ := os.ReadFile(p)
q := filepath.Join(ev.pair.QuarantineDir, "99", "slave", rel)
os.MkdirAll(filepath.Dir(q), 0o755)
os.WriteFile(q, orig, 0o644)
os.WriteFile(p, orig[:len(orig)/3], 0o644) // truncated by the "crash"
d, _ := json.Marshal(tagDetail{Side: SideSlave, Mode: tagModeInPlace})
o := store.Operation{PairID: ev.pair.ID, Action: store.ActTags, RelSlave: rel, QuarantinePath: q, QuarantineSide: SideSlave, Status: store.OpRunning, Detail: d}
if err := ev.st.CreateOp(ctx, &o); err != nil {
t.Fatal(err)
}
ev.e.Recover(ctx)
if now, _ := os.ReadFile(p); string(now) != string(orig) {
t.Fatal("recovery should restore the original file")
}
got, _ := ev.st.Op(ctx, o.ID)
if got.Status != store.OpInterrupted {
t.Fatalf("op should be marked interrupted, got %s", got.Status)
}
}