Skip to content

Push notifications

Implementation contracts and behavior for push notifications.

Cantinarr never holds APNs or FCM credentials; it talks to a self-hosted push gateway and forwards each device’s platform + token verbatim (iOS and Android ride the same registry and send path). Setting CANTINARR_PUSH_GATEWAY_URL enables push: with no API key the server auto-enrolls on first start and persists its issued key encrypted in the DB (delete the push_api_key settings row to force re-enrollment). Enrollment self-heals: a gateway that is down is retried on a backoff that starts at 60s and doubles up to 30 minutes, and stored device tokens are re-registered once it comes up. A stored auto-enrolled key the gateway refuses (401, for example after the gateway’s database was replaced) is discarded on the spot and the server re-enrolls on the same backoff, then re-registers every stored device token with its new tenant. An explicit CANTINARR_PUSH_API_KEY is never replaced (there is nothing to replace it with): a refusal is logged and shows up as the “Push notifications are not being delivered” issue until the operator fixes or clears the key.

Delivery failures report themselves. Sends are fire-and-forget and are never retried, so a gateway outage silently swallows alerts: and a notification nobody receives is indistinguishable from one nobody needed to send, which makes push the one dependency that cannot report its own failure through the usual channel. Two consecutive failed sends therefore open one source=system admin-only issue (dedupe key system:push-delivery); further failures refresh it and count the notifications lost in occurrences, and the next successful send resolves it with resolution_kind=push_delivery_restored. One failure is treated as a blip. No probe is involved: real sends already report success or failure, which is cheaper than a synthetic test and reports what actually failed. The issue is the right surface precisely because it is visible in the app without any notification being delivered; the alert it raises rides the usual hold-down and lands once delivery recovers, telling the admin their alerts were lost.

Server and personal controls. Production delivery requires the server master, server category, personal master, and personal category to allow it, in addition to existing role, library, and content restrictions. These checks run again immediately before asynchronous dispatch. Muting keeps device registrations and live library observation intact, preserves individual selections, causes no delivery-failure issue, and does not queue a catch-up burst on re-enable. Already dispatched notifications can still arrive. Auto-approved alerts consume the same new-submission event as Discord, independently of its configuration; later manual approvals, retries, and shared-book subscriptions do not create another alert. They open media detail and carry no approval badge.

Media-server access alerts open /media-servers. Account-server alerts follow newly committed grant rows: unchanged saves, revocations, account setup, and restarts do not repeat them. Plex waits for a successful share and asks for acceptance only while that invitation is pending. Access granted entirely outside Cantinarr is not announced. The expanded media_server_access preference inherits the former Plex-invite choice, including server-wide opt-outs. Personal preference responses and saves also accept the plex_invite_sent spelling for older apps; both names control the same choice.

Notification categories (per-user preferences; admin-scoped ones are enforced in SQL, not just defaults):

Category Default Audience Sent when
request_decision off requester their request is approved/denied
request_pending on admins a new request needs review (badge = queue depth)
request_auto_approved off admins a new movie/TV/book/music request was accepted without human review; includes requester and title
new_movie on the importing library’s visible users (pinned or granted there, or unpinned users when it is the default library; an admin with no radarr rows hears every library), minus the kids accounts the title is hidden from (a title that cannot be identified or rated reaches no child) a movie finishes importing (collapse-keyed per title)
new_episode on same library-visibility rule as new_movie, over sonarr rows, minus the kids accounts the series is hidden from new episode(s) import for a series
new_book on that instance’s assigned users (pinned or access-granted); an assignment scopes admins too; an admin with no books assignment hears every instance a Chaptarr book import lands (witnessed by queue polling; per format, since a title’s ebook and audiobook are separate records)
new_music on that instance’s assigned users; exactly the new_book rule over lidarr rows a Lidarr album import lands (webhook or queue-departure witness; one record per album, no format axis, and the body names the artist)
issue_created on admins a tracked problem becomes actionable after the quiet recovery window, or durable status proof remains unavailable; held behind a 3-minute confirmation and coalesced per source (see below)
agent_action_pending on admins the agent proposed a fix needing approval; held behind the same 3-minute confirmation and coalesced (see below)
issue_question on (shares the issue_created preference) admins the investigation needs information; fixed body and an issue-thread link
issue_fix_confirm on (shares the agent_action_pending preference) admins an applied repair needs a human check before successful closure; fixed body and an issue-thread link
agent_autoapproval_paused on (shares the agent_action_pending preference) admins a standing auto-approval rule disarmed itself after a failed or unverifiable outcome; fixed body, collapse-keyed per rule, deep-links the triggering issue. Rules the boot repair pauses (a restart interrupted an auto-approved fix) are announced once at worker start through this same event
profile_change_pending on (shares the agent_action_pending preference) admins an external MCP agent parked a quality-profile change awaiting in-app approval; fixed body (profile/instance names ride only as data fields), collapse-keyed per target so a superseding proposal replaces the stale alert, deep-links the approvals screen
remediation_autodispatch_disabled on (shares the agent_action_pending preference) admins the circuit breaker switched automatic problem detection off; the durable record is its own auto-resolving system issue (system:remediation-breaker), closed by re-enabling auto-dispatch
plex_access_request on admins a user shared their Plex email (collapse-keyed per user; body says whether their grants or auto-approve already sent the invite, or it failed, or they are waiting to be granted)
media_server_access on recipient new Jellyfin/Emby/Audiobookshelf access is granted (including an admin account link or a re-grant), or Cantinarr successfully sends/restores a Plex share; the message names the server and explains account setup, invite acceptance, or ready access
issue_report_update on reporter Ready to try again when their own user-submitted report closes as resolved (issue_closed), except a close they confirmed themselves; questions, pending repairs, unsuccessful closures, and automatic/system incidents stay silent
agent_digest on admins the weekly agent scoreboard; the one push that reports success, in outcome vocabulary with attribution glued to the number (“Last 7 days: 41 resolved; 2 by the agent · 1 by your rules · 37 on their own. Right now: 1 needs you”), skipped entirely on a week with nothing to say
content_upgraded off admins an existing movie/episode/book/album file was replaced by a quality upgrade. The upgrade is proven by the webhook’s isUpgrade flag, or on catch-up by a paired delete-for-upgrade history record; without positive proof the import broadcasts as new_movie/new_episode/new_book/new_music; suppression fails open, so drift can only re-page, never silence. A proven upgrade also claims the broadcast dedupe key silently, which is what keeps the queue poller from re-announcing the same import to everyone

Bodies are server-authored templates (untrusted text never hits the lock screen), sends are fire-and-forget with a 30s timeout, a 10-minute dedupe window absorbs the overlap between queue polling and webhooks (scoped per library: the same title importing on the HD and the 4K instance alerts each library’s own audience, while a webhook and the poller re-witnessing one import still collapse), and tokens the gateway reports dead are pruned automatically. That window is held in content_alert_claims, so it survives a restart; durable, not permanent: a claim whose send failed is re-claimable once the window lapses, and a database error fails open (a duplicate beats a silenced channel). The same ledger backs a storm breaker: at most 12 distinct content alerts fan out per 10-minute window, so a mass job; a bulk manual import firing one webhook per title; delivers the first dozen and suppresses (and logs) the rest instead of paging every opted-in household member once per title; the app’s live view is the answer past that rate. Each claim carries a storm_scope so the breaker’s budgets stay separate: broadcast claims count toward the new-content cap, upgrade claims toward their own identical 12-per-window cap for content_upgraded, and the silent claims a proven upgrade records against the broadcast key count toward no cap at all; a mass cutoff-upgrade sweep can exhaust its own budget but never a genuine new-content alert’s. Payloads carry deep-link data (type, tmdb_id/issue_id/user_id; book payloads; request decisions and new_book alerts; add foreign_id, the Chaptarr foreignBookId, plus title, book_format, and the pinned instance_id, since books store tmdb_id 0; music payloads carry the same shape with the MusicBrainz release-group id as foreign_id and no format field) the app routes on tap. Books have two new_book witnesses. The Chaptarr webhook announces an import the moment it lands, which matters because a small ebook can be grabbed and imported inside a single 30s poll interval and would otherwise never be witnessed at all. The Chaptarr queue poller is the fallback for instances without instant updates configured, and for imports of files already on disk, which the Readarr lineage does not announce. Either way the rule is the same: a record with a file on disk imported; one without a file failed and stays silent. The two dedupe against each other through the same content_alert_claims window, so an import announced by both alerts once.

TV import alerts reverse the same bundled/custom season corrections used by requests and title status. New episodes available names the imported story, and its existing tmdb_id/instance_id payload opens that story in the importing Sonarr library. Download callbacks retain their episode seasons; the poller reads one bounded import-history page for corrected parents seen in the current or previous queue, including when another story on that parent is still downloading. History retains episode identity through restart catch-up and verifies each imported episode against the live library. Existing files or a parent leaving the queue never identify a corrected story, and Cantinarr request records are not needed. Paused, ambiguous, unmapped, or unverifiable scopes skip their title-specific alerts with a logged reason while library refresh continues. Imports group by corrected title before notification copy, child-content filtering, per-library deduplication, collapse IDs, and recovery caps. A title with mixed new imports and upgrades broadcasts as new content; an all-upgrade title keeps the admin-only content_upgraded audience and silently claims its matching new-episode key. The alert announces imported episodes immediately, without waiting for a complete season. Recovery retains the six-hour cutoff, single-page history bound, independent ten-title new/upgrade caps and enrollment hold; first-boot seeding and existing claims prevent historical replay. A versioned Sonarr witness in the existing media_ids JSON distinguishes older parent-only snapshots: their first resume skips corrected imports from before startup, so an upgrade does not replay old parent alerts under new story identities. Subsequent restarts retain the full recovery window.

Music mirrors the book witness pair. The Lidarr webhook’s Download event announces new_music the moment an import lands (a single can be grabbed and imported inside one 30s poll interval), and the queue poller’s departure witness; durable across restarts, seeded by wave one’s Lidarr membership so the category launched with continuity rather than amnesia; is the fallback for instances without instant updates and for files imported straight from disk. Both re-verify identity against the live album record (a record with track files on disk imported; one without stays silent) and dedupe through the same content_alert_claims window. Upgrade proof for music comes from the webhook’s isUpgrade flag or a paired trackFileDeleted-with-reason-Upgrade history record; Lidarr has no trackFileDelete webhook toggle, so the history pairing is the only delete witness; and as everywhere, an unproven import broadcasts rather than silencing.

issue_created has its own damping, because incidents are scoped exactly; one per movie, per episode, per book record; so a single batch cause (a season’s downloads stalling together, a download client dropping out) promotes a whole wave at once; and promotes it in a trickle, not an instant: a season pack’s per-episode promotions land a minute or two apart as each crosses its own observation window. Promotion is an edge, not a verdict: the next complete queue snapshot routinely hands a promoted incident back to passive tracking, and the agent can close it outright, so alerting on the edge announced “did not recover automatically” about downloads that were in fact recovering. Instead, promotion queues the owed push in issue_alert_queue, and delivery waits twice: each issue must stay out of tracking for a 3-minute hold-down, and the deliverable wave as a whole must go quiet for that same window (no newer deliverable row); with a 15-minute ceiling from the oldest owed row so a continuous trickle cannot defer the page forever. Delivering per-tick instead of per-quiesce is what once turned one season pack into six pushes in twenty minutes. An incident that falls back to tracking restarts its clock without holding the wave hostage (the row survives, so a problem that later sticks still pages), one that closes inside the window is dropped unannounced, and an issue the queue already paged in the last 24 hours is considered announced; re-promotions inside that budget are dropped, with issues.last_paged_at as the durable per-issue record that a page actually went out. Whatever clears together is announced as one alert per source: a single issue keeps its issue_id deep link, while a wave carries a server-computed count, a collapse id of issue_created:<source> so a later summary replaces rather than stacks, and no issue_id (the app opens the Issues list).

agent_action_pending gets the same treatment for the same reason one step later in the pipeline: a batch cause dispatches a batch of runs, and each parks its own proposal; but a wave parks in a trickle (a dozen runs across two workers spread over several minutes), not in one instant. Parking a proposal queues the owed push in agent_action_alert_queue; the observation sweeper delivers only once the wave has stayed quiet for the 3-minute hold-down (no new proposal parked), with a 15-minute ceiling from the oldest owed row so a continuous trickle cannot defer the page forever. A proposal decided or superseded inside the window (or whose issue closed) is dropped unannounced; the admin was already acting, or there is nothing left to approve; and unlike issue alerts there is no re-arm rule: supersession deletes the row, and a later re-proposal queues a fresh one. A proposal a standing auto-approval rule approves is dropped the same way: the rule sweep runs just before the alert flush on the same sweeper tick, so automated fixes never page anyone. Whatever clears together is one push: a single proposal keeps its issue_id deep link and the original copy, while a wave carries a server-computed count, the plural copy, a collapse id of agent_action_pending, and no issue_id (the app opens the approval queue either way).

The poller’s queue memory is durable (arr_queue_witness), so a completion that landed while the server was down is witnessed on the first poll after boot instead of being lost to a re-seed from empty. Completions no queue snapshot ever saw; content added directly in the arr, grabbed, and fully imported while Cantinarr was down (or while the arr/network was unreachable for 5+ minutes, which is gated the same way without needing a restart); are recovered by a bounded import-history catch-up: one page of the arr’s own import event log since the last successful poll (eventType=3, re-matched by name; downloadFolderImported, the Readarr-lineage bookFileImported vocabulary, or Lidarr’s trackFileImported, each shared with its webhook receiver), the same events its webhook would have delivered. History may only ever add alerts: a failed read degrades to the witnessed departures, and every recovered id passes the same live re-verification before anything is announced. The resume is bounded: windows older than 6 hours are dropped whole (the user has long since found the content in the app), the merged batch; departures plus catch-up; announces at most 10 alerts per instance (above that, or when one page cannot prove it covered the window, the whole batch is dropped in favor of the app’s live view), and a boot resume waits for the push gateway to enroll before announcing so recovered alerts are not dropped into an unenrolled client. Membership is persisted before anything is announced, so a crashlooping container cannot replay alerts. On a first boot there is nothing stored, so an upgrade can never produce a burst. Only set membership is ever read back; every departure is re-verified live against the arr before it is announced; and rows are dropped when an instance’s URL changes or the instance is deleted.

View the maintained source for this page.