feat(notifications): opt-in notifier for new Pulsarr releases#1155
feat(notifications): opt-in notifier for new Pulsarr releases#1155bommerts wants to merge 1 commit into
Conversation
Adds a daily scheduled job that checks GitHub for a newer Pulsarr release and, when the user has opted in via a new `notifyOnUpdate` config flag, sends a one-shot system notification through the existing Discord webhook and Apprise system endpoint pipelines. * New service `update-check.service.ts`: fetches /releases/latest, semver-compares against APP_VERSION, returns a structured result. * New orchestrator `notifications/orchestration/update-available.ts`: reuses the existing Discord webhook + Apprise SystemNotification paths (same channels delete-sync uses), with body truncation that respects Discord's 4096-char embed limit and a link out to the full release. * New scheduled plugin `plugins/custom/update-check.ts`: daily cron at 09:00 modeled on metadata-refresh, gated by `notifyOnUpdate`, deduped against `lastNotifiedVersion` so a release is announced at most once. * Migration 090 adds `notifyOnUpdate` (boolean, default false) and `lastNotifiedVersion` (nullable string) columns. * UI: simple Switch in the existing General notification settings form. Defaults preserve existing behavior — feature stays off until explicitly enabled. No new dependencies. Addresses jamcalli#1153 (notifier piece). Builds on jamcalli#1154 (release notes UI). Auto-apply remains explicitly out of scope per maintainer guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
WalkthroughThis pull request introduces update notification functionality to the application by adding database schema columns for tracking notification preferences, implementing a GitHub release check service, scheduling daily update checks, composing Discord and Apprise notifications with release details, and integrating a settings toggle in the general notifications form. Changes
Sequence DiagramsequenceDiagram
participant Scheduler as Scheduler (Cron)
participant Plugin as Update Check Plugin
participant Service as Update Check Service
participant GitHub as GitHub API
participant Database as Database
participant NotifOrch as Notification Orchestrator
participant Discord as Discord Webhook
participant Apprise as Apprise Service
Scheduler->>Plugin: Trigger daily job (9:00)
Plugin->>Service: checkForUpdate()
Service->>GitHub: GET /releases/latest
GitHub-->>Service: Release metadata
Service->>Service: Parse & compare versions (semver)
Service-->>Plugin: UpdateCheckResult
alt Update Available & notifyOnUpdate=true
Plugin->>Database: Read lastNotifiedVersion
Database-->>Plugin: Last notified version
Plugin->>Plugin: Compare versions (semver.gt)
alt Version not yet notified
Plugin->>NotifOrch: sendUpdateAvailableNotification()
par
NotifOrch->>Discord: Send embed with release info
Discord-->>NotifOrch: Sent
and
NotifOrch->>Apprise: Send system notification
Apprise-->>NotifOrch: Sent
end
NotifOrch-->>Plugin: Success status
Plugin->>Database: Update lastNotifiedVersion
Database-->>Plugin: Persisted
end
else No Update or notifyOnUpdate=false
Plugin->>Plugin: Skip notification
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/plugins/custom/update-check.ts (1)
57-68: Dedupe falls open iflastNotifiedVersionis non-semver.If
lastNotifiedis truthy butsemver.valid(lastNotified)is false (e.g. legacy/corrupt value), the guard short-circuits and the notification is re-sent on every run until a successful send overwrites the value. That's safe-by-default for fresh installs but means a single bad row would produce daily duplicate notifications. Worth at least logging a warning in that branch so it's diagnosable.♻️ Suggested log on the unparseable case
const lastNotified = fastify.config.lastNotifiedVersion - if ( - lastNotified && - semver.valid(lastNotified) && - semver.gte(lastNotified, result.latestVersion) - ) { - fastify.log.debug( - { lastNotified, latestVersion: result.latestVersion }, - 'Already notified for this version or newer; skipping', - ) - return + if (lastNotified) { + if (!semver.valid(lastNotified)) { + fastify.log.warn( + { lastNotified }, + 'lastNotifiedVersion is not valid semver; treating as un-notified', + ) + } else if (semver.gte(lastNotified, result.latestVersion)) { + fastify.log.debug( + { lastNotified, latestVersion: result.latestVersion }, + 'Already notified for this version or newer; skipping', + ) + return + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/plugins/custom/update-check.ts` around lines 57 - 68, The dedupe guard around fastify.config.lastNotifiedVersion currently skips only when semver.valid(lastNotified) is true, so non-semver truthy values silently fall through; update the block around lastNotified / semver.valid(...) / semver.gte(...) to explicitly handle the invalid-semver case by emitting a warning (use fastify.log.warn) that includes the malformed lastNotified value and context (e.g., latestVersion from result) so operators can diagnose corrupt/legacy values; keep the existing semver.gte short-circuit logic and only warn when semver.valid(lastNotified) is false and lastNotified is set.src/services/notifications/orchestration/update-available.ts (1)
147-154: Apprise notification title is not length-bounded.For Apprise endpoints that map to Discord (or other services with a 256-char title cap), an unusually long
result.releaseNamewould produce an over-limit title. The Discord webhook branch already truncates to 256 (Line 75); the Apprise branch should do the same.♻️ Mirror the title truncation used by the Discord branch
+ const rawAppriseTitle = + result.releaseName?.trim() || + `Pulsarr v${result.latestVersion} available` + const appriseTitle = + rawAppriseTitle.length > 256 + ? `${rawAppriseTitle.slice(0, 253)}...` + : rawAppriseTitle + const notification: SystemNotification = { type: 'system', username: 'Pulsarr', - title: - result.releaseName?.trim() || - `Pulsarr v${result.latestVersion} available`, + title: appriseTitle, embedFields: fields, }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/services/notifications/orchestration/update-available.ts` around lines 147 - 154, The notification title uses result.releaseName without enforcing the 256-character limit, so update the construction of the SystemNotification (variable notification) to mirror the Discord branch by trimming result.releaseName and truncating it to 256 characters before using it; if the trimmed/truncated releaseName is empty, fall back to the existing `Pulsarr v${result.latestVersion} available` string — apply this logic where notification.title is set so any long result.releaseName is safely capped.src/services/update-check.service.ts (1)
100-112: Consider adding a diagnostic log whencurrentVersionfails semver validation.The code correctly guards against invalid
APP_VERSIONby checkingsemver.valid(currentVersion)before comparing versions —updateAvailablewill befalseand no notification will trigger. However, there's no log indicating why. If debugging a dev build or unusual version string, this silent behavior makes it harder to diagnose.Adding a debug log after computing
updateAvailablewould help:Suggested diagnostic log
const updateAvailable = semver.valid(currentVersion) !== null && semver.gt(latestVersion, currentVersion) + if (!semver.valid(currentVersion)) { + log.debug( + { currentVersion, latestVersion }, + 'Current APP_VERSION is not valid semver; treating updateAvailable as false', + ) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/services/update-check.service.ts` around lines 100 - 112, After computing currentVersion via normalizeVersion(APP_VERSION) and determining updateAvailable, add a diagnostic debug log when semver.valid(currentVersion) is falsy to surface why no update check occurs; specifically, in the logic around currentVersion / semver.valid(currentVersion) and updateAvailable, call log.debug (or existing logger) with context including APP_VERSION and currentVersion and a message like "Invalid current version; skipping semver comparison" so maintainers can see the raw APP_VERSION and the normalized result when validation fails (refer to normalizeVersion, APP_VERSION, currentVersion, semver.valid, and updateAvailable to locate the spot).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/plugins/custom/update-check.ts`:
- Around line 57-68: The dedupe guard around fastify.config.lastNotifiedVersion
currently skips only when semver.valid(lastNotified) is true, so non-semver
truthy values silently fall through; update the block around lastNotified /
semver.valid(...) / semver.gte(...) to explicitly handle the invalid-semver case
by emitting a warning (use fastify.log.warn) that includes the malformed
lastNotified value and context (e.g., latestVersion from result) so operators
can diagnose corrupt/legacy values; keep the existing semver.gte short-circuit
logic and only warn when semver.valid(lastNotified) is false and lastNotified is
set.
In `@src/services/notifications/orchestration/update-available.ts`:
- Around line 147-154: The notification title uses result.releaseName without
enforcing the 256-character limit, so update the construction of the
SystemNotification (variable notification) to mirror the Discord branch by
trimming result.releaseName and truncating it to 256 characters before using it;
if the trimmed/truncated releaseName is empty, fall back to the existing
`Pulsarr v${result.latestVersion} available` string — apply this logic where
notification.title is set so any long result.releaseName is safely capped.
In `@src/services/update-check.service.ts`:
- Around line 100-112: After computing currentVersion via
normalizeVersion(APP_VERSION) and determining updateAvailable, add a diagnostic
debug log when semver.valid(currentVersion) is falsy to surface why no update
check occurs; specifically, in the logic around currentVersion /
semver.valid(currentVersion) and updateAvailable, call log.debug (or existing
logger) with context including APP_VERSION and currentVersion and a message like
"Invalid current version; skipping semver comparison" so maintainers can see the
raw APP_VERSION and the normalized result when validation fails (refer to
normalizeVersion, APP_VERSION, currentVersion, semver.valid, and updateAvailable
to locate the spot).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: be5ac408-3045-4577-848a-24c9bb9d7e63
📒 Files selected for processing (8)
migrations/migrations/090_20260426_add_update_notify_columns.tssrc/client/features/notifications/components/general/general-settings-form.tsxsrc/plugins/custom/update-check.tssrc/schemas/config/config.schema.tssrc/services/database/methods/config.tssrc/services/notifications/orchestration/update-available.tssrc/services/update-check.service.tssrc/types/config.types.ts
|
Thanks for the submission. Some rather large issues with this and #1154. I'd rather see both reworked into a single pr than land them separately. The plugin dependency name 'notifications' doesn't exist, the actual name is 'notification-service'. fastify-plugin would fail at app.ready() and the app wouldn't boot. Did you actually test this locally? On the structure, the right shape is one pr that threads the update info all the way through to the api layer. The server fetches GitHub once on the cron and caches the result, a new backend route exposes that cached result, and the client useVersionCheck consumes the route instead of hitting GitHub directly. The browser polling can go away entirely, there's no reason every session should be calling out to GitHub on its own when the server is doing it already. On the service file itself, as written it doesn't warrant being a class at all, there's no state, it's one fetch and a semver compare, so it'd be better off as a function in src/utils/version.ts next to APP_VERSION. Reworked with the route it has two ingestion points and real state to hold across calls, and at that point a class makes sense. But then it needs to follow the conventions of the other services, class instantiated in its plugin with (log, fastify), decorated onto fastify, accessed via fastify.updateCheck. Have a look at delete-sync or plex-server for the shape. notifyOnUpdate stays on config since that's the user-facing toggle, but the read-side update info (current, latest, release notes, etc) wants its own endpoint and schema rather than living on config. Follow the existing convention, schema under src/schemas/system with inferred types imported into the client, route under src/routes/v1/system. lastNotifiedVersion is internal bookkeeping and shouldn't be writable through config either, drop it from ConfigUpdateSchema. Daily is also too slow for a version check, hourly would be fine, especially once the cron is just refreshing a cache the client polls. The apprise side is broken as written. Every existing apprise caller passes format: 'text' with a separately built body_html produced by a helper in apprise-html.ts. None of them pass markdown through, so the GitHub release body gets sent as raw markdown characters. Worse, your call into sendSystemNotification ends up routed through createSystemNotificationHtml, which is hardcoded for approval notifications. The textBody literally starts with "Content Approval Required" and the html expects fields like Content, Type, Requested by, so an update notification would come out as a broken approval card with empty fields and a wall of # headings and bold characters in the body. You'll need a new createUpdateAvailableNotificationHtml helper alongside the existing ones, and a matching createUpdateAvailableEmbed in discord-embeds.ts instead of building the Discord embed inline. One last thing, please drop the Co-authored-by lines from the commit. I don't mind the use of AI tools (I use them myself), but I would prefer all contributors keep them from co-authoring commits. Spend some time with this and actually test it, then I will happily review again. Closing out for now. |
|
Got it. And yes, sorry, I rushed this one out. I'm working on an update now. Ideally I personally would love auto-update, but it's your app and I respect that. Thanks for the feedback. |
|
Thanks for the detailed feedback — all of it landed. Reworked into a single PR: #1166. Quick map of what changed in response:
Tested end-to-end on a real Windows native install this time: migration applied cleanly, plugin loaded, cron registered, baseline-on-enable behaved correctly, and after forcing the watermark back I confirmed Apprise → Telegram delivery of the rich card. Happy to iterate on #1166 if anything still doesn't fit. |
Adds an opt-in toggle so Pulsarr sends a one-time Discord webhook + Apprise system notification when a new GitHub release is detected. Replaces the old browser-side fetch with a server-side hourly cron + cached backend route, and surfaces release notes inline via a popover next to the version label. Server - New 'UpdateCheckService' (cached, in-flight refresh dedup, semver normalisation, GitHub fetch with timeout/rate-limit handling) - New 'update-check' plugin: hourly cron, baseline-on-enable, single-flight notification dispatch, watermark-advance-after-attempt to prevent retry storms - New GET '/v1/system/update-status' route returning the cached payload - New 'sendUpdateAvailableNotification' on AppriseService + 'sendUpdateAvailable' orchestrator + facade method on NotificationService - New 'createUpdateAvailableNotificationHtml' (Apprise) and 'createUpdateAvailableEmbed' (Discord) templates - dedicated, NOT routed through the approval-card system template - 'notifyOnUpdate' added to Config / ConfigFull / ConfigUpdate schemas. 'lastNotifiedVersion' is internal-only - dedicated DB accessors that bypass ALLOWED_COLUMNS. Re-enabling 'notifyOnUpdate' resets the watermark so users only get notified for releases newer than the moment they enabled the toggle. - Migration 090 adds 'notifyOnUpdate' (boolean default false) and 'lastNotifiedVersion' (nullable string) columns Client - 'useVersionCheck' rewritten to call '/v1/system/update-status'; no more direct GitHub fetches from the browser - 'VersionDisplay' replaces the tooltip with a popover showing release name, truncated body, and a 'View on GitHub' link - New 'Notify me when a Pulsarr update is available' Switch in general notification settings Supersedes jamcalli#1154 and jamcalli#1155. Refs jamcalli#1153.
Adds an opt-in toggle so Pulsarr sends a one-time Discord webhook + Apprise system notification when a new GitHub release is detected. Replaces the old browser-side fetch with a server-side hourly cron + cached backend route, and surfaces release notes inline via a popover next to the version label. Server - New 'UpdateCheckService' (cached, in-flight refresh dedup, semver normalisation, GitHub fetch with timeout/rate-limit handling) - New 'update-check' plugin: hourly cron, baseline-on-enable, single-flight notification dispatch, watermark-advance-after-attempt to prevent retry storms - New GET '/v1/system/update-status' route returning the cached payload - New 'sendUpdateAvailableNotification' on AppriseService + 'sendUpdateAvailable' orchestrator + facade method on NotificationService - New 'createUpdateAvailableNotificationHtml' (Apprise) and 'createUpdateAvailableEmbed' (Discord) templates - dedicated, NOT routed through the approval-card system template - 'notifyOnUpdate' added to Config / ConfigFull / ConfigUpdate schemas. 'lastNotifiedVersion' is internal-only - dedicated DB accessors that bypass ALLOWED_COLUMNS. Re-enabling 'notifyOnUpdate' resets the watermark so users only get notified for releases newer than the moment they enabled the toggle. - Migration 090 adds 'notifyOnUpdate' (boolean default false) and 'lastNotifiedVersion' (nullable string) columns Client - 'useVersionCheck' rewritten to call '/v1/system/update-status'; no more direct GitHub fetches from the browser - 'VersionDisplay' replaces the tooltip with a popover showing release name, truncated body, and a 'View on GitHub' link - New 'Notify me when a Pulsarr update is available' Switch in general notification settings Supersedes jamcalli#1154 and jamcalli#1155. Refs jamcalli#1153.
…1166) * feat(notifications): in-app update notifications with release notes Adds an opt-in toggle so Pulsarr sends a one-time Discord webhook + Apprise system notification when a new GitHub release is detected. Replaces the old browser-side fetch with a server-side hourly cron + cached backend route, and surfaces release notes inline via a popover next to the version label. Server - New 'UpdateCheckService' (cached, in-flight refresh dedup, semver normalisation, GitHub fetch with timeout/rate-limit handling) - New 'update-check' plugin: hourly cron, baseline-on-enable, single-flight notification dispatch, watermark-advance-after-attempt to prevent retry storms - New GET '/v1/system/update-status' route returning the cached payload - New 'sendUpdateAvailableNotification' on AppriseService + 'sendUpdateAvailable' orchestrator + facade method on NotificationService - New 'createUpdateAvailableNotificationHtml' (Apprise) and 'createUpdateAvailableEmbed' (Discord) templates - dedicated, NOT routed through the approval-card system template - 'notifyOnUpdate' added to Config / ConfigFull / ConfigUpdate schemas. 'lastNotifiedVersion' is internal-only - dedicated DB accessors that bypass ALLOWED_COLUMNS. Re-enabling 'notifyOnUpdate' resets the watermark so users only get notified for releases newer than the moment they enabled the toggle. - Migration 090 adds 'notifyOnUpdate' (boolean default false) and 'lastNotifiedVersion' (nullable string) columns Client - 'useVersionCheck' rewritten to call '/v1/system/update-status'; no more direct GitHub fetches from the browser - 'VersionDisplay' replaces the tooltip with a popover showing release name, truncated body, and a 'View on GitHub' link - New 'Notify me when a Pulsarr update is available' Switch in general notification settings Supersedes #1154 and #1155. Refs #1153. * refactor(update-check): simplify boot dispatch using apprise.whenReady() * refactor(config): drop notifyOnUpdate transition reset * refactor(update-check): render release notes via GitHub /markdown API * feat(notifications): multi-channel update notifications * docs(notifications): document general notification settings * fix(update-check): poll status faster until first refresh resolves * refactor(update-check): drop unused service arg and dead version-reset path * fix(notifications): include release notes in update text fallback --------- Co-authored-by: joefarrell <joefarrell@users.noreply.github.com> Co-authored-by: jamcalli <48490664+jamcalli@users.noreply.github.com>
Description
Adds an opt-in daily notifier that pings the user (via the existing Discord webhook + Apprise system endpoint pipelines) when a newer Pulsarr release is published. Strictly notify-first — no auto-apply, anywhere, per your guidance.
This is the second of the two pieces I split out from #1153, after #1154 (release notes UI). It is independent of #1154 and can be merged in either order.
What lands
migrations/migrations/090_20260426_add_update_notify_columns.tsnotifyOnUpdate(bool, defaultfalse) andlastNotifiedVersion(nullable string) toconfigs.src/services/update-check.service.tscheckForUpdate(log)—GET https://api.github.com/repos/jamcalli/Pulsarr/releases/latest, semver-compare againstAPP_VERSION, returns a structuredUpdateCheckResult(ornullon rate-limit / network error — caller logs & moves on). Skips drafts/prereleases defensively.src/services/notifications/orchestration/update-available.tsfastify.notifications.discordWebhookandfastify.notifications.apprise(the same channelsdelete-syncuses). Discord embed body truncated at 3500 chars (4096 limit minus link suffix), AppriseSystemNotificationwith current/latest/release/notes fields. Returns success-count so the plugin knows whether to recordlastNotifiedVersion.src/plugins/custom/update-check.tsmetadata-refresh.ts. Daily cron at 09:00, registers itself viafastify.scheduler.scheduleJobandfastify.db.createSchedule. Gated byconfig.notifyOnUpdate; deduped againstconfig.lastNotifiedVersion.src/types/config.types.ts,src/services/database/methods/config.ts,src/schemas/config/config.schema.tstagNamingSource(#89).src/client/features/notifications/components/general/general-settings-form.tsxWhat does not land (and why)
prereleaseflag)./v1/system/update-statusREST route + SSE — not needed; the existing clientuseVersionCheckalready covers UI-time checks. This PR is purely the push side.delete-syncso it slots in without designing new templates.Related Issues
Addresses #1153. Pairs with #1154 (release-notes popover), but does not depend on it.
Type of Change
Testing Performed
bun run typecheck(server + client + tests) — all pass.bun run fix(Biome check --write) on every changed/new file — clean.up/downreviewed against the project's existing column-add pattern (mirrors Send to multiple instances #89 exactly).UpdateCheckResultwith both a short and a 10k-char body — Discord truncation breaks on the nearest newline; Apprise body field renders end-to-end throughcreateSystemNotificationHtml.fastify.notifications.discordWebhook/fastify.notifications.apprisedecoration paths againstnotification.service.ts(line 49: "Single decoration point: fastify.notifications") and against thedelete-syncorchestrator's existing usage.Screenshots
UI is one Switch added to the existing General settings panel — happy to add a screenshot in a follow-up comment after building locally if useful.
Checklist
docs/if you'd likeNotes for reviewer
notifyOnUpdate, but only dispatches when the flag is on. This keeps the option open to add e.g. a "version data is stale > N hours" warning in the future without paying for a second poll. If you'd rather skip the poll entirely when the flag is off, happy to gate the whole handler.Summary by CodeRabbit