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:
1 parent
1da0132d5e
commit
3eb02e2c8d
17 files changed
+1842
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+52
-1
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+50
-26
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -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<string, [string, string]> };
|
||||
};
|
||||
|
||||
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<string, string>;
|
||||
other: Record<string, string>;
|
||||
links: number;
|
||||
size: number;
|
||||
};
|
||||
@@ -37,6 +37,7 @@ const actionLabel: Record<Operation["action"], string> = {
|
||||
build: "Built into slave",
|
||||
remove: "Removed from slave",
|
||||
rename: "Renamed slave folder",
|
||||
tags: "Edited tags",
|
||||
};
|
||||
|
||||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
||||
@@ -95,9 +96,19 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate mono text-[13px]" title={o.rel_slave}>
|
||||
{o.rel_slave}
|
||||
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
|
||||
{o.action === "tags" && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
|
||||
{o.rel_slave || o.rel_master}
|
||||
</div>
|
||||
{o.detail?.changes && (
|
||||
<div className="text-xs text-muted">
|
||||
{Object.entries(o.detail.changes).map(([k, [from, to]]) => (
|
||||
<div key={k} className="truncate">
|
||||
<span className="text-gray-400">{k.replace("_", " ")}</span>: {from || "(none)"} → <span className="text-gray-200">{to || "(removed)"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
|
||||
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
|
||||
)}
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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" && <Review pair={pair} />}
|
||||
{tab === "build" && <Build pair={pair} />}
|
||||
{tab === "tags" && <TagsTab pair={pair} />}
|
||||
{tab === "history" && <OpsTable pairId={pair.id} />}
|
||||
|
||||
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
|
||||
|
||||
@@ -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 | "downgrade" | "promote">(null);
|
||||
const [tagSide, setTagSide] = useState<Side | null>(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 }) {
|
||||
<Button variant={v?.better === "slave" ? "warn" : "secondary"} onClick={() => setConfirm("promote")}>
|
||||
<Crown className="size-4" /> Use slave copy as master
|
||||
</Button>
|
||||
<span className="mx-1 w-px self-stretch bg-line" aria-hidden />
|
||||
<Button variant="ghost" onClick={() => setTagSide("slave")}>
|
||||
<TagsIcon className="size-4" /> Slave tags
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setTagSide("master")}>
|
||||
<TagsIcon className="size-4" /> Master tags
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">Whatever gets replaced is kept in quarantine and can be restored from History.</p>
|
||||
|
||||
@@ -526,6 +535,14 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
{tagSide && (
|
||||
<TagEditor
|
||||
pairId={m.pair_id}
|
||||
side={tagSide}
|
||||
paths={[tagSide === "slave" ? m.rel_slave : m.rel_master]}
|
||||
onClose={() => setTagSide(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Side>("slave");
|
||||
const [path, setPath] = useState("");
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<string[] | null>(null);
|
||||
useEffect(() => setSel(new Set()), [side, path]);
|
||||
|
||||
const files = useQuery({
|
||||
queryKey: ["files", pair.id, side, path],
|
||||
queryFn: () => api.get<FileEntry[]>(`/pairs/${pair.id}/files?side=${side}&path=${encodeURIComponent(path)}`),
|
||||
});
|
||||
|
||||
if (info.data && !info.data.tag_editing)
|
||||
return <Alert tone="amber">Tag editing needs ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-lg border border-line p-0.5" role="tablist" aria-label="Library">
|
||||
{(["slave", "master"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={side === s}
|
||||
onClick={() => {
|
||||
setSide(s);
|
||||
setPath("");
|
||||
}}
|
||||
className={cx("rounded-md px-3 py-1 text-sm capitalize cursor-pointer", side === s ? "bg-panel-2 text-white" : "text-muted hover:text-white")}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
|
||||
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPath("")}>
|
||||
{side}
|
||||
</button>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<ChevronRight className="size-3.5 text-muted" />
|
||||
<button className="hover:underline cursor-pointer" onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}>
|
||||
{c}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" disabled={sel.size === 0} onClick={() => setEditing([...sel])}>
|
||||
<TagsIcon className="size-3.5" /> Edit tags{sel.size > 0 && ` (${sel.size})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{files.isLoading && (
|
||||
<div className="p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{files.error && (
|
||||
<div className="p-4">
|
||||
<Alert tone="red">{files.error.message}</Alert>
|
||||
</div>
|
||||
)}
|
||||
{files.data?.length === 0 && <Empty icon={<Folder className="size-8" />} title="Empty folder" />}
|
||||
{songs.length > 0 && (
|
||||
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
checked={allSelected}
|
||||
onChange={() => setSel(allSelected ? new Set() : new Set(songs.map((s) => s.rel)))}
|
||||
/>
|
||||
Select all {songs.length} songs in this folder
|
||||
</label>
|
||||
)}
|
||||
{entries.map((e) => (
|
||||
<div key={e.rel} className={cx("flex items-center gap-3 border-b border-line px-4 py-2 last:border-0", sel.has(e.rel) && "bg-accent/5")}>
|
||||
{e.dir ? (
|
||||
<>
|
||||
<span className="w-4" />
|
||||
<Folder className="size-4 shrink-0 text-accent" />
|
||||
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => setPath(e.rel)}>
|
||||
{e.name}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-violet-500"
|
||||
disabled={!e.editable}
|
||||
checked={sel.has(e.rel)}
|
||||
onChange={(ev) =>
|
||||
setSel((s) => {
|
||||
const n = new Set(s);
|
||||
if (ev.target.checked) n.add(e.rel);
|
||||
else n.delete(e.rel);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FileAudio className="size-4 shrink-0 text-muted" />
|
||||
<button
|
||||
className={cx("min-w-0 flex-1 truncate text-left text-sm", e.editable ? "hover:underline cursor-pointer" : "text-muted cursor-not-allowed")}
|
||||
disabled={!e.editable}
|
||||
title={e.editable ? "Edit tags" : "Editing tags of this file type is not supported"}
|
||||
onClick={() => setEditing([e.rel])}
|
||||
>
|
||||
{e.name}
|
||||
</button>
|
||||
{(e.links ?? 1) > 1 && (
|
||||
<Badge tone="green" className="shrink-0">
|
||||
<Link2 className="size-3" /> shared
|
||||
</Badge>
|
||||
)}
|
||||
<span className="w-20 text-right text-xs tabular-nums text-muted">{formatBytes(e.size ?? 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
<p className="text-xs text-muted">
|
||||
Click a song to edit its tags, or tick several (for example a whole album) to edit the tags they share. <b>shared</b> marks files that are
|
||||
hardlinked to the other library.
|
||||
</p>
|
||||
{editing && (
|
||||
<TagEditor
|
||||
pairId={pair.id}
|
||||
side={side}
|
||||
paths={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => setSel(new Set())}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- editor ----
|
||||
|
||||
type Common = Record<string, { value: string; mixed: boolean; present: boolean }>;
|
||||
|
||||
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<Record<string, string>>({});
|
||||
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 (
|
||||
<Modal open onClose={onClose} title={title} wide>
|
||||
{read.isLoading && <Spinner />}
|
||||
{read.error && <Alert tone="red">{read.error.message}</Alert>}
|
||||
{read.data && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="text-xs text-muted">
|
||||
<Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>{" "}
|
||||
<span className="mono">{multi ? `${editable.length} files in ${commonFolder(editable.map((f) => f.rel))}` : paths[0]}</span>
|
||||
</div>
|
||||
|
||||
{blocked.length > 0 && (
|
||||
<Alert tone="amber">
|
||||
{blocked.length} file{blocked.length > 1 && "s"} can't be edited and will be left alone:
|
||||
<ul className="mt-1 text-xs">
|
||||
{blocked.slice(0, 5).map((f) => (
|
||||
<li key={f.rel}>
|
||||
<span className="mono">{f.rel.split("/").pop()}</span>: {f.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{editable.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 md:grid-cols-4">
|
||||
{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 (
|
||||
<label key={f.id} className={cx("block space-y-1", f.span === 2 ? "col-span-2" : "col-span-1")}>
|
||||
<span className="flex items-center justify-between text-xs font-medium text-gray-300">
|
||||
{f.label}
|
||||
{isChanged && (
|
||||
<button type="button" className="flex items-center gap-0.5 text-[11px] text-muted hover:text-white cursor-pointer" onClick={() => setEdits(({ [f.id]: _, ...rest }) => rest)}>
|
||||
<RotateCcw className="size-3" /> revert
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className={cx(inputCls, f.multiline && "h-16 py-2", isChanged && "border-accent ring-1 ring-accent/40", "pr-7")}
|
||||
value={value}
|
||||
disabled={lockedPerSong}
|
||||
placeholder={lockedPerSong ? "different per song" : c.mixed ? "(multiple values: keep)" : ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setEdits((s) => ({ ...s, [f.id]: e.target.value }))}
|
||||
/>
|
||||
{!lockedPerSong && (value !== "" || c.mixed) && (
|
||||
<button
|
||||
type="button"
|
||||
title="Remove this tag"
|
||||
className="absolute right-2 top-2.5 text-muted hover:text-white cursor-pointer"
|
||||
onClick={() => setEdits((s) => ({ ...s, [f.id]: "" }))}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{removing && <span className="text-[11px] text-amber-300">will be removed</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!multi && editable[0] && Object.keys(editable[0].other).length > 0 && (
|
||||
<div>
|
||||
<button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowOther(!showOther)}>
|
||||
{showOther ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
||||
{Object.keys(editable[0].other).length} other tags (kept as they are)
|
||||
</button>
|
||||
{showOther && (
|
||||
<table className="mt-2 w-full text-xs">
|
||||
<tbody>
|
||||
{Object.entries(editable[0].other).map(([k, v]) => (
|
||||
<tr key={k} className="border-t border-line/60">
|
||||
<td className="py-1 pr-3 mono text-muted">{k}</td>
|
||||
<td className="py-1 break-all">{v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shared > 0 && (
|
||||
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
|
||||
<div>
|
||||
{shared === editable.length ? (multi ? "These files are" : "This file is") : `${shared} of these files are`} <b>hardlinked</b>:{" "}
|
||||
the {side === "slave" ? "master" : "slave"} library uses the same file. Editing changes the tags in <b>both</b> libraries, and the
|
||||
files stay linked.
|
||||
</div>
|
||||
<label className="mt-2 flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={separate} onChange={(e) => setSeparate(e.target.checked)} />
|
||||
<span>
|
||||
Change only the {side} library's copy instead. This breaks the link: the {side} gets its own file, which uses extra disk space.
|
||||
</span>
|
||||
</label>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
{dry && <Alert tone="blue">Dry run is on, so the edit is recorded as planned in Activity. No files change.</Alert>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
{changed.length > 0 && (
|
||||
<span className="mr-auto text-xs text-muted">
|
||||
{changed.length} field{changed.length > 1 && "s"} will change on {editable.length} song{editable.length > 1 && "s"}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" disabled={changed.length === 0 || editable.length === 0} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save tags
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
Reference in new issue
Block a user