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), for every file // Files adds per-song changes (an album's track titles and numbers). Files []FileEdit `json:"files,omitempty"` // Art applies album art to the edited files and/or their album folder. Art *ArtApply `json:"art,omitempty"` // Separate gives a hardlinked file its own edited copy instead of // updating it in place for every linked path. Separate bool `json:"separate"` } type FileEdit struct { Path string `json:"path"` Set map[string]string `json:"set"` } // ArtApply is a prepared cover (see art.Store) and where it goes. type ArtApply struct { Token string `json:"token"` Embed bool `json:"embed"` // into each song (MP3, FLAC, M4A) FolderFile bool `json:"folder_file"` // as cover.jpg in the album folder } func validTagChanges(set map[string]string) error { 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) } // Every file gets the shared changes plus its own. perFile := map[string]map[string]string{} var order []string add := func(p string, set map[string]string) error { c, err := fsops.CleanRel(p) if err != nil { return err } if _, ok := perFile[c]; !ok { perFile[c] = map[string]string{} order = append(order, c) } for k, v := range set { perFile[c][k] = v } return nil } for _, p := range req.Paths { if err := add(p, req.Set); err != nil { return nil, err } } for _, f := range req.Files { if err := add(f.Path, req.Set); err != nil { return nil, err } if err := add(f.Path, f.Set); err != nil { return nil, err } } if len(order) == 0 { return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk) } if len(order) > maxTagFiles { return nil, fmt.Errorf("%w: select at most %d files at a time", ErrInvalidBulk, maxTagFiles) } changed := false for _, set := range perFile { if len(set) > 0 { if err := validTagChanges(set); err != nil { return nil, err } changed = true } } var cover string if req.Art != nil && (req.Art.Embed || req.Art.FolderFile) { p, err := e.preparedArt(req.Art.Token) if err != nil { return nil, err } cover = p } if !changed && cover == "" { return nil, fmt.Errorf("%w: nothing changed", ErrInvalidBulk) } return e.startJob(pairID, "edit-tags", func(ctx context.Context, j *Job) error { l, err := e.loadPair(ctx, pairID) if err != nil { return err } embed := "" if cover != "" && req.Art.Embed { embed = cover } j.phase("Editing tags", int64(len(order))) for _, rel := range order { if ctx.Err() != nil { return ctx.Err() } if err := e.editTagsOne(ctx, j, l, req.Side, rel, perFile[rel], embed, req.Separate); err != nil { j.skip(rel + ": " + err.Error()) } j.tick() } if cover != "" && req.Art.FolderFile { for _, folder := range albumFolders(order) { if err := e.writeFolderCover(ctx, l, req.Side, folder, cover); err != nil { j.skip(folder + " cover.jpg: " + err.Error()) } else { j.count("folder art") } } } return nil }) } // albumFolders lists the distinct album folders of some songs (disc folders // folded into their album). func albumFolders(rels []string) []string { seen := map[string]bool{} var out []string for _, r := range rels { f := AlbumFolder(r) if f != "" && !seen[f] { seen[f] = true out = append(out, f) } } return out } type tagDetail struct { Side string `json:"side"` Mode string `json:"mode"` Changes map[string][2]string `json:"changes"` // field -> [old, new] Cover bool `json:"cover,omitempty"` // new art embedded } // editTagsOne applies tag changes and/or a new embedded cover to one file. func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel string, set map[string]string, cover 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 cover != "" && !f.Format.EmbedsCover() { cover = "" // Ogg-based files: the folder image carries their art if len(changes) == 0 { j.count("art in folder only") return nil } } d.Cover = cover != "" if len(changes) == 0 && cover == "" { 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, cover); err != nil { return err } if err := e.tagTools.Verify(ctx, abs, tmp, changes, cover != ""); 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" }