Skip to content

Cascade explicit PUT monitored into tri-state fields so it actually persists - #87

Open
jbob06 wants to merge 2 commits into
Chaptarr:developfrom
jbob06:fix/author-monitored-false-not-persisting
Open

Cascade explicit PUT monitored into tri-state fields so it actually persists#87
jbob06 wants to merge 2 commits into
Chaptarr:developfrom
jbob06:fix/author-monitored-false-not-persisting

Conversation

@jbob06

@jbob06 jbob06 commented Aug 26, 2026

Copy link
Copy Markdown

Summary

Fixes the "PUT monitored:false does not persist" sub-bug from #17. (The V5-matching / multi-pocket part of that issue is a metadata-server-side data quality problem, not something fixable client-side, and is left open.)

AuthorService.UpdateAuthor/UpdateAuthors recompute the legacy Author.Monitored flag from the per-media-type tri-state settings on every save (author.Monitored = author.IsMonitoredFromMediaSettings()), and separately fill any still-null tri-state field from the root folder's defaults before that recompute runs. So a PUT that only sets the top-level monitored field - without touching any of the four tri-state fields - has it silently discarded: whatever those fields already were (or get defaulted to) wins, and the recompute overwrites the requested value right back before it's ever persisted. monitored:true "works" only when it happens to already match the derived value; monitored:false never sticks once any tri-state field for a configured media type is already on.

(Note: AuthorController's own validators require Path and at least one quality profile id on every PUT regardless of this fix, so the minimal request that actually reaches this code still has to carry those - it isn't as bare as {id, monitored:false}.)

What changed

  • AuthorResource.Monitored is now bool? instead of bool (matching the existing bool? on AuthorEditorResource), since a non-nullable bool can't distinguish "client omitted this field" from "client explicitly sent false." Every other read of the field preserves its old omitted-means-false behavior, so this isn't a behavior change for any existing caller, including the Readarr-facade write path (covered by two new tests).

  • AuthorController.UpdateAuthor cascades an explicit Monitored value into MonitorFuture/MonitorExisting for whichever media types the author is configured for. A non-nullable bool alone isn't enough to know intent though: almost every real client - Chaptarr's own UI included, and any script that does GET → flip one field → PUT the whole object back - sends a full object on every PUT, so a stale monitored and stale (unchanged) tri-state field values both ride along on saves that have nothing to do with them. The cascade only treats a field as "touched" when its sent value actually differs from what was stored - by field, not by media type (see below) - so a same-value echo of either kind is always a no-op. The cascade fires per media type when the requested top-level value genuinely differs from what's already true for the author overall, or when that media type is gaining a root folder in this exact request (the one case where "nothing changed" isn't safe to skip).

  • The bulk author editor (PUT /api/v1/author/editor, AuthorEditorController.SaveAll) has the identical underlying recompute bug (AuthorService.UpdateAuthors) and now gets the same cascade, skipped under a Readarr-facade request the same way the single-author path is. The shared cascade logic was refactored into a thin AuthorResource-shaped wrapper over a primitive-parameter core so both controllers share it without duplicating it.

  • Touched-detection is per FIELD, not per media type. Chaptarr's own bulk editor UI always sends MonitorExisting when changing bulk monitoring but never MonitorFuture - an earlier version of this fix protected a whole media type from cascading the moment either field was present, which made it a no-op for that exact payload. Turning off always needs both fields cleared (MonitorExisting > 0 alone keeps an author monitored even with MonitorFuture false); turning on only forces MonitorFuture when MonitorExisting (as the request leaves it) doesn't already satisfy "monitored" on its own - otherwise it would silently flip on "monitor new releases" for a preference nobody touched. This asymmetry was caught in review and is now covered by dedicated tests for both directions.

  • Root folder paths, the four tri-state field values, and the author's overall derived-monitored status are all captured from the stored author - into one StoredAuthorMonitoringState value, in one line - before ToModel/ApplyChanges (or the bulk editor's own field writes) mutate that object in place.

  • Also updates openapi.json's AuthorResource.monitored schema to nullable: true.

Known gaps, left out of this PR

  • A media type losing its root folder in the same request as an explicit monitored:true isn't guarded (single-author path only - the bulk editor has no mechanism to clear a root folder at all). This is downstream of a broader, pre-existing issue: Author.ApplyChanges copies root folder paths unconditionally, including to null on simple omission, and that ambiguity is load-bearing elsewhere (BookController's facade normalization relies on it). Filed separately as PUT /api/v1/author/{id} silently clears root folder paths (and Path) when a request omits them #88, since it's a real, independently-reachable bug beyond this PR's scope.
  • The bulk editor's media-type-scoped modes (Audiobook only / Ebook only) build a different frontend payload that never includes monitored at all, so monitored:false still won't persist through those - a frontend gap, not something this backend fix can reach.
  • The cascade skips an unconfigured media type entirely, but the recompute (IsMonitoredFromMediaSettings) doesn't - it reads all four tri-state fields regardless of whether either media type has a root folder. A stale MonitorFuture=true left on a never-configured media type (writable via the bulk editor's separate "Monitor New Items" action, which isn't root-folder-gated either) can still keep an author "monitored" after an explicit monitored:false. Pre-existing, not introduced here, and out of scope for the same reason as PUT /api/v1/author/{id} silently clears root folder paths (and Path) when a request omits them #88.
  • A Readarr-facade-scoped bulk request still can't persist monitored:false - the facade guard makes that a deliberate no-op, since there's no per-media-type ToModel equivalent for this endpoint to fall back on.

Test plan

  • AuthorControllerMonitoredCascadeFixture (15 cases): the reported bug, the GET-modify-PUT echo case, the UI-toggle stale-echo case, the newly-added-root-folder case, the turn-on-doesn't-force-future-when-existing-satisfies-it case, facade pass-through, and an end-to-end seam test through the real ToModel call
  • AuthorEditorControllerMonitoredCascadeFixture (7 cases), built against the real AuthorEditorController.SaveAll end to end (reusing this project's existing AuthorServiceProxy/RootFolderServiceProxy/RecordingCommandQueue test doubles, with a real Monitored recompute added so the tests assert on the actual outcome, not just the cascade's direct writes): the exact bulk-editor-UI payload shape for both monitored on and off, independent correctness across two authors with different prior states in one bulk request, a genuine per-field edit not being overridden, monitored omitted, a media type gaining a root folder in the same bulk request, and the Readarr-facade skip
  • Two new tests in AuthorResourceMapperFixture confirming the facade write path's omitted-Monitored-defaults-to-false behavior is unchanged
  • Full existing test suite passes unchanged (2875/2875)

@jbob06
jbob06 requested a review from robertlordhood as a code owner August 26, 2026 17:27
…ersists

AuthorService.UpdateAuthor recomputes the legacy Author.Monitored flag
from the per-media-type tri-state settings on every save
(author.Monitored = author.IsMonitoredFromMediaSettings()), and
separately fills any still-null tri-state field from the root folder's
defaults before that recompute runs. So a PUT that only sets the
top-level "monitored" field - without touching any of the four
tri-state fields - has it silently discarded: whatever those fields
already were (or get defaulted to) wins, and the recompute overwrites
the requested value right back before it's ever persisted.
monitored:true "works" only when it happens to already match the
derived value; monitored:false never sticks once any tri-state field
for a configured media type is already on. Note that AuthorController's
own PutValidator/SharedValidator rules require Path and at least one
quality profile id on every PUT regardless of this fix, so the minimal
request that actually reaches this code still has to carry those - it
isn't as bare as "just send {id, monitored:false}".

Two changes to fix this for the native (non-facade) single-author PUT:

- AuthorResource.Monitored is now bool? instead of bool (matching the
  existing bool? on AuthorEditorResource), since a non-nullable bool
  can't distinguish "client omitted this field" from "client explicitly
  sent false". Every other read of the field preserves its old
  omitted-means-false behavior via `?? false` / `== true`, so this is
  not a behavior change for any existing caller, including the
  Readarr-facade write path, which is deliberately left exactly as it
  was (now covered by two new tests in AuthorResourceMapperFixture).

- AuthorController.UpdateAuthor cascades an explicit Monitored value
  into MonitorFuture (and, on turning off, MonitorExisting - zeroed,
  since MonitorExisting > 0 alone keeps the derived flag true) for
  whichever media types the author is configured for. A non-nullable
  bool alone isn't enough to know intent, though: almost every real
  client - Chaptarr's own UI monitor toggle included, and any script
  that does GET -> flip one field -> PUT the whole object back - sends
  a full object on every PUT, so a stale `monitored` AND stale
  (unchanged) tri-state field values both ride along on saves that
  have nothing to do with them. The cascade only treats a tri-state
  field as "touched" by the client when its sent value actually
  differs from what was stored - an earlier version of this fix
  treated the field's mere presence as "touched," which broke the
  GET-modify-PUT client shape specifically (its echoed, unchanged
  tri-state fields read as edits, so every media type got skipped and
  monitored:false silently failed to persist all over again). The
  cascade itself only fires per media type when the requested
  top-level value genuinely differs from what's already true for the
  author as a whole, or when that media type is gaining a root folder
  in this exact request (the one case where "nothing changed" isn't
  safe to skip, since AuthorService's root-folder-defaults fill is
  about to resolve that media type's tri-state fields for the first
  time and could re-enable monitoring regardless of what was asked
  for) - so a same-value echo of either kind is always a no-op, and
  can't leak into a media type the request never meant to touch.

- Everything the cascade needs to know about the author's state BEFORE
  this request - root folder paths, the four tri-state field values,
  and the derived overall monitored status - is captured from the
  stored author into one StoredAuthorMonitoringState value, in one
  line, immediately after fetching it and BEFORE ToModel/ApplyChanges
  mutate that same object in place (a PUT that doesn't include a root
  folder path, for instance, would otherwise see that field already
  nulled out by the time the cascade runs). The cascade method and
  this new type are both internal (with the existing Chaptarr.Api.V1
  -> Chaptarr.Core.Test InternalsVisibleTo) rather than private, so
  tests call them directly instead of via reflection.

Known gap, left out of this PR: this doesn't guard a media type LOSING
its root folder in the same request - e.g. a PUT that clears
AudiobookRootFolderPath while also asking for monitored:true could
still cascade MonitorFuture=true onto a media type that no longer has
anywhere to look. That's downstream of a broader, pre-existing issue
in ApplyChanges: it copies Path/AudiobookRootFolderPath/
EbookRootFolderPath from the incoming resource unconditionally
(including to null, when a request simply omits them - not just when
it explicitly clears them), independent of this PR and already
reachable before it via the pre-existing unconditional Monitored
recompute. Fixing that asymmetry felt like its own change rather than
part of this one.

Only applies to the native, non-facade PUT path; the Readarr-facade
single-media-type request already gets an equivalent cascade inside
ToModel itself. The bulk author editor (AuthorEditorController) has
the identical underlying recompute bug (AuthorService.UpdateAuthors,
not UpdateAuthor) but is left out of this PR - it edits N authors at
once and needs its own per-author root-folder/prior-state snapshot
pass, which felt like its own change rather than a one-line extension
of this one. Happy to follow up on either gap if wanted.

Also updates openapi.json's AuthorResource.monitored schema to
nullable: true, matching the type change (AuthorEditorResource.monitored
was already documented that way).

Fixes the "PUT monitored:false does not persist" part of Chaptarr#17 (the
V5-matching / multi-pocket part of that issue is a metadata-server-side
data quality problem, not something fixable client-side, and is left
open).
AuthorService.UpdateAuthors has the identical recompute bug UpdateAuthor
had: it unconditionally sets author.Monitored = author.IsMonitoredFromMediaSettings()
on every save, discarding an explicit PUT /api/v1/author/editor
{"monitored": false} unless the tri-state fields also happen to end up
agreeing with it. AuthorEditorController.SaveAll already gates every
field write on the resource actually providing it (unlike the
single-author ApplyChanges path, it never overwrites a root folder path
with null just because a request omits it - it only ever sets one when
the resource sends a non-empty string), and already snapshots each
author's pre-mutation root folder paths for its own hydration-detection
purposes, so this fix is considerably more contained than the
single-author one was.

Refactors AuthorController.CascadeExplicitMonitoredIntoMediaTypeSettings
into a thin AuthorResource-shaped wrapper plus a primitive-parameter
core, so AuthorEditorController can call the same logic directly with
AuthorEditorResource's own fields (same four tri-state fields plus
Monitored, just not wrapped in an AuthorResource) without duplicating
it. Each author's pre-mutation state is captured via the same
StoredAuthorMonitoringState this PR already introduced, into a
per-author-id dictionary alongside the existing
previousRootFoldersByAuthorId/previousSyncByAuthorId snapshots, and the
cascade runs once per author inside the existing per-author loop, after
that author's own root-folder/tri-state field writes and before
AuthorService.UpdateAuthors is called. Skipped under a Readarr-facade
request, same as the single-author path, since this endpoint isn't
media-type-scoped by its own resource shape and a facade client could
otherwise leak a single-media-type intent into both.

While extracting the shared core, changed what "touched" means: from
"the client's request includes either of this media type's two
tri-state fields" to "the client's request includes a field, and its
value genuinely differs from what was stored" - per FIELD, not per
media type. The previous, coarser version protected a whole media type
from any cascading the moment ONE of its two fields was present in the
request, which is safe for the single-author PUT's echo scenarios (a
GET-modify-PUT client sends both fields together, unchanged) but turns
out to be a no-op for the actual bulk editor UI's own payload: it
always sends MonitorExisting when changing bulk monitoring but never
MonitorFuture, so the old logic saw MonitorExisting present, protected
the whole media type, and left MonitorFuture (and therefore
IsMonitoredFromMediaSettings()) exactly where it was - the reported bug,
reachable through Chaptarr's own UI.

Per-field protection on its own would still force MonitorFuture=true
whenever an untouched-and-turning-on media type's MonitorExisting was
being set, even when Existing alone already satisfies "monitored" - the
bulk editor's own "Monitored" (on) action sends exactly that shape
(MonitorExisting only, never MonitorFuture), and forcing Future there
would silently flip on "monitor new releases" for a preference the
request never touched. Turning OFF doesn't have a symmetric escape:
MonitorExisting > 0 alone keeps the author monitored even with Future
false, so both fields genuinely need clearing there. So the two
directions are asymmetric on purpose: turning off always cascades
Future (and Existing, when untouched); turning on only cascades Future
when Existing (as this request leaves it) doesn't already make the
media type monitored on its own.

This also changes single-author semantics in one narrow,
unlikely-in-practice case, unrelated to the asymmetry above: an
explicit edit to only one of a media type's two fields, in a direction
that genuinely contradicts the top-level flag (e.g. monitored:false
sent alongside an explicit MonitorFuture:true), now lets the untouched
field (MonitorExisting) cascade instead of protecting the whole media
type. I think that's the more defensible reading of "per field" as a
design principle, applied consistently, but it's a real, deliberate
behavior change from the four-review-round single-author fix this
builds on.

New tests in AuthorEditorControllerMonitoredCascadeFixture reuse the
AuthorServiceProxy/RootFolderServiceProxy/RecordingCommandQueue/
TestQualityProfileService/TestMetadataProfileService doubles already
established in AuthorEditorMissingRootHydrationFixture, rather than
hand-rolling weaker ones - notably, this fixture's AuthorServiceProxy
applies the real Monitored recompute in UpdateAuthors (the sibling
fixture's doesn't need to for its own purposes), since that recompute
is the actual bug being guarded against; a stub that skipped it could
only prove the cascade wrote plausible tri-state fields, not that
persisting monitored:false (or :true) through this endpoint actually
works. Covers: the exact bulk-editor-UI payload shape for both
directions (on and off) end to end, independent correctness across two
authors with different prior monitoring states in one bulk request, a
genuine per-field edit not being overridden, monitored omitted
entirely, a media type gaining a root folder in the same bulk request
(the one case whose correctness depends on the cascade running after
this author's field writes in the loop, not before), and the
Readarr-facade skip.

Known gaps, not addressed here:
- This covers the bulk editor's "All media types" mode. Its
  media-type-scoped modes (Audiobook only / Ebook only) build a
  different payload that never includes "monitored" at all, so
  monitored:false still won't persist through those - a frontend gap,
  not something this backend fix can reach.
- The cascade skips a media type with no root folder entirely, but
  AuthorService's recompute (IsMonitoredFromMediaSettings) doesn't - it
  reads all four tri-state fields regardless of whether either media
  type actually has anywhere to look. So a stale MonitorFuture=true
  left over on a never-configured media type can still keep an author
  "monitored" after an explicit monitored:false, if that field was
  written by some other path (e.g. the bulk editor's separate "Monitor
  New Items" action, which isn't gated on root folder configuration
  either). Pre-existing behavior, not introduced by this PR, and out of
  scope for the same reason Chaptarr#88 is: fixing it means changing shared
  monitoring-status logic well beyond what a single PUT endpoint owns.
- A Readarr-facade-scoped bulk request still can't persist
  monitored:false - the facade guard above makes that a deliberate
  no-op (there's no per-media-type ToModel equivalent for this endpoint
  to fall back on the way the single-author PUT path has).

This was the second of the two gaps left open in the original PR
description; the first (a media type losing its root folder in the
same request that also asks to turn monitoring on) remains open and is
filed separately as Chaptarr#88, since it's entangled with a separate,
pre-existing ApplyChanges issue the bulk editor's endpoint doesn't
share (see the PR description for detail on why that one isn't safely
fixable as a small, contained change).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant