From 1bf3c7687bba1a32bc0957deedf12cb6293f20c5 Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:03:31 +1100 Subject: [PATCH 1/2] fix(web): keep an autoscan source's stored config on unrelated edits Saving the source edit dialog always rebuilt source_config from the form, which fills in declared defaults, drops keys the descriptor no longer declares and folds legacy keys. When that differed from the stored map, the server took it as a config change and reset the source's poll marker, so a label or interval edit restarted the source from now. Send the stored config back unless the config form or the webhook provider was changed, as the list's enable switch already does. --- .../pages/admin/autoscan/sourceForm.test.ts | 21 +++++++++++++++++++ web/src/pages/admin/autoscan/sourceForm.ts | 19 ++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/web/src/pages/admin/autoscan/sourceForm.test.ts b/web/src/pages/admin/autoscan/sourceForm.test.ts index 4eba5046e8..725a38bec0 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,26 @@ 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("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..c6b24f0b23 100644 --- a/web/src/pages/admin/autoscan/sourceForm.ts +++ b/web/src/pages/admin/autoscan/sourceForm.ts @@ -224,6 +224,17 @@ export function draftConnectionId(draft: Pick): str return draft.connectionId && draft.connectionId !== "__none__" ? draft.connectionId : null; } +/** + * Whether the dialog changed the source's config. The schema form marks the + * draft dirty; the webhook provider has its own control, so it is compared + * with the stored value instead. + */ +function configEdited(source: AutoscanSource, draft: SourceDraft): boolean { + if (draft.configDirty) return true; + const chosen = draft.sourceConfig[WEBHOOK_PROVIDER_KEY]; + return chosen !== undefined && chosen !== source.source_config?.[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 +254,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(), }; } From dd71e13ea150741141181a99839ecef332f9308e Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:05:22 +1100 Subject: [PATCH 2/2] fix(web): still migrate legacy CephFS keys on a source's first save A row holding the superseded nested-path keys is migrated forward by its first save from the editor. Keep doing that when only another field was edited, instead of leaving the legacy keys until the config is changed. --- web/src/pages/admin/autoscan/sourceForm.test.ts | 12 ++++++++++++ web/src/pages/admin/autoscan/sourceForm.ts | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/web/src/pages/admin/autoscan/sourceForm.test.ts b/web/src/pages/admin/autoscan/sourceForm.test.ts index 725a38bec0..45db57e253 100644 --- a/web/src/pages/admin/autoscan/sourceForm.test.ts +++ b/web/src/pages/admin/autoscan/sourceForm.test.ts @@ -79,6 +79,18 @@ describe("editedSourceBody", () => { ).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); diff --git a/web/src/pages/admin/autoscan/sourceForm.ts b/web/src/pages/admin/autoscan/sourceForm.ts index c6b24f0b23..751b193857 100644 --- a/web/src/pages/admin/autoscan/sourceForm.ts +++ b/web/src/pages/admin/autoscan/sourceForm.ts @@ -225,14 +225,22 @@ export function draftConnectionId(draft: Pick): str } /** - * Whether the dialog changed the source's config. The schema form marks the - * draft dirty; the webhook provider has its own control, so it is compared - * with the stored value instead. + * 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 !== source.source_config?.[WEBHOOK_PROVIDER_KEY]; + return chosen !== undefined && chosen !== stored[WEBHOOK_PROVIDER_KEY]; } /**