Files
profilarr/docs/backend/notifications.md
2026-04-18 15:35:31 +09:30

10 KiB
Raw Permalink Blame History

Notification System

Table of Contents

Overview

Profilarr's notification system sends alerts when jobs complete, databases sync, or things go wrong. Two design goals:

  1. Extensibility without coupling. Adding a new service or event is a self-contained change that doesn't touch unrelated code.
  2. 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):

  1. Query notification_services for enabled services
  2. Filter by enabled_types (JSON array includes notification type)
  3. createNotifier() builds the notifier from service_type + config JSON
  4. Send in parallel via Promise.allSettled()
  5. 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 topic in 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.