10 KiB
Notification System
Table of Contents
- Overview
- Architecture
- Notification Payload
- Definitions
- NotificationManager
- Notifiers
- Service Tiers
- Testing
- Adding a New Service
Overview
Profilarr's notification system sends alerts when jobs complete, databases sync, or things go wrong. Two design goals:
- Extensibility without coupling. Adding a new service or event is a self-contained change that doesn't touch unrelated code.
- Definitions don't know about services. The code that decides what to say produces a structured, service-agnostic payload. Each notifier decides how to render it.
Notifications are fire-and-forget. A failed webhook never blocks a job. Errors are logged and recorded in history, never propagated.
Architecture
Three layers:
| Layer | Responsibility | Knows about |
|---|---|---|
| Definition | Decides what to say: title, message, blocks, severity | Domain data (job logs, statuses) |
| Manager | Decides who to tell: queries services, filters by type | Service configs, type subscriptions |
| Notifier | Decides how to render: maps payload to platform format | Platform API (embeds, JSON, etc.) |
Adding a new service never touches definitions. Adding a new event never touches notifiers.
flowchart TD
JOB[Job Handler] -->|"passes job log"| DEF[Definition]
DEF -->|"returns Notification"| NM[NotificationManager]
NM -->|get enabled services| DB[(notification_services)]
NM -->|filter by enabled_types| FILTER[Type Filter]
FILTER -->|create per service| FACTORY[createNotifier]
FACTORY -->|discord| DISCORD[DiscordNotifier]
FACTORY -->|ntfy| NTFY[NtfyNotifier]
FACTORY -->|webhook| WEBHOOK[WebhookNotifier]
FACTORY -->|telegram| TELEGRAM[TelegramNotifier]
FACTORY -->|future| OTHER[...]
DISCORD -->|renders to embeds| WEBHOOK_D[Discord Webhook]
NTFY -->|renders to ntfy JSON| WEBHOOK_N[Ntfy Server]
WEBHOOK -->|raw JSON| WEBHOOK_W[Webhook Endpoint]
TELEGRAM -->|renders to HTML| WEBHOOK_T[Telegram Bot API]
OTHER -->|renders| WEBHOOK_O[...]
NM -->|record result| HISTORY[(notification_history)]
Notification Payload
src/lib/server/notifications/types.ts
interface Notification {
type: string;
severity: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
messageFormat?: 'plain' | 'code';
blocks?: NotificationBlock[];
}
type NotificationBlock = FieldBlock | SectionBlock;
interface FieldBlock {
kind: 'field';
label: string;
value: string;
inline?: boolean;
}
interface SectionBlock {
kind: 'section';
title: string;
content: string;
imageUrl?: string;
}
The payload is a structured document, not a rendering instruction. blocks is a
single ordered array (not separate fields[] and sections[]) because ordering
is meaningful: stats first, then content, then errors.
messageFormat is an optional rendering hint. When set to 'code', Detail-tier
notifiers (Discord) wrap the message in a code block. Summary-tier notifiers
(Ntfy, Telegram) and Passthrough (Webhook) ignore it and render the message as
plain text. Use for filename/path-style messages where monospace is desirable.
Severity
Each notifier maps severity to its platform's concept:
| Notifier | success | error | warning | info |
|---|---|---|---|---|
| Discord | Green embed | Red embed | Yellow embed | Blue embed |
| Ntfy | Priority 3 (default) | Priority 5 (urgent) | Priority 4 (high) | Priority 3 (default) |
| Telegram | ✅ prefix | ❌ prefix | ⚠️ prefix | ℹ️ prefix |
| Webhook | No mapping | No mapping | No mapping | No mapping |
Definitions
src/lib/server/notifications/definitions/
Pure functions that take domain data and return a Notification. They never
import anything from notifiers/. No EmbedBuilder, no Colors, no
service-specific types.
export function rename({ log }: RenameNotificationParams): Notification {
return {
type: `rename.${log.status}`,
severity: log.status === 'failed' ? 'error' : 'success',
title: `Rename Complete - ${log.instanceName}`,
message: `Renamed ${log.results.filesRenamed} files`,
blocks: [{ kind: 'field', label: 'Files', value: '5/5', inline: true }, ...buildSections(log)]
};
}
NotificationManager
src/lib/server/notifications/NotificationManager.ts
The only way notifications get sent. On notify(notification):
- Query
notification_servicesfor enabled services - Filter by
enabled_types(JSON array includes notification type) createNotifier()builds the notifier fromservice_type+configJSON- Send in parallel via
Promise.allSettled() - Record success/failure in
notification_history
sendToService(serviceId, notification) bypasses the type filter for test
notifications from the UI. Every send attempt is recorded in history regardless
of outcome.
Notifiers
src/lib/server/notifications/notifiers/
Each notifier is a standalone class that uses getWebhookClient() for HTTP.
Two methods: notify(notification) and getName().
BaseHttpNotifier exists as an abstract base with rate limiting, but current
notifiers (Discord, Ntfy) are standalone because they need custom HTTP behavior
(chunking, auth headers).
Discord (Detail Tier)
Renders everything: embeds with color-coded severity, thumbnails, inline fields,
code blocks for sections. Handles Discord's embed limits via pagination. Supports
@here mentions.
Ntfy (Summary Tier)
Quick ping: title, message, and field blocks only. Section blocks are omitted.
POSTs JSON to the server root with topic in the payload. Maps severity to
priority (3/4/5) and emoji tags. Optional Authorization: Bearer header for
authenticated topics.
Webhook (Passthrough Tier)
POSTs the raw Notification object as JSON to a user-configured URL. No
rendering, no severity mapping, no block filtering — the payload is the
notification. Optional Authorization header for authenticated endpoints
(user provides the full header value, e.g. Bearer token or Basic base64).
Telegram (Summary Tier)
Quick ping via the Telegram Bot API. Title, message, and field blocks only.
Section blocks are omitted. POSTs to
https://api.telegram.org/bot{token}/sendMessage with HTML parse mode. Severity
mapped to emoji prefix (✅/❌/⚠️/ℹ️). Messages truncated at 4096 chars.
Config: bot_token (secret), chat_id.
Service Tiers
Not every service renders the same content. The tier determines what the notifier includes from the structured payload.
| Tier | Renders | Drops | Example |
|---|---|---|---|
| Detail | Everything: fields, sections, images | Nothing | Discord |
| Summary | Title, message, field blocks | Section blocks, images | Ntfy, Telegram |
| Passthrough | Raw Notification JSON |
Nothing (no rendering) | Webhook |
The tier is a design-time decision baked into the renderer, not a runtime config.
Testing
tests/integration/notifications/
harness/
mock-server.ts - Deno.serve() that captures HTTP requests
specs/
test.test.ts - test notification (definition + all notifiers)
upgrade.test.ts - upgrade notification (definition + all notifiers)
rename.test.ts - rename notification (definition + all notifiers)
arrSync.test.ts - arr sync notification (definition + all notifiers)
pcdSync.test.ts - PCD sync notification (definition + all notifiers)
Tests are organized by event, not by notifier. Each spec covers definition output, rendering for all notifiers (Discord, Ntfy, Webhook, Telegram) via mock server, and optionally real sends.
deno task test integration notifications # all
deno task test integration notifications test # test event only
deno task test integration notifications upgrade # upgrade only
deno task test integration notifications telegram # (no standalone file — telegram tests are in each event spec)
Real send tests are skipped without .env. Copy .env.example and fill in
values for visual verification. Not part of CI. Telegram requires
TEST_TELEGRAM_BOT_TOKEN and TEST_TELEGRAM_CHAT_ID.
Adding a New Service
Order: scope → tests → UI → backend.
1. Define the scope. What tier? What's the API? What config does the user
provide? Which fields are secrets? How does severity map? Document this in
docs/todo/ or the PR description. Example (ntfy):
Tier: Summary. API: POST JSON to server root with
topicin body. Config:server_url,topic,access_token(secret). Severity: success/info → priority 3, warning → 4, error → 5.
2. Write tests. Add a section for the new notifier to each existing event
spec (test.test.ts, rename.test.ts, etc.) with mock tests (severity mapping,
payload structure, block rendering per tier) and real send tests (skipped without
.env). Tests are organized by event, not by notifier.
3. Build the UI. Config form component, add to type dropdown in
NotificationServiceForm.svelte, add config parsing in new/ and edit/ page
servers, strip secrets in load functions.
4. Implement the backend. Config interface in types.ts, notifier class in
notifiers/{service}/, add case in NotificationManager.createNotifier().
Tests from step 2 should pass.