fix(autoscan): stop warning "No paths configured" on runtime-path sources (#519)

The per-source targets summary derived a source's paths only from its path
rewrites or path-shaped config, and marked it unresolvable when both were
empty unless the descriptor declared emits_native_paths. That treats "no
configured paths" as a misconfiguration for every source, but only a local
watcher is handed its roots up front.

A webhook source reads paths out of each delivery payload, and a source with
a bound connection gets them from the provider's root folders — where empty
rewrites are the correct steady state, not an omission. SuggestRewrites
deliberately proposes nothing when a reported root already equals the Silo
path (suggest.go:176), so a correctly configured shared-namespace deployment
lands on exactly the state the row flagged as broken.

Gate the warning on whether the paths could have been known from stored
config at all, and report the rest as unknown. A local watcher with declared
path fields left empty still warns — that is the silent-failure case the
check exists for. Sources that do have rewrites are unaffected.

Fixes #518

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-30 11:54:56 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent dc4b9a0909
commit b1b093007c
2 changed files with 130 additions and 15 deletions
@@ -107,13 +107,97 @@ describe("sourceTargets", () => {
expect(got.libraries.map((l) => l.name)).toEqual(["Movies", "TV Shows"]);
});
it("flags a source with no path information as unresolvable", () => {
it("flags a local watcher with no path information as unresolvable", () => {
// The silent-failure case: looks configured, can never match anything.
const got = sourceTargets(source(), pathDescriptor, libraries);
expect(got.unresolvable).toBe(true);
expect(got.unknown).toBe(false);
expect(got.libraries).toEqual([]);
});
it("treats a connection-backed poll source with no rewrites as unknown", () => {
// Empty rewrites are a valid passthrough: the provider already reports
// paths that are valid Silo library paths, so nothing needs mapping.
const got = sourceTargets(
source({ connection_id: "c1" }),
{ delivery_modes: ["poll"], connection: "optional" },
libraries,
);
expect(got.unresolvable).toBe(false);
expect(got.unknown).toBe(true);
});
it("treats a webhook source with no rewrites as unknown", () => {
// The built-in ARR webhook descriptor does not declare emits_native_paths,
// but its paths arrive in the delivery payload, not from configuration.
const got = sourceTargets(
source({ delivery_mode: "webhook" }),
{ delivery_modes: ["webhook"], connection: "none" },
libraries,
);
expect(got.unresolvable).toBe(false);
expect(got.unknown).toBe(true);
});
it("treats a native-path source with no rewrites as unknown", () => {
const got = sourceTargets(
source(),
{ delivery_modes: ["poll"], connection: "none", emits_native_paths: true },
libraries,
);
expect(got.unresolvable).toBe(false);
expect(got.unknown).toBe(true);
});
it("still warns an optional-connection source with nothing bound", () => {
// `optional` plus no connection is a local watcher in practice: nothing
// will hand it paths at runtime, so empty path fields are a real fault.
const got = sourceTargets(
source({ connection_id: null }),
{ ...pathDescriptor, connection: "optional" },
libraries,
);
expect(got.unresolvable).toBe(true);
expect(got.unknown).toBe(false);
});
it("does not warn a required-connection source that has none bound", () => {
// The row already reports the missing connection; a second warning about
// paths points at the wrong fault.
const got = sourceTargets(
source({ connection_id: null }),
{ ...pathDescriptor, connection: "required" },
libraries,
);
expect(got.unresolvable).toBe(false);
expect(got.unknown).toBe(true);
});
it("does not warn a local watcher that declares no path fields", () => {
// Nothing for the operator to fill in, so "no paths configured" would point
// at a control that does not exist.
const got = sourceTargets(
source(),
{ delivery_modes: ["poll"], connection: "none" },
libraries,
);
expect(got.unresolvable).toBe(false);
expect(got.unknown).toBe(true);
});
it("still matches libraries for a webhook source that has rewrites", () => {
const got = sourceTargets(
source({
delivery_mode: "webhook",
path_rewrites: [{ from: "/data/tv", to: "/mnt/media/tv" }],
}),
{ delivery_modes: ["webhook"], connection: "none" },
libraries,
);
expect(got.unknown).toBe(false);
expect(got.libraries.map((l) => l.name)).toEqual(["TV Shows"]);
});
it("distinguishes configured-but-unmatched from unconfigured", () => {
const got = sourceTargets(
source({ path_rewrites: [{ from: "/dl", to: "/mnt/other/stuff" }] }),
+45 -14
View File
@@ -17,16 +17,17 @@ export interface SourceTargets {
/** Libraries whose roots overlap this source's resolved paths. */
libraries: Library[];
/**
* True when the source has no path information at all, so nothing it reports
* could ever resolve to a library. This is the silent-failure case: the
* source looks configured and runs cleanly, but can never match.
* True when the source was asked for paths and given none, so nothing it
* reports could ever resolve to a library. This is the silent-failure case: a
* local watcher that looks configured and runs cleanly, but can never match.
* Reserved for sources that own their paths — see targetsAreKnowable.
*/
unresolvable: boolean;
/**
* True when the source declares emits_native_paths and has nothing configured
* to inspect. Its targets are simply not knowable up front — it discovers
* already-resolvable Silo paths while polling — so the UI must say "unknown"
* rather than warn that it is broken.
* True when the source's targets are not knowable up front: it learns its
* paths at runtime (from a provider it polls, from a webhook payload, or
* because it declares emits_native_paths) and has no rewrites narrowing them
* down. The UI must say "unknown" rather than warn that it is broken.
*/
unknown: boolean;
}
@@ -85,12 +86,45 @@ export function resolvedPathsFor(
return pathsFromConfig(source, descriptor);
}
/**
* Whether a source's targets could have been known from its stored
* configuration alone — which is what makes an empty result a misconfiguration
* rather than simply unknown.
*
* Three kinds of source learn their paths at runtime instead, so empty rewrites
* are a valid passthrough for them, not a silent failure:
*
* - a webhook source reads paths out of each delivery payload;
* - a source with a bound connection gets them from the provider's root folders
* (`SuggestRewrites` deliberately proposes nothing when a reported root
* already equals the Silo path, so "correctly configured" and "no rewrites"
* are the same state);
* - an `emits_native_paths` source returns Silo paths directly.
*
* What remains is a source that must be handed its roots up front. It is only
* worth warning about when the descriptor gives the operator a field to put
* them in — otherwise the warning points at a control that does not exist.
*/
function targetsAreKnowable(
source: AutoscanSource,
descriptor: AutoscanScanSourceDescriptor,
): boolean {
if (source.delivery_mode === "webhook") return false;
if (descriptor.emits_native_paths) return false;
// The bound connection is what matters, not just the descriptor's
// requirement: an `optional` source with nothing bound is a local watcher for
// this purpose, and must still be warned about when its path fields are
// empty. A `required` source with nothing bound is excluded because the row
// already reports the missing connection, and that is the fault to fix first.
if (source.connection_id || descriptor.connection === "required") return false;
return configFields(descriptor).length > 0;
}
/**
* Match a source's resolved paths against library roots.
*
* A source that emits native paths but has neither rewrites nor path config is
* still reported as unresolvable — it has genuinely told us nothing about where
* its media lands.
* A source with no paths is only reported as unresolvable when it is one whose
* paths could have been known up front; otherwise its targets are unknown.
*/
export function sourceTargets(
source: AutoscanSource,
@@ -99,10 +133,7 @@ export function sourceTargets(
): SourceTargets {
const paths = resolvedPathsFor(source, descriptor);
if (paths.length === 0) {
// A native-path source needs no configured root: it emits Silo paths
// directly, discovered at poll time. Flagging that as broken would warn on
// exactly the case the descriptor field exists to describe.
if (descriptor.emits_native_paths) {
if (!targetsAreKnowable(source, descriptor)) {
return { libraries: [], unresolvable: false, unknown: true };
}
return { libraries: [], unresolvable: true, unknown: false };