Files
playlink/internal/engine/ops.go
T
cdnutterandClaude Opus 5.5 ac47e61b08 Initial commit: playlink music library hardlink deduplicator
Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds
songs present in both a host and a slave music library and replaces the
slave copy with a hardlink to the host copy.

- Path-first matching with format/title/album-folder fallbacks
- Auto-linking of byte-identical files, re-verified with cmp before linking
- Manual review with ffprobe quality comparison, downgrade guard and
  promote-to-host override
- Build mode to populate an empty slave library from the host
- Dry run by default, atomic stage+rename replacement, quarantine with
  retention, undo, path confinement and authenticated UI
- Dockerfile and compose file for Unraid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:38:25 -07:00

835 lines
22 KiB
Go

package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
type LinkOptions struct {
// AllowDowngrade confirms linking when the host copy scored lower.
AllowDowngrade bool
Trigger string
}
// snapshot holds the verified state of both files right before an operation.
type snapshot struct {
hostAbs, slaveAbs string
hi, si fsops.Info
}
// preflight re-validates a match against the filesystem: both files must be
// plain regular files inside their roots, on one device, and unchanged since
// the scan recorded them.
func preflight(l *libs, m store.Match) (snapshot, error) {
var s snapshot
var err error
if s.hostAbs, err = l.host.Resolve(m.RelHost); err != nil {
return s, err
}
if s.slaveAbs, err = l.slave.Resolve(m.RelSlave); err != nil {
return s, err
}
if s.hi, err = fsops.RegularFile(s.hostAbs); err != nil {
return s, fmt.Errorf("host file: %w", err)
}
if s.si, err = fsops.RegularFile(s.slaveAbs); err != nil {
return s, fmt.Errorf("slave file: %w", err)
}
if s.hi.Ino != m.HostIno || !s.hi.Unchanged(m.HostSize, m.HostMtime) {
return s, fmt.Errorf("host %w", fsops.ErrChanged)
}
if s.si.Ino != m.SlaveIno || !s.si.Unchanged(m.SlaveSize, m.SlaveMtime) {
return s, fmt.Errorf("slave %w", fsops.ErrChanged)
}
if s.hi.Dev != s.si.Dev || s.hi.Dev != l.dev {
return s, fsops.ErrCrossDevice
}
if s.hi.SameInode(s.si) {
return s, errors.New("already linked")
}
return s, nil
}
func quarantinePath(l *libs, opID int64, side, rel string) string {
return filepath.Join(l.quarantine, strconv.FormatInt(opID, 10), side, rel)
}
// quarantine adds an extra hardlink of p (whose identity is in) inside the
// quarantine. The original stays in place; nothing is lost if a later step
// fails. The caller removes or replaces p afterwards.
func quarantine(p string, in fsops.Info, q string) error {
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
return err
}
return fsops.LinkNew(p, in, q)
}
// dropQuarantine removes a quarantine link that turned out not to be needed.
func dropQuarantine(l *libs, q string, keep fsops.Info) {
if err := fsops.RemoveLink(q, keep); err == nil {
pruneEmpty(filepath.Dir(q), l.quarantine)
}
}
// removeOriginal deletes an original file after it was safely superseded. The
// inode must still be the one we verified.
func removeOriginal(p string, expect fsops.Info) error {
now, err := fsops.RegularFile(p)
if err != nil {
return err
}
if !now.SameInode(expect) || !now.Unchanged(expect.Size, expect.MtimeNs) {
return fmt.Errorf("%s %w", p, fsops.ErrChanged)
}
if err := os.Remove(p); err != nil {
return err
}
return fsops.SyncDir(filepath.Dir(p))
}
// pruneEmpty removes empty directories from dir upward, stopping at stop.
func pruneEmpty(dir, stop string) {
for dir != stop && fsops.Within(stop, dir) {
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
func (e *Engine) begin(ctx context.Context, o *store.Operation) error {
o.DryRun = e.st.DryRun(ctx)
if o.Trigger == "" {
o.Trigger = "manual"
}
o.Status = store.OpRunning
if o.DryRun {
o.Status = store.OpPlanned
}
return e.st.CreateOp(ctx, o)
}
func (e *Engine) finish(ctx context.Context, o *store.Operation, err error) error {
if err != nil {
o.Status, o.Error = store.OpFailed, err.Error()
e.log.Warn("operation failed", "op", o.ID, "action", o.Action, "slave", o.RelSlave, "err", err)
} else {
o.Status = store.OpDone
e.log.Info("operation done", "op", o.ID, "action", o.Action, "slave", o.RelSlave, "host", o.RelHost)
}
if ferr := e.st.FinishOp(ctx, o.ID, o.Status, o.Error); ferr != nil && err == nil {
return ferr
}
return err
}
func verdictOf(m store.Match) probe.Verdict {
var v probe.Verdict
_ = json.Unmarshal(m.Verdict, &v)
return v
}
// Link replaces the slave file with a hardlink of the host file.
//
// Identical files are re-compared byte for byte right before linking and no
// quarantine copy is kept (the data exists in the host). Non-identical files
// are quarantined first so the operation can be undone.
func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (store.Operation, error) {
e.mu.Lock()
defer e.mu.Unlock()
m, err := e.st.Match(ctx, matchID)
if err != nil {
return store.Operation{}, err
}
l, err := e.loadPair(ctx, m.PairID)
if err != nil {
return store.Operation{}, err
}
switch m.Status {
case store.StatusIdentical, store.StatusDifferent, store.StatusSimilar:
default:
return store.Operation{}, fmt.Errorf("cannot link a match with status %q", m.Status)
}
if m.Ignored {
return store.Operation{}, errors.New("match is ignored; un-ignore it first")
}
identical := m.Status == store.StatusIdentical
if !identical && verdictOf(m).Better == "slave" && !opt.AllowDowngrade {
return store.Operation{}, ErrDowngrade
}
s, err := preflight(l, m)
if err != nil {
return store.Operation{}, err
}
if identical {
same, err := fsops.SameContent(s.hostAbs, s.slaveAbs)
if err != nil {
return store.Operation{}, err
}
if !same {
e.refresh(ctx, l, m.RelSlave)
return store.Operation{}, errors.New("files are no longer identical; moved to manual review")
}
}
newRel := filepath.Join(filepath.Dir(m.RelSlave), filepath.Base(m.RelHost))
newAbs, err := l.slave.Resolve(newRel)
if err != nil {
return store.Operation{}, err
}
rename := newRel != m.RelSlave
if rename {
if _, err := os.Lstat(newAbs); err == nil {
return store.Operation{}, fmt.Errorf("slave already has %s: %w", newRel, fsops.ErrExists)
}
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActLink, RelHost: m.RelHost, RelSlave: m.RelSlave,
RelNew: newRel, Trigger: opt.Trigger}
if identical {
o.Bytes = s.si.Size // space freed
} else {
o.QuarantineSide = "slave"
}
if err := e.begin(ctx, &o); err != nil {
return o, err
}
if o.DryRun {
return o, nil
}
err = func() error {
var q string
if !identical {
q = quarantinePath(l, o.ID, "slave", m.RelSlave)
if err := quarantine(s.slaveAbs, s.si, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
o.QuarantinePath = q
if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil {
dropQuarantine(l, q, s.si)
return err
}
}
if !rename {
if err := fsops.ReplaceWithLink(s.hostAbs, s.hi, s.slaveAbs); err != nil {
if q != "" {
if cur, e2 := fsops.Lstat(s.slaveAbs); e2 == nil && cur.SameInode(s.si) {
dropQuarantine(l, q, s.si) // original untouched
e.clearQuarantine(ctx, o.ID)
}
}
return err
}
return nil
}
if err := fsops.LinkNew(s.hostAbs, s.hi, newAbs); err != nil {
if q != "" {
dropQuarantine(l, q, s.si)
e.clearQuarantine(ctx, o.ID)
}
return err
}
return removeOriginal(s.slaveAbs, s.si)
}()
err = e.finish(ctx, &o, err)
e.refresh(ctx, l, m.RelSlave)
if rename {
e.refresh(ctx, l, newRel)
}
return o, err
}
func (e *Engine) clearQuarantine(ctx context.Context, opID int64) {
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = '' WHERE id = ?`, opID)
}
// Promote is the override path: the slave copy is better, so it replaces the
// host file (whose original is quarantined) and both libraries share it.
func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (store.Operation, error) {
if !confirmed {
return store.Operation{}, ErrConfirm
}
e.mu.Lock()
defer e.mu.Unlock()
m, err := e.st.Match(ctx, matchID)
if err != nil {
return store.Operation{}, err
}
l, err := e.loadPair(ctx, m.PairID)
if err != nil {
return store.Operation{}, err
}
if m.Status != store.StatusDifferent && m.Status != store.StatusSimilar {
return store.Operation{}, fmt.Errorf("cannot promote a match with status %q", m.Status)
}
s, err := preflight(l, m)
if err != nil {
return store.Operation{}, err
}
newHostRel := filepath.Join(filepath.Dir(m.RelHost), filepath.Base(m.RelSlave))
newHostAbs, err := l.host.Resolve(newHostRel)
if err != nil {
return store.Operation{}, err
}
rename := newHostRel != m.RelHost
if rename {
if _, err := os.Lstat(newHostAbs); err == nil {
return store.Operation{}, fmt.Errorf("host already has %s: %w", newHostRel, fsops.ErrExists)
}
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActPromote, RelHost: m.RelHost, RelSlave: m.RelSlave,
RelNew: newHostRel, QuarantineSide: "host", Trigger: "manual"}
if err := e.begin(ctx, &o); err != nil {
return o, err
}
if o.DryRun {
return o, nil
}
err = func() error {
q := quarantinePath(l, o.ID, "host", m.RelHost)
if err := quarantine(s.hostAbs, s.hi, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
o.QuarantinePath = q
if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil {
dropQuarantine(l, q, s.hi)
return err
}
if !rename {
if err := fsops.ReplaceWithLink(s.slaveAbs, s.si, s.hostAbs); err != nil {
if cur, e2 := fsops.Lstat(s.hostAbs); e2 == nil && cur.SameInode(s.hi) {
dropQuarantine(l, q, s.hi)
e.clearQuarantine(ctx, o.ID)
}
return err
}
return nil
}
if err := fsops.LinkNew(s.slaveAbs, s.si, newHostAbs); err != nil {
dropQuarantine(l, q, s.hi)
e.clearQuarantine(ctx, o.ID)
return err
}
return removeOriginal(s.hostAbs, s.hi)
}()
err = e.finish(ctx, &o, err)
e.refresh(ctx, l, m.RelSlave)
return o, err
}
// ---- build (fresh slave library) and remove ----
// expand turns a list of relative files/folders into audio files under root.
func expand(ctx context.Context, l *libs, root fsops.Root, rels []string) ([]string, error) {
set := map[string]fsops.Info{}
for _, rel := range rels {
abs, err := root.Resolve(rel)
if err != nil {
return nil, err
}
fi, err := os.Lstat(abs)
if err != nil {
return nil, err
}
switch {
case fi.IsDir():
sub, err := fsops.NewRoot(abs)
if err != nil {
return nil, err
}
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, sub, files); err != nil {
return nil, err
}
for r, in := range files {
set[filepath.Join(rel, r)] = in
}
case fi.Mode().IsRegular() && l.hasExt(abs):
set[filepath.Clean(rel)] = fsops.Info{}
}
}
return sortedKeys(set), nil
}
// StartBuild hardlinks the chosen host files/folders into the slave library,
// mirroring the host path. Existing slave files are never overwritten.
func (e *Engine) StartBuild(pairID int64, rels []string) (*Job, error) {
return e.startJob(pairID, "build", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
files, err := expand(ctx, l, l.host, rels)
if err != nil {
return err
}
j.phase("Linking into slave library", int64(len(files)))
for _, rel := range files {
if ctx.Err() != nil {
return ctx.Err()
}
e.buildOne(ctx, j, l, rel)
j.tick()
}
return nil
})
}
func (e *Engine) buildOne(ctx context.Context, j *Job, l *libs, rel string) {
e.mu.Lock()
defer e.mu.Unlock()
hostAbs, err := l.host.Resolve(rel)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
slaveAbs, err := l.slave.Resolve(rel)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
hi, err := fsops.RegularFile(hostAbs)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
if si, err := fsops.Lstat(slaveAbs); err == nil {
if si.SameInode(hi) {
j.count("already linked")
} else {
j.skip(rel + ": a different file already exists in the slave library (scan, then use Review)")
}
return
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActBuild, RelHost: rel, RelSlave: rel, RelNew: rel, Trigger: "build"}
if err := e.begin(ctx, &o); err != nil {
j.skip(rel + ": " + err.Error())
return
}
if o.DryRun {
j.count("planned")
return
}
err = fsops.MkdirAllLike(filepath.Dir(slaveAbs))
if err == nil {
err = fsops.LinkNew(hostAbs, hi, slaveAbs)
}
if err = e.finish(ctx, &o, err); err != nil {
j.skip(rel + ": " + err.Error())
return
}
j.count("linked")
e.refresh(ctx, l, rel)
}
// StartRemove deletes slave files that are hardlinks of the host file at the
// same path. Anything that is not such a link is left alone.
func (e *Engine) StartRemove(pairID int64, rels []string) (*Job, error) {
return e.startJob(pairID, "remove", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
files, err := expand(ctx, l, l.slave, rels)
if err != nil {
return err
}
j.phase("Removing links from slave library", int64(len(files)))
for _, rel := range files {
if ctx.Err() != nil {
return ctx.Err()
}
e.removeOne(ctx, j, l, rel)
j.tick()
}
return nil
})
}
func (e *Engine) removeOne(ctx context.Context, j *Job, l *libs, rel string) {
e.mu.Lock()
defer e.mu.Unlock()
hostAbs, err1 := l.host.Resolve(rel)
slaveAbs, err2 := l.slave.Resolve(rel)
if err1 != nil || err2 != nil {
j.skip(rel + ": invalid path")
return
}
hi, err := fsops.RegularFile(hostAbs)
if err != nil {
j.skip(rel + ": no host file at the same path, so it is not removed")
return
}
si, err := fsops.RegularFile(slaveAbs)
if err != nil {
j.skip(rel + ": " + err.Error())
return
}
if !si.SameInode(hi) {
j.skip(rel + ": not a hardlink of the host file, so it is not removed")
return
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActRemove, RelHost: rel, RelSlave: rel, Trigger: "build"}
if err := e.begin(ctx, &o); err != nil {
j.skip(rel + ": " + err.Error())
return
}
if o.DryRun {
j.count("planned")
return
}
err = fsops.RemoveLink(slaveAbs, hi)
if err = e.finish(ctx, &o, err); err != nil {
j.skip(rel + ": " + err.Error())
return
}
pruneEmpty(filepath.Dir(slaveAbs), l.slave.Path)
j.count("removed")
e.refresh(ctx, l, rel)
}
// ---- undo, restore and purge ----
// Undo reverts a completed operation. Every branch verifies that the current
// file is still exactly what the operation left behind before touching it.
func (e *Engine) Undo(ctx context.Context, opID int64) error {
e.mu.Lock()
defer e.mu.Unlock()
o, err := e.st.Op(ctx, opID)
if err != nil {
return err
}
if o.Status != store.OpDone || o.UndoneAt != nil {
return errors.New("only completed operations that were not undone can be reverted")
}
if o.PurgedAt != nil {
return errors.New("the quarantined original was already purged")
}
l, err := e.loadPair(ctx, o.PairID)
if err != nil {
return err
}
res := func(r fsops.Root, rel string) string {
p, e := r.Resolve(rel)
if e != nil && err == nil {
err = e
}
return p
}
hostAbs := res(l.host, o.RelHost)
slaveAbs := res(l.slave, o.RelSlave)
if err != nil {
return err
}
switch o.Action {
case store.ActBuild:
hi, err := fsops.RegularFile(hostAbs)
if err != nil {
return err
}
if err := fsops.RemoveLink(slaveAbs, hi); err != nil {
return err
}
pruneEmpty(filepath.Dir(slaveAbs), l.slave.Path)
case store.ActRemove:
hi, err := fsops.RegularFile(hostAbs)
if err != nil {
return err
}
if err := fsops.MkdirAllLike(filepath.Dir(slaveAbs)); err != nil {
return err
}
if err := fsops.LinkNew(hostAbs, hi, slaveAbs); err != nil {
return err
}
case store.ActLink:
newAbs := res(l.slave, o.RelNew)
if err != nil {
return err
}
hi, err := fsops.RegularFile(hostAbs)
if err != nil {
return err
}
cur, err := fsops.RegularFile(newAbs)
if err != nil {
return err
}
if !cur.SameInode(hi) {
return errors.New("the slave file is no longer linked to the host file; refusing to undo")
}
renamed := o.RelNew != o.RelSlave
if renamed {
if _, err := os.Lstat(slaveAbs); err == nil {
return fmt.Errorf("%s: %w", o.RelSlave, fsops.ErrExists)
}
}
if o.QuarantinePath == "" {
// Identical link: give the slave its own copy of the bytes again.
if err := fsops.CopyReplace(hostAbs, slaveAbs, hi.Mode); err != nil {
return err
}
} else {
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
if renamed {
err = fsops.LinkNew(o.QuarantinePath, qi, slaveAbs)
} else {
err = fsops.ReplaceWithLink(o.QuarantinePath, qi, slaveAbs)
}
if err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
}
if renamed {
if err := fsops.RemoveLink(newAbs, hi); err != nil {
return err
}
}
e.refresh(ctx, l, o.RelNew)
case store.ActPromote:
newHostAbs := res(l.host, o.RelNew)
if err != nil {
return err
}
si, err := fsops.RegularFile(slaveAbs)
if err != nil {
return err
}
cur, err := fsops.RegularFile(newHostAbs)
if err != nil {
return err
}
if !cur.SameInode(si) {
return errors.New("the host file changed after promotion; refusing to undo")
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
if o.RelNew == o.RelHost {
err = fsops.ReplaceWithLink(o.QuarantinePath, qi, hostAbs)
} else {
if _, e2 := os.Lstat(hostAbs); e2 == nil {
return fmt.Errorf("%s: %w", o.RelHost, fsops.ErrExists)
}
if err = fsops.LinkNew(o.QuarantinePath, qi, hostAbs); err == nil {
err = fsops.RemoveLink(newHostAbs, si)
}
}
if err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
default:
return fmt.Errorf("cannot undo %q", o.Action)
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("operation undone", "op", o.ID, "action", o.Action)
e.refresh(ctx, l, o.RelSlave)
return nil
}
// quarantined validates a quarantine path recorded in the database.
func (e *Engine) quarantined(l *libs, q string) (fsops.Info, error) {
q = filepath.Clean(q)
if !fsops.Within(l.quarantine, q) || q == l.quarantine {
return fsops.Info{}, fmt.Errorf("quarantine path %s: %w", q, fsops.ErrOutsideRoot)
}
return fsops.RegularFile(q)
}
// Purge permanently deletes a quarantined original.
func (e *Engine) Purge(ctx context.Context, opID int64) error {
e.mu.Lock()
defer e.mu.Unlock()
o, err := e.st.Op(ctx, opID)
if err != nil {
return err
}
return e.purge(ctx, o)
}
func (e *Engine) purge(ctx context.Context, o store.Operation) error {
if o.QuarantinePath == "" || o.PurgedAt != nil || o.UndoneAt != nil {
return errors.New("nothing to purge for this operation")
}
l, err := e.loadPair(ctx, o.PairID)
if err != nil {
return err
}
opDir := filepath.Join(l.quarantine, strconv.FormatInt(o.ID, 10))
if !fsops.Within(l.quarantine, filepath.Clean(o.QuarantinePath)) || !fsops.Within(opDir, filepath.Clean(o.QuarantinePath)) {
return fsops.ErrOutsideRoot
}
if err := os.RemoveAll(opDir); err != nil {
return err
}
e.log.Info("quarantine purged", "op", o.ID, "path", o.QuarantinePath)
return e.st.MarkPurged(ctx, o.ID)
}
// Maintenance purges expired quarantine entries and prunes sessions.
func (e *Engine) Maintenance(ctx context.Context) {
e.st.PruneSessions(ctx)
pairs, err := e.st.Pairs(ctx)
if err != nil {
return
}
for _, p := range pairs {
if p.RetentionDays <= 0 {
continue // keep forever
}
cutoff := time.Now().Add(-time.Duration(p.RetentionDays) * 24 * time.Hour).Unix()
ops, err := e.st.ExpiredQuarantine(ctx, p.ID, cutoff)
if err != nil {
continue
}
for _, o := range ops {
e.mu.Lock()
if err := e.purge(ctx, o); err != nil {
e.log.Warn("retention purge failed", "op", o.ID, "err", err)
}
e.mu.Unlock()
}
}
}
// Recover runs at startup: operations left running by a crash are marked
// interrupted and their disposable staging files are removed. Originals are
// never at risk because every replacement is an atomic rename.
func (e *Engine) Recover(ctx context.Context) {
ops, err := e.st.RunningOps(ctx)
if err != nil {
return
}
for _, o := range ops {
if p, err := e.st.Pair(ctx, o.PairID); err == nil {
for _, root := range []string{p.SlaveRoot, p.HostRoot} {
for _, rel := range []string{o.RelSlave, o.RelNew, o.RelHost} {
if rel == "" {
continue
}
dir := filepath.Join(root, filepath.Dir(rel))
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if strings.HasPrefix(en.Name(), fsops.TmpPrefix) && en.Type().IsRegular() {
_ = os.Remove(filepath.Join(dir, en.Name()))
}
}
}
}
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
e.log.Warn("marked interrupted operation", "op", o.ID)
}
}
// ---- browsing (build mode) ----
type TreeEntry struct {
Name string `json:"name"`
Rel string `json:"rel"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
State string `json:"state,omitempty"` // files: linked, conflict, missing
Linked int `json:"linked,omitempty"`
}
// Tree lists a host directory and shows how much of it is in the slave.
func (e *Engine) Tree(ctx context.Context, pairID int64, rel string) ([]TreeEntry, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
dir := l.host.Path
if rel != "" {
if dir, err = l.host.Resolve(rel); err != nil {
return nil, err
}
}
ents, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
linked, _ := e.st.LinkedHostPaths(ctx, pairID, rel)
perChild := map[string]int{}
for _, p := range linked {
sub := p
if rel != "" {
sub = strings.TrimPrefix(p, rel+string(filepath.Separator))
}
if i := strings.IndexByte(sub, filepath.Separator); i > 0 {
perChild[sub[:i]]++
}
}
out := []TreeEntry{}
for _, en := range ents {
name := en.Name()
if strings.HasPrefix(name, ".") || en.Type()&fs.ModeSymlink != 0 {
continue
}
child := name
if rel != "" {
child = filepath.Join(rel, name)
}
te := TreeEntry{Name: name, Rel: child, Dir: en.IsDir()}
if en.IsDir() {
te.Linked = perChild[name]
} else {
if !l.hasExt(name) {
continue
}
hi, err := fsops.Lstat(filepath.Join(dir, name))
if err != nil {
continue
}
te.Size = hi.Size
te.State = "missing"
if sp, err := l.slave.Resolve(child); err == nil {
if si, err := fsops.Lstat(sp); err == nil {
te.State = "conflict"
if si.SameInode(hi) {
te.State = "linked"
}
}
}
}
out = append(out, te)
}
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
}