From 89b7d2508447adc3b4003f6d2e6293e0dd187dd1 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:14:45 -0400 Subject: [PATCH 1/2] fix(autoscan): match rewrite rules against UNC webhook paths Incoming webhook paths were only separator-swapped (normalizeSeparators), while rewrite From values went through normalizePath, which also collapses duplicate slashes. A Windows UNC root from a Windows-hosted arr (\\NAS\Media\TV -> //NAS/Media/TV) therefore never prefix-matched its rewrite rule (/NAS/Media/TV), so every import logged "webhook paths matched no library folder" and nothing scanned. applyRewrites now normalizes the incoming path with the same normalizePath used for the stored From, and normalizes the joined result so a trailing-slash To cannot produce a doubled separator. Reported via internal Discord thread (Sonarr on Windows with a UNC TV root posting to the autoscan webhook). Co-Authored-By: Claude Fable 5 --- internal/autoscan/rewrite.go | 12 ++++++++++-- internal/autoscan/rewrite_test.go | 29 +++++++++++++++++++++++++++-- internal/autoscan/service.go | 2 +- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/internal/autoscan/rewrite.go b/internal/autoscan/rewrite.go index 5aca46e2..d4fa0df5 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,12 @@ 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. + path = normalizePath(path) bestIdx := -1 bestLen := -1 var bestTrimmed string @@ -44,5 +50,7 @@ func applyRewrites(path string, rewrites []PathRewrite) string { if bestIdx < 0 { return 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 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..a63ae39e 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,32 @@ 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) + } +} + // 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 From 65d94459f48f4b75b88415cf82f9331ac3b711ce Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:40:38 -0400 Subject: [PATCH 2/2] fix(autoscan): preserve trailing separator through path rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: normalizePath strips a trailing slash, but the slash 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 widened targeted directory notifications into parent/library scans. applyRewrites now records whether the incoming path ended with a separator (either form) and restores it on the returned path. Co-Authored-By: Claude Fable 5 --- internal/autoscan/rewrite.go | 17 +++++++++++++++-- internal/autoscan/rewrite_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/autoscan/rewrite.go b/internal/autoscan/rewrite.go index d4fa0df5..11a00523 100644 --- a/internal/autoscan/rewrite.go +++ b/internal/autoscan/rewrite.go @@ -23,7 +23,20 @@ func applyRewrites(path string, rewrites []PathRewrite) string { // `\\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 @@ -48,9 +61,9 @@ func applyRewrites(path string, rewrites []PathRewrite) string { } } if bestIdx < 0 { - return path + return restoreTrailing(path) } // Normalize the joined result too: a To stored with a trailing slash would // otherwise yield a doubled separator at the join point. - return normalizePath(strings.TrimSpace(rewrites[bestIdx].To) + strings.TrimPrefix(path, bestTrimmed)) + 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 a63ae39e..f69ad4df 100644 --- a/internal/autoscan/rewrite_test.go +++ b/internal/autoscan/rewrite_test.go @@ -78,6 +78,35 @@ func TestApplyRewritesUNCPath(t *testing.T) { } } +// 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) {