diff --git a/web/src/pages/admin/autoscan/sourceTargets.test.ts b/web/src/pages/admin/autoscan/sourceTargets.test.ts index 06a4e2b4..8eecf9c8 100644 --- a/web/src/pages/admin/autoscan/sourceTargets.test.ts +++ b/web/src/pages/admin/autoscan/sourceTargets.test.ts @@ -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" }] }), diff --git a/web/src/pages/admin/autoscan/sourceTargets.ts b/web/src/pages/admin/autoscan/sourceTargets.ts index 40bc10f1..78b5654e 100644 --- a/web/src/pages/admin/autoscan/sourceTargets.ts +++ b/web/src/pages/admin/autoscan/sourceTargets.ts @@ -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 };