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" }