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

271 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Notification System
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Notification Payload](#notification-payload)
- [Severity](#severity)
- [Definitions](#definitions)
- [NotificationManager](#notificationmanager)
- [Notifiers](#notifiers)
- [Discord (Detail Tier)](#discord-detail-tier)
- [Ntfy (Summary Tier)](#ntfy-summary-tier)
- [Webhook (Passthrough Tier)](#webhook-passthrough-tier)
- [Telegram (Summary Tier)](#telegram-summary-tier)
- [Service Tiers](#service-tiers)
- [Testing](#testing)
- [Adding a New Service](#adding-a-new-service)
## 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.
```mermaid
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
```
```typescript
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.
```typescript
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.
```bash
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.