diff --git a/internal/autoscan/rewrite.go b/internal/autoscan/rewrite.go index 5aca46e2..11a00523 100644 --- a/internal/autoscan/rewrite.go +++ b/internal/autoscan/rewrite.go @@ -10,7 +10,7 @@ func normalizeSeparators(path string) string { } // applyRewrites returns path with the MOST-SPECIFIC matching prefix rewrite -// applied, or path unchanged when none match. +// applied, or the normalized path unchanged when none match. // // "Most-specific" means the longest matching From wins, not the first one in the // slice. A first-match strategy lets a broad rewrite (From="/data") shadow a @@ -18,6 +18,25 @@ func normalizeSeparators(path string) string { // be listed first; the arr plugin review flagged exactly this. Selecting the // longest matching prefix makes the result independent of rule ordering. func applyRewrites(path string, rewrites []PathRewrite) string { + // Normalize the incoming path the SAME way the stored From is normalized + // below. Separator swapping alone is not enough: a Windows UNC path like + // `\\NAS\Media\TV\...` becomes `//NAS/Media/TV/...`, while normalizePath + // collapses the From's leading `//` to `/NAS/Media/TV` — an asymmetry that + // made UNC roots unmatchable by any rewrite rule. + // + // A trailing separator is semantic downstream — filepath.Dir("/x/Show/") + // is the directory itself while filepath.Dir("/x/Show") is its parent, so + // legacy-scope changes rely on it to scan the notified directory rather + // than collapsing to a broader parent scan. normalizePath strips it for + // matching; restore it on whatever we return. + trailing := strings.HasSuffix(normalizeSeparators(strings.TrimSpace(path)), "/") + path = normalizePath(path) + restoreTrailing := func(p string) string { + if trailing && p != "/" { + return p + "/" + } + return p + } bestIdx := -1 bestLen := -1 var bestTrimmed string @@ -42,7 +61,9 @@ func applyRewrites(path string, rewrites []PathRewrite) string { } } if bestIdx < 0 { - return path + return restoreTrailing(path) } - return strings.TrimSpace(rewrites[bestIdx].To) + strings.TrimPrefix(path, bestTrimmed) + // Normalize the joined result too: a To stored with a trailing slash would + // otherwise yield a doubled separator at the join point. + return restoreTrailing(normalizePath(strings.TrimSpace(rewrites[bestIdx].To) + strings.TrimPrefix(path, bestTrimmed))) } diff --git a/internal/autoscan/rewrite_test.go b/internal/autoscan/rewrite_test.go index 0791a81b..f69ad4df 100644 --- a/internal/autoscan/rewrite_test.go +++ b/internal/autoscan/rewrite_test.go @@ -33,8 +33,7 @@ func TestApplyRewrites(t *testing.T) { // TestApplyRewritesNormalizesStoredFrom verifies that a Windows-style / dup-slash // stored From is normalized the same way coveredBy/normalizePath does, so a -// rewrite the suggester reports as "covered" actually matches at poll time. The -// incoming path is already separator-normalized by PollOnce before applyRewrites. +// rewrite the suggester reports as "covered" actually matches at poll time. func TestApplyRewritesNormalizesStoredFrom(t *testing.T) { // Backslash From: a Windows-hosted arr root stored verbatim. winFrom := []PathRewrite{{From: `D:\data\tv`, To: "/mnt/media/tv"}} @@ -53,6 +52,61 @@ func TestApplyRewritesNormalizesStoredFrom(t *testing.T) { } } +// TestApplyRewritesUNCPath verifies a Windows UNC root (\\NAS\Media\TV) from a +// Windows-hosted arr matches its rewrite rule. Separator swapping alone turns +// the incoming path into //NAS/... while the From normalizes to /NAS/..., so +// the prefix never matched — both sides must go through normalizePath. +func TestApplyRewritesUNCPath(t *testing.T) { + incoming := `\\NAS\Media\TV\Show\S01\E01.mkv` + for _, from := range []string{`\\NAS\Media\TV`, "//NAS/Media/TV", "/NAS/Media/TV"} { + rw := []PathRewrite{{From: from, To: "/mnt/media/tv"}} + if got := applyRewrites(incoming, rw); got != "/mnt/media/tv/Show/S01/E01.mkv" { + t.Fatalf("UNC path with From=%q: got %q", from, got) + } + } + + // An unmatched UNC path still comes back normalized (collapsed slashes), + // consistent with what the resolver sees for matched paths. + if got := applyRewrites(incoming, nil); got != "/NAS/Media/TV/Show/S01/E01.mkv" { + t.Fatalf("unmatched UNC path: got %q", got) + } + + // A trailing-slash To must not produce a doubled separator at the join. + slashTo := []PathRewrite{{From: `\\NAS\Media\TV`, To: "/mnt/media/tv/"}} + if got := applyRewrites(incoming, slashTo); got != "/mnt/media/tv/Show/S01/E01.mkv" { + t.Fatalf("trailing-slash To: got %q", got) + } +} + +// TestApplyRewritesPreservesTrailingSlash verifies a trailing separator on the +// incoming path survives normalization and rewriting. It is semantic for +// legacy-scope changes: filepath.Dir("/x/Show/") is the directory itself while +// filepath.Dir("/x/Show") is its parent, so dropping it would widen a targeted +// directory notification into a parent/library scan. +func TestApplyRewritesPreservesTrailingSlash(t *testing.T) { + rw := []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}} + if got := applyRewrites("/data/tv/Show/", rw); got != "/mnt/media/tv/Show/" { + t.Fatalf("rewritten dir: got %q", got) + } + // Unmatched paths keep it too. + if got := applyRewrites("/other/Show/", rw); got != "/other/Show/" { + t.Fatalf("unmatched dir: got %q", got) + } + // Windows separator form: trailing backslash counts as a trailing separator. + unc := []PathRewrite{{From: `\\NAS\Media\TV`, To: "/mnt/media/tv"}} + if got := applyRewrites(`\\NAS\Media\TV\Show\`, unc); got != "/mnt/media/tv/Show/" { + t.Fatalf("UNC dir: got %q", got) + } + // Files without a trailing separator stay without one. + if got := applyRewrites("/data/tv/Show/E01.mkv", rw); got != "/mnt/media/tv/Show/E01.mkv" { + t.Fatalf("file: got %q", got) + } + // Bare root never doubles. + if got := applyRewrites("/", nil); got != "/" { + t.Fatalf("root: got %q", got) + } +} + // TestApplyRewritesMostSpecificWins verifies the longest matching From wins // regardless of slice ordering: a broad rule must not shadow a nested one. func TestApplyRewritesMostSpecificWins(t *testing.T) { diff --git a/internal/autoscan/service.go b/internal/autoscan/service.go index 9c1fded8..e7f2e0f9 100644 --- a/internal/autoscan/service.go +++ b/internal/autoscan/service.go @@ -629,7 +629,7 @@ func (s *Service) resolveConnection(ctx context.Context, connectionID string) (R func rewriteChanges(changes []Change, rewrites []PathRewrite) []Change { rewritten := make([]Change, 0, len(changes)) for _, change := range changes { - path := applyRewrites(normalizeSeparators(change.SourcePath), rewrites) + path := applyRewrites(change.SourcePath, rewrites) rewritten = append(rewritten, Change{SourcePath: path, Scope: change.Scope}) } return rewritten