Requests, approvals & live availability
Implementation contracts and behavior for requests, approvals & live availability.
Allowances use media_type (movie, tv, book, music) plus book_format (ebook or audiobook for books). A rule has nullable count (null = unlimited; zero = no new units) and window_days (1, 7, or 30). Per-user rules replace a whole rule or use inherit:true. Settings live on separate endpoints so older clients cannot erase them. /api/config advertises request_quotas; compatibility floors are unchanged.
Each durably accepted movie, source TV season (no specials), book format, or album spends one unit, including pending approvals. Limits span libraries; a separate acquisition in another library spends the same pool. Each subscriber to shared pending book work owns their charge. Both book formats and TV approval additions are atomic. Duplicate work and delivery retries add no charge; a pilot expands within its current window without charging that season again. Corrective TV-match repairs and unchanged approvals are free. Unknown book/music library state may reserve selected units, then a verified no-op refunds them. Denial/cancellation refunds only units whose delivery could not yet have begun: a permanent per-job and per-item marker is committed immediately before the first library mutation. Reads never set it, and retries never clear it. Ownership transfers preserve charge ownership.
internal/requestquota takes the SQLite write lock before reading current authority, policy, or usage; the same short transaction writes intent, subscriptions, delivery jobs, and itemized charges. Provider calls stay outside it, and database errors fail closed. Rolling windows are exact elapsed UTC time, excluding a charge at its expiration boundary. Policy changes immediately recalculate recent usage; accepted work stays valid. Resets clear selected allowance history from accounting and retain the administrator audit and request history. Upgrades backfill no charges; all new accepted work is recorded even while unlimited, so enabling a limit uses recent activity.
The preview returns allowances with effective rules, used, remaining, requested_units, next_replenishes_at, and fully_replenishes_at, plus fits, normalized TV seasons, earliest_fits_at, and reduce_selection. next_change_at schedules usage refreshes, including when usage exceeds a newly lowered limit. Submission always reprices; refusal is HTTP 429 with code: request_quota_exceeded, a readable error, affected allowances and requested units, and the earliest time the selection can fit. A selection larger than its entire limit has reduce_selection:true and no fit date.
Every media type saves intent and its allowance charges before delivery. Movies attempt delivery immediately and retain recoverable state after interruption or Radarr failure. Movie approval replays the stored selection; TV approval validates its stored source scope and target revision. Every approval records the decision before delivery begins. Book format choices remain immutable at approval. Pending book work is shared by source/native identity and pinned instance, with each subscriber’s requested formats retained separately. A successful format is not repeated when its sibling fails. Delivery progress and approval decisions remain visible independently.
For a brand-new Chaptarr title, Cantinarr resolves each concrete format independently: it selects the unique quality profile typed ebook or audiobook, the corresponding metadata profile typed 2 or 1, and one accessible root path matching that format. Legacy untyped entries retain deterministic format-name, sole-profile, or unique-Default fallbacks, and a sole generic root remains valid; ambiguous choices fail with an admin-fixable error. Adding a missing sibling format beside an existing canonical book instead reuses that author’s live quality profile, metadata profile, and root path for the requested format. Bounded in-process striped locks serialize conflicting canonical-book mutations and instance projection refreshes without a single global network-call lock; the supported deployment remains the repository’s single-process SQLite server, not multiple independent writers.
Availability is always derived live from the arrs: TV availability comes from the real episode list (aired episodes with files), never from Sonarr’s monitored-only percentage: so a show with one monitored season never reads “available” while most of it is missing. Series with some-but-not-all aired episodes read partial, with per-season detail and a one-tap “request more” path that adds seasons without unmonitoring what’s already there. An empty TV season reads requested only when both its series and season are monitored, including the season-statistics fallback; pausing a series leaves saved child flags intact but stops reporting empty seasons as active requests. Stale request rows are reconciled against reality (a “requested” title the arr has since imported reads available; a deleted one falls back to unavailable).
Music requests normally save and acknowledge native or catalog-reference submissions before provider calls, preserving approval requirements and waking the durable delivery worker. If the selection exceeds an allowance, a bounded read can first prove that it is already available or fully monitored and costs nothing; an unresolved library cannot waive a charge. Pending native and legacy MusicBrainz submissions for the same verified identity reuse the saved receipt within the same user and instance. include_live=false on delivery-status reads saved intent independently from availability; /api/requests/music-saved provides the instance-scoped saved receipts for search badges. Music follows the book shape with the format axis removed: one album is one Lidarr record, addressed by its MusicBrainz release-group id in foreign_id, and media_type is music. The worker adds with a single POST /api/v1/album with the artist nested inside; Lidarr finds-or-adds the artist inline and fetches metadata during the call, so there is no author-import park analogue and no waiter table: two users requesting one album are two per-user rows (the movie model), and each approval replays an idempotent add. The artist is added monitored with monitorNewItems: none and no add-time monitor option (verified live: monitor: none unmonitors the artist itself, whose albums then never count as wanted); hydrated siblings arrive unmonitored, so a request for one album never subscribes the discography while the requested album stays genuinely wanted; profile selection reads the chosen root folder’s defaultQualityProfileId/defaultMetadataProfileId (a Lidarr root folder carries per-folder defaults), falling back to the first profiles and skipping Lidarr’s hidden “None” metadata profile. The exact-id lookup term (lidarr:<mbid>) resolves deterministically; the requester’s own search_term and the title forms remain the fallbacks under the same exact-foreignAlbumId-only acceptance rule, and an unresolved identity leaves a saved delivery needing attention. Temporary metadata/service failures retry automatically. Fulfilled music rows reuse request_log.book_record_id as the Lidarr album record id, and music-status resolves live truth through it when MusicBrainz merges re-key the album; returning canonical_foreign_id exactly like books. Album completeness drives the status vocabulary: all monitored tracks on disk reads available, some tracks partial, an active download downloading, a bare monitored record requested. music-artists/music-artist follow the authors rules above (capped sorted row + uncached detail, empty list without a grant but 403 from the detail).
Chaptarr can file a created book record under its own canonical foreignBookId rather than the metadata lookup id the request used. Fulfilled book rows therefore persist the numeric Chaptarr record id (request_log.book_record_id), and book status reads resolve live truth through that id when the requested foreign id no longer matches the library: returning canonical_foreign_id (on both the create response and book-status) so clients resolve library actions by that ID while preserving the original selected native ID and metadata for requests. A stored record id whose record is gone still heals to unavailable, so deleting the record in Chaptarr keeps making the format requestable again.
Book identity binding first prefers an exact native ID, then unambiguous shared provider work/edition identifiers or checksum-valid ISBNs. Goodreads work and edition IDs occupy different namespaces; valid ISBN-10 is normalized to ISBN-13. Multiple matching library title groups fail closed instead of selecting one or merging rows. book-library.titles[].identity_keys preserves this evidence, including a leading edition ID from lean Chaptarr records. The optional book-status title and q parameters recover the originally selected lookup record only; they never select by similar title. Status, delivery and approval reconciliation use the same binding rules. The worker records the library binding in existing request_dispatch.canonical_foreign_id and book_record_id columns, retaining the original native ID and title in the receipt. Availability remains live, with a 15-second projection cache and webhook invalidation. An unreadable library is not absence; unknown reads retain saved delivery and return status_known:false, with status_unknown_reason distinguishing identity_ambiguous, format_unresolved, and library_unavailable.
The library digest also returns an uncapped authors list in the existing LibraryAuthor shape. It uses the same title reduction as the author shelf/detail, so one book with both formats counts once. authors:null means the author read failed; authors:[] means that complete read found none. Book ownership remains usable when only authors fail. Author bibliographies preserve every native title group. Dates more than five years beyond the current year sort with undated records while keeping the raw year for an unconfirmed-date label.
book-library now carries each title’s series and series_position, so a plain requester’s book detail page can show them without a numeric Chaptarr record id (both book/{id} and author/{authorId} require one, which a plain requester’s digest row does not carry). The split is the same last-" #" rule book-series documents below; one implementation, reused here rather than restated. The digest’s author name is joined from the library’s own author records, because Chaptarr’s full-library book list carries no embedded author object at all (only the per-author read nests one); that join fails soft on a GetAllAuthors error, in contrast to the authors row’s own read below, which fails closed on the same call; a wrong author count asserts something false, while a missing author name asserts nothing at all.
book-series and book-series-detail back the Books tab’s Series row and the requester-facing series page. Chaptarr exposes no library-wide series read; GET /series returns nothing without an authorId, and the per-author variant embeds whole book objects (≈184KB for one prolific author, so a fan-out across a real library would move tens of megabytes per refresh); so a series is derived from the seriesTitle string each book already carries in the full-library fetch the other digests make anyway. That string is "Discworld #13": the split is on the last " #", so a series whose own name contains one keeps it, and the position is passed through raw ("2A", "1.5, 1.6, 1.7", "3, Part 1 of 2" are all real) with only its leading number used for ordering. A title stating no position sorts last rather than claiming position zero.
Each series carries the covers of its first books; book one at the front, then the run in order (capped at three, duplicates dropped; a title’s ebook and audiobook records share one image, and stacking it three times reads as a rendering fault rather than a series) so the card can stack them: a single cover is indistinguishable from the book cards in the row above it. The numbered run (position >= 1) ranks ahead of everything else, because real libraries file boxed sets, companions and omnibus collections at position 0; and 0 sorts before 1, so ranking on the raw number alone puts a photograph of book spines on the front of Discworld. Ownership outranks position, because the row is a library rather than a bibliography: book one leads whenever it is on disk, otherwise the lowest-numbered book that is, and only if the library holds none of the series with art does an un-owned cover fill the frame; showing art for a book nobody has, beside a count that already says what is missing, is the wrong picture. Sub-one positions come next, then records with no stated position. The final tie-break is the title, so a series whose metadata files several records at the same position (Discworld has twenty at #1, two of them omnibuses) picks the same one on every fetch instead of following whatever order the arr answered in. The series name is the identity; there is no id to address one by; which is also what keeps the ~25 series that legitimately span authors in one piece rather than splitting them per author. Counts are reduced by the same groupKey the ownership digest uses, so a title held as both an eBook and an Audiobook counts once.
A series with nothing on disk is omitted: adding one author imports their whole bibliography, so a library knows about several times more series than it holds (817 vs 143 on the library this was built against), and listing them all would make a shelf of things you own out of mostly things you do not. The series page is the opposite; it lists every title the library knows about, because the gap is the reason to open it. Ordering, capping, caching, total, and the 403-vs-404 split all follow the authors rules above, with one deliberate difference: there is no “date added” order, because a series is not a record and carries no date, and neither does a book; the only dates in reach are publication and file-import, and neither answers “when did this series enter my library”. Since no book record on this fork embeds its author, the page joins author names on authorId from the author list, so a multi-author series still names who wrote what.
book-authors and book-author back the Books tab’s Authors row and the requester-facing author page. Both counts on an author: how many titles the library tracks and how many have a file: are reduced from the library’s own book records the same way the ownership digest is, so a title held as both an eBook and an Audiobook (two Chaptarr records sharing a foreignBookId) counts once; Chaptarr’s author statistics count file records and would double it. Authors with no book records are omitted: a failed or still-pending metadata import leaves the author behind, and the card would open onto an empty page. The row is capped, ordered by what is on disk with the name as a stable tie-break, cached per instance, and dropped by the Chaptarr webhook alongside the other book digests. The row’s order (sort=books|name|added; most-collected, alphabetical, or newest arrival) is applied server-side, before the cap, and that ordering is the whole reason the parameter exists rather than the app sorting what it received. The row is capped, and a real library holds more authors than it shows, so sorting a capped list would make “by name” mean “the most-collected authors, alphabetised”; a row that looks complete while silently omitting everyone below the cut. An author with no added date makes no recency claim and trails the dated ones instead of leading as the beginning of time; every order ends in the same name tie-break so an unchanged library never reshuffles between fetches. The cache holds the authors unordered and uncapped, so one entry serves all three orders, switching order never refetches the library, and each order is computed over the whole library before the cap is applied; a capped cache would pin every order to whichever authors won the one that happened to populate it. The response also carries total, the library’s author count before the cap, so a client showing fewer can say so instead of letting the shelf’s end imply there are no more. An unknown sort falls back to the default rather than erroring, so a newer client cannot break the row. book-author is deliberately uncached: it is opened to decide what to request, so it must show a book requested seconds ago as requested: and returns the same per-title shape book-library does, so the app renders the same per-format pills. A user with no Chaptarr grant gets an empty authors list, but a 403 from book-author: “this library has no such author” and “you cannot see this library at all” are different answers, and a 404 would claim the library was searched. Author art follows the same rule as covers: the relative /MediaCover path or the metadata provider’s CDN copy, never an arr-origin URL.
book-recent backs the app’s Recently Added row. Recency is the newest bookFile.dateAdded on a record: when the file landed, never when the book was added to the library: so a title requested months ago and downloaded today sorts first. A title’s eBook and Audiobook are separate Chaptarr records sharing a foreignBookId, and they are merged into one entry dated by the newest arrival; so a late-landing audiobook still floats its title to the front of the row. They were once returned separately, back when each card announced its own format; the card now leads with the title’s ownership (“eBook + Audiobook” plus an availability pill), which is a fact about the title rather than the record, so two entries rendered identical text behind an identical link (the detail route is keyed by foreignBookId). Grouping uses the same key the ownership digest does, so a record with no foreignBookId never merges into another title, and a format is emitted only when every record behind the entry agrees on one; a card covering both formats names neither. Chaptarr exposes no library-wide file read (its bookfile API demands an author, book, or id filter), so the digest is assembled by a bounded per-author fan-out on the server and cached per instance; a partial fan-out fails rather than returning a list that could silently omit the very import the user is looking for. The Chaptarr webhook drops the cached digest on import, so a book that lands surfaces immediately. Covers are emitted only as the relative /MediaCover path the requester proxy allowlists, or the metadata provider’s CDN copy: never an arr-origin URL a client cannot reach. A user with no Chaptarr grant gets an empty list rather than an error.
Freshness has three layers: WebSocket queue polling (30s), instant arr webhooks for out-of-band changes, and short-TTL caches that mutations/events invalidate. Book status is a per-instance live projection: a file is available, a healthy active item in the fully paginated Chaptarr queue is downloading, and monitored-without-a-file is requested, even when no Cantinarr request-log row exists. Warning, blocked, failed, and error queue rows remain requested rather than claiming active progress. The projection and reduced owned-books digest are cached briefly across search-result calls and invalidated together after mutations. Live state outranks stale decided history. When legacy Chaptarr data cannot be mapped safely to eBook versus audiobook, targeted status returns status_known: false and the library digest marks that title the same way; clients must present an unknown/unresolved state rather than treating it as requestable.