diff --git a/README.md b/README.md index adf8c43..93fa1b2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,18 @@ anything runs, a preview shows how many songs will be processed and which are sk downgrades, approximate matches, duration mismatches and files that move folders. Downgrades are skipped unless you tick a box, and replacing master files needs an explicit acknowledgement. Each song then goes through the same checks as a single action. +**Tag editor.** The *Tags* tab browses either library. Click a song to edit its tags (title, artist, album artist, album, track and +disc numbers with totals, year, genre, composer, comment), or tick several, such as a whole album, to edit the tags they share. It +works for MP3, FLAC, M4A, OGG and Opus; other tags and cover art are kept. Review's compare panel also has **Slave tags** / **Master +tags** buttons. Edits are safe with hardlinks: + +- 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 to the other library is updated in place, so both libraries see the new tags and stay linked; a verified backup + copy goes to quarantine first. You can instead choose to change only one library's copy, which breaks the link. +- Every written file is verified: the new values must read back, and the decoded audio must be identical to the original. +- Each edit is listed in History (old → new values) and can be undone; an in-place edit interrupted by a crash is rolled back on + startup. Dry run applies. + **Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan. Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate, bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice @@ -149,7 +161,8 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions | `PLAYLINK_DATA_DIR` | `/config` (Docker) or `./config` | Where the SQLite database lives. | | `PLAYLINK_ALLOWED_ROOTS` | `/data` | Comma-separated folders that libraries and the quarantine must live in. `/` is refused. | | `PLAYLINK_SECURE_COOKIES` | `false` | Set to `true` when served over HTTPS. | -| `PLAYLINK_FFPROBE` | `ffprobe` | Path to ffprobe. Without it, quality comparison is disabled but everything else works. | +| `PLAYLINK_FFPROBE` | `ffprobe` | Path to ffprobe. Without it, quality comparison and tag editing are disabled. | +| `PLAYLINK_FFMPEG` | `ffmpeg` | Path to ffmpeg, used by the tag editor. | | `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. | **Hide quarantine from Plex** (Settings → Media servers) writes a `.plexignore` into each pair's quarantine folder so Plex does not diff --git a/cmd/playlink/main.go b/cmd/playlink/main.go index 5e48476..2a841d7 100644 --- a/cmd/playlink/main.go +++ b/cmd/playlink/main.go @@ -18,6 +18,7 @@ import ( "github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" + "github.com/cdnutter/playlink/internal/tags" "github.com/cdnutter/playlink/web" ) @@ -89,6 +90,12 @@ func main() { defer st.DB.Close() eng := engine.New(st, probe.Prober{Bin: ffprobe}, roots, log) + // The tag editor writes with ffmpeg (verified with ffprobe). + if ff, err := exec.LookPath(env("PLAYLINK_FFMPEG", "ffmpeg")); err == nil && ffprobe != "" { + eng.SetTagTools(tags.Tools{FFmpeg: ff, FFprobe: ffprobe}) + } else { + log.Warn("ffmpeg not found; tag editing disabled") + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/internal/api/server.go b/internal/api/server.go index b3a6100..c547f93 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -22,6 +22,7 @@ import ( "github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/store" + "github.com/cdnutter/playlink/internal/tags" ) type Config struct { @@ -87,6 +88,9 @@ func (s *Server) Handler() http.Handler { r.Get("/groups", s.handleGroups) r.Post("/bulk/preview", s.handleBulkPreview) r.Post("/bulk", s.handleBulk) + r.Get("/files", s.handleFiles) + r.Post("/tags/read", s.handleReadTags) + r.Post("/tags", s.handleEditTags) r.Get("/renames", s.handleRenamePreview) r.Post("/renames", s.handleRenames) r.Post("/link-identical", s.handleLinkIdentical) @@ -178,7 +182,7 @@ func opErr(w http.ResponseWriter, err error) { errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists): writeErr(w, 409, err) case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice), - errors.Is(err, engine.ErrInvalidBulk): + errors.Is(err, engine.ErrInvalidBulk), errors.Is(err, engine.ErrNoTagTools): writeErr(w, 400, err) default: writeErr(w, 500, err) @@ -194,6 +198,8 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { "default_extensions": engine.DefaultExtensions, "dry_run": s.st.DryRun(r.Context()), "plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1", + "tag_editing": s.eng.TagEditing(), + "tag_fields": tags.FieldIDs, }) } @@ -688,3 +694,48 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) { s.log.Info("folder renames started", "pair", id, "selected", len(body.Only)) writeJSON(w, 202, j) } + +// ---- tag editor ---- + +func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + q := r.URL.Query() + ents, err := s.eng.ListFiles(r.Context(), id, q.Get("side"), q.Get("path")) + if err != nil { + opErr(w, err) + return + } + writeJSON(w, 200, ents) +} + +func (s *Server) handleReadTags(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body struct { + Side string `json:"side"` + Paths []string `json:"paths"` + } + if !readJSON(w, r, &body) { + return + } + files, err := s.eng.ReadTags(r.Context(), id, body.Side, body.Paths) + if err != nil { + opErr(w, err) + return + } + writeJSON(w, 200, map[string]any{"files": files}) +} + +func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) { + id, _ := idParam(r) + var body engine.TagEdit + if !readJSON(w, r, &body) { + return + } + j, err := s.eng.StartTagEdit(id, body) + if err != nil { + opErr(w, err) + return + } + s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate) + writeJSON(w, 202, j) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 1a130cd..ef7146c 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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 diff --git a/internal/engine/ops.go b/internal/engine/ops.go index b8bccf2..be88b76 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -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) diff --git a/internal/engine/tagedit.go b/internal/engine/tagedit.go new file mode 100644 index 0000000..1b5da66 --- /dev/null +++ b/internal/engine/tagedit.go @@ -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" +} diff --git a/internal/engine/tagedit_test.go b/internal/engine/tagedit_test.go new file mode 100644 index 0000000..7589053 --- /dev/null +++ b/internal/engine/tagedit_test.go @@ -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) + } +} diff --git a/internal/fsops/fsops.go b/internal/fsops/fsops.go index a587e57..afadb59 100644 --- a/internal/fsops/fsops.go +++ b/internal/fsops/fsops.go @@ -403,3 +403,70 @@ func CopyReplace(src, dst string, mode fs.FileMode) error { func MkdirAllLike(dir string) error { return os.MkdirAll(dir, 0o775) } + +// CopyNew copies src to dst, which must not exist. The copy is fsynced and +// verified byte for byte. Used for backups that must not share an inode with +// the original (the original is about to be modified in place). +func CopyNew(src, dst string, mode fs.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm()) + if err != nil { + return err + } + fail := func(e error) error { out.Close(); _ = os.Remove(dst); return e } + if _, err := io.Copy(out, in); err != nil { + return fail(err) + } + if err := out.Sync(); err != nil { + return fail(err) + } + if err := out.Close(); err != nil { + _ = os.Remove(dst) + return err + } + if same, err := SameContent(src, dst); err != nil || !same { + _ = os.Remove(dst) + if err == nil { + err = fmt.Errorf("%w: backup differs from the original", ErrVerifyFailed) + } + return err + } + return SyncDir(filepath.Dir(dst)) +} + +// OverwriteInPlace replaces the contents of dst with the bytes of src while +// keeping dst's inode, so every hardlink of dst sees the new contents. This is +// not atomic: callers keep a backup (CopyNew) to restore from on failure. +func OverwriteInPlace(dst, src string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + if same, err := SameContent(src, dst); err != nil || !same { + if err == nil { + err = fmt.Errorf("%w: file does not match what was written", ErrVerifyFailed) + } + return err + } + return nil +} diff --git a/internal/store/models.go b/internal/store/models.go index 2414a36..c6d0f57 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -623,27 +623,28 @@ func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) { // ---- operations ---- type Operation struct { - ID int64 `json:"id"` - PairID int64 `json:"pair_id"` - Action string `json:"action"` - RelMaster string `json:"rel_master"` - RelSlave string `json:"rel_slave"` - RelNew string `json:"rel_new"` - MatchKind string `json:"match_kind"` - MatchReason string `json:"match_reason"` - LinkPreexisted bool `json:"link_preexisted"` - QuarantinePath string `json:"quarantine_path"` - QuarantineSide string `json:"quarantine_side"` - TmpPath string `json:"-"` - Bytes int64 `json:"bytes"` - DryRun bool `json:"dry_run"` - Trigger string `json:"trigger"` - Status string `json:"status"` - Error string `json:"error"` - CreatedAt int64 `json:"created_at"` - FinishedAt *int64 `json:"finished_at"` - UndoneAt *int64 `json:"undone_at"` - PurgedAt *int64 `json:"purged_at"` + ID int64 `json:"id"` + PairID int64 `json:"pair_id"` + Action string `json:"action"` + RelMaster string `json:"rel_master"` + RelSlave string `json:"rel_slave"` + RelNew string `json:"rel_new"` + MatchKind string `json:"match_kind"` + MatchReason string `json:"match_reason"` + LinkPreexisted bool `json:"link_preexisted"` + Detail json.RawMessage `json:"detail,omitempty"` + QuarantinePath string `json:"quarantine_path"` + QuarantineSide string `json:"quarantine_side"` + TmpPath string `json:"-"` + Bytes int64 `json:"bytes"` + DryRun bool `json:"dry_run"` + Trigger string `json:"trigger"` + Status string `json:"status"` + Error string `json:"error"` + CreatedAt int64 `json:"created_at"` + FinishedAt *int64 `json:"finished_at"` + UndoneAt *int64 `json:"undone_at"` + PurgedAt *int64 `json:"purged_at"` } // Operation actions. @@ -653,6 +654,7 @@ const ( ActBuild = "build" // master file linked into the slave (fresh library) ActRemove = "remove" // slave hardlink removed (master copy kept) ActRename = "rename" // slave folder renamed to the master's spelling + ActTags = "tags" // tags of a file edited ) // Operation statuses. @@ -665,13 +667,17 @@ const ( ) const opCols = `id, pair_id, action, rel_master, rel_slave, rel_new, quarantine_path, quarantine_side, tmp_path, bytes, - dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at, match_kind, match_reason, link_preexisted` + dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at, match_kind, match_reason, link_preexisted, detail` func scanOp(r scanner) (Operation, error) { var o Operation var fin, und, pur sql.NullInt64 + var detail string err := r.Scan(&o.ID, &o.PairID, &o.Action, &o.RelMaster, &o.RelSlave, &o.RelNew, &o.QuarantinePath, &o.QuarantineSide, - &o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur, &o.MatchKind, &o.MatchReason, &o.LinkPreexisted) + &o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur, &o.MatchKind, &o.MatchReason, &o.LinkPreexisted, &detail) + if detail != "" { + o.Detail = json.RawMessage(detail) + } if fin.Valid { o.FinishedAt = &fin.Int64 } @@ -687,10 +693,10 @@ func scanOp(r scanner) (Operation, error) { func (s *Store) CreateOp(ctx context.Context, o *Operation) error { o.CreatedAt = now() res, err := s.DB.ExecContext(ctx, `INSERT INTO operations (pair_id, action, rel_master, rel_slave, rel_new, quarantine_path, - quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at, match_kind, match_reason, link_preexisted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at, match_kind, match_reason, link_preexisted, detail) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, o.PairID, o.Action, o.RelMaster, o.RelSlave, o.RelNew, o.QuarantinePath, o.QuarantineSide, o.TmpPath, o.Bytes, - o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt, o.MatchKind, o.MatchReason, o.LinkPreexisted) + o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt, o.MatchKind, o.MatchReason, o.LinkPreexisted, string(o.Detail)) if err != nil { return err } @@ -800,3 +806,21 @@ func (s *Store) ExpiredQuarantine(ctx context.Context, pairID, cutoff int64) ([] } return out, rows.Err() } + +// MatchesByMaster lists the matches of a pair that point at a master file. +func (s *Store) MatchesByMaster(ctx context.Context, pairID int64, relMaster string) ([]Match, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ? AND rel_master = ?`, pairID, relMaster) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Match + for rows.Next() { + m, err := scanMatch(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} diff --git a/internal/store/store.go b/internal/store/store.go index 04b9b9f..445b464 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -118,6 +118,9 @@ var migrations = []string{ // 5: a link whose target already existed (only the original was retired); // undo must then leave that target alone. `ALTER TABLE operations ADD COLUMN link_preexisted INTEGER NOT NULL DEFAULT 0;`, + + // 6: operation-specific details as JSON (e.g. which tags an edit changed). + `ALTER TABLE operations ADD COLUMN detail TEXT NOT NULL DEFAULT '';`, } // Open opens (and migrates) the database in dataDir. diff --git a/internal/tags/tags.go b/internal/tags/tags.go new file mode 100644 index 0000000..7edee2d --- /dev/null +++ b/internal/tags/tags.go @@ -0,0 +1,344 @@ +// Package tags reads and writes audio metadata with ffprobe/ffmpeg. +// +// Writing always produces a new file (the caller decides how it replaces the +// original) and is verified: the requested fields must read back as set, and +// the decoded audio must be bit-for-bit identical to the original's. +package tags + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// Field ids, in display order. +const ( + Title = "title" + Artist = "artist" + Album = "album" + AlbumArtist = "album_artist" + Track = "track" + TrackTotal = "track_total" + Disc = "disc" + DiscTotal = "disc_total" + Date = "date" + Genre = "genre" + Composer = "composer" + Comment = "comment" +) + +var FieldIDs = []string{Title, Artist, Album, AlbumArtist, Track, TrackTotal, Disc, DiscTotal, Date, Genre, Composer, Comment} + +// aliases are the tag names (lower-cased, as ffprobe reports them) that hold +// each field. ffprobe already maps most format-specific names to these. +var aliases = map[string][]string{ + Title: {"title"}, + Artist: {"artist"}, + Album: {"album"}, + AlbumArtist: {"album_artist", "albumartist", "album artist"}, + Track: {"track", "tracknumber"}, + TrackTotal: {"tracktotal", "totaltracks"}, + Disc: {"disc", "discnumber"}, + DiscTotal: {"disctotal", "totaldiscs"}, + Date: {"date", "year"}, + Genre: {"genre"}, + Composer: {"composer"}, + Comment: {"comment", "description"}, +} + +// ignoredKeys are container/encoder details, not tags a user edits. +var ignoredKeys = map[string]bool{"encoder": true, "major_brand": true, "minor_version": true, + "compatible_brands": true, "handler_name": true, "language": true, "vendor_id": true} + +// Format describes how a file stores its tags. +type Format struct { + Name string // mp3, flac, m4a, ogg, opus + Vorbis bool // Vorbis comments (flac, ogg, opus) + StreamTags bool // tags live on the audio stream (ogg, opus) +} + +var formats = map[string]Format{ + ".mp3": {Name: "mp3"}, + ".flac": {Name: "flac", Vorbis: true}, + ".m4a": {Name: "m4a"}, + ".ogg": {Name: "ogg", Vorbis: true, StreamTags: true}, + ".oga": {Name: "ogg", Vorbis: true, StreamTags: true}, + ".opus": {Name: "opus", Vorbis: true, StreamTags: true}, +} + +// FormatOf returns the tag format for a file name, or false when editing +// that file type is not supported. +func FormatOf(path string) (Format, bool) { + f, ok := formats[strings.ToLower(filepath.Ext(path))] + return f, ok +} + +// File is the metadata of one audio file. +type File struct { + Format Format `json:"-"` + Raw map[string]string `json:"-"` // tag name as reported -> value + Fields map[string]string `json:"fields"` // field id -> value (only present fields) + Other map[string]string `json:"other"` // tags that are not one of the fields +} + +// Tools locates ffprobe and ffmpeg. +type Tools struct { + FFprobe string + FFmpeg string +} + +func (t Tools) run(ctx context.Context, bin string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if len(msg) > 300 { + msg = msg[:300] + "…" + } + return nil, fmt.Errorf("%s: %v: %s", filepath.Base(bin), err, msg) + } + return out, nil +} + +type probeOut struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Duration string `json:"duration"` + Tags map[string]string `json:"tags"` + } `json:"streams"` +} + +// Read returns the tags of path. +func (t Tools) Read(ctx context.Context, path string) (File, error) { + fmtInfo, ok := FormatOf(path) + if !ok { + return File{}, fmt.Errorf("editing tags of %s files is not supported", strings.TrimPrefix(filepath.Ext(path), ".")) + } + out, err := t.run(ctx, t.FFprobe, "-v", "error", "-show_entries", "format_tags:stream_tags:stream=codec_type,codec_name", + "-of", "json", "--", path) + if err != nil { + return File{}, err + } + var p probeOut + if err := json.Unmarshal(out, &p); err != nil { + return File{}, err + } + raw := p.Format.Tags + if fmtInfo.StreamTags { + raw = nil + for _, s := range p.Streams { + if s.CodecType == "audio" { + raw = s.Tags + break + } + } + } + return parse(fmtInfo, raw), nil +} + +func parse(f Format, raw map[string]string) File { + file := File{Format: f, Raw: map[string]string{}, Fields: map[string]string{}, Other: map[string]string{}} + byAlias := map[string]string{} + for id, names := range aliases { + for _, n := range names { + byAlias[n] = id + } + } + for k, v := range raw { + file.Raw[k] = v + lk := strings.ToLower(k) + if ignoredKeys[lk] { + continue + } + id, ok := byAlias[lk] + if !ok { + file.Other[k] = v + continue + } + if _, dup := file.Fields[id]; !dup { + file.Fields[id] = v + } + } + // "3/12" in the track tag carries the total unless a total tag exists. + for _, pair := range [][2]string{{Track, TrackTotal}, {Disc, DiscTotal}} { + if n, total, ok := strings.Cut(file.Fields[pair[0]], "/"); ok { + file.Fields[pair[0]] = strings.TrimSpace(n) + if _, has := file.Fields[pair[1]]; !has && strings.TrimSpace(total) != "" { + file.Fields[pair[1]] = strings.TrimSpace(total) + } + } + } + return file +} + +// keysFor returns the tag names in the file holding a field. +func (f File) keysFor(id string) []string { + var keys []string + for k := range f.Raw { + for _, a := range aliases[id] { + if strings.EqualFold(k, a) { + keys = append(keys, k) + } + } + } + sort.Strings(keys) + return keys +} + +// canonical is the tag name for a field the file does not have yet. These are +// ffmpeg's own names, so ffmpeg writes the right frame/atom/comment for each +// format (e.g. album_artist -> TPE2 / aART / ALBUMARTIST). +func canonical(f Format, id string) string { + switch id { + case AlbumArtist, Track, Disc, Comment: + return id + case TrackTotal: + return "TRACKTOTAL" + case DiscTotal: + return "DISCTOTAL" + } + if f.Vorbis { + return strings.ToUpper(id) + } + return id +} + +// Plan turns field changes into tag assignments for the file. A change to "" +// removes the field. The result maps tag name -> value ("" deletes). +func (f File) Plan(changes map[string]string) (map[string]string, error) { + set := map[string]string{} + assign := func(id, value string) { + keys := f.keysFor(id) + if len(keys) == 0 { + if value != "" { + set[canonical(f.Format, id)] = value + } + return + } + // The first spelling the file uses gets the value; duplicates under + // other spellings (DATE and YEAR) are removed so they cannot disagree. + set[keys[0]] = value + for _, k := range keys[1:] { + set[k] = "" + } + } + for id := range changes { + if _, ok := aliases[id]; !ok { + return nil, fmt.Errorf("unknown tag field %q", id) + } + } + for id, v := range changes { + if id == Track || id == TrackTotal || id == Disc || id == DiscTotal { + continue + } + assign(id, v) + } + // Number and total are written together, the way the file stores them: + // "3/12" in one tag, or a separate total tag (common in FLAC). + for _, pair := range [][2]string{{Track, TrackTotal}, {Disc, DiscTotal}} { + n, nChanged := changes[pair[0]] + total, tChanged := changes[pair[1]] + if !nChanged && !tChanged { + continue + } + if !nChanged { + n = f.Fields[pair[0]] + } + if !tChanged { + total = f.Fields[pair[1]] + } + separate := len(f.keysFor(pair[1])) > 0 || (f.Format.Vorbis && len(f.keysFor(pair[0])) == 0) + if separate { + assign(pair[0], n) + assign(pair[1], total) + continue + } + v := n + if n != "" && total != "" { + v = n + "/" + total + } + assign(pair[0], v) + // A stale separate total tag would contradict the combined value. + for _, k := range f.keysFor(pair[1]) { + set[k] = "" + } + } + return set, nil +} + +// Write copies src to dst (which must not exist and must keep src's +// extension) with the planned tags applied. Audio and other streams, such +// as cover art, are copied untouched. +func (t Tools) Write(ctx context.Context, src, dst string, f File, set map[string]string) error { + args := []string{"-v", "error", "-nostdin", "-n", "-i", src, "-map", "0", "-c", "copy", + "-map_metadata", "0", "-fflags", "+bitexact"} + target := "-metadata" + if f.Format.StreamTags { + args = append(args, "-map_metadata:s:a", "0:s:a") + target = "-metadata:s:a:0" + } + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + args = append(args, target, k+"="+set[k]) + } + args = append(args, dst) + _, err := t.run(ctx, t.FFmpeg, args...) + return err +} + +// ErrVerify means a written file did not pass verification. +var ErrVerify = errors.New("tag write verification failed") + +// Verify checks that dst carries the requested field values and exactly the +// same audio as src. +func (t Tools) Verify(ctx context.Context, src, dst string, changes map[string]string) error { + got, err := t.Read(ctx, dst) + if err != nil { + return fmt.Errorf("%w: %v", ErrVerify, err) + } + for id, want := range changes { + if got.Fields[id] != want { + return fmt.Errorf("%w: %s reads back as %q, not %q", ErrVerify, id, got.Fields[id], want) + } + } + a, err := t.audioHash(ctx, src) + if err != nil { + return err + } + b, err := t.audioHash(ctx, dst) + if err != nil { + return err + } + if a != b { + return fmt.Errorf("%w: the audio changed", ErrVerify) + } + return nil +} + +// audioHash is the MD5 of the file's decoded audio streams. +func (t Tools) audioHash(ctx context.Context, path string) (string, error) { + out, err := t.run(ctx, t.FFmpeg, "-v", "error", "-nostdin", "-i", path, "-map", "0:a", "-f", "md5", "-") + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/tags/tags_test.go b/internal/tags/tags_test.go new file mode 100644 index 0000000..55fc07c --- /dev/null +++ b/internal/tags/tags_test.go @@ -0,0 +1,177 @@ +package tags + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var ctx = context.Background() + +func tools(t *testing.T) Tools { + t.Helper() + ffprobe, err1 := exec.LookPath("ffprobe") + ffmpeg, err2 := exec.LookPath("ffmpeg") + if err1 != nil || err2 != nil { + t.Skip("ffmpeg/ffprobe not installed") + } + return Tools{FFprobe: ffprobe, FFmpeg: ffmpeg} +} + +// makeFile creates a short tagged file; mp3, flac and m4a get cover art. +func makeFile(t *testing.T, tl Tools, dir, ext string) string { + t.Helper() + cover := filepath.Join(dir, "cover.png") + if _, err := os.Stat(cover); err != nil { + if out, err := exec.Command(tl.FFmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "color=c=red:s=32x32:d=1", "-frames:v", "1", cover).CombinedOutput(); err != nil { + t.Fatal(err, string(out)) + } + } + p := filepath.Join(dir, "song."+ext) + args := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1"} + if ext == "mp3" || ext == "flac" || ext == "m4a" { + args = append(args, "-i", cover, "-map", "0", "-map", "1", "-c:v", "png", "-disposition:v:0", "attached_pic") + } + if ext == "m4a" { + args = append(args, "-c:a", "aac") + } + for _, kv := range []string{"title=Old Title", "artist=Avenged Sevenfold", "album_artist=A7X", "track=3/12", "date=2010", "genre=Metal"} { + args = append(args, "-metadata", kv) + } + if out, err := exec.Command(tl.FFmpeg, append(args, p)...).CombinedOutput(); err != nil { + t.Fatal(err, string(out)) + } + return p +} + +func streams(t *testing.T, tl Tools, p string) int { + out, err := exec.Command(tl.FFprobe, "-v", "error", "-show_entries", "stream=index", "-of", "csv=p=0", p).Output() + if err != nil { + t.Fatal(err) + } + return len(strings.Fields(string(out))) +} + +func TestEditEveryFormat(t *testing.T) { + tl := tools(t) + for _, ext := range []string{"mp3", "flac", "m4a", "ogg", "opus"} { + t.Run(ext, func(t *testing.T) { + dir := t.TempDir() + src := makeFile(t, tl, dir, ext) + f, err := tl.Read(ctx, src) + if err != nil { + t.Fatal(err) + } + if f.Fields[Title] != "Old Title" || f.Fields[Track] != "3" || f.Fields[TrackTotal] != "12" || f.Fields[AlbumArtist] != "A7X" { + t.Fatalf("read: %+v", f.Fields) + } + changes := map[string]string{Title: "Nightmare", AlbumArtist: "Avenged Sevenfold", Genre: "", Track: "4", TrackTotal: "13", Album: "Nightmare"} + set, err := f.Plan(changes) + if err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, ".tmp-out."+ext) + if err := tl.Write(ctx, src, dst, f, set); err != nil { + t.Fatal(err) + } + if err := tl.Verify(ctx, src, dst, changes); err != nil { + t.Fatal(err) + } + got, _ := tl.Read(ctx, dst) + for k, v := range got.Raw { + if strings.Contains(v, ";") { + t.Errorf("tag %s was duplicated: %q", k, v) + } + } + if _, has := got.Fields[Genre]; has { + t.Error("genre should be removed") + } + if got.Fields[Artist] != "Avenged Sevenfold" || got.Fields[Date] != "2010" { + t.Errorf("untouched fields must survive: %+v", got.Fields) + } + if streams(t, tl, src) != streams(t, tl, dst) { + t.Error("cover art or another stream was lost") + } + }) + } +} + +// FLACs tagged by other tools use raw Vorbis names; they must keep their +// spelling, their separate TRACKTOTAL and unrelated tags like ReplayGain. +func TestEditRawVorbisFLAC(t *testing.T) { + tl := tools(t) + metaflac, err := exec.LookPath("metaflac") + if err != nil { + t.Skip("metaflac not installed") + } + dir := t.TempDir() + src := makeFile(t, tl, dir, "flac") + cmd := exec.Command(metaflac, "--remove-all-tags", "--set-tag=TITLE=Raw", "--set-tag=ALBUMARTIST=A7X", "--set-tag=TRACKNUMBER=3", + "--set-tag=TRACKTOTAL=12", "--set-tag=REPLAYGAIN_TRACK_GAIN=-6.5 dB", "--set-tag=YEAR=2009", "--set-tag=DATE=2010", src) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatal(err, string(out)) + } + f, _ := tl.Read(ctx, src) + changes := map[string]string{Title: "Nightmare", AlbumArtist: "Avenged Sevenfold", Track: "4", Date: "2011"} + set, _ := f.Plan(changes) + dst := filepath.Join(dir, ".tmp-out.flac") + if err := tl.Write(ctx, src, dst, f, set); err != nil { + t.Fatal(err) + } + if err := tl.Verify(ctx, src, dst, changes); err != nil { + t.Fatal(err) + } + out, _ := exec.Command(metaflac, "--export-tags-to=-", dst).Output() + tagsText := string(out) + for _, want := range []string{"TITLE=Nightmare", "ALBUMARTIST=Avenged Sevenfold", "TRACKNUMBER=4", "TRACKTOTAL=12", "REPLAYGAIN_TRACK_GAIN=-6.5 dB", "DATE=2011"} { + if !strings.Contains(tagsText, want+"\n") { + t.Errorf("missing %q in:\n%s", want, tagsText) + } + } + if strings.Count(tagsText, "ALBUMARTIST=") != 1 || strings.Count(tagsText, "TRACKNUMBER=") != 1 || strings.Contains(tagsText, "YEAR=") { + t.Errorf("duplicate or stale tags:\n%s", tagsText) + } + if strings.Contains(strings.ToLower(tagsText), "encoder=") { + t.Errorf("no encoder tag should be added:\n%s", tagsText) + } +} + +func TestVerifyRejectsChangedAudio(t *testing.T) { + tl := tools(t) + dir := t.TempDir() + a := makeFile(t, tl, dir, "flac") + b := filepath.Join(dir, "other.flac") + if out, err := exec.Command(tl.FFmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "sine=f=900:d=1", "-metadata", "title=Old Title", b).CombinedOutput(); err != nil { + t.Fatal(err, string(out)) + } + if err := tl.Verify(ctx, a, b, map[string]string{Title: "Old Title"}); !errors.Is(err, ErrVerify) { + t.Fatalf("different audio must fail verification, got %v", err) + } +} + +func TestPlanTotals(t *testing.T) { + combined := parse(Format{Name: "mp3"}, map[string]string{"track": "3/12"}) + set, _ := combined.Plan(map[string]string{TrackTotal: "14"}) + if set["track"] != "3/14" { + t.Errorf("mp3 total change should rewrite the combined tag, got %v", set) + } + set, _ = combined.Plan(map[string]string{Track: "", TrackTotal: ""}) + if v, ok := set["track"]; !ok || v != "" { + t.Errorf("clearing both should delete the tag, got %v", set) + } + fresh := parse(Format{Name: "flac", Vorbis: true}, map[string]string{}) + set, _ = fresh.Plan(map[string]string{Track: "1", TrackTotal: "9", Title: "x"}) + if set["track"] != "1" || set["TRACKTOTAL"] != "9" || set["TITLE"] != "x" { + t.Errorf("new FLAC tags should use standard names, got %v", set) + } + if _, err := fresh.Plan(map[string]string{"bogus": "1"}); err == nil { + t.Error("unknown fields must be rejected") + } + if _, ok := FormatOf("x.dsf"); ok { + t.Error("dsf editing is not supported by ffmpeg and must be refused") + } +} diff --git a/web/src/api.ts b/web/src/api.ts index abbd93b..605a2ff 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -34,6 +34,8 @@ export type Info = { default_extensions: string[]; dry_run: boolean; plex_ignore: boolean; + tag_editing: boolean; + tag_fields: string[]; }; export type Job = { @@ -139,7 +141,7 @@ export type Match = { export type Operation = { id: number; pair_id: number; - action: "link" | "promote" | "build" | "remove" | "rename"; + action: "link" | "promote" | "build" | "remove" | "rename" | "tags"; rel_master: string; rel_slave: string; rel_new: string; @@ -154,6 +156,7 @@ export type Operation = { finished_at: number | null; undone_at: number | null; purged_at: number | null; + detail?: { side?: "master" | "slave"; mode?: "replace" | "inplace"; changes?: Record }; }; export type TreeEntry = { @@ -211,3 +214,17 @@ export type ScanWarning = { }; export type FolderRename = { slave: string; master: string; ready: boolean; reason?: string }; + +export type Side = "master" | "slave"; + +export type FileEntry = { name: string; rel: string; dir: boolean; size?: number; links?: number; editable?: boolean }; + +export type TagFile = { + rel: string; + editable: boolean; + reason?: string; + fields: Record; + other: Record; + links: number; + size: number; +}; diff --git a/web/src/pages/Activity.tsx b/web/src/pages/Activity.tsx index 91c4d45..8d5a862 100644 --- a/web/src/pages/Activity.tsx +++ b/web/src/pages/Activity.tsx @@ -37,6 +37,7 @@ const actionLabel: Record = { build: "Built into slave", remove: "Removed from slave", rename: "Renamed slave folder", + tags: "Edited tags", }; const statusTone: Record = { @@ -95,9 +96,19 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
-
- {o.rel_slave} +
+ {o.action === "tags" && {o.detail?.side}} + {o.rel_slave || o.rel_master}
+ {o.detail?.changes && ( +
+ {Object.entries(o.detail.changes).map(([k, [from, to]]) => ( +
+ {k.replace("_", " ")}: {from || "(none)"} → {to || "(removed)"} +
+ ))} +
+ )} {o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
now {o.rel_new}
)} @@ -178,6 +189,10 @@ function undoText(o: Operation) { return "The hardlink is removed from the slave library. The master file is not affected."; case "remove": return "The hardlink is recreated in the slave library."; + case "tags": + return o.detail?.mode === "inplace" + ? "The previous version of the file is written back from quarantine, restoring the old tags everywhere the file is linked." + : "The previous file is put back from quarantine, restoring the old tags."; case "rename": return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`; } diff --git a/web/src/pages/PairPage.tsx b/web/src/pages/PairPage.tsx index d704835..2696b94 100644 --- a/web/src/pages/PairPage.tsx +++ b/web/src/pages/PairPage.tsx @@ -10,6 +10,7 @@ import { Review } from "./Review"; import { Build } from "./Build"; import { OpsTable } from "./Activity"; import { WarningList } from "./Warnings"; +import { TagsTab } from "./Tags"; export function usePair(id: number) { return useQuery({ @@ -122,6 +123,7 @@ function LastJob({ job }: { job: Job }) { const tabs = [ { id: "review", label: "Review" }, { id: "build", label: "Build" }, + { id: "tags", label: "Tags" }, { id: "history", label: "History" }, ]; @@ -202,6 +204,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) { {tab === "review" && } {tab === "build" && } + {tab === "tags" && } {tab === "history" && } setEditing(false)} /> diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index da7a153..d038949 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react"; -import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api"; +import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, Tags as TagsIcon, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react"; +import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo, type Side } from "../api"; +import { TagEditor } from "./Tags"; import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui"; import { useToast } from "../toast"; import { AlbumView, BulkBar, TriCheckbox, toggleIDs, type Selection } from "./Bulk"; @@ -375,6 +376,7 @@ const rows: [string, (p: ProbeInfo, size: number) => string][] = [ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) { const toast = useToast(); const [confirm, setConfirm] = useState(null); + const [tagSide, setTagSide] = useState(null); const mp = m.master_probe; const sp = m.slave_probe; const v = m.verdict; @@ -489,6 +491,13 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) { + + +

Whatever gets replaced is kept in quarantine and can be restored from History.

@@ -526,6 +535,14 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
+ {tagSide && ( + setTagSide(null)} + /> + )} ); } diff --git a/web/src/pages/Tags.tsx b/web/src/pages/Tags.tsx new file mode 100644 index 0000000..60d79e4 --- /dev/null +++ b/web/src/pages/Tags.tsx @@ -0,0 +1,376 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, ChevronRight, FileAudio, Folder, Link2, RotateCcw, Tags as TagsIcon, X } from "lucide-react"; +import { api, type FileEntry, type Pair, type Side, type TagFile } from "../api"; +import { useInfo } from "../App"; +import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, inputCls } from "../ui"; +import { useToast } from "../toast"; + +// ---- field layout ---- + +type FieldDef = { id: string; label: string; span?: 2; perSong?: boolean; multiline?: boolean }; + +const FIELDS: FieldDef[] = [ + { id: "title", label: "Title", span: 2, perSong: true }, + { id: "artist", label: "Artist" }, + { id: "album_artist", label: "Album artist" }, + { id: "album", label: "Album", span: 2 }, + { id: "track", label: "Track", perSong: true }, + { id: "track_total", label: "of" }, + { id: "disc", label: "Disc" }, + { id: "disc_total", label: "of" }, + { id: "date", label: "Year / date" }, + { id: "genre", label: "Genre" }, + { id: "composer", label: "Composer", span: 2 }, + { id: "comment", label: "Comment", span: 2, multiline: true }, +]; + +// ---- browser tab ---- + +export function TagsTab({ pair }: { pair: Pair }) { + const info = useInfo(); + const [side, setSide] = useState("slave"); + const [path, setPath] = useState(""); + const [sel, setSel] = useState>(new Set()); + const [editing, setEditing] = useState(null); + useEffect(() => setSel(new Set()), [side, path]); + + const files = useQuery({ + queryKey: ["files", pair.id, side, path], + queryFn: () => api.get(`/pairs/${pair.id}/files?side=${side}&path=${encodeURIComponent(path)}`), + }); + + if (info.data && !info.data.tag_editing) + return Tag editing needs ffmpeg and ffprobe, which were not found. The Docker image includes both.; + + const entries = files.data ?? []; + const songs = entries.filter((e) => !e.dir && e.editable); + const crumbs = path ? path.split("/") : []; + const allSelected = songs.length > 0 && songs.every((s) => sel.has(s.rel)); + + return ( +
+
+
+
+ {(["slave", "master"] as const).map((s) => ( + + ))} +
+ +
+ +
+ + + {files.isLoading && ( +
+ +
+ )} + {files.error && ( +
+ {files.error.message} +
+ )} + {files.data?.length === 0 && } title="Empty folder" />} + {songs.length > 0 && ( + + )} + {entries.map((e) => ( +
+ {e.dir ? ( + <> + + + + + ) : ( + <> + + setSel((s) => { + const n = new Set(s); + if (ev.target.checked) n.add(e.rel); + else n.delete(e.rel); + return n; + }) + } + /> + + + {(e.links ?? 1) > 1 && ( + + shared + + )} + {formatBytes(e.size ?? 0)} + + )} +
+ ))} +
+

+ Click a song to edit its tags, or tick several (for example a whole album) to edit the tags they share. shared marks files that are + hardlinked to the other library. +

+ {editing && ( + setEditing(null)} + onSaved={() => setSel(new Set())} + /> + )} +
+ ); +} + +// ---- editor ---- + +type Common = Record; + +export function TagEditor({ + pairId, + side, + paths, + onClose, + onSaved, +}: { + pairId: number; + side: Side; + paths: string[]; + onClose: () => void; + onSaved?: () => void; +}) { + const qc = useQueryClient(); + const toast = useToast(); + const dry = useInfo().data?.dry_run ?? true; + const read = useQuery({ + queryKey: ["tags", pairId, side, paths.join("\n")], + queryFn: () => api.post<{ files: TagFile[] }>(`/pairs/${pairId}/tags/read`, { side, paths }), + gcTime: 0, + }); + const [edits, setEdits] = useState>({}); + const [separate, setSeparate] = useState(false); + const [showOther, setShowOther] = useState(false); + + const editable = useMemo(() => (read.data?.files ?? []).filter((f) => f.editable), [read.data]); + const blocked = (read.data?.files ?? []).filter((f) => !f.editable); + const multi = editable.length > 1; + const shared = editable.filter((f) => f.links > 1).length; + + const common: Common = useMemo(() => { + const c: Common = {}; + for (const f of FIELDS) { + const vals = editable.map((file) => file.fields[f.id] ?? ""); + c[f.id] = { value: vals[0] ?? "", mixed: vals.some((v) => v !== vals[0]), present: vals.some((v) => v !== "") }; + } + return c; + }, [editable]); + + const changed = Object.keys(edits).filter((id) => common[id]?.mixed || edits[id] !== common[id]?.value); + const set = Object.fromEntries(changed.map((id) => [id, edits[id].trim()])); + + const save = useMutation({ + mutationFn: () => api.post(`/pairs/${pairId}/tags`, { side, paths: editable.map((f) => f.rel), set, separate }), + onSuccess: () => { + toast("ok", dry ? "Started (dry run): the edit is recorded as planned only" : `Saving tags for ${editable.length} song${editable.length > 1 ? "s" : ""}…`); + qc.invalidateQueries({ queryKey: ["pair", pairId] }); + qc.invalidateQueries({ queryKey: ["files"] }); + onSaved?.(); + onClose(); + }, + onError: (e) => toast("err", e.message), + }); + + const title = multi ? `Edit tags of ${editable.length} songs` : `Edit tags: ${paths[0].split("/").pop()}`; + + return ( + + {read.isLoading && } + {read.error && {read.error.message}} + {read.data && ( +
+
+ {side}{" "} + {multi ? `${editable.length} files in ${commonFolder(editable.map((f) => f.rel))}` : paths[0]} +
+ + {blocked.length > 0 && ( + + {blocked.length} file{blocked.length > 1 && "s"} can't be edited and will be left alone: +
    + {blocked.slice(0, 5).map((f) => ( +
  • + {f.rel.split("/").pop()}: {f.reason} +
  • + ))} +
+
+ )} + + {editable.length > 0 && ( +
+ {FIELDS.map((f) => { + const c = common[f.id]; + const isChanged = changed.includes(f.id); + const lockedPerSong = multi && f.perSong; + const value = edits[f.id] ?? (c.mixed ? "" : c.value); + const removing = isChanged && edits[f.id] === "" && (c.present || c.mixed); + const Input = f.multiline ? "textarea" : "input"; + return ( + + ); + })} +
+ )} + + {!multi && editable[0] && Object.keys(editable[0].other).length > 0 && ( +
+ + {showOther && ( + + + {Object.entries(editable[0].other).map(([k, v]) => ( + + + + + ))} + +
{k}{v}
+ )} +
+ )} + + {shared > 0 && ( + }> +
+ {shared === editable.length ? (multi ? "These files are" : "This file is") : `${shared} of these files are`} hardlinked:{" "} + the {side === "slave" ? "master" : "slave"} library uses the same file. Editing changes the tags in both libraries, and the + files stay linked. +
+ +
+ )} + +

+ Only the tags are rewritten; the audio is verified to be identical afterwards. The previous version goes to quarantine and the edit can + be undone from History. +

+ {dry && Dry run is on, so the edit is recorded as planned in Activity. No files change.} +
+ )} +
+ {changed.length > 0 && ( + + {changed.length} field{changed.length > 1 && "s"} will change on {editable.length} song{editable.length > 1 && "s"} + + )} + + +
+
+ ); +} + +function commonFolder(rels: string[]) { + const dirs = rels.map((r) => r.split("/").slice(0, -1)); + const first = dirs[0] ?? []; + let n = first.length; + for (const d of dirs) { + let i = 0; + while (i < n && d[i] === first[i]) i++; + n = i; + } + return first.slice(0, n).join("/") || "the library root"; +}