diff --git a/web/src/pages/admin/autoscan/sourceForm.test.ts b/web/src/pages/admin/autoscan/sourceForm.test.ts index 4eba5046e8..45db57e253 100644 --- a/web/src/pages/admin/autoscan/sourceForm.test.ts +++ b/web/src/pages/admin/autoscan/sourceForm.test.ts @@ -38,6 +38,7 @@ describe("editedSourceBody", () => { const source = pollSource(); const draft = { ...draftFromSource(source, descriptor), + configDirty: true, label: " Basement ", intervalStr: "1200", mappings: [newMapping("/mnt/media/tv", " /data/tv "), newMapping("", "")], @@ -67,6 +68,38 @@ describe("editedSourceBody", () => { expect(editedSourceBody(source, draft, {}).connection_id).toBeNull(); }); + it("sends the stored config back when the config was not edited", () => { + // Saving rebuilds the config from the form, which fills in declared + // defaults. Sending that for a label change would make the server + // restart the source's poll marker from now. + const source = pollSource(); + const draft = { ...draftFromSource(source, descriptor), label: "Renamed", intervalStr: "1200" }; + expect( + editedSourceBody(source, draft, { lookback: "24h", window: "7d" }).source_config, + ).toEqual(source.source_config); + }); + + it("still migrates a row holding legacy CephFS keys on its first save", () => { + const source = pollSource({ + plugin_id: "silo.autoscan.cephfs", + capability_id: "cephfs", + source_config: { tv_nested_paths: "/b" }, + }); + const draft = { ...draftFromSource(source, descriptor), label: "Renamed" }; + expect(editedSourceBody(source, draft, { tv_flat_paths: "/b" }).source_config).toEqual({ + tv_flat_paths: "/b", + }); + }); + + it("counts a webhook provider change as a config edit", () => { + const source = webhookSource(); + const draft = draftFromSource(source, descriptor); + draft.sourceConfig = { ...draft.sourceConfig, webhook_provider: "radarr" }; + expect(editedSourceBody(source, draft, { webhook_provider: "radarr" }).source_config).toEqual({ + webhook_provider: "radarr", + }); + }); + it("round-trips a webhook source unchanged", () => { const source = webhookSource(); const draft = draftFromSource(source, descriptor); diff --git a/web/src/pages/admin/autoscan/sourceForm.ts b/web/src/pages/admin/autoscan/sourceForm.ts index af1ce27141..751b193857 100644 --- a/web/src/pages/admin/autoscan/sourceForm.ts +++ b/web/src/pages/admin/autoscan/sourceForm.ts @@ -224,6 +224,25 @@ export function draftConnectionId(draft: Pick): str return draft.connectionId && draft.connectionId !== "__none__" ? draft.connectionId : null; } +/** + * Whether the save has to send the config built from the form: the schema + * form marked the draft dirty, the webhook provider (which has its own + * control) differs from the stored one, or the row still holds legacy keys + * that its first save migrates forward. + */ +function configEdited(source: AutoscanSource, draft: SourceDraft): boolean { + if (draft.configDirty) return true; + const stored = source.source_config ?? {}; + if ( + ownsLegacyAliases(source) && + Object.keys(LEGACY_CONFIG_KEY_ALIASES).some((key) => key in stored) + ) { + return true; + } + const chosen = draft.sourceConfig[WEBHOOK_PROVIDER_KEY]; + return chosen !== undefined && chosen !== stored[WEBHOOK_PROVIDER_KEY]; +} + /** * The complete PUT body for an edited source. Every field is sent: the edit * dialog owns all of them, and the list's enabled switch owns `enabled`, which @@ -243,7 +262,13 @@ export function editedSourceBody( // rather than clearing it if one slips through. poll_interval_seconds: interval.valid ? interval.seconds : source.poll_interval_seconds, path_rewrites: usableMappings(draft.mappings), - source_config: normalizeSourceConfig(serializedConfig), + // A config the operator did not touch goes back exactly as stored. + // Rebuilding it fills in defaults and drops keys the descriptor no longer + // declares, and the server restarts a poll source from now whenever its + // source_config changes. + source_config: configEdited(source, draft) + ? normalizeSourceConfig(serializedConfig) + : { ...(source.source_config ?? {}) }, label: draft.label.trim(), }; }