Search iTunes, Deezer, MusicBrainz, DuckDuckGo and Google for square covers, embed them and/or save cover.jpg, auto-match covers for albums, and edit an album's shared tags and track list in one place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
586 lines
16 KiB
Go
586 lines
16 KiB
Go
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"
|
|
}
|