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.
Only my requests. The personal requester_only preference defaults to false and restricts native new_movie, new_episode, new_book, and new_music alerts to accepted requests in the importing instance. It does not change Discord or administrator events. Shared book subscribers match their own format and the live bound record, including rekeyed identities. TV requires positive imported episode IDs within the saved season/pilot selection; missing-only following includes submitted or available canonical episode work and excludes covered or unsubmitted episodes. Stopping following retains already submitted work. Fresh provider snapshots are shared within each import-matching batch; an unverifiable historical request cannot suppress independently verified matches. Delivery rechecks preferences, request decisions/subscriptions, grants and child-content policy after provider reads. Older clients omitting the field preserve its saved value atomically; clients hide the control when a server does not return it.
Requester audiences use per-user, per-library title claims in content_alert_claims, with the same ten-minute aggregation window and a separate twelve-title budget per user. One user’s requested season cannot consume another user’s first eligible alert. Broad recipients also retain a per-user claim so changing the preference does not repeat the same import. Explicit upgrade receipts remain silent; TV receipts name the upgraded episodes so a different newly imported episode can still qualify. Muted import receipts name the recipient and exact TV episode, so re-enabling cannot replay a muted witness or suppress another episode. These receipts are delivery history, never current availability.
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 import-history polling; 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. While remediation is on and auto-dispatch is off it refuses an admin resolve or dismiss with 409 and the remedy as the error text, so the off state always has one visible indicator; switching remediation itself off lifts the refusal |
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 by polling 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, without waiting for the next 30-second history poll. 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. Both paths require a recognized import event and a live book record with a file. An existing file or a disappearing queue row alone never authorizes an alert. 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 after each successful queue read, for ordinary and corrected series alike, including when another story on that parent is still downloading or the queue is empty. History retains episode identity through restart catch-up and verifies each corrected 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 when an import lands, and import-history polling supplies the fallback for instances without instant updates and files imported straight from disk. Both verify the live album identity and require track files; an existing track file alone is not fresh import evidence. Both dedupe through the same content_alert_claims window. Upgrade proof comes from the webhook’s isUpgrade flag or a paired trackFileDeleted record with reason Upgrade. Lidarr has no trackFileDelete webhook toggle, so history provides that pairing. Missing upgrade proof leaves a confirmed import classified as new content; missing import proof produces no alert.
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 persists queue membership and its import-history cursor in arr_queue_witness. The first poll establishes a baseline through the last complete second without replaying old imports. Every later successful queue read checks one bounded history page after the last resolved cursor through the last complete second, matching recognized import event names. The current second stays open because arr history may use whole-second timestamps; genuine imports within that second, including the partial startup second, become eligible on the next poll. Queue disappearance, including a successful empty response during an arr restart, never proves completion. Fresh receipts can announce imports while a title remains queued or after an empty queue, including downloads that complete entirely between polls. An unreadable history response sends nothing and retains the previous cursor and membership for retry, including across a Cantinarr restart. Receipts arriving during a poll wait for a later completed window, so advancing the cursor neither skips nor replays them. Each proven movie, book or album import still requires its live file check; corrected TV episodes retain their exact episode verification.
Recovery retains the existing limits: windows older than six hours and histories that exceed one page are dropped whole. After a restart or a poll gap of at least five minutes, new-content and upgrade batches each have an independent ten-title cap. A boot recovery waits for gateway enrollment before announcing. The resolved cursor is saved before alerts are sent, and the shared content ledger deduplicates webhook and polling delivery. Rows are removed when an instance’s URL changes or the instance is deleted.