Repository: jamcalli/Pulsarr
Pull request #195: Add pending webhooks service and schedule safety check for delete-sync
Author: login: jamcalli
Created: 2025-05-15T23:48:53Z | Merged: 2025-05-16T07:06:43Z

## Description
<!-- A clear and concise description of the changes in this PR -->

  ⏱️ Pending Webhooks Service
  - New service to handle race conditions between webhooks and RSS feed processing
  - Temporarily stores webhooks that arrive before RSS items are matched to users
  - Processes pending webhooks every 20 seconds with automatic retry
  - 10-minute expiration for webhook storage
  - Cleanup job runs every 60 seconds to remove expired webhooks

  🔧 Schedule Safety Enhancement
  - Added explicit schedule enabled check in delete-sync plugin
  - Schedule must be explicitly enabled via UI toggle, independent of deletion settings
  - Prevents accidental deletion runs when schedule is disabled but deletion features are configured
 
## Related Issues
<!-- Link to any related issues this PR addresses (e.g., "Fixes #123", "Addresses #456") -->

## Type of Change
<!-- Please delete options that are not relevant -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Performance improvement
- [ ] Code refactoring
- [ ] Documentation update
- [ ] Dependency update

## Testing Performed
<!-- Describe the testing you've done to verify your changes -->

## Screenshots
<!-- If applicable, add screenshots to help explain your changes -->

## Checklist
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] My changes work with existing functionality

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced support for managing and processing pending webhooks, allowing webhook events to be queued and retried if matching media items are not immediately found.
  - Added a new service to handle scheduling, processing, and cleanup of pending webhooks.
  - Added configuration options for controlling pending webhook retry intervals, maximum age, and cleanup frequency.
  - Implemented queuing of unmatched Radarr and Sonarr webhook events for deferred processing.

- **Improvements**
  - Enhanced notification services to log successful notification deliveries to users.
  - Improved conditional logic for scheduled jobs to avoid unnecessary processing and logging.
  - Refined manual sync job to perform watchlist reconciliation only when needed.

- **Bug Fixes**
  - Deferred notification processing for webhooks until media items are recognized, reducing missed notifications due to race conditions.

- **Chores**
  - Added new database table and types to support pending webhook management.
  - Updated configuration schemas and environment variables for new pending webhook features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/migrations/migrations/026_20250515_add_pending_webhooks.ts b/migrations/migrations/026_20250515_add_pending_webhooks.ts
new file mode 100644
index 000000000..a66355d24
--- /dev/null
+++ b/migrations/migrations/026_20250515_add_pending_webhooks.ts
@@ -0,0 +1,36 @@
+import type { Knex } from 'knex'
+
+/**
+ * Creates the `pending_webhooks` table for storing incoming webhook events.
+ *
+ * The table includes columns for webhook source type, associated instance, unique identifiers, payload data, and timestamps. It enforces allowed values for `instance_type` and `media_type`, and adds indexes for efficient querying by `guid`, `media_type`, and `expires_at`.
+ */
+export async function up(knex: Knex): Promise<void> {
+  await knex.schema.createTable('pending_webhooks', (table) => {
+    table.increments('id').primary()
+    table.string('instance_type', 10).notNullable() // 'radarr' or 'sonarr'
+    table.integer('instance_id').unsigned().nullable() // Can be null when instance is unknown
+    table.string('guid', 255).notNullable()
+    table.string('title', 255).notNullable()
+    table.string('media_type', 10).notNullable() // 'movie' or 'show'
+    table.json('payload').notNullable() // Full webhook payload
+    // Timestamps are stored as UTC via ISO strings in the application layer
+    table.timestamp('received_at').defaultTo(knex.fn.now()).notNullable()
+    table.timestamp('expires_at').notNullable()
+    
+    // Simple index for quick lookups by guid
+    table.index(['guid', 'media_type'], 'idx_guid_media')
+    table.index('expires_at', 'idx_expires')
+    
+    // Check constraints for SQLite
+    table.check(`"instance_type" IN ('radarr', 'sonarr')`)
+    table.check(`"media_type" IN ('movie', 'show')`)
+  })
+}
+
+/**
+ * Drops the `pending_webhooks` table if it exists, reversing the migration.
+ */
+export async function down(knex: Knex): Promise<void> {
+  await knex.schema.dropTableIfExists('pending_webhooks')
+}
\ No newline at end of file
diff --git a/src/client/features/notifications/components/general/general-settings-form.tsx b/src/client/features/notifications/components/general/general-settings-form.tsx
index 0d91a8281..a68591bb0 100644
--- a/src/client/features/notifications/components/general/general-settings-form.tsx
+++ b/src/client/features/notifications/components/general/general-settings-form.tsx
@@ -1,5 +1,5 @@
 // src/client/features/notifications/components/general/general-settings-form.tsx
-import { useEffect, useState } from 'react'
+import { useEffect, useState, useCallback } from 'react'
 import { useForm } from 'react-hook-form'
 import { zodResolver } from '@hookform/resolvers/zod'
 import { Loader2, Save, X, InfoIcon } from 'lucide-react'
@@ -36,12 +36,12 @@ const DEFAULT_NEW_EPISODE_THRESHOLD = 172800000 // 48 hours (2 days)
 const DEFAULT_UPGRADE_BUFFER_TIME = 2000 // 2 seconds
 
 /**
- * Displays a form for editing general notification settings, including queue wait time, new episode threshold, and upgrade buffer time.
+ * Renders a form for editing general notification settings, allowing users to configure queue wait time, new episode threshold, and upgrade buffer time.
  *
- * Converts between user-facing units (minutes, hours, seconds) and internal millisecond storage. Provides validation, contextual tooltips, and feedback on submission status.
+ * Converts between user-facing units (minutes, hours, seconds) and internal millisecond storage. Provides validation, contextual tooltips, and visual feedback for submission status.
  *
- * @param isInitialized - Whether the configuration data has loaded and the form is ready for interaction.
- * @returns The React element for the general settings form.
+ * @param isInitialized - Indicates whether configuration data has loaded and the form is ready for interaction.
+ * @returns The React element representing the general settings form.
  */
 export function GeneralSettingsForm({
   isInitialized,
@@ -62,60 +62,42 @@ export function GeneralSettingsForm({
     },
   })
 
-  // Convert milliseconds to appropriate display units
-  useEffect(() => {
-    if (config) {
-      // Convert milliseconds to minutes for queueWaitTime
-      const queueWaitTimeMinutes = Math.round(
-        (config.queueWaitTime || DEFAULT_QUEUE_WAIT_TIME) / (60 * 1000),
-      )
+  // Helper functions to convert between storage and display units
+  const getDisplayValues = useCallback((configData: typeof config) => {
+    if (!configData) return null
 
-      // Convert milliseconds to hours for newEpisodeThreshold
-      const newEpisodeThresholdHours = Math.round(
-        (config.newEpisodeThreshold || DEFAULT_NEW_EPISODE_THRESHOLD) /
+    return {
+      queueWaitTime: Math.round(
+        (configData.queueWaitTime || DEFAULT_QUEUE_WAIT_TIME) / (60 * 1000),
+      ),
+      newEpisodeThreshold: Math.round(
+        (configData.newEpisodeThreshold || DEFAULT_NEW_EPISODE_THRESHOLD) /
           (60 * 60 * 1000),
-      )
+      ),
+      upgradeBufferTime: Math.round(
+        (configData.upgradeBufferTime || DEFAULT_UPGRADE_BUFFER_TIME) / 1000,
+      ),
+    }
+  }, [])
 
-      // Convert milliseconds to seconds for upgradeBufferTime
-      const upgradeBufferTimeSeconds = Math.round(
-        (config.upgradeBufferTime || DEFAULT_UPGRADE_BUFFER_TIME) / 1000,
+  // Convert milliseconds to appropriate display units
+  useEffect(() => {
+    const displayValues = getDisplayValues(config)
+    if (displayValues) {
+      generalForm.setValue('queueWaitTime', displayValues.queueWaitTime)
+      generalForm.setValue(
+        'newEpisodeThreshold',
+        displayValues.newEpisodeThreshold,
       )
-
-      generalForm.setValue('queueWaitTime', queueWaitTimeMinutes)
-      generalForm.setValue('newEpisodeThreshold', newEpisodeThresholdHours)
-      generalForm.setValue('upgradeBufferTime', upgradeBufferTimeSeconds)
-
-      generalForm.reset({
-        queueWaitTime: queueWaitTimeMinutes,
-        newEpisodeThreshold: newEpisodeThresholdHours,
-        upgradeBufferTime: upgradeBufferTimeSeconds,
-      })
+      generalForm.setValue('upgradeBufferTime', displayValues.upgradeBufferTime)
+      generalForm.reset(displayValues)
     }
-  }, [config, generalForm])
+  }, [config, generalForm, getDisplayValues])
 
   const resetForm = () => {
-    if (config) {
-      // Convert milliseconds to minutes for queueWaitTime
-      const queueWaitTimeMinutes = Math.round(
-        (config.queueWaitTime || DEFAULT_QUEUE_WAIT_TIME) / (60 * 1000),
-      )
-
-      // Convert milliseconds to hours for newEpisodeThreshold
-      const newEpisodeThresholdHours = Math.round(
-        (config.newEpisodeThreshold || DEFAULT_NEW_EPISODE_THRESHOLD) /
-          (60 * 60 * 1000),
-      )
-
-      // Convert milliseconds to seconds for upgradeBufferTime
-      const upgradeBufferTimeSeconds = Math.round(
-        (config.upgradeBufferTime || DEFAULT_UPGRADE_BUFFER_TIME) / 1000,
-      )
-
-      generalForm.reset({
-        queueWaitTime: queueWaitTimeMinutes,
-        newEpisodeThreshold: newEpisodeThresholdHours,
-        upgradeBufferTime: upgradeBufferTimeSeconds,
-      })
+    const displayValues = getDisplayValues(config)
+    if (displayValues) {
+      generalForm.reset(displayValues)
     }
   }
 
diff --git a/src/plugins/custom/delete-sync.ts b/src/plugins/custom/delete-sync.ts
index a5c526a16..5dd394688 100644
--- a/src/plugins/custom/delete-sync.ts
+++ b/src/plugins/custom/delete-sync.ts
@@ -17,7 +17,6 @@ declare module 'fastify' {
 export default fp(
   async (fastify: FastifyInstance) => {
     fastify.log.info('Initializing delete sync plugin')
-
     // Create and register the delete sync service
     const service = new DeleteSyncService(fastify.log, fastify)
     fastify.decorate('deleteSync', service)
@@ -26,15 +25,34 @@ export default fp(
     fastify.ready().then(async () => {
       // Register the handler for the job
       await fastify.scheduler.scheduleJob('delete-sync', async (jobName) => {
-        // Run the service but don't return its result to conform to the JobHandler type
-        await service.run()
-        // The JobHandler expects void return
-      })
+        // First check if the schedule itself is enabled
+        const schedule = await fastify.db.getScheduleByName('delete-sync')
+        if (!schedule || !schedule.enabled) {
+          // Schedule is disabled - don't run
+          return
+        }
 
-      fastify.log.info('Delete sync job handler registered with scheduler')
-    })
+        // Then check if delete sync functionality is enabled
+        const config = fastify.config
+        const isDeleteFunctionEnabled =
+          config.deleteMovie ||
+          config.deleteEndedShow ||
+          config.deleteContinuingShow
 
-    fastify.log.info('Delete sync plugin initialized successfully')
+        if (isDeleteFunctionEnabled) {
+          const result = await service.run()
+          const totalProcessed = result.total.processed || 0
+          const totalDeleted = result.total.deleted || 0
+
+          // Only log if work was actually done
+          if (totalProcessed > 0 || totalDeleted > 0) {
+            fastify.log.info(
+              `Delete sync completed: ${totalDeleted} items deleted, ${totalProcessed} total items processed`,
+            )
+          }
+        }
+      })
+    })
   },
   {
     name: 'delete-sync-service',
diff --git a/src/plugins/custom/pending-webhooks.ts b/src/plugins/custom/pending-webhooks.ts
new file mode 100644
index 000000000..04b34a0ec
--- /dev/null
+++ b/src/plugins/custom/pending-webhooks.ts
@@ -0,0 +1,53 @@
+import fp from 'fastify-plugin'
+import type { FastifyInstance } from 'fastify'
+import { PendingWebhooksService } from '@services/pending-webhooks.service.js'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+
+declare module 'fastify' {
+  interface FastifyInstance {
+    pendingWebhooks: PendingWebhooksService
+  }
+}
+
+export default fp(
+  async (fastify: FastifyInstance) => {
+    // Configuration values with defaults
+    const config: Partial<PendingWebhooksConfig> = {
+      retryInterval: fastify.config.pendingWebhookRetryInterval || 20, // seconds
+      maxAge: fastify.config.pendingWebhookMaxAge || 10, // minutes
+      cleanupInterval: fastify.config.pendingWebhookCleanupInterval || 60, // seconds
+    }
+
+    // Create service instance
+    const pendingWebhooksService = new PendingWebhooksService(
+      fastify.log.child({ plugin: 'pending-webhooks' }),
+      fastify,
+      config,
+    )
+
+    // Decorate the Fastify instance
+    fastify.decorate('pendingWebhooks', pendingWebhooksService)
+
+    // Initialize handler after server is ready
+    fastify.addHook('onReady', async () => {
+      try {
+        await pendingWebhooksService.initialize()
+        fastify.log.info('PendingWebhooksService initialized successfully')
+      } catch (error) {
+        fastify.log.error(
+          { error },
+          'Failed to initialize PendingWebhooksService',
+        )
+      }
+    })
+  },
+  {
+    name: 'pending-webhooks',
+    dependencies: [
+      'database',
+      'scheduler',
+      'discord-notification-service',
+      'apprise-notification-service',
+    ],
+  },
+)
diff --git a/src/plugins/external/env.ts b/src/plugins/external/env.ts
index aab653e83..7725e99c3 100644
--- a/src/plugins/external/env.ts
+++ b/src/plugins/external/env.ts
@@ -100,6 +100,18 @@ const schema = {
       type: 'number',
       default: 2000,
     },
+    pendingWebhookRetryInterval: {
+      type: 'number',
+      default: 20,
+    },
+    pendingWebhookMaxAge: {
+      type: 'number',
+      default: 10,
+    },
+    pendingWebhookCleanupInterval: {
+      type: 'number',
+      default: 60,
+    },
     sonarrBaseUrl: {
       type: 'string',
       default: 'http://localhost:8989',
diff --git a/src/routes/v1/notifications/webhook.ts b/src/routes/v1/notifications/webhook.ts
index 3cc949b67..d1df36bc4 100644
--- a/src/routes/v1/notifications/webhook.ts
+++ b/src/routes/v1/notifications/webhook.ts
@@ -13,6 +13,7 @@ import {
   processQueuedWebhooks,
   webhookQueue,
   checkForUpgrade,
+  queuePendingWebhook,
 } from '@root/utils/webhookQueue.js'
 
 const plugin: FastifyPluginAsync = async (fastify) => {
@@ -106,12 +107,25 @@ const plugin: FastifyPluginAsync = async (fastify) => {
         }
 
         if (body.instanceName === 'Radarr' && 'movie' in body) {
+          const tmdbGuid = `tmdb:${body.movie.tmdbId}`
+          const matchingItems =
+            await fastify.db.getWatchlistItemsByGuid(tmdbGuid)
+
+          // If no matching items, queue webhook for later processing
+          if (matchingItems.length === 0) {
+            await queuePendingWebhook(fastify, {
+              instanceType: 'radarr',
+              instanceId: instance?.id ?? null,
+              guid: tmdbGuid,
+              title: body.movie.title,
+              mediaType: 'movie',
+              payload: body,
+            })
+            return { success: true }
+          }
+
           if (instance) {
             try {
-              const tmdbGuid = `tmdb:${body.movie.tmdbId}`
-              const matchingItems =
-                await fastify.db.getWatchlistItemsByGuid(tmdbGuid)
-
               for (const item of matchingItems) {
                 const itemId =
                   typeof item.id === 'string'
@@ -238,6 +252,23 @@ const plugin: FastifyPluginAsync = async (fastify) => {
               )
 
               if (isRecentEp) {
+                const tvdbGuid = `tvdb:${tvdbId}`
+                const matchingItems =
+                  await fastify.db.getWatchlistItemsByGuid(tvdbGuid)
+
+                // If no matching items, queue webhook for later processing
+                if (matchingItems.length === 0) {
+                  await queuePendingWebhook(fastify, {
+                    instanceType: 'sonarr',
+                    instanceId: instance?.id ?? null,
+                    guid: tvdbGuid,
+                    title: body.series.title,
+                    mediaType: 'show',
+                    payload: body,
+                  })
+                  return { success: true }
+                }
+
                 const mediaInfo = {
                   type: 'show' as const,
                   guid: `tvdb:${tvdbId}`,
@@ -264,6 +295,7 @@ const plugin: FastifyPluginAsync = async (fastify) => {
                     lastUpdated: new Date(),
                     notifiedSeasons: new Set(),
                     upgradeTracker: new Map(),
+                    instanceId: instance?.id ?? null,
                     timeoutId: setTimeout(() => {
                       processQueuedWebhooks(tvdbId, seasonNumber, fastify)
                     }, fastify.config.queueWaitTime),
@@ -293,6 +325,7 @@ const plugin: FastifyPluginAsync = async (fastify) => {
               seasonNumber,
               episodeNumber,
               body.isUpgrade === true,
+              instance?.id ?? null,
               fastify,
             )
             fastify.log.debug('Skipping initial download webhook')
@@ -305,6 +338,7 @@ const plugin: FastifyPluginAsync = async (fastify) => {
               seasonNumber,
               episodeNumber,
               false,
+              instance?.id ?? null,
               fastify,
             )
 
@@ -338,6 +372,23 @@ const plugin: FastifyPluginAsync = async (fastify) => {
                 'Processing recent episodes for immediate notification',
               )
 
+              const tvdbGuid = `tvdb:${tvdbId}`
+              const matchingItems =
+                await fastify.db.getWatchlistItemsByGuid(tvdbGuid)
+
+              // If no matching items, queue webhook for later processing
+              if (matchingItems.length === 0) {
+                await queuePendingWebhook(fastify, {
+                  instanceType: 'sonarr',
+                  instanceId: instance?.id ?? null,
+                  guid: tvdbGuid,
+                  title: body.series.title,
+                  mediaType: 'show',
+                  payload: body,
+                })
+                return { success: true }
+              }
+
               const mediaInfo = {
                 type: 'show' as const,
                 guid: `tvdb:${tvdbId}`,
@@ -392,6 +443,7 @@ const plugin: FastifyPluginAsync = async (fastify) => {
                   lastUpdated: new Date(),
                   notifiedSeasons: new Set(),
                   upgradeTracker: new Map(),
+                  instanceId: instance?.id ?? null,
                   timeoutId: setTimeout(() => {
                     fastify.log.info(
                       { tvdbId, seasonNumber },
diff --git a/src/schemas/config/config.schema.ts b/src/schemas/config/config.schema.ts
index 5bab8ca6b..a8d20aeea 100644
--- a/src/schemas/config/config.schema.ts
+++ b/src/schemas/config/config.schema.ts
@@ -54,6 +54,13 @@ export const ConfigSchema = z.object({
   queueWaitTime: z.number().optional(),
   newEpisodeThreshold: z.number().optional(),
   upgradeBufferTime: z.number().optional(),
+  // Pending Webhooks Config
+  // How often to retry processing pending webhooks (in seconds)
+  pendingWebhookRetryInterval: z.number().optional(),
+  // Maximum age of a pending webhook before it expires (in minutes)
+  pendingWebhookMaxAge: z.number().optional(),
+  // How often to clean up expired webhooks (in seconds)
+  pendingWebhookCleanupInterval: z.number().optional(),
   // Other configs
   plexTokens: z.array(z.string()).optional(),
   skipFriendSync: z.boolean().optional(),
diff --git a/src/services/apprise-notifications.service.ts b/src/services/apprise-notifications.service.ts
index 939d5c9c0..a516e1f8e 100644
--- a/src/services/apprise-notifications.service.ts
+++ b/src/services/apprise-notifications.service.ts
@@ -310,7 +310,18 @@ export class AppriseNotificationService {
       }
 
       // Send to the user's Apprise URL
-      return await this.sendNotification(user.apprise, appriseNotification)
+      const success = await this.sendNotification(
+        user.apprise,
+        appriseNotification,
+      )
+
+      if (success) {
+        this.log.info(
+          `Apprise notification sent successfully to ${user.alias || user.name} for "${notification.title}"`,
+        )
+      }
+
+      return success
     } catch (error) {
       this.log.error(
         `Error sending media notification to user ${user.name}:`,
diff --git a/src/services/database.service.ts b/src/services/database.service.ts
index f4ec8bdd3..4521177e4 100644
--- a/src/services/database.service.ts
+++ b/src/services/database.service.ts
@@ -33,6 +33,10 @@ import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
 import knex, { type Knex } from 'knex'
 import type { Config, User } from '@root/types/config.types.js'
 import { DefaultInstanceError } from '@root/types/errors.js'
+import type {
+  PendingWebhook,
+  PendingWebhookCreate,
+} from '@root/types/pending-webhooks.types.js'
 import type {
   TokenWatchlistItem,
   Item as WatchlistItem,
@@ -532,6 +536,10 @@ export class DatabaseService {
       queueWaitTime: config.queueWaitTime || 120000,
       newEpisodeThreshold: config.newEpisodeThreshold || 172800000,
       upgradeBufferTime: config.upgradeBufferTime || 2000,
+      // Handle pending webhook configuration
+      pendingWebhookRetryInterval: config.pendingWebhookRetryInterval || 20,
+      pendingWebhookMaxAge: config.pendingWebhookMaxAge || 10,
+      pendingWebhookCleanupInterval: config.pendingWebhookCleanupInterval || 60,
       // Handle Apprise configuration
       enableApprise: Boolean(config.enableApprise),
       appriseUrl: config.appriseUrl || '',
@@ -591,6 +599,11 @@ export class DatabaseService {
         queueWaitTime: config.queueWaitTime || 120000,
         newEpisodeThreshold: config.newEpisodeThreshold || 172800000,
         upgradeBufferTime: config.upgradeBufferTime || 2000,
+        // Pending webhook configuration
+        pendingWebhookRetryInterval: config.pendingWebhookRetryInterval || 20,
+        pendingWebhookMaxAge: config.pendingWebhookMaxAge || 10,
+        pendingWebhookCleanupInterval:
+          config.pendingWebhookCleanupInterval || 60,
         // Apprise fields
         enableApprise: config.enableApprise || false,
         appriseUrl: config.appriseUrl || '',
@@ -5902,4 +5915,152 @@ export class DatabaseService {
       return true
     }
   }
+
+  // ============================================
+  // Pending Webhooks Methods
+  // ============================================
+
+  /**
+   * Creates a new pending webhook entry
+   *
+   * @param webhook - The webhook data to create
+   * @returns Promise resolving to the created webhook with its ID
+   */
+  async createPendingWebhook(
+    webhook: PendingWebhookCreate,
+  ): Promise<PendingWebhook> {
+    try {
+      const result = await this.knex('pending_webhooks')
+        .insert({
+          ...webhook,
+          received_at: this.timestamp,
+          expires_at: webhook.expires_at.toISOString(),
+          payload: JSON.stringify(webhook.payload),
+        })
+        .returning('id')
+
+      const id =
+        typeof result[0] === 'object' && result[0] !== null
+          ? result[0].id
+          : result[0]
+
+      return {
+        id,
+        ...webhook,
+        received_at: new Date(this.timestamp),
+        expires_at: webhook.expires_at,
+      }
+    } catch (error) {
+      this.log.error('Error creating pending webhook:', error)
+      throw new Error('Failed to create pending webhook')
+    }
+  }
+
+  /**
+   * Gets all pending webhooks that haven't expired
+   *
+   * @param limit - Optional limit of results
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getPendingWebhooks(limit = 50): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where('expires_at', '>', new Date().toISOString())
+        .orderBy('received_at', 'asc')
+        .limit(limit)
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: (() => {
+          try {
+            return JSON.parse(webhook.payload ?? '{}')
+          } catch (e) {
+            this.log.warn(
+              { webhookId: webhook.id, error: e },
+              'Malformed webhook payload - using empty object',
+            )
+            return {}
+          }
+        })(),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error('Error getting pending webhooks:', error)
+      return []
+    }
+  }
+
+  /**
+   * Deletes a processed webhook
+   *
+   * @param id - The webhook ID to delete
+   * @returns Promise resolving to boolean indicating success
+   */
+  async deletePendingWebhook(id: number): Promise<boolean> {
+    try {
+      const deleted = await this.knex('pending_webhooks').where({ id }).delete()
+
+      return deleted > 0
+    } catch (error) {
+      this.log.error(`Error deleting pending webhook ${id}:`, error)
+      return false
+    }
+  }
+
+  /**
+   * Cleans up expired webhooks
+   *
+   * @returns Promise resolving to number of deleted webhooks
+   */
+  async cleanupExpiredWebhooks(): Promise<number> {
+    try {
+      const deleted = await this.knex('pending_webhooks')
+        .where('expires_at', '<', new Date().toISOString())
+        .delete()
+
+      return deleted
+    } catch (error) {
+      this.log.error('Error cleaning up expired webhooks:', error)
+      return 0
+    }
+  }
+
+  /**
+   * Gets webhooks by GUID and media type
+   *
+   * @param guid - The GUID to search for
+   * @param mediaType - The media type (movie or show)
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getWebhooksByGuid(
+    guid: string,
+    mediaType: 'movie' | 'show',
+  ): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where({ guid, media_type: mediaType })
+        .where('expires_at', '>', new Date().toISOString())
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: (() => {
+          try {
+            return JSON.parse(webhook.payload ?? '{}')
+          } catch (e) {
+            this.log.warn(
+              { webhookId: webhook.id, guid, error: e },
+              'Malformed webhook payload - using empty object',
+            )
+            return {}
+          }
+        })(),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error(`Error getting webhooks for ${guid}:`, error)
+      return []
+    }
+  }
 }
diff --git a/src/services/discord-notifications.service.ts b/src/services/discord-notifications.service.ts
index 47a6b0ee0..d501ab208 100644
--- a/src/services/discord-notifications.service.ts
+++ b/src/services/discord-notifications.service.ts
@@ -640,6 +640,10 @@ export class DiscordNotificationService {
         embeds: [embed],
       })
 
+      this.log.info(
+        `Discord notification sent successfully to ${user.username} for "${notification.title}"`,
+      )
+
       return true
     } catch (error) {
       this.log.error({ error, discordId }, 'Failed to send direct message')
diff --git a/src/services/pending-webhooks.service.ts b/src/services/pending-webhooks.service.ts
new file mode 100644
index 000000000..99e3720ad
--- /dev/null
+++ b/src/services/pending-webhooks.service.ts
@@ -0,0 +1,275 @@
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+/**
+ * Service to handle webhooks that arrive before RSS feed matching is complete.
+ * This solves the race condition where content is grabbed quickly and the webhook
+ * arrives while the RSS item is still being processed.
+ */
+export class PendingWebhooksService {
+  private readonly _config: PendingWebhooksConfig
+  private isRunning = false
+  private _processingWebhooks = false
+  private _cleaningUp = false
+
+  constructor(
+    private readonly log: FastifyBaseLogger,
+    private readonly fastify: FastifyInstance,
+    config?: Partial<PendingWebhooksConfig>,
+  ) {
+    this._config = {
+      retryInterval: 20, // Process every 20 seconds
+      maxAge: 10, // Keep webhooks for max 10 minutes
+      cleanupInterval: 60, // Clean up expired webhooks every minute
+      ...config,
+    }
+  }
+
+  /**
+   * Get the configuration (for access by other services/routes)
+   */
+  get config(): PendingWebhooksConfig {
+    return this._config
+  }
+
+  /**
+   * Initialize the pending webhooks service
+   */
+  async initialize(): Promise<void> {
+    // Schedule the processing job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-processor',
+      async (jobName: string) => {
+        const processed = await this.processWebhooks()
+        // Only log completion if we actually processed something
+        if (processed > 0) {
+          this.log.info(`Processed ${processed} pending webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every 20 seconds
+    await this.fastify.db.updateSchedule('pending-webhooks-processor', {
+      type: 'interval',
+      config: { seconds: this._config.retryInterval },
+      enabled: true,
+    })
+
+    // Schedule the cleanup job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-cleanup',
+      async (jobName: string) => {
+        const cleaned = await this.cleanupExpired()
+        // Only log if we actually cleaned something
+        if (cleaned > 0) {
+          this.log.info(`Cleaned up ${cleaned} expired webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run at configured interval
+    await this.fastify.db.updateSchedule('pending-webhooks-cleanup', {
+      type: 'interval',
+      config: { seconds: this._config.cleanupInterval },
+      enabled: true,
+    })
+
+    this.isRunning = true
+
+    // Process any existing webhooks immediately on startup
+    await this.processWebhooks()
+  }
+
+  /**
+   * Process pending webhooks
+   * @returns Number of webhooks processed
+   */
+  private async processWebhooks(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping executions
+    if (this._processingWebhooks) {
+      this.log.debug(
+        'Webhook processing already in progress, skipping this cycle',
+      )
+      return 0
+    }
+
+    this._processingWebhooks = true
+
+    try {
+      const webhooks = await this.fastify.db.getPendingWebhooks()
+
+      if (webhooks.length === 0) {
+        return 0 // Silent return when no webhooks to process
+      }
+
+      let processedCount = 0
+
+      for (const webhook of webhooks) {
+        try {
+          // Check if RSS items exist for this GUID
+          const matchingItems = await this.fastify.db.getWatchlistItemsByGuid(
+            webhook.guid,
+          )
+
+          if (matchingItems.length > 0) {
+            this.log.info(
+              `Found ${matchingItems.length} items for ${webhook.guid}, processing webhook`,
+            )
+
+            // Process based on instance type
+            if (webhook.media_type === 'movie') {
+              // For movies, we don't need to parse the payload
+              const mediaInfo = {
+                type: 'movie' as const,
+                guid: webhook.guid,
+                title: webhook.title,
+              }
+
+              const notificationResults =
+                await this.fastify.db.processNotifications(mediaInfo, false)
+
+              // Process notifications concurrently to reduce latency
+              await Promise.all(
+                notificationResults.map(async (result) => {
+                  if (result.user.notify_discord && result.user.discord_id) {
+                    await this.fastify.discord.sendDirectMessage(
+                      result.user.discord_id,
+                      result.notification,
+                    )
+                  }
+
+                  if (result.user.notify_apprise) {
+                    await this.fastify.apprise.sendMediaNotification(
+                      result.user,
+                      result.notification,
+                    )
+                  }
+                }),
+              )
+            } else if (webhook.media_type === 'show') {
+              // For shows, we need to parse the payload to get episode information
+              let body: WebhookPayload
+              try {
+                // Safe parse payload in case it's a string (defensive programming)
+                body =
+                  typeof webhook.payload === 'string'
+                    ? JSON.parse(webhook.payload)
+                    : webhook.payload
+              } catch (parseError) {
+                this.log.error(
+                  `Failed to parse payload for webhook ${webhook.id}:`,
+                  parseError,
+                )
+                throw parseError
+              }
+
+              // Handle episode notifications
+              // Note: instanceName comparison is case-sensitive to match the webhook schema
+              if (
+                'instanceName' in body &&
+                body.instanceName === 'Sonarr' &&
+                'episodes' in body &&
+                body.episodes.length > 0
+              ) {
+                const mediaInfo = {
+                  type: 'show' as const,
+                  guid: webhook.guid,
+                  title: webhook.title,
+                  episodes: body.episodes,
+                }
+
+                const notificationResults =
+                  await this.fastify.db.processNotifications(
+                    mediaInfo,
+                    body.episodes.length > 1,
+                  )
+
+                // Process notifications concurrently to reduce latency
+                await Promise.all(
+                  notificationResults.map(async (result) => {
+                    if (result.user.notify_discord && result.user.discord_id) {
+                      await this.fastify.discord.sendDirectMessage(
+                        result.user.discord_id,
+                        result.notification,
+                      )
+                    }
+
+                    if (result.user.notify_apprise) {
+                      await this.fastify.apprise.sendMediaNotification(
+                        result.user,
+                        result.notification,
+                      )
+                    }
+                  }),
+                )
+              }
+            }
+
+            // Delete the processed webhook
+            if (webhook.id) {
+              const deleted = await this.fastify.db.deletePendingWebhook(
+                webhook.id,
+              )
+              if (deleted) {
+                processedCount++
+              } else {
+                this.log.warn(
+                  `Failed to delete processed webhook ${webhook.id}`,
+                )
+              }
+            }
+          } else {
+            this.log.debug(
+              `No items found for ${webhook.guid}, webhook remains pending`,
+            )
+          }
+        } catch (webhookError) {
+          this.log.error(
+            `Error processing pending webhook ${webhook.id}:`,
+            webhookError,
+          )
+        }
+      }
+
+      return processedCount
+    } catch (error) {
+      this.log.error('Error processing pending webhooks:', error)
+      return 0
+    } finally {
+      this._processingWebhooks = false
+    }
+  }
+
+  /**
+   * Clean up expired webhooks
+   * @returns Number of webhooks cleaned up
+   */
+  private async cleanupExpired(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping cleanup executions
+    if (this._cleaningUp) {
+      this.log.debug('Cleanup already in progress, skipping this cycle')
+      return 0
+    }
+
+    this._cleaningUp = true
+
+    try {
+      const deleted = await this.fastify.db.cleanupExpiredWebhooks()
+      return deleted
+    } catch (error) {
+      this.log.error('Error cleaning up expired webhooks:', error)
+      return 0
+    } finally {
+      this._cleaningUp = false
+    }
+  }
+}
diff --git a/src/services/scheduler.service.ts b/src/services/scheduler.service.ts
index f6e16a1f1..f6371ca47 100644
--- a/src/services/scheduler.service.ts
+++ b/src/services/scheduler.service.ts
@@ -144,7 +144,8 @@ export class SchedulerService {
       `${name}-task`,
       async () => {
         try {
-          this.log.info(`Running scheduled job: ${name}`)
+          // Only log the start of the job at debug level for cleaner logs
+          this.log.debug(`Running scheduled job: ${name}`)
           await handler(name)
 
           // Update last run time
@@ -189,7 +190,7 @@ export class SchedulerService {
             })
           }
 
-          this.log.info(`Job ${name} completed successfully`)
+          this.log.debug(`Job ${name} completed successfully`)
         } catch (error) {
           this.log.error(`Error in job ${name}:`, error)
 
diff --git a/src/services/watchlist-workflow.service.ts b/src/services/watchlist-workflow.service.ts
index 44000203c..80a7bbafb 100644
--- a/src/services/watchlist-workflow.service.ts
+++ b/src/services/watchlist-workflow.service.ts
@@ -1388,11 +1388,19 @@ export class WatchlistWorkflowService {
       await this.fastify.scheduler.scheduleJob(
         this.MANUAL_SYNC_JOB_NAME,
         async (jobName: string) => {
+          let reconciliationPerformed = false
           try {
-            this.log.info('Starting manual watchlist reconciliation')
-            await this.fetchWatchlists()
-            await this.syncWatchlistItems()
-            this.log.info('Manual watchlist reconciliation completed')
+            // Check if reconciliation is actually needed
+            const shouldDefer = await this.shouldDeferProcessing()
+            if (shouldDefer) {
+              await this.fetchWatchlists()
+              await this.syncWatchlistItems()
+              reconciliationPerformed = true
+            }
+
+            if (reconciliationPerformed) {
+              this.log.info('Manual watchlist reconciliation completed')
+            }
           } catch (error) {
             this.log.error('Error in manual watchlist reconciliation:', {
               error,
diff --git a/src/types/config.types.ts b/src/types/config.types.ts
index bb39910d5..3f69783ee 100644
--- a/src/types/config.types.ts
+++ b/src/types/config.types.ts
@@ -66,6 +66,10 @@ export interface Config {
   queueWaitTime: number
   newEpisodeThreshold: number
   upgradeBufferTime: number
+  // Pending Webhooks Config
+  pendingWebhookRetryInterval: number
+  pendingWebhookMaxAge: number
+  pendingWebhookCleanupInterval: number
   // Sonarr Config
   sonarrBaseUrl: string
   sonarrApiKey: string
diff --git a/src/types/pending-webhooks.types.ts b/src/types/pending-webhooks.types.ts
new file mode 100644
index 000000000..fe8f35d50
--- /dev/null
+++ b/src/types/pending-webhooks.types.ts
@@ -0,0 +1,29 @@
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+export interface PendingWebhook {
+  id?: number
+  instance_type: 'radarr' | 'sonarr'
+  instance_id: number | null
+  guid: string
+  title: string
+  media_type: 'movie' | 'show'
+  payload: WebhookPayload // Full webhook payload
+  received_at: Date
+  expires_at: Date
+}
+
+export interface PendingWebhookCreate {
+  instance_type: 'radarr' | 'sonarr'
+  instance_id: number | null
+  guid: string
+  title: string
+  media_type: 'movie' | 'show'
+  payload: WebhookPayload
+  expires_at: Date
+}
+
+export interface PendingWebhooksConfig {
+  retryInterval: number // in seconds (default: 20)
+  maxAge: number // in minutes (default: 10)
+  cleanupInterval: number // in seconds (default: 60)
+}
diff --git a/src/types/webhook.types.ts b/src/types/webhook.types.ts
index a830c5c83..90f00b9fd 100644
--- a/src/types/webhook.types.ts
+++ b/src/types/webhook.types.ts
@@ -33,6 +33,7 @@ export interface SeasonQueue {
   notifiedSeasons: Set<number>
   timeoutId: NodeJS.Timeout
   upgradeTracker: Map<string, RecentWebhook[]>
+  instanceId?: number | null
 }
 
 export interface WebhookQueue {
diff --git a/src/utils/webhookQueue.ts b/src/utils/webhookQueue.ts
index 7bef04545..b7ad26b0e 100644
--- a/src/utils/webhookQueue.ts
+++ b/src/utils/webhookQueue.ts
@@ -1,8 +1,62 @@
 import type { FastifyInstance } from 'fastify'
 import type { WebhookQueue, RecentWebhook } from '@root/types/webhook.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
 
 export const webhookQueue: WebhookQueue = {}
 
+/**
+ * Queues a pending webhook in the database when no matching media items are found.
+ *
+ * Stores the webhook with an expiration time for later processing, ensuring that duplicate webhook resends are avoided even if database insertion fails.
+ *
+ * @param data - Metadata and payload for the webhook to be queued.
+ */
+export async function queuePendingWebhook(
+  fastify: FastifyInstance,
+  data: {
+    instanceType: 'radarr' | 'sonarr'
+    instanceId: number | null
+    guid: string
+    title: string
+    mediaType: 'movie' | 'show'
+    payload: WebhookPayload
+  },
+): Promise<void> {
+  const maxAgeMinutes = fastify.pendingWebhooks?.config?.maxAge || 10
+  const expires = new Date()
+  expires.setMinutes(expires.getMinutes() + maxAgeMinutes)
+
+  try {
+    await fastify.db.createPendingWebhook({
+      instance_type: data.instanceType,
+      instance_id: data.instanceId,
+      guid: data.guid,
+      title: data.title,
+      media_type: data.mediaType,
+      payload: data.payload,
+      expires_at: expires,
+    })
+
+    fastify.log.info(
+      `No matching items found for ${data.guid}, queued webhook for later processing`,
+    )
+  } catch (error) {
+    fastify.log.error(
+      { error, guid: data.guid, title: data.title },
+      `Failed to create pending webhook for ${data.mediaType}, but returning success to prevent resends`,
+    )
+    // Still return success to prevent webhook resends
+  }
+}
+
+/**
+ * Determines whether an episode's air date is within the configured recent threshold.
+ *
+ * Returns false if {@link airDateUtc} is missing or invalid.
+ *
+ * @param airDateUtc - The UTC air date of the episode as an ISO string.
+ * @returns True if the episode aired within the recent threshold; otherwise, false.
+ */
 export function isRecentEpisode(
   airDateUtc: string,
   fastify: FastifyInstance,
@@ -46,6 +100,7 @@ export async function checkForUpgrade(
   seasonNumber: number,
   episodeNumber: number,
   isUpgrade: boolean,
+  instanceId: number | null,
   fastify: FastifyInstance,
 ): Promise<boolean> {
   fastify.log.debug(
@@ -83,6 +138,7 @@ export async function checkForUpgrade(
         )
       }, 0),
       upgradeTracker: new Map(),
+      instanceId: instanceId,
     }
   }
 
@@ -227,6 +283,51 @@ export async function processQueuedWebhooks(
       'Processed notifications from queue',
     )
 
+    // If no notifications were generated, check if we have watchlist matches
+    if (notificationResults.length === 0) {
+      const matchingItems = await fastify.db.getWatchlistItemsByGuid(
+        `tvdb:${tvdbId}`,
+      )
+
+      if (matchingItems.length === 0) {
+        // No matches found, queue to pending_webhooks
+        const sonarrPayload: WebhookPayload = {
+          eventType: 'Download',
+          instanceName: 'Sonarr',
+          series: {
+            title: queue.title,
+            tvdbId: Number(tvdbId),
+          },
+          episodes: episodes,
+          episodeFiles: episodes.map((ep, idx) => ({
+            id: idx,
+            relativePath: '',
+            quality: '',
+            qualityVersion: 1,
+            size: 0,
+          })),
+          release: {
+            releaseType: 'bulk',
+          },
+          fileCount: episodes.length,
+        }
+
+        await queuePendingWebhook(fastify, {
+          instanceType: 'sonarr',
+          instanceId: seasonQueue.instanceId ?? null,
+          guid: `tvdb:${tvdbId}`,
+          title: queue.title,
+          mediaType: 'show',
+          payload: sonarrPayload,
+        })
+
+        fastify.log.info(
+          { tvdbId, seasonNumber, episodeCount: episodes.length },
+          'No watchlist matches found, queued to pending webhooks',
+        )
+      }
+    }
+
     for (const result of notificationResults) {
       if (result.user.notify_discord && result.user.discord_id) {
         try {


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 4**

<details>
<summary>🧹 Nitpick comments (12)</summary><blockquote>

<details>
<summary>src/schemas/config/config.schema.ts (1)</summary><blockquote>

`57-60`: **Good addition of pending webhook configuration options**

These new configuration parameters appropriately support the pending webhooks service introduced in this PR, allowing for customization of:
- Retry interval for processing pending webhooks
- Maximum age before a webhook is considered expired
- Cleanup interval for removing expired webhooks

Consider adding default values or documentation comments to clarify the expected units (seconds/minutes) for these numeric parameters.


```diff
 // Pending Webhooks Config
-  pendingWebhookRetryInterval: z.number().optional(),
-  pendingWebhookMaxAge: z.number().optional(),
-  pendingWebhookCleanupInterval: z.number().optional(),
+  // How often to retry processing pending webhooks (in seconds)
+  pendingWebhookRetryInterval: z.number().optional(),
+  // Maximum age of a pending webhook before it expires (in seconds)
+  pendingWebhookMaxAge: z.number().optional(),
+  // How often to clean up expired webhooks (in seconds)
+  pendingWebhookCleanupInterval: z.number().optional(),
```

</blockquote></details>
<details>
<summary>src/utils/webhookQueue.ts (1)</summary><blockquote>

`7-46`: **Well-designed webhook queueing function with robust error handling.**

The `queuePendingWebhook` function effectively:
1. Uses configurable expiration time with a sensible default (10 minutes)
2. Properly structures the database entry
3. Implements excellent error handling that logs issues but doesn't propagate errors
4. Includes informative logging for both success and failure cases

The intentional suppression of errors to prevent webhook resends is a good design decision and is well-documented in both the code and logs.

Consider adding metrics tracking to monitor how many webhooks are being queued over time.


```diff
  async function queuePendingWebhook(
    fastify: FastifyInstance,
    data: {
      instanceType: 'radarr' | 'sonarr'
      instanceId: number | null
      guid: string
      title: string
      mediaType: 'movie' | 'show'
      payload: WebhookPayload
    },
  ): Promise<void> {
    const maxAgeMinutes = fastify.pendingWebhooks?.config?.maxAge || 10
    const expires = new Date()
    expires.setMinutes(expires.getMinutes() + maxAgeMinutes)

    try {
      await fastify.db.createPendingWebhook({
        instance_type: data.instanceType,
        instance_id: data.instanceId,
        guid: data.guid,
        title: data.title,
        media_type: data.mediaType,
        payload: data.payload,
        expires_at: expires,
      })

+     // Track metric for monitoring purposes
+     if (fastify.metrics) {
+       fastify.metrics.increment('pending_webhooks.queued', {
+         media_type: data.mediaType,
+         instance_type: data.instanceType
+       })
+     }

      fastify.log.info(
        `No matching items found for ${data.guid}, queued webhook for later processing`,
      )
    } catch (error) {
      fastify.log.error(
        { error, guid: data.guid, title: data.title },
        `Failed to create pending webhook for ${data.mediaType}, but returning success to prevent resends`,
      )
+     // Track failure metric
+     if (fastify.metrics) {
+       fastify.metrics.increment('pending_webhooks.queue_failures', {
+         media_type: data.mediaType,
+         instance_type: data.instanceType
+       })
+     }
      // Still return success to prevent webhook resends
    }
  }
```

</blockquote></details>
<details>
<summary>src/plugins/custom/pending-webhooks.ts (1)</summary><blockquote>

`1-54`: **Well-structured Fastify plugin with proper TypeScript type extensions.**

The plugin follows Fastify best practices:
1. Properly extends the FastifyInstance interface
2. Uses fastify-plugin for correct scope handling
3. Explicitly declares dependencies
4. Uses appropriate configuration defaults
5. Includes proper error handling in the initialization hook

The design of decorating the Fastify instance with the service and initializing it in the onReady hook is clean and maintainable.

Two small improvements to consider:
1. Add more detailed logging of configuration values at startup
2. Consider implementing a graceful shutdown handler



```diff
  async (fastify: FastifyInstance) => {
    // Configuration values with defaults
    const config: Partial<PendingWebhooksConfig> = {
      retryInterval: fastify.config.pendingWebhookRetryInterval || 20, // seconds
      maxAge: fastify.config.pendingWebhookMaxAge || 10, // minutes
      cleanupInterval: fastify.config.pendingWebhookCleanupInterval || 60, // seconds
    }

+   // Log configuration details
+   fastify.log.info({
+     retryInterval: config.retryInterval,
+     maxAge: config.maxAge,
+     cleanupInterval: config.cleanupInterval
+   }, 'Initializing pending webhooks service with configuration')

    // Create service instance
    const pendingWebhooksService = new PendingWebhooksService(
      fastify.log.child({ plugin: 'pending-webhooks' }),
      fastify,
      config,
    )

    // Decorate the Fastify instance
    fastify.decorate('pendingWebhooks', pendingWebhooksService)

    // Initialize handler after server is ready
    fastify.addHook('onReady', async () => {
      try {
        await pendingWebhooksService.initialize()
        fastify.log.info('PendingWebhooksService initialized successfully')
      } catch (error) {
        fastify.log.error(
          { error },
          'Failed to initialize PendingWebhooksService',
        )
      }
    })
    
+   // Add graceful shutdown hook
+   fastify.addHook('onClose', async () => {
+     try {
+       if (pendingWebhooksService) {
+         await pendingWebhooksService.shutdown();
+         fastify.log.info('PendingWebhooksService shut down successfully')
+       }
+     } catch (error) {
+       fastify.log.error({ error }, 'Error while shutting down PendingWebhooksService')
+     }
+   })
  },
```

</blockquote></details>
<details>
<summary>src/types/pending-webhooks.types.ts (1)</summary><blockquote>

`1-30`: **Well-designed type definitions with clear separation of concerns.**

The type definitions are well-structured:
1. Clear separation between `PendingWebhook` (database entity) and `PendingWebhookCreate` (creation input)
2. Comprehensive property definitions with appropriate types
3. Good documentation of configuration options with default values

The types correctly leverage the existing `WebhookPayload` type for consistency.

Consider adding a few JSDoc comments to improve developer experience with hover documentation.


```diff
 import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'

+/**
+ * Represents a pending webhook stored in the database
+ */
 export interface PendingWebhook {
   id?: number
   instance_type: 'radarr' | 'sonarr'
   instance_id: number | null
   guid: string
   title: string
   media_type: 'movie' | 'show'
   payload: WebhookPayload // Full webhook payload
   received_at: Date
   expires_at: Date
 }

+/**
+ * Data needed to create a new pending webhook
+ */
 export interface PendingWebhookCreate {
   instance_type: 'radarr' | 'sonarr'
   instance_id: number | null
   guid: string
   title: string
   media_type: 'movie' | 'show'
   payload: WebhookPayload
   expires_at: Date
 }

+/**
+ * Configuration options for the pending webhooks processing system
+ */
 export interface PendingWebhooksConfig {
   retryInterval: number // in seconds (default: 20)
   maxAge: number // in minutes (default: 10)
   cleanupInterval: number // in seconds (default: 60)
 }
```

</blockquote></details>
<details>
<summary>migrations/migrations/026_20250515_add_pending_webhooks.ts (1)</summary><blockquote>

`8-11`: **Guard against duplicate entries with a composite UNIQUE**

A single webhook can be sent multiple times by Radarr/Sonarr, which may result in duplicate rows and repeated notifications.  A composite key on the stable identifiers prevents that:

```diff
+table.unique(['instance_type', 'guid', 'media_type'], 'uq_pending_webhooks_guid_media')
```

Place it directly after the index definitions.  



Also applies to: 16-18

</blockquote></details>
<details>
<summary>src/services/pending-webhooks.service.ts (4)</summary><blockquote>

`104-112`: **Consider batching or limiting the result set from `getPendingWebhooks()`**

Fetching _all_ pending rows can hold a connection and keep the Node.js event-loop busy if the table grows.  Passing a `LIMIT` (e.g. 100) and re-scheduling immediately when more remain keeps memory bounded:

```diff
-const webhooks = await this.fastify.db.getPendingWebhooks()
+const webhooks = await this.fastify.db.getPendingWebhooks({ limit: 100 })
```

---

`110-118`: **Potentially CPU-bound sequential loop**

Each webhook is processed serially.  If webhook processing is network-bound (Discord/Apprise I/O) you can gain throughput with concurrency control, e.g. `p-map` with a small concurrency of 3-5:

```ts
await pMap(webhooks, async (wh) => processOne(wh), { concurrency: 5 })
```

This keeps latency low without DDOS’ing external services.

---

`125-154`: **Duplicate notification-sending code – extract to helper to DRY**

Both movie and show branches build `notificationResults` then iterate to dispatch Discord and Apprise messages.  Extracting a private `sendNotifications(results)` method reduces duplication and centralises error handling.

---

`212-216`: **Delete result unchecked – verify rows affected**

`deletePendingWebhook` might silently fail.  To avoid infinite loops on a stuck row:

```ts
const deleted = await this.fastify.db.deletePendingWebhook(webhook.id)
if (deleted) processedCount++
else this.log.warn(`Failed to delete pending webhook ${webhook.id}`)
```

</blockquote></details>
<details>
<summary>src/services/database.service.ts (3)</summary><blockquote>

`539-543`: **Check consistency of new config defaults & units**

The new `pendingWebhook*` settings default to `20`, `10`, and `60` but the unit (seconds vs minutes) is not documented here.  
Please verify that:

1. The units match the expectations inside `PendingWebhooksService` (retry interval **seconds**, max-age **minutes?**, cleanup interval **seconds**).  
2. The same units are used when persisting values in `createConfig` / consuming them elsewhere.

A short JSDoc comment on each field or a TS‐type alias (e.g. `Seconds`, `Minutes`) would avoid future confusion.

---

`602-607`: **Insertion helpers could drift from DB schema**

The three new columns are inserted with fallback values but the surrounding insert list is starting to get unwieldy (> 60 properties).  
Consider extracting the “timing” / “pending-webhook” blocks into small helper functions so future additions don’t have to touch this mega-statement in three different places (get/insert/update).

---

`6001-6011`: **Add an index on `expires_at` for faster cleanup**

Both `getPendingWebhooks` and `cleanupExpiredWebhooks` filter on `expires_at`.  
A simple SQLite index (`CREATE INDEX IF NOT EXISTS idx_pending_webhooks_expires ON pending_webhooks(expires_at);`) will keep these queries ≤ O(log n) as the table grows.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yml**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6a372f5934324080c3a60aa97d13fb6fa2bf99c2 and 8c18a7ac80959c62b2c08b5dd4465b1a4ec900bf.

</details>

<details>
<summary>📒 Files selected for processing (16)</summary>

* `migrations/migrations/026_20250515_add_pending_webhooks.ts` (1 hunks)
* `src/client/features/notifications/components/general/general-settings-form.tsx` (2 hunks)
* `src/plugins/custom/delete-sync.ts` (1 hunks)
* `src/plugins/custom/pending-webhooks.ts` (1 hunks)
* `src/plugins/external/env.ts` (1 hunks)
* `src/routes/v1/notifications/webhook.ts` (4 hunks)
* `src/schemas/config/config.schema.ts` (1 hunks)
* `src/services/apprise-notifications.service.ts` (1 hunks)
* `src/services/database.service.ts` (4 hunks)
* `src/services/discord-notifications.service.ts` (1 hunks)
* `src/services/pending-webhooks.service.ts` (1 hunks)
* `src/services/scheduler.service.ts` (2 hunks)
* `src/services/watchlist-workflow.service.ts` (1 hunks)
* `src/types/config.types.ts` (1 hunks)
* `src/types/pending-webhooks.types.ts` (1 hunks)
* `src/utils/webhookQueue.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (4)</summary>

<details>
<summary>src/plugins/custom/delete-sync.ts (1)</summary><blockquote>

<details>
<summary>src/services/delete-sync.service.ts (1)</summary>

* `config` (93-95)

</details>

</blockquote></details>
<details>
<summary>src/types/pending-webhooks.types.ts (1)</summary><blockquote>

<details>
<summary>src/schemas/notifications/webhook.schema.ts (1)</summary>

* `WebhookPayload` (83-83)

</details>

</blockquote></details>
<details>
<summary>src/routes/v1/notifications/webhook.ts (1)</summary><blockquote>

<details>
<summary>src/utils/webhookQueue.ts (1)</summary>

* `queuePendingWebhook` (10-46)

</details>

</blockquote></details>
<details>
<summary>src/services/pending-webhooks.service.ts (2)</summary><blockquote>

<details>
<summary>src/types/pending-webhooks.types.ts (1)</summary>

* `PendingWebhooksConfig` (25-29)

</details>
<details>
<summary>src/schemas/notifications/webhook.schema.ts (1)</summary>

* `WebhookPayload` (83-83)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (21)</summary><blockquote>

<details>
<summary>src/client/features/notifications/components/general/general-settings-form.tsx (3)</summary>

`65-81`: **Well-structured conversion of configuration values using memoization**

Great job extracting the conversion logic into a memoized helper function. This centralizes the millisecond-to-display-unit conversion logic, which improves maintainability and ensures consistency across the component.

---

`83-95`: **Good refactoring of useEffect with the helper function**

The refactored useEffect hook efficiently uses the new getDisplayValues helper, eliminating repeated conversion logic and making the code more maintainable. The dependency array correctly includes getDisplayValues.

---

`97-101`: **Consistent form reset implementation**

Good job applying the same pattern to the resetForm function, ensuring consistent behavior between initial form setup and manual resets.

</details>
<details>
<summary>src/plugins/external/env.ts (1)</summary>

`103-114`: **Well-structured addition of pending webhook configuration properties.**

These three new configuration properties support the pending webhooks service by defining essential timing parameters:
- `pendingWebhookRetryInterval`: How often (in seconds) to retry processing pending webhooks
- `pendingWebhookMaxAge`: Maximum age (in minutes) before a webhook expires
- `pendingWebhookCleanupInterval`: How often (in seconds) to clean up expired webhooks

The implementation follows existing patterns with appropriate defaults.

</details>
<details>
<summary>src/services/scheduler.service.ts (2)</summary>

`147-148`: **Good logging level adjustment for routine job execution.**

Changing this log statement from `info` to `debug` is appropriate for reducing log verbosity while still maintaining observability for debugging purposes.

---

`193-193`: **Consistent debug-level logging for job completion.**

This change maintains consistency with the earlier log level adjustment for job execution start. Moving routine job completion logs to debug level prevents log noise while preserving the information for troubleshooting.

</details>
<details>
<summary>src/services/discord-notifications.service.ts (1)</summary>

`643-646`: **Improved logging for successful direct message delivery.**

This info-level log provides valuable feedback about notification delivery success, including recipient username and notification title, which helps with auditability and troubleshooting.

</details>
<details>
<summary>src/types/config.types.ts (1)</summary>

`69-72`: **Config interface properly extended for pending webhooks feature.**

The new properties maintain consistency with the environment schema additions in `env.ts` and are appropriately grouped under a descriptive comment. These timing parameters will support the new pending webhooks workflow for handling race conditions.

</details>
<details>
<summary>src/services/watchlist-workflow.service.ts (1)</summary>

`1391-1403`: **Good optimization for conditional reconciliation**

The code now conditionally performs the watchlist reconciliation only when needed, based on the `shouldDeferProcessing()` check. This optimization prevents unnecessary processing and database operations when reconciliation isn't required.

The addition of the `reconciliationPerformed` flag to track whether work was actually done is also a good practice, as it allows for more meaningful logging.

</details>
<details>
<summary>src/services/apprise-notifications.service.ts (1)</summary>

`313-324`: **Improved notification logging for better observability**

This change enhances observability by adding a success log message with user identification and notification title information. This will help with tracking successful notification delivery in production environments.

</details>
<details>
<summary>src/plugins/custom/delete-sync.ts (2)</summary>

`28-33`: **Good safety check for schedule enablement**

This safety check ensures that the delete sync job only runs when the schedule is explicitly enabled, which prevents accidental deletion runs. This aligns well with the Schedule Safety Enhancement goal mentioned in the PR.

---

`35-53`: **Improved execution logic with feature checks and conditional logging**

This change adds two important enhancements:

1. It ensures the delete sync only runs when at least one of the delete features is actually enabled in the config
2. It only logs when work was actually performed, reducing log noise

These improvements help prevent unnecessary operations and improve log clarity.

</details>
<details>
<summary>src/routes/v1/notifications/webhook.ts (4)</summary>

`16-16`: **Added import for new webhook queueing functionality.**

The import of `queuePendingWebhook` function is used to implement the new pending webhooks mechanism that addresses race conditions.

---

`110-125`: **Well-implemented queue mechanism for Radarr webhooks.**

This code elegantly implements the pending webhook feature for Radarr by:
1. Extracting the TMDB ID and converting it to the proper GUID format
2. Checking for matching watchlist items
3. Queuing the webhook if no matches are found
4. Returning early to prevent further processing

The early return pattern is clean and ensures proper flow control.

---

`255-270`: **Consistent implementation of webhook queueing for Sonarr individual episodes.**

This follows the same pattern as the Radarr implementation, maintaining consistency across different media types. The code correctly identifies when a webhook arrives before the corresponding watchlist item exists.

---

`372-387`: **Consistent implementation for batch episode processing.**

The code follows the same pattern as the other webhook queueing implementations, maintaining consistency throughout the codebase. This helps address the race condition when webhooks arrive before RSS items are matched to users.

</details>
<details>
<summary>src/utils/webhookQueue.ts (1)</summary>

`3-3`: **Added import for webhook payload type.**

The import of `WebhookPayload` type is necessary for the new function's type safety.

</details>
<details>
<summary>migrations/migrations/026_20250515_add_pending_webhooks.ts (1)</summary>

`15-22`: **`CHECK` constraint builder support is limited – verify migrations on all targets**

Knex’s `.check()` helper is currently supported only for SQLite ≥ 3.37 & PostgreSQL.  
If your production stack includes MySQL/MariaDB, this migration will silently ignore the check or fail at deploy time depending on the driver version.  

Options:

```diff
- table.check(`"instance_type" IN ('radarr', 'sonarr')`)
- table.check(`"media_type" IN ('movie', 'show')`)
+ if (knex.client.config.client !== 'mysql' && knex.client.config.client !== 'mysql2') {
+   table.check(`"instance_type" IN ('radarr', 'sonarr')`)
+   table.check(`"media_type" IN ('movie', 'show')`)
+ }
```

or move validation to application code only.  
  
Would you confirm which SQL engines are deployed so we can decide the best approach?

</details>
<details>
<summary>src/services/pending-webhooks.service.ts (3)</summary>

`93-101`: **Good use of an execution flag to prevent overlaps**  
This simple mutex prevents double runs when a previous invocation overruns the interval.

---

`155-171`: **JSON.parse may throw – already handled, but stringification assumption can be simplified**

`payload` is stored as JSON in the DB, therefore the driver already returns an object for Postgres/MySQL and a string for SQLite < 3.38. Instead of a ternary, use a small helper:

```ts
const body = typeof webhook.payload === 'string'
  ? JSON.parse(webhook.payload)
  : webhook.payload as WebhookPayload
```

Minor readability gain only.

---

`243-265`: **Cleanup interval configurable but hard-coded below**

`updateSchedule('pending-webhooks-cleanup', { minutes: 1 })` ignores `this._config.cleanupInterval`.  Either pass the configured seconds or rename the config to clarify that it’s always one minute.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T23:51:51Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>♻️ Duplicate comments (4)</summary><blockquote>

<details>
<summary>migrations/migrations/026_20250515_add_pending_webhooks.ts (1)</summary><blockquote>

`17-19`: _🛠️ Refactor suggestion_

**Prefer `timestamp` with timezone over `datetime` for consistency across drivers.**

`table.datetime()` produces a `DATETIME` column on MySQL/MariaDB that is _not_ timezone-aware, while on Postgres it maps to `timestamp without time zone`. Consider using timestamp columns with timezone support for better cross-database compatibility.



```diff
-table.datetime('received_at').defaultTo(knex.fn.now()).notNullable()
-table.datetime('expires_at').notNullable()
+table.timestamp('received_at', { useTz: true })
+  .defaultTo(knex.fn.now())
+  .notNullable()
+table.timestamp('expires_at', { useTz: true }).notNullable()
```

</blockquote></details>
<details>
<summary>src/services/database.service.ts (2)</summary><blockquote>

`5929-5951`: **`createPendingWebhook` – fragile ID extraction & timestamp mismatch**

This method has two issues that were mentioned in a previous review but remain unaddressed:
1. The ID extraction with `id.id || id` is fragile and may coerce `0` to `false`.
2. There's a timestamp mismatch: you insert `received_at: this.timestamp` but return `received_at: new Date()`.


```diff
- const [id] = await this.knex('pending_webhooks').insert({...}).returning('id')
+ const result = await this.knex('pending_webhooks')
+   .insert({...})
+   .returning('id')
+
+ const id =
+   typeof result[0] === 'object' && result[0] !== null
+     ? result[0].id
+     : result[0]

 ...

-   return {
-     id: id.id || id,
-     ...webhook,
-     received_at: new Date(),
-     expires_at: webhook.expires_at,
-   }
+   return {
+     id,
+     ...webhook,
+     received_at: new Date(this.timestamp),
+     expires_at: webhook.expires_at,
+   }
```

---

`5960-5973`: **Robust JSON parsing & error propagation**

The JSON.parse call can throw if a webhook payload is corrupted, causing the entire batch to fail. This was flagged in a previous review and hasn't been addressed.


```diff
- payload: JSON.parse(webhook.payload || '{}'),
+ payload: (() => {
+   try {
+     return JSON.parse(webhook.payload ?? '{}')
+   } catch (e) {
+     this.log.warn({ id: webhook.id }, 'Malformed webhook payload – skipping')
+     return {}
+   }
+ })(),
```

</blockquote></details>
<details>
<summary>src/services/pending-webhooks.service.ts (1)</summary><blockquote>

`172-176`: _⚠️ Potential issue_

**Case-sensitive `instanceName` check will miss lowercase `"sonarr"` payloads**  
`instanceName` can arrive as `"sonarr"` or be omitted entirely.  
Use a defensive, case-insensitive comparison to avoid discarding valid webhooks.

```diff
-'instanceName' in body &&
-  body.instanceName === 'Sonarr' &&
+typeof body.instanceName === 'string' &&
+  body.instanceName.toLowerCase() === 'sonarr' &&
```

  
  
Ignoring these payloads will leave webhooks stuck in the queue until they expire.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (5)</summary><blockquote>

<details>
<summary>src/plugins/custom/delete-sync.ts (1)</summary><blockquote>

`42-53`: **Improved logging with contextual information.**

The change smartly limits logging to only when actual work was performed (items processed or deleted), which reduces noise in the logs while still providing important operational information.


Consider adding a note in the log message indicating this was a scheduled run to differentiate from manual runs:

```diff
-          `Delete sync completed: ${totalDeleted} items deleted, ${totalProcessed} total items processed`,
+          `Scheduled delete sync completed: ${totalDeleted} items deleted, ${totalProcessed} total items processed`,
```

</blockquote></details>
<details>
<summary>src/routes/v1/notifications/webhook.ts (1)</summary><blockquote>

`110-388`: **Consider refactoring duplicated webhook queuing logic.**

There's similar webhook queuing logic implemented in three different branches of the code. In the future, consider extracting this pattern into a helper function to reduce duplication.


```diff
+ // Helper function to check for matching items and queue webhook if needed
+ async function checkAndQueueWebhook(
+   fastify: FastifyInstance,
+   guid: string,
+   instance: any | null,
+   title: string,
+   instanceType: 'radarr' | 'sonarr',
+   mediaType: 'movie' | 'show',
+   payload: WebhookPayload
+ ): Promise<boolean> {
+   const matchingItems = await fastify.db.getWatchlistItemsByGuid(guid)
+   
+   if (matchingItems.length === 0) {
+     await queuePendingWebhook(fastify, {
+       instanceType,
+       instanceId: instance?.id ?? null,
+       guid,
+       title,
+       mediaType,
+       payload,
+     })
+     return true
+   }
+   
+   return false
+ }
```

This helper could then be used in all three places to reduce code duplication.

</blockquote></details>
<details>
<summary>src/services/database.service.ts (1)</summary><blockquote>

`6000-6012`: **Consider adding a log message for successful webhook cleanup**

The `cleanupExpiredWebhooks` method has error logging but no success logging. Adding a debug log message for successful cleanups would help with monitoring.


```diff
    const deleted = await this.knex('pending_webhooks')
      .where('expires_at', '<', new Date().toISOString())
      .delete()

+   if (deleted > 0) {
+     this.log.debug(`Cleaned up ${deleted} expired webhooks`)
+   }
+   
    return deleted
```

</blockquote></details>
<details>
<summary>src/services/pending-webhooks.service.ts (2)</summary><blockquote>

`133-153`: **Duplicate notification-dispatch logic – extract to a helper**

The movie and show branches repeat the exact same notification fan-out (Discord/Apprise).  
Duplicated code increases maintenance cost and the risk of future divergences.

```diff
-const notificationResults = …
-await Promise.all(notificationResults.map(async (result) => {
-  if (result.user.notify_discord && result.user.discord_id) {
-    …
-  }
-  if (result.user.notify_apprise) {
-    …
-  }
-}))
+await this.sendNotifications(notificationResults)
```

and add

```ts
private async sendNotifications(results: NotificationResult[]) {
  await Promise.all(
    results.map(async ({ user, notification }) => {
      if (user.notify_discord && user.discord_id) {
        await this.fastify.discord.sendDirectMessage(
          user.discord_id,
          notification,
        )
      }
      if (user.notify_apprise) {
        await this.fastify.apprise.sendMediaNotification(user, notification)
      }
    }),
  )
}
```

  


Also applies to: 191-208

---

`88-100`: **Minor: redundant `isRunning` guard**

`processWebhooks` is called only by the scheduler _after_ `initialize` sets `isRunning = true`, so the early-exit guard is effectively dead code.  
If you intend to support a future `stop()` method, keep it; otherwise the flag can be removed to simplify the control flow.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yml**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6a372f5934324080c3a60aa97d13fb6fa2bf99c2 and 820843c7e61ec4543de0fc3a5bc7a5b4febaf240.

</details>

<details>
<summary>📒 Files selected for processing (16)</summary>

* `migrations/migrations/026_20250515_add_pending_webhooks.ts` (1 hunks)
* `src/client/features/notifications/components/general/general-settings-form.tsx` (3 hunks)
* `src/plugins/custom/delete-sync.ts` (1 hunks)
* `src/plugins/custom/pending-webhooks.ts` (1 hunks)
* `src/plugins/external/env.ts` (1 hunks)
* `src/routes/v1/notifications/webhook.ts` (4 hunks)
* `src/schemas/config/config.schema.ts` (1 hunks)
* `src/services/apprise-notifications.service.ts` (1 hunks)
* `src/services/database.service.ts` (4 hunks)
* `src/services/discord-notifications.service.ts` (1 hunks)
* `src/services/pending-webhooks.service.ts` (1 hunks)
* `src/services/scheduler.service.ts` (2 hunks)
* `src/services/watchlist-workflow.service.ts` (1 hunks)
* `src/types/config.types.ts` (1 hunks)
* `src/types/pending-webhooks.types.ts` (1 hunks)
* `src/utils/webhookQueue.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (4)</summary>

<details>
<summary>src/utils/webhookQueue.ts (3)</summary><blockquote>

<details>
<summary>src/types/webhook.types.ts (1)</summary>

* `WebhookQueue` (38-45)

</details>
<details>
<summary>src/plugins/custom/scrypt.ts (1)</summary>

* `FastifyInstance` (5-8)

</details>
<details>
<summary>src/schemas/notifications/webhook.schema.ts (1)</summary>

* `WebhookPayload` (83-83)

</details>

</blockquote></details>
<details>
<summary>src/plugins/custom/delete-sync.ts (1)</summary><blockquote>

<details>
<summary>src/services/delete-sync.service.ts (1)</summary>

* `config` (93-95)

</details>

</blockquote></details>
<details>
<summary>src/types/pending-webhooks.types.ts (1)</summary><blockquote>

<details>
<summary>src/schemas/notifications/webhook.schema.ts (1)</summary>

* `WebhookPayload` (83-83)

</details>

</blockquote></details>
<details>
<summary>src/services/pending-webhooks.service.ts (2)</summary><blockquote>

<details>
<summary>src/types/pending-webhooks.types.ts (1)</summary>

* `PendingWebhooksConfig` (25-29)

</details>
<details>
<summary>src/schemas/notifications/webhook.schema.ts (1)</summary>

* `WebhookPayload` (83-83)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (33)</summary><blockquote>

<details>
<summary>src/plugins/external/env.ts (1)</summary>

`103-114`: **Configuration additions look good for the new pending webhook feature**

The added configuration properties provide the necessary settings to control the pending webhooks functionality, with sensible defaults:
- `pendingWebhookRetryInterval`: 20 seconds between retry attempts
- `pendingWebhookMaxAge`: 10 minutes expiration time for stored webhooks
- `pendingWebhookCleanupInterval`: 60 seconds between cleanup jobs

This matches the PR objectives of handling race conditions between webhooks and RSS feed processing.

</details>
<details>
<summary>src/services/discord-notifications.service.ts (1)</summary>

`643-645`: **Good addition of success logging for Discord notifications**

The added logging statement improves observability by recording successful direct message deliveries, including recipient and notification title. This will help with tracking notification flow in the new pending webhooks system.

</details>
<details>
<summary>src/services/scheduler.service.ts (2)</summary>

`147-148`: **Appropriate log level adjustment for routine job starts**

Changing this log from `info` to `debug` level reduces log noise for routine job execution. This is especially beneficial as the new pending webhooks feature will likely introduce additional scheduled jobs.

---

`193-193`: **Appropriate log level adjustment for routine job completions**

Similar to the job start logs, reducing the verbosity of job completion logs to `debug` level is a good practice that will improve log readability while still preserving the information for debugging purposes.

</details>
<details>
<summary>src/services/watchlist-workflow.service.ts (1)</summary>

`1391-1403`: **Optimized reconciliation logic to prevent unnecessary processing**

This change intelligently optimizes the manual sync job callback to only perform watchlist reconciliation when necessary, based on the `shouldDeferProcessing()` check. The addition of the `reconciliationPerformed` flag ensures that completion logs are only emitted when actual work was done.

This optimization addresses a key aspect of the PR's objectives by handling race conditions more efficiently, preventing unnecessary processing when no reconciliation is needed.

</details>
<details>
<summary>src/types/config.types.ts (1)</summary>

`69-72`: **Good addition of pending webhook configuration properties.**

These new configuration properties align well with the PR objectives for adding the Pending Webhooks Service feature. The properties provide configurable settings for retry intervals, maximum age, and cleanup intervals of pending webhooks.

</details>
<details>
<summary>src/schemas/config/config.schema.ts (1)</summary>

`57-60`: **Schema validation correctly added for pending webhook configuration.**

The Zod schema validation for the new pending webhook configuration properties is properly implemented as optional numbers, matching the type definitions in the Config interface.

</details>
<details>
<summary>src/services/apprise-notifications.service.ts (1)</summary>

`312-324`: **Enhanced logging for successful notifications.**

Good improvement for observability - adding a detailed log message when notifications are successfully sent provides better traceability and debugging information.

</details>
<details>
<summary>src/plugins/custom/delete-sync.ts (2)</summary>

`28-33`: **Important safety check for schedule enablement.**

This change addresses the PR objective of adding an explicit schedule enabled check for delete-sync operations. This prevents accidental deletion runs when the schedule is disabled but deletion features are still configured.

---

`35-41`: **Secondary safety check for deletion functionality.**

Good addition of a second layer of verification that checks if any deletion functionality is actually enabled in the configuration before proceeding.

</details>
<details>
<summary>src/client/features/notifications/components/general/general-settings-form.tsx (4)</summary>

`65-81`: **Good refactoring of unit conversion logic.**

The extraction of conversion logic into a memoized `getDisplayValues` function improves code maintainability and reuse. The function properly handles the null case and is correctly memoized with `useCallback`.

---

`83-95`: **Clear implementation of display values in useEffect.**

Using the new `getDisplayValues` function in the `useEffect` hook streamlines the code and ensures consistent conversion logic across the component.

---

`97-102`: **Consistent reuse of getDisplayValues in resetForm.**

The `resetForm` function now correctly uses the same conversion logic as the `useEffect` hook, ensuring consistency throughout the component.

---

`38-45`: **Improved JSDoc documentation.**

The component documentation has been updated to clearly describe its purpose and functionality.

</details>
<details>
<summary>src/routes/v1/notifications/webhook.ts (3)</summary>

`110-126`: **Good implementation of Radarr pending webhook queue.**

The code correctly checks for matching watchlist items by TMDB GUID before processing notifications, and queues the webhook for later processing when no matches are found. This helps address race conditions between webhooks and RSS feed processing.

---

`255-271`: **Good implementation of Sonarr single-episode pending webhook queue.**

The code efficiently detects when a webhook for a single recent episode has no matching watchlist items and queues it for later processing.

---

`372-388`: **Good implementation of Sonarr batch episode pending webhook queue.**

The code correctly handles batch episodes by checking for matching watchlist items and queueing the webhook for later processing when no matches are found.

</details>
<details>
<summary>src/utils/webhookQueue.ts (2)</summary>

`7-50`: **Well implemented webhook queueing function with good error handling.**

The `queuePendingWebhook` function correctly handles the storage of webhooks that couldn't be processed immediately due to missing watchlist items. The error handling is robust - capturing errors but not propagating failures, which prevents webhook resends.


However, consider validating the max age value to ensure it's positive:

```diff
- const maxAgeMinutes = fastify.pendingWebhooks?.config?.maxAge || 10
+ const maxAgeMinutes = Math.max(1, fastify.pendingWebhooks?.config?.maxAge || 10)
```

---

`52-59`: **Improved JSDoc comments for isRecentEpisode function.**

The detailed JSDoc comments enhance code readability and maintainability.

</details>
<details>
<summary>src/plugins/custom/pending-webhooks.ts (3)</summary>

`6-10`: **Correctly extends the FastifyInstance type.**

The module augmentation for Fastify is properly implemented, making TypeScript aware of the new `pendingWebhooks` property.

---

`12-43`: **Well-structured Fastify plugin implementation.**

The plugin follows best practices by using `fastify-plugin`, properly initializing the service, and implementing error handling in the initialization hook.


Consider adding validation for the configuration values to ensure they're positive:

```diff
    // Configuration values with defaults
    const config: Partial<PendingWebhooksConfig> = {
-      retryInterval: fastify.config.pendingWebhookRetryInterval || 20, // seconds
-      maxAge: fastify.config.pendingWebhookMaxAge || 10, // minutes
-      cleanupInterval: fastify.config.pendingWebhookCleanupInterval || 60, // seconds
+      retryInterval: Math.max(5, fastify.config.pendingWebhookRetryInterval || 20), // seconds
+      maxAge: Math.max(1, fastify.config.pendingWebhookMaxAge || 10), // minutes
+      cleanupInterval: Math.max(10, fastify.config.pendingWebhookCleanupInterval || 60), // seconds
    }
```

This ensures that even if negative or very small values are provided, the service will still function correctly.

---

`44-53`: **Correctly specifies plugin dependencies.**

The plugin properly declares its dependencies on other services, ensuring they're loaded before this plugin is initialized.

</details>
<details>
<summary>src/types/pending-webhooks.types.ts (3)</summary>

`1-13`: **Well-structured interface with appropriate type constraints.**

The `PendingWebhook` interface is well-designed with proper type constraints for `instance_type` and `media_type`, making it type-safe. The inclusion of timestamps for tracking when webhooks were received and when they expire aligns well with the PR's objective of implementing automatic retries and expiration.

---

`15-23`: **Appropriate separation of create interface from full entity.**

The separation of `PendingWebhookCreate` from `PendingWebhook` follows good practice by excluding auto-generated fields (`id` and `received_at`), which helps maintain clear boundaries between what users provide and what the system generates.

---

`25-29`: **Configuration interface with clear documentation.**

The `PendingWebhooksConfig` interface is well-documented with comments indicating the units and default values for each configuration property. This aligns with the PR's description of implementing a service that processes pending webhooks every 20 seconds with a 10-minute expiration and cleanup every 60 seconds.

</details>
<details>
<summary>migrations/migrations/026_20250515_add_pending_webhooks.ts (5)</summary>

`3-7`: **Clear and comprehensive documentation.**

The docstring provides an excellent overview of the table's purpose and structure, making it easier for developers to understand the migration's intent without having to analyze the code in detail.

---

`9-16`: **Well-structured table definition with appropriate constraints.**

The table structure properly implements the fields from the `PendingWebhook` interface with appropriate column types and constraints. The comments provide helpful context for field value restrictions.

---

`20-22`: **Well-designed indexes for query optimization.**

The indexes on `[guid, media_type]` and `expires_at` are appropriate for the expected query patterns. The composite index will optimize lookups for specific media items, while the index on `expires_at` will improve the performance of the cleanup job that runs every 60 seconds to remove expired webhooks.

---

`24-27`: **Good use of check constraints for data integrity.**

The check constraints ensure that only valid values are stored in the `instance_type` and `media_type` fields, which aligns with the string literal types defined in the TypeScript interfaces. This is especially important for SQLite compatibility as noted in the comment.

---

`30-35`: **Clean migration rollback.**

The `down` function properly implements the rollback by dropping the table if it exists, which is the correct approach for cleanly reverting this migration.

</details>
<details>
<summary>src/services/database.service.ts (2)</summary>

`540-542`: **Good configuration defaults with fallback values**

The configuration defaults for pending webhooks are well-defined and appropriately set with sensible fallback values. These align with the interval values in the PR description (20s retry and 10-minute expiration).

---

`5919-5921`: **Clear documentation and section organization**

The pending webhooks section is well-documented with clear method grouping and descriptive comments, making the code more maintainable.

</details>
<details>
<summary>src/services/pending-webhooks.service.ts (1)</summary>

`52-58`: **Questionable double-registration of the same schedule**

You first register the job with `scheduler.scheduleJob`, then immediately call `db.updateSchedule` for the *same* key.  
If the scheduler plugin already persisted the job internally, `updateSchedule` might either:

1. Overwrite the schedule you just created, or  
2. Fail because the key does not yet exist in the DB (depending on implementation).

Either way this introduces hidden coupling between two services that may diverge.

Consider creating the _initial_ schedule only once, e.g.:

```diff
-await this.fastify.scheduler.scheduleJob('pending-webhooks-processor', handler)
-// overwrite afterwards
-await this.fastify.db.updateSchedule('pending-webhooks-processor', { … })
+await this.fastify.scheduler.scheduleJob(
+  'pending-webhooks-processor',
+  { type: 'interval', config: { seconds: this._config.retryInterval } },
+  handler,
+)
```

or call only `updateSchedule` if the record is known to exist.

  
Please confirm how the two helpers interact to avoid duplicate/invalid rows.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T03:22:46Z
user: login: jamcalli
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-16T06:30:19Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T06:30:38Z
user: login: jamcalli
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-16T06:32:58Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T06:33:21Z
user: login: jamcalli
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-16T06:48:26Z
user: login: jamcalli
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-16T06:48:35Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T06:48:55Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T06:50:43Z

Review comments:
diff_hunk: @@ -0,0 +1,27 @@
+import type { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise<void> {
+  await knex.schema.createTable('pending_webhooks', (table) => {
+    table.increments('id').primary()
+    table.string('instance_type', 10).notNullable() // 'radarr' or 'sonarr'
+    table.integer('instance_id').unsigned().nullable() // Can be null when instance is unknown
+    table.string('guid', 255).notNullable()
+    table.string('title', 255).notNullable()
+    table.string('media_type', 10).notNullable() // 'movie' or 'show'
+    table.json('payload').notNullable() // Full webhook payload
+    table.datetime('received_at').defaultTo(knex.fn.now()).notNullable()
+    table.datetime('expires_at').notNullable()
+    
path: migrations/migrations/026_20250515_add_pending_webhooks.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Prefer `timestamp` with timezone over `datetime` for consistency across drivers**

`table.datetime()` produces a `DATETIME` column on MySQL/MariaDB that is _not_ timezone-aware, while on Postgres it maps to `timestamp without time zone`.  Consider using

```diff
-table.datetime('received_at').defaultTo(knex.fn.now()).notNullable()
-table.datetime('expires_at').notNullable()
+table.timestamp('received_at', { useTz: true })
+  .defaultTo(knex.fn.now())
+  .notNullable()
+table.timestamp('expires_at', { useTz: true }).notNullable()
```

to store UTC values unambiguously and avoid subtle DST issues.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    table.timestamp('received_at', { useTz: true })
      .defaultTo(knex.fn.now())
      .notNullable()
    table.timestamp('expires_at', { useTz: true }).notNullable()
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In migrations/migrations/026_20250515_add_pending_webhooks.ts around lines 12 to
14, replace the use of table.datetime() with table.timestamp() to ensure the
columns are timezone-aware. This change will make the timestamps store UTC
values consistently across different database drivers, avoiding issues with
daylight saving time and timezone ambiguity.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-15T23:51:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,266 @@
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+/**
+ * Service to handle webhooks that arrive before RSS feed matching is complete.
+ * This solves the race condition where content is grabbed quickly and the webhook
+ * arrives while the RSS item is still being processed.
+ */
+export class PendingWebhooksService {
+  private readonly _config: PendingWebhooksConfig
+  private isRunning = false
+  private _processingWebhooks = false
+  private _cleaningUp = false
+
+  constructor(
+    private readonly log: FastifyBaseLogger,
+    private readonly fastify: FastifyInstance,
+    config?: Partial<PendingWebhooksConfig>,
+  ) {
+    this._config = {
+      retryInterval: 20, // Process every 20 seconds
+      maxAge: 10, // Keep webhooks for max 10 minutes
+      cleanupInterval: 60, // Clean up expired webhooks every minute
+      ...config,
+    }
+  }
+
+  /**
+   * Get the configuration (for access by other services/routes)
+   */
+  get config(): PendingWebhooksConfig {
+    return this._config
+  }
+
+  /**
+   * Initialize the pending webhooks service
+   */
+  async initialize(): Promise<void> {
+    // Schedule the processing job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-processor',
+      async (jobName: string) => {
+        const processed = await this.processWebhooks()
+        // Only log completion if we actually processed something
+        if (processed > 0) {
+          this.log.info(`Processed ${processed} pending webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every 20 seconds
+    await this.fastify.db.updateSchedule('pending-webhooks-processor', {
+      type: 'interval',
+      config: { seconds: this._config.retryInterval },
+      enabled: true,
+    })
+
+    // Schedule the cleanup job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-cleanup',
+      async (jobName: string) => {
+        const cleaned = await this.cleanupExpired()
+        // Only log if we actually cleaned something
+        if (cleaned > 0) {
+          this.log.info(`Cleaned up ${cleaned} expired webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every minute
+    await this.fastify.db.updateSchedule('pending-webhooks-cleanup', {
+      type: 'interval',
+      config: { minutes: 1 },
+      enabled: true,
+    })
+
+    this.isRunning = true
+
+    // Process any existing webhooks immediately on startup
+    await this.processWebhooks()
+  }
+
+  /**
+   * Process pending webhooks
+   * @returns Number of webhooks processed
+   */
+  private async processWebhooks(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping executions
+    if (this._processingWebhooks) {
+      this.log.debug(
+        'Webhook processing already in progress, skipping this cycle',
+      )
+      return 0
+    }
+
+    this._processingWebhooks = true
+
+    try {
+      const webhooks = await this.fastify.db.getPendingWebhooks()
+
+      if (webhooks.length === 0) {
+        return 0 // Silent return when no webhooks to process
+      }
+
+      let processedCount = 0
+
+      for (const webhook of webhooks) {
+        try {
+          // Check if RSS items exist for this GUID
+          const matchingItems = await this.fastify.db.getWatchlistItemsByGuid(
+            webhook.guid,
+          )
+
+          if (matchingItems.length > 0) {
+            this.log.info(
+              `Found ${matchingItems.length} items for ${webhook.guid}, processing webhook`,
+            )
+
+            // Process based on instance type
+            if (webhook.media_type === 'movie') {
+              // For movies, we don't need to parse the payload
+              const mediaInfo = {
+                type: 'movie' as const,
+                guid: webhook.guid,
+                title: webhook.title,
+              }
+
+              const notificationResults =
+                await this.fastify.db.processNotifications(mediaInfo, false)
+
+              // Process notifications concurrently to reduce latency
+              await Promise.all(
+                notificationResults.map(async (result) => {
+                  if (result.user.notify_discord && result.user.discord_id) {
+                    await this.fastify.discord.sendDirectMessage(
+                      result.user.discord_id,
+                      result.notification,
+                    )
+                  }
+
+                  if (result.user.notify_apprise) {
+                    await this.fastify.apprise.sendMediaNotification(
+                      result.user,
+                      result.notification,
+                    )
+                  }
+                }),
+              )
+            } else if (webhook.media_type === 'show') {
+              // For shows, we need to parse the payload to get episode information
+              let body: WebhookPayload
+              try {
+                // Safe parse payload in case it's a string (defensive programming)
+                body =
+                  typeof webhook.payload === 'string'
+                    ? JSON.parse(webhook.payload)
+                    : webhook.payload
+              } catch (parseError) {
+                this.log.error(
+                  `Failed to parse payload for webhook ${webhook.id}:`,
+                  parseError,
+                )
+                throw parseError
+              }
+
+              // Handle episode notifications
+              if (
+                'instanceName' in body &&
+                body.instanceName === 'Sonarr' &&
+                'episodes' in body &&
+                body.episodes.length > 0
+              ) {
+                const mediaInfo = {
+                  type: 'show' as const,
+                  guid: webhook.guid,
+                  title: webhook.title,
+                  episodes: body.episodes,
+                }
+
+                const notificationResults =
+                  await this.fastify.db.processNotifications(
+                    mediaInfo,
+                    body.episodes.length > 1,
+                  )
path: src/services/pending-webhooks.service.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Case-sensitive `'instanceName'` comparison may miss Sonarr variants**

Some payloads send `"instanceName": "sonarr"` (lowercase) or omit the field.  
Use a case-insensitive check to avoid silently discarding valid webhooks:

```diff
-'instanceName' in body && body.instanceName === 'Sonarr'
+typeof body.instanceName === 'string' &&
+  body.instanceName.toLowerCase() === 'sonarr'
```




<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
              // Handle episode notifications
              if (
                typeof body.instanceName === 'string' &&
                  body.instanceName.toLowerCase() === 'sonarr' &&
                'episodes' in body &&
                body.episodes.length > 0
              ) {
                const mediaInfo = {
                  type: 'show' as const,
                  guid: webhook.guid,
                  title: webhook.title,
                  episodes: body.episodes,
                }

                const notificationResults =
                  await this.fastify.db.processNotifications(
                    mediaInfo,
                    body.episodes.length > 1,
                  )
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/services/pending-webhooks.service.ts around lines 171 to 189, the code
checks if body.instanceName strictly equals 'Sonarr', which is case-sensitive
and may miss valid payloads like 'sonarr'. Modify the condition to perform a
case-insensitive comparison by converting body.instanceName to lowercase and
comparing it to 'sonarr'. Also, ensure the check handles the possibility that
instanceName might be missing or undefined.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-15T23:51:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5902,4 +5915,127 @@ export class DatabaseService {
       return true
     }
   }
+
+  // ============================================
+  // Pending Webhooks Methods
+  // ============================================
+
+  /**
+   * Creates a new pending webhook entry
+   *
+   * @param webhook - The webhook data to create
+   * @returns Promise resolving to the created webhook with its ID
+   */
+  async createPendingWebhook(
+    webhook: PendingWebhookCreate,
+  ): Promise<PendingWebhook> {
+    try {
+      const [id] = await this.knex('pending_webhooks')
+        .insert({
+          ...webhook,
+          received_at: this.timestamp,
+          expires_at: webhook.expires_at.toISOString(),
+          payload: JSON.stringify(webhook.payload),
+        })
+        .returning('id')
+
+      return {
+        id: id.id || id,
+        ...webhook,
+        received_at: new Date(),
+        expires_at: webhook.expires_at,
+      }
+    } catch (error) {
+      this.log.error('Error creating pending webhook:', error)
+      throw new Error('Failed to create pending webhook')
+    }
+  }
+
+  /**
+   * Gets all pending webhooks that haven't expired
+   *
+   * @param limit - Optional limit of results
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getPendingWebhooks(limit = 50): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where('expires_at', '>', new Date().toISOString())
+        .orderBy('received_at', 'asc')
+        .limit(limit)
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: JSON.parse(webhook.payload || '{}'),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
path: src/services/database.service.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Robust JSON parsing & error propagation**

`JSON.parse(webhook.payload || '{}')` will throw if the stored payload is corrupted.  
Instead of swallowing the whole batch, wrap the parse in `try/catch` and log + skip the malformed row (or mark it for deletion).

```diff
- payload: JSON.parse(webhook.payload || '{}'),
+ payload: (() => {
+   try {
+     return JSON.parse(webhook.payload ?? '{}')
+   } catch (e) {
+     this.log.warn({ id: webhook.id }, 'Malformed webhook payload – skipping')
+     return {}
+   }
+ })(),
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
      return webhooks.map((webhook) => ({
        ...webhook,
        payload: (() => {
          try {
            return JSON.parse(webhook.payload ?? '{}')
          } catch (e) {
            this.log.warn({ id: webhook.id }, 'Malformed webhook payload – skipping')
            return {}
          }
        })(),
        received_at: new Date(webhook.received_at),
        expires_at: new Date(webhook.expires_at),
      }))
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/services/database.service.ts around lines 5960 to 5973, the JSON.parse
call on webhook.payload can throw an error if the payload is corrupted, causing
the entire batch to fail. To fix this, wrap the JSON.parse in a try/catch block
inside the map function, log the error for the malformed payload, and skip or
exclude that webhook from the returned array to avoid breaking the whole batch
processing.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 7c01570
created_at: 2025-05-15T23:51:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5902,4 +5915,127 @@ export class DatabaseService {
       return true
     }
   }
+
+  // ============================================
+  // Pending Webhooks Methods
+  // ============================================
+
+  /**
+   * Creates a new pending webhook entry
+   *
+   * @param webhook - The webhook data to create
+   * @returns Promise resolving to the created webhook with its ID
+   */
+  async createPendingWebhook(
+    webhook: PendingWebhookCreate,
+  ): Promise<PendingWebhook> {
+    try {
+      const [id] = await this.knex('pending_webhooks')
+        .insert({
+          ...webhook,
+          received_at: this.timestamp,
+          expires_at: webhook.expires_at.toISOString(),
+          payload: JSON.stringify(webhook.payload),
+        })
+        .returning('id')
+
+      return {
+        id: id.id || id,
+        ...webhook,
+        received_at: new Date(),
+        expires_at: webhook.expires_at,
+      }
+    } catch (error) {
+      this.log.error('Error creating pending webhook:', error)
+      throw new Error('Failed to create pending webhook')
+    }
path: src/services/database.service.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`createPendingWebhook` – fragile ID extraction & timestamp mismatch**

1. `returning('id')` may give either `[number]` **or** `[{id:number}]` depending on the Sqlite driver / knex version.  
   The current `id.id || id` shortcut works but is brittle and may coerce `0` to `false`. Re-using the safe-guard you already implemented in `createUser` would be clearer.

2. You insert `received_at: this.timestamp` (ISO string) but return `received_at: new Date()`, which yields a `Date` object a few ms later than what was written.  
   Returning `new Date(this.timestamp)` (or parsing the DB value) keeps them consistent.

```diff
- const [id] = await this.knex('pending_webhooks').insert({...}).returning('id')
+ const result = await this.knex('pending_webhooks')
+   .insert({...})
+   .returning('id')
+
+ const id =
+   typeof result[0] === 'object' && result[0] !== null
+     ? result[0].id
+     : result[0]

 ...

-   return {
-     id: id.id || id,
-     ...webhook,
-     received_at: new Date(),
-     expires_at: webhook.expires_at,
-   }
+   return {
+     id,
+     ...webhook,
+     received_at: new Date(this.timestamp),
+     expires_at: webhook.expires_at,
+   }
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
  async createPendingWebhook(
    webhook: PendingWebhookCreate,
  ): Promise<PendingWebhook> {
    try {
      const result = await this.knex('pending_webhooks')
        .insert({
          ...webhook,
          received_at: this.timestamp,
          expires_at: webhook.expires_at.toISOString(),
          payload: JSON.stringify(webhook.payload),
        })
        .returning('id')

      const id =
        typeof result[0] === 'object' && result[0] !== null
          ? result[0].id
          : result[0]

      return {
        id,
        ...webhook,
        received_at: new Date(this.timestamp),
        expires_at: webhook.expires_at,
      }
    } catch (error) {
      this.log.error('Error creating pending webhook:', error)
      throw new Error('Failed to create pending webhook')
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/services/database.service.ts around lines 5929 to 5951, the ID extraction
from the insert result is fragile because it assumes the ID can be accessed as
id.id or id, which may fail or coerce 0 to false; fix this by using a more
robust check like the one in createUser to safely extract the ID. Also, the
returned received_at timestamp is a new Date() created after insertion, causing
a mismatch with the inserted this.timestamp string; fix this by returning new
Date(this.timestamp) to keep the timestamps consistent.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 7c01570
created_at: 2025-05-15T23:51:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5902,4 +5915,127 @@ export class DatabaseService {
       return true
     }
   }
+
+  // ============================================
+  // Pending Webhooks Methods
+  // ============================================
+
+  /**
+   * Creates a new pending webhook entry
+   *
+   * @param webhook - The webhook data to create
+   * @returns Promise resolving to the created webhook with its ID
+   */
+  async createPendingWebhook(
+    webhook: PendingWebhookCreate,
+  ): Promise<PendingWebhook> {
+    try {
+      const [id] = await this.knex('pending_webhooks')
+        .insert({
+          ...webhook,
+          received_at: this.timestamp,
+          expires_at: webhook.expires_at.toISOString(),
+          payload: JSON.stringify(webhook.payload),
+        })
+        .returning('id')
+
+      return {
+        id: id.id || id,
+        ...webhook,
+        received_at: new Date(),
+        expires_at: webhook.expires_at,
+      }
+    } catch (error) {
+      this.log.error('Error creating pending webhook:', error)
+      throw new Error('Failed to create pending webhook')
+    }
+  }
+
+  /**
+   * Gets all pending webhooks that haven't expired
+   *
+   * @param limit - Optional limit of results
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getPendingWebhooks(limit = 50): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where('expires_at', '>', new Date().toISOString())
+        .orderBy('received_at', 'asc')
+        .limit(limit)
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: JSON.parse(webhook.payload || '{}'),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error('Error getting pending webhooks:', error)
+      return []
+    }
+  }
+
+  /**
+   * Deletes a processed webhook
+   *
+   * @param id - The webhook ID to delete
+   * @returns Promise resolving to boolean indicating success
+   */
+  async deletePendingWebhook(id: number): Promise<boolean> {
+    try {
+      const deleted = await this.knex('pending_webhooks').where({ id }).delete()
+
+      return deleted > 0
+    } catch (error) {
+      this.log.error(`Error deleting pending webhook ${id}:`, error)
+      return false
+    }
+  }
+
+  /**
+   * Cleans up expired webhooks
+   *
+   * @returns Promise resolving to number of deleted webhooks
+   */
+  async cleanupExpiredWebhooks(): Promise<number> {
+    try {
+      const deleted = await this.knex('pending_webhooks')
+        .where('expires_at', '<', new Date().toISOString())
+        .delete()
+
+      return deleted
+    } catch (error) {
+      this.log.error('Error cleaning up expired webhooks:', error)
+      return 0
+    }
+  }
+
+  /**
+   * Gets webhooks by GUID and media type
+   *
+   * @param guid - The GUID to search for
+   * @param mediaType - The media type (movie or show)
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getWebhooksByGuid(
+    guid: string,
+    mediaType: 'movie' | 'show',
+  ): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where({ guid, media_type: mediaType })
+        .where('expires_at', '>', new Date().toISOString())
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: JSON.parse(webhook.payload || '{}'),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error(`Error getting webhooks for ${guid}:`, error)
+      return []
+    }
+  }
path: src/services/database.service.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Apply same JSON.parse safety to getWebhooksByGuid method**

This method has the same JSON parsing vulnerability as getPendingWebhooks. A single corrupt payload could prevent processing all webhooks for a specific GUID.


```diff
  return webhooks.map((webhook) => ({
    ...webhook,
-   payload: JSON.parse(webhook.payload || '{}'),
+   payload: (() => {
+     try {
+       return JSON.parse(webhook.payload ?? '{}')
+     } catch (e) {
+       this.log.warn({ id: webhook.id }, 'Malformed webhook payload – skipping')
+       return {}
+     }
+   })(),
    received_at: new Date(webhook.received_at),
    expires_at: new Date(webhook.expires_at),
  }))
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
  async getWebhooksByGuid(
    guid: string,
    mediaType: 'movie' | 'show',
  ): Promise<PendingWebhook[]> {
    try {
      const webhooks = await this.knex('pending_webhooks')
        .where({ guid, media_type: mediaType })
        .where('expires_at', '>', new Date().toISOString())

      return webhooks.map((webhook) => ({
        ...webhook,
        payload: (() => {
          try {
            return JSON.parse(webhook.payload ?? '{}')
          } catch (e) {
            this.log.warn({ id: webhook.id }, 'Malformed webhook payload – skipping')
            return {}
          }
        })(),
        received_at: new Date(webhook.received_at),
        expires_at: new Date(webhook.expires_at),
      }))
    } catch (error) {
      this.log.error(`Error getting webhooks for ${guid}:`, error)
      return []
    }
  }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/services/database.service.ts around lines 6021 to 6040, the
getWebhooksByGuid method parses webhook payloads using JSON.parse without error
handling, which can cause the entire method to fail if a single payload is
corrupt. To fix this, wrap the JSON.parse call in a try-catch block or use a
safe parsing function that returns a default value on failure, ensuring that one
bad payload does not prevent processing all webhooks.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->

✅ Addressed in commit 7c01570
created_at: 2025-05-16T03:22:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,266 @@
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+/**
+ * Service to handle webhooks that arrive before RSS feed matching is complete.
+ * This solves the race condition where content is grabbed quickly and the webhook
+ * arrives while the RSS item is still being processed.
+ */
+export class PendingWebhooksService {
+  private readonly _config: PendingWebhooksConfig
+  private isRunning = false
+  private _processingWebhooks = false
+  private _cleaningUp = false
+
+  constructor(
+    private readonly log: FastifyBaseLogger,
+    private readonly fastify: FastifyInstance,
+    config?: Partial<PendingWebhooksConfig>,
+  ) {
+    this._config = {
+      retryInterval: 20, // Process every 20 seconds
+      maxAge: 10, // Keep webhooks for max 10 minutes
+      cleanupInterval: 60, // Clean up expired webhooks every minute
+      ...config,
+    }
+  }
+
+  /**
+   * Get the configuration (for access by other services/routes)
+   */
+  get config(): PendingWebhooksConfig {
+    return this._config
+  }
+
+  /**
+   * Initialize the pending webhooks service
+   */
+  async initialize(): Promise<void> {
+    // Schedule the processing job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-processor',
+      async (jobName: string) => {
+        const processed = await this.processWebhooks()
+        // Only log completion if we actually processed something
+        if (processed > 0) {
+          this.log.info(`Processed ${processed} pending webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every 20 seconds
+    await this.fastify.db.updateSchedule('pending-webhooks-processor', {
+      type: 'interval',
+      config: { seconds: this._config.retryInterval },
+      enabled: true,
+    })
+
+    // Schedule the cleanup job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-cleanup',
+      async (jobName: string) => {
+        const cleaned = await this.cleanupExpired()
+        // Only log if we actually cleaned something
+        if (cleaned > 0) {
+          this.log.info(`Cleaned up ${cleaned} expired webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every minute
+    await this.fastify.db.updateSchedule('pending-webhooks-cleanup', {
+      type: 'interval',
+      config: { minutes: 1 },
+      enabled: true,
+    })
+
+    this.isRunning = true
+
+    // Process any existing webhooks immediately on startup
+    await this.processWebhooks()
+  }
+
+  /**
+   * Process pending webhooks
+   * @returns Number of webhooks processed
+   */
+  private async processWebhooks(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping executions
+    if (this._processingWebhooks) {
+      this.log.debug(
+        'Webhook processing already in progress, skipping this cycle',
+      )
+      return 0
+    }
+
+    this._processingWebhooks = true
+
+    try {
+      const webhooks = await this.fastify.db.getPendingWebhooks()
+
+      if (webhooks.length === 0) {
+        return 0 // Silent return when no webhooks to process
+      }
+
+      let processedCount = 0
+
+      for (const webhook of webhooks) {
+        try {
+          // Check if RSS items exist for this GUID
+          const matchingItems = await this.fastify.db.getWatchlistItemsByGuid(
+            webhook.guid,
+          )
+
+          if (matchingItems.length > 0) {
+            this.log.info(
+              `Found ${matchingItems.length} items for ${webhook.guid}, processing webhook`,
+            )
+
+            // Process based on instance type
+            if (webhook.media_type === 'movie') {
+              // For movies, we don't need to parse the payload
+              const mediaInfo = {
+                type: 'movie' as const,
+                guid: webhook.guid,
+                title: webhook.title,
+              }
path: src/services/pending-webhooks.service.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Serial processing may throttle throughput**

Each webhook is processed sequentially.  
In busy libraries a single run could contain dozens of webhooks, and waiting for network I/O (Discord, Apprise) will keep the loop blocked.

Consider batching with limited parallelism (e.g. `p-map` with a concurrency of 3-5) so I/O waits do not block unrelated webhooks:

```ts
import pMap from 'p-map'

await pMap(webhooks, async (webhook) => {
  /* … existing per-webhook logic … */
}, { concurrency: 5 })
```

This preserves back-pressure while boosting throughput.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/services/pending-webhooks.service.ts around lines 112 to 131, the current
code processes each webhook sequentially, causing potential throughput
bottlenecks due to waiting on network I/O. Refactor the loop to use a
concurrency-controlled parallel processing approach, such as the p-map library
with a concurrency of 3-5. Import p-map, then replace the for-loop with
pMap(webhooks, async (webhook) => { /* existing webhook logic */ }, {
concurrency: 5 }) to allow multiple webhooks to be processed in parallel without
overwhelming resources.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-16T03:22:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+import type { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise<void> {
+  await knex.schema.createTable('pending_webhooks', (table) => {
+    table.increments('id').primary()
+    table.string('instance_type', 10).notNullable() // 'radarr' or 'sonarr'
+    table.integer('instance_id').unsigned().nullable() // Can be null when instance is unknown
+    table.string('guid', 255).notNullable()
+    table.string('title', 255).notNullable()
+    table.string('media_type', 10).notNullable() // 'movie' or 'show'
+    table.json('payload').notNullable() // Full webhook payload
+    table.datetime('received_at').defaultTo(knex.fn.now()).notNullable()
+    table.datetime('expires_at').notNullable()
+    
path: migrations/migrations/026_20250515_add_pending_webhooks.ts
user: login: jamcalli
body: All timestamp values are converted to ISO strings (UTC) before storage.

Good catch though.
created_at: 2025-05-16T06:30:19Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,27 @@
+import type { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise<void> {
+  await knex.schema.createTable('pending_webhooks', (table) => {
+    table.increments('id').primary()
+    table.string('instance_type', 10).notNullable() // 'radarr' or 'sonarr'
+    table.integer('instance_id').unsigned().nullable() // Can be null when instance is unknown
+    table.string('guid', 255).notNullable()
+    table.string('title', 255).notNullable()
+    table.string('media_type', 10).notNullable() // 'movie' or 'show'
+    table.json('payload').notNullable() // Full webhook payload
+    table.datetime('received_at').defaultTo(knex.fn.now()).notNullable()
+    table.datetime('expires_at').notNullable()
+    
path: migrations/migrations/026_20250515_add_pending_webhooks.ts
user: login: coderabbitai[bot]
body: @jamcalli Thanks for the clarification! If you're consistently converting all timestamp values to ISO strings (UTC) before storage in your application layer, that's a perfectly valid approach to handle timezone consistency. Appreciate you considering the suggestion.


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@jamcalli Thanks for the clarification! If you're consistently converting all timestamp values to ISO strings (UTC) before storage in your application layer, that's a perfectly valid approach to handle timezone consistency. Appreciate you considering the suggestion.</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-16T06:30:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,266 @@
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+/**
+ * Service to handle webhooks that arrive before RSS feed matching is complete.
+ * This solves the race condition where content is grabbed quickly and the webhook
+ * arrives while the RSS item is still being processed.
+ */
+export class PendingWebhooksService {
+  private readonly _config: PendingWebhooksConfig
+  private isRunning = false
+  private _processingWebhooks = false
+  private _cleaningUp = false
+
+  constructor(
+    private readonly log: FastifyBaseLogger,
+    private readonly fastify: FastifyInstance,
+    config?: Partial<PendingWebhooksConfig>,
+  ) {
+    this._config = {
+      retryInterval: 20, // Process every 20 seconds
+      maxAge: 10, // Keep webhooks for max 10 minutes
+      cleanupInterval: 60, // Clean up expired webhooks every minute
+      ...config,
+    }
+  }
+
+  /**
+   * Get the configuration (for access by other services/routes)
+   */
+  get config(): PendingWebhooksConfig {
+    return this._config
+  }
+
+  /**
+   * Initialize the pending webhooks service
+   */
+  async initialize(): Promise<void> {
+    // Schedule the processing job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-processor',
+      async (jobName: string) => {
+        const processed = await this.processWebhooks()
+        // Only log completion if we actually processed something
+        if (processed > 0) {
+          this.log.info(`Processed ${processed} pending webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every 20 seconds
+    await this.fastify.db.updateSchedule('pending-webhooks-processor', {
+      type: 'interval',
+      config: { seconds: this._config.retryInterval },
+      enabled: true,
+    })
+
+    // Schedule the cleanup job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-cleanup',
+      async (jobName: string) => {
+        const cleaned = await this.cleanupExpired()
+        // Only log if we actually cleaned something
+        if (cleaned > 0) {
+          this.log.info(`Cleaned up ${cleaned} expired webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every minute
+    await this.fastify.db.updateSchedule('pending-webhooks-cleanup', {
+      type: 'interval',
+      config: { minutes: 1 },
+      enabled: true,
+    })
+
+    this.isRunning = true
+
+    // Process any existing webhooks immediately on startup
+    await this.processWebhooks()
+  }
+
+  /**
+   * Process pending webhooks
+   * @returns Number of webhooks processed
+   */
+  private async processWebhooks(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping executions
+    if (this._processingWebhooks) {
+      this.log.debug(
+        'Webhook processing already in progress, skipping this cycle',
+      )
+      return 0
+    }
+
+    this._processingWebhooks = true
+
+    try {
+      const webhooks = await this.fastify.db.getPendingWebhooks()
+
+      if (webhooks.length === 0) {
+        return 0 // Silent return when no webhooks to process
+      }
+
+      let processedCount = 0
+
+      for (const webhook of webhooks) {
+        try {
+          // Check if RSS items exist for this GUID
+          const matchingItems = await this.fastify.db.getWatchlistItemsByGuid(
+            webhook.guid,
+          )
+
+          if (matchingItems.length > 0) {
+            this.log.info(
+              `Found ${matchingItems.length} items for ${webhook.guid}, processing webhook`,
+            )
+
+            // Process based on instance type
+            if (webhook.media_type === 'movie') {
+              // For movies, we don't need to parse the payload
+              const mediaInfo = {
+                type: 'movie' as const,
+                guid: webhook.guid,
+                title: webhook.title,
+              }
+
+              const notificationResults =
+                await this.fastify.db.processNotifications(mediaInfo, false)
+
+              // Process notifications concurrently to reduce latency
+              await Promise.all(
+                notificationResults.map(async (result) => {
+                  if (result.user.notify_discord && result.user.discord_id) {
+                    await this.fastify.discord.sendDirectMessage(
+                      result.user.discord_id,
+                      result.notification,
+                    )
+                  }
+
+                  if (result.user.notify_apprise) {
+                    await this.fastify.apprise.sendMediaNotification(
+                      result.user,
+                      result.notification,
+                    )
+                  }
+                }),
+              )
+            } else if (webhook.media_type === 'show') {
+              // For shows, we need to parse the payload to get episode information
+              let body: WebhookPayload
+              try {
+                // Safe parse payload in case it's a string (defensive programming)
+                body =
+                  typeof webhook.payload === 'string'
+                    ? JSON.parse(webhook.payload)
+                    : webhook.payload
+              } catch (parseError) {
+                this.log.error(
+                  `Failed to parse payload for webhook ${webhook.id}:`,
+                  parseError,
+                )
+                throw parseError
+              }
+
+              // Handle episode notifications
+              if (
+                'instanceName' in body &&
+                body.instanceName === 'Sonarr' &&
+                'episodes' in body &&
+                body.episodes.length > 0
+              ) {
+                const mediaInfo = {
+                  type: 'show' as const,
+                  guid: webhook.guid,
+                  title: webhook.title,
+                  episodes: body.episodes,
+                }
+
+                const notificationResults =
+                  await this.fastify.db.processNotifications(
+                    mediaInfo,
+                    body.episodes.length > 1,
+                  )
path: src/services/pending-webhooks.service.ts
user: login: jamcalli
body: The case-sensitive comparison is intentional. Our webhook schema uses z.literal('Sonarr') which enforces exact casing at the validation layer. Webhooks with incorrect casing are rejected when they arrive at the API endpoint, so by the time payloads reach the pending webhooks service, they've already been validated. Maintaining strict casing throughout the codebase ensures consistency with our schema contracts and prevents accepting malformed webhooks that should have  been rejected at the entry point.
created_at: 2025-05-16T06:32:58Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,266 @@
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+/**
+ * Service to handle webhooks that arrive before RSS feed matching is complete.
+ * This solves the race condition where content is grabbed quickly and the webhook
+ * arrives while the RSS item is still being processed.
+ */
+export class PendingWebhooksService {
+  private readonly _config: PendingWebhooksConfig
+  private isRunning = false
+  private _processingWebhooks = false
+  private _cleaningUp = false
+
+  constructor(
+    private readonly log: FastifyBaseLogger,
+    private readonly fastify: FastifyInstance,
+    config?: Partial<PendingWebhooksConfig>,
+  ) {
+    this._config = {
+      retryInterval: 20, // Process every 20 seconds
+      maxAge: 10, // Keep webhooks for max 10 minutes
+      cleanupInterval: 60, // Clean up expired webhooks every minute
+      ...config,
+    }
+  }
+
+  /**
+   * Get the configuration (for access by other services/routes)
+   */
+  get config(): PendingWebhooksConfig {
+    return this._config
+  }
+
+  /**
+   * Initialize the pending webhooks service
+   */
+  async initialize(): Promise<void> {
+    // Schedule the processing job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-processor',
+      async (jobName: string) => {
+        const processed = await this.processWebhooks()
+        // Only log completion if we actually processed something
+        if (processed > 0) {
+          this.log.info(`Processed ${processed} pending webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every 20 seconds
+    await this.fastify.db.updateSchedule('pending-webhooks-processor', {
+      type: 'interval',
+      config: { seconds: this._config.retryInterval },
+      enabled: true,
+    })
+
+    // Schedule the cleanup job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-cleanup',
+      async (jobName: string) => {
+        const cleaned = await this.cleanupExpired()
+        // Only log if we actually cleaned something
+        if (cleaned > 0) {
+          this.log.info(`Cleaned up ${cleaned} expired webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every minute
+    await this.fastify.db.updateSchedule('pending-webhooks-cleanup', {
+      type: 'interval',
+      config: { minutes: 1 },
+      enabled: true,
+    })
+
+    this.isRunning = true
+
+    // Process any existing webhooks immediately on startup
+    await this.processWebhooks()
+  }
+
+  /**
+   * Process pending webhooks
+   * @returns Number of webhooks processed
+   */
+  private async processWebhooks(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping executions
+    if (this._processingWebhooks) {
+      this.log.debug(
+        'Webhook processing already in progress, skipping this cycle',
+      )
+      return 0
+    }
+
+    this._processingWebhooks = true
+
+    try {
+      const webhooks = await this.fastify.db.getPendingWebhooks()
+
+      if (webhooks.length === 0) {
+        return 0 // Silent return when no webhooks to process
+      }
+
+      let processedCount = 0
+
+      for (const webhook of webhooks) {
+        try {
+          // Check if RSS items exist for this GUID
+          const matchingItems = await this.fastify.db.getWatchlistItemsByGuid(
+            webhook.guid,
+          )
+
+          if (matchingItems.length > 0) {
+            this.log.info(
+              `Found ${matchingItems.length} items for ${webhook.guid}, processing webhook`,
+            )
+
+            // Process based on instance type
+            if (webhook.media_type === 'movie') {
+              // For movies, we don't need to parse the payload
+              const mediaInfo = {
+                type: 'movie' as const,
+                guid: webhook.guid,
+                title: webhook.title,
+              }
+
+              const notificationResults =
+                await this.fastify.db.processNotifications(mediaInfo, false)
+
+              // Process notifications concurrently to reduce latency
+              await Promise.all(
+                notificationResults.map(async (result) => {
+                  if (result.user.notify_discord && result.user.discord_id) {
+                    await this.fastify.discord.sendDirectMessage(
+                      result.user.discord_id,
+                      result.notification,
+                    )
+                  }
+
+                  if (result.user.notify_apprise) {
+                    await this.fastify.apprise.sendMediaNotification(
+                      result.user,
+                      result.notification,
+                    )
+                  }
+                }),
+              )
+            } else if (webhook.media_type === 'show') {
+              // For shows, we need to parse the payload to get episode information
+              let body: WebhookPayload
+              try {
+                // Safe parse payload in case it's a string (defensive programming)
+                body =
+                  typeof webhook.payload === 'string'
+                    ? JSON.parse(webhook.payload)
+                    : webhook.payload
+              } catch (parseError) {
+                this.log.error(
+                  `Failed to parse payload for webhook ${webhook.id}:`,
+                  parseError,
+                )
+                throw parseError
+              }
+
+              // Handle episode notifications
+              if (
+                'instanceName' in body &&
+                body.instanceName === 'Sonarr' &&
+                'episodes' in body &&
+                body.episodes.length > 0
+              ) {
+                const mediaInfo = {
+                  type: 'show' as const,
+                  guid: webhook.guid,
+                  title: webhook.title,
+                  episodes: body.episodes,
+                }
+
+                const notificationResults =
+                  await this.fastify.db.processNotifications(
+                    mediaInfo,
+                    body.episodes.length > 1,
+                  )
path: src/services/pending-webhooks.service.ts
user: login: coderabbitai[bot]
body: @jamcalli Thank you for explaining your validation approach. That makes perfect sense - since you're using `z.literal('Sonarr')` in your webhook schema to validate at the API entry point, any payloads reaching this service would already have the correct casing. Maintaining consistent validation expectations throughout the codebase is indeed a good practice in this case.

I'll withdraw my suggestion and respect the intentional design choice you've made here.



(\ /)
(•ᴥ•)
/ >🎉

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Keeping%20strict%20case%20checks%20like%20'Sonarr'%20ensures%20schema%20consistency%20and%20rejects%20malformed%20webhooks%20early%2C%20preventing%20silent%20errors%20downstream.%20Strong%20validation%20%3D%20robust%20code!%20%F0%9F%94%92%E2%9C%A8%20%40coderabbitai&url=https%3A%2F%2Fgithub.com%2Fjamcalli%2FPulsarr%2Fpull%2F195%23discussion_r2092113375)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@jamcalli Thank you for explaining your validation approach. That makes perfect sense - since you're using `z.literal('Sonarr')` in your webhook schema to validate at the API entry point, any payloads reaching this service would already have the correct casing. Maintaining consistent validation expectations throughout the codebase is indeed a good practice in this case.

I'll withdraw my suggestion and respect the intentional design choice you've made here.



(\ /)
(•ᴥ•)
/ >🎉

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Keeping%20strict%20case%20checks%20like%20'Sonarr'%20ensures%20schema%20consistency%20and%20rejects%20malformed%20webhooks%20early%2C%20preventing%20silent%20errors%20downstream.%20Strong%20validation%20%3D%20robust%20code!%20%F0%9F%94%92%E2%9C%A8%20%40coderabbitai&url=https%3A%2F%2Fgithub.com%2Fjamcalli%2FPulsarr%2Fpull%2F195%23discussion_r2092113375)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-16T06:33:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,266 @@
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+/**
+ * Service to handle webhooks that arrive before RSS feed matching is complete.
+ * This solves the race condition where content is grabbed quickly and the webhook
+ * arrives while the RSS item is still being processed.
+ */
+export class PendingWebhooksService {
+  private readonly _config: PendingWebhooksConfig
+  private isRunning = false
+  private _processingWebhooks = false
+  private _cleaningUp = false
+
+  constructor(
+    private readonly log: FastifyBaseLogger,
+    private readonly fastify: FastifyInstance,
+    config?: Partial<PendingWebhooksConfig>,
+  ) {
+    this._config = {
+      retryInterval: 20, // Process every 20 seconds
+      maxAge: 10, // Keep webhooks for max 10 minutes
+      cleanupInterval: 60, // Clean up expired webhooks every minute
+      ...config,
+    }
+  }
+
+  /**
+   * Get the configuration (for access by other services/routes)
+   */
+  get config(): PendingWebhooksConfig {
+    return this._config
+  }
+
+  /**
+   * Initialize the pending webhooks service
+   */
+  async initialize(): Promise<void> {
+    // Schedule the processing job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-processor',
+      async (jobName: string) => {
+        const processed = await this.processWebhooks()
+        // Only log completion if we actually processed something
+        if (processed > 0) {
+          this.log.info(`Processed ${processed} pending webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every 20 seconds
+    await this.fastify.db.updateSchedule('pending-webhooks-processor', {
+      type: 'interval',
+      config: { seconds: this._config.retryInterval },
+      enabled: true,
+    })
+
+    // Schedule the cleanup job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-cleanup',
+      async (jobName: string) => {
+        const cleaned = await this.cleanupExpired()
+        // Only log if we actually cleaned something
+        if (cleaned > 0) {
+          this.log.info(`Cleaned up ${cleaned} expired webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every minute
+    await this.fastify.db.updateSchedule('pending-webhooks-cleanup', {
+      type: 'interval',
+      config: { minutes: 1 },
+      enabled: true,
+    })
+
+    this.isRunning = true
+
+    // Process any existing webhooks immediately on startup
+    await this.processWebhooks()
+  }
+
+  /**
+   * Process pending webhooks
+   * @returns Number of webhooks processed
+   */
+  private async processWebhooks(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping executions
+    if (this._processingWebhooks) {
+      this.log.debug(
+        'Webhook processing already in progress, skipping this cycle',
+      )
+      return 0
+    }
+
+    this._processingWebhooks = true
+
+    try {
+      const webhooks = await this.fastify.db.getPendingWebhooks()
+
+      if (webhooks.length === 0) {
+        return 0 // Silent return when no webhooks to process
+      }
+
+      let processedCount = 0
+
+      for (const webhook of webhooks) {
+        try {
+          // Check if RSS items exist for this GUID
+          const matchingItems = await this.fastify.db.getWatchlistItemsByGuid(
+            webhook.guid,
+          )
+
+          if (matchingItems.length > 0) {
+            this.log.info(
+              `Found ${matchingItems.length} items for ${webhook.guid}, processing webhook`,
+            )
+
+            // Process based on instance type
+            if (webhook.media_type === 'movie') {
+              // For movies, we don't need to parse the payload
+              const mediaInfo = {
+                type: 'movie' as const,
+                guid: webhook.guid,
+                title: webhook.title,
+              }
path: src/services/pending-webhooks.service.ts
user: login: jamcalli
body: The serial processing is intentional for now - it provides natural rate limiting for external services and simplifies error handling. The service runs every 20 seconds which provides batching. We can optimize with controlled concurrency if performance becomes an issue in production.
created_at: 2025-05-16T06:48:26Z
author_association: OWNER
diff_hunk: @@ -5902,4 +5915,127 @@ export class DatabaseService {
       return true
     }
   }
+
+  // ============================================
+  // Pending Webhooks Methods
+  // ============================================
+
+  /**
+   * Creates a new pending webhook entry
+   *
+   * @param webhook - The webhook data to create
+   * @returns Promise resolving to the created webhook with its ID
+   */
+  async createPendingWebhook(
+    webhook: PendingWebhookCreate,
+  ): Promise<PendingWebhook> {
+    try {
+      const [id] = await this.knex('pending_webhooks')
+        .insert({
+          ...webhook,
+          received_at: this.timestamp,
+          expires_at: webhook.expires_at.toISOString(),
+          payload: JSON.stringify(webhook.payload),
+        })
+        .returning('id')
+
+      return {
+        id: id.id || id,
+        ...webhook,
+        received_at: new Date(),
+        expires_at: webhook.expires_at,
+      }
+    } catch (error) {
+      this.log.error('Error creating pending webhook:', error)
+      throw new Error('Failed to create pending webhook')
+    }
+  }
+
+  /**
+   * Gets all pending webhooks that haven't expired
+   *
+   * @param limit - Optional limit of results
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getPendingWebhooks(limit = 50): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where('expires_at', '>', new Date().toISOString())
+        .orderBy('received_at', 'asc')
+        .limit(limit)
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: JSON.parse(webhook.payload || '{}'),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error('Error getting pending webhooks:', error)
+      return []
+    }
+  }
+
+  /**
+   * Deletes a processed webhook
+   *
+   * @param id - The webhook ID to delete
+   * @returns Promise resolving to boolean indicating success
+   */
+  async deletePendingWebhook(id: number): Promise<boolean> {
+    try {
+      const deleted = await this.knex('pending_webhooks').where({ id }).delete()
+
+      return deleted > 0
+    } catch (error) {
+      this.log.error(`Error deleting pending webhook ${id}:`, error)
+      return false
+    }
+  }
+
+  /**
+   * Cleans up expired webhooks
+   *
+   * @returns Promise resolving to number of deleted webhooks
+   */
+  async cleanupExpiredWebhooks(): Promise<number> {
+    try {
+      const deleted = await this.knex('pending_webhooks')
+        .where('expires_at', '<', new Date().toISOString())
+        .delete()
+
+      return deleted
+    } catch (error) {
+      this.log.error('Error cleaning up expired webhooks:', error)
+      return 0
+    }
+  }
+
+  /**
+   * Gets webhooks by GUID and media type
+   *
+   * @param guid - The GUID to search for
+   * @param mediaType - The media type (movie or show)
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getWebhooksByGuid(
+    guid: string,
+    mediaType: 'movie' | 'show',
+  ): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where({ guid, media_type: mediaType })
+        .where('expires_at', '>', new Date().toISOString())
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: JSON.parse(webhook.payload || '{}'),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error(`Error getting webhooks for ${guid}:`, error)
+      return []
+    }
+  }
path: src/services/database.service.ts
user: login: jamcalli
body: done please review
created_at: 2025-05-16T06:48:35Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,266 @@
+import type { FastifyBaseLogger, FastifyInstance } from 'fastify'
+import type { PendingWebhooksConfig } from '@root/types/pending-webhooks.types.js'
+import type { WebhookPayload } from '@root/schemas/notifications/webhook.schema.js'
+
+/**
+ * Service to handle webhooks that arrive before RSS feed matching is complete.
+ * This solves the race condition where content is grabbed quickly and the webhook
+ * arrives while the RSS item is still being processed.
+ */
+export class PendingWebhooksService {
+  private readonly _config: PendingWebhooksConfig
+  private isRunning = false
+  private _processingWebhooks = false
+  private _cleaningUp = false
+
+  constructor(
+    private readonly log: FastifyBaseLogger,
+    private readonly fastify: FastifyInstance,
+    config?: Partial<PendingWebhooksConfig>,
+  ) {
+    this._config = {
+      retryInterval: 20, // Process every 20 seconds
+      maxAge: 10, // Keep webhooks for max 10 minutes
+      cleanupInterval: 60, // Clean up expired webhooks every minute
+      ...config,
+    }
+  }
+
+  /**
+   * Get the configuration (for access by other services/routes)
+   */
+  get config(): PendingWebhooksConfig {
+    return this._config
+  }
+
+  /**
+   * Initialize the pending webhooks service
+   */
+  async initialize(): Promise<void> {
+    // Schedule the processing job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-processor',
+      async (jobName: string) => {
+        const processed = await this.processWebhooks()
+        // Only log completion if we actually processed something
+        if (processed > 0) {
+          this.log.info(`Processed ${processed} pending webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every 20 seconds
+    await this.fastify.db.updateSchedule('pending-webhooks-processor', {
+      type: 'interval',
+      config: { seconds: this._config.retryInterval },
+      enabled: true,
+    })
+
+    // Schedule the cleanup job with a wrapper that suppresses logs when no work is done
+    await this.fastify.scheduler.scheduleJob(
+      'pending-webhooks-cleanup',
+      async (jobName: string) => {
+        const cleaned = await this.cleanupExpired()
+        // Only log if we actually cleaned something
+        if (cleaned > 0) {
+          this.log.info(`Cleaned up ${cleaned} expired webhooks`)
+        }
+      },
+    )
+
+    // Update the schedule to run every minute
+    await this.fastify.db.updateSchedule('pending-webhooks-cleanup', {
+      type: 'interval',
+      config: { minutes: 1 },
+      enabled: true,
+    })
+
+    this.isRunning = true
+
+    // Process any existing webhooks immediately on startup
+    await this.processWebhooks()
+  }
+
+  /**
+   * Process pending webhooks
+   * @returns Number of webhooks processed
+   */
+  private async processWebhooks(): Promise<number> {
+    if (!this.isRunning) {
+      return 0
+    }
+
+    // Prevent overlapping executions
+    if (this._processingWebhooks) {
+      this.log.debug(
+        'Webhook processing already in progress, skipping this cycle',
+      )
+      return 0
+    }
+
+    this._processingWebhooks = true
+
+    try {
+      const webhooks = await this.fastify.db.getPendingWebhooks()
+
+      if (webhooks.length === 0) {
+        return 0 // Silent return when no webhooks to process
+      }
+
+      let processedCount = 0
+
+      for (const webhook of webhooks) {
+        try {
+          // Check if RSS items exist for this GUID
+          const matchingItems = await this.fastify.db.getWatchlistItemsByGuid(
+            webhook.guid,
+          )
+
+          if (matchingItems.length > 0) {
+            this.log.info(
+              `Found ${matchingItems.length} items for ${webhook.guid}, processing webhook`,
+            )
+
+            // Process based on instance type
+            if (webhook.media_type === 'movie') {
+              // For movies, we don't need to parse the payload
+              const mediaInfo = {
+                type: 'movie' as const,
+                guid: webhook.guid,
+                title: webhook.title,
+              }
path: src/services/pending-webhooks.service.ts
user: login: coderabbitai[bot]
body: @jamcalli Thanks for explaining your rationale for the serial processing approach. Your design trade-off makes sense - prioritizing simple error handling and built-in rate limiting is a pragmatic choice for the initial implementation. The 20-second batch schedule provides a good balance, and optimizing for concurrency later if needed is a solid engineering approach. 👍


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@jamcalli Thanks for explaining your rationale for the serial processing approach. Your design trade-off makes sense - prioritizing simple error handling and built-in rate limiting is a pragmatic choice for the initial implementation. The 20-second batch schedule provides a good balance, and optimizing for concurrency later if needed is a solid engineering approach. 👍</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-16T06:48:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5902,4 +5915,127 @@ export class DatabaseService {
       return true
     }
   }
+
+  // ============================================
+  // Pending Webhooks Methods
+  // ============================================
+
+  /**
+   * Creates a new pending webhook entry
+   *
+   * @param webhook - The webhook data to create
+   * @returns Promise resolving to the created webhook with its ID
+   */
+  async createPendingWebhook(
+    webhook: PendingWebhookCreate,
+  ): Promise<PendingWebhook> {
+    try {
+      const [id] = await this.knex('pending_webhooks')
+        .insert({
+          ...webhook,
+          received_at: this.timestamp,
+          expires_at: webhook.expires_at.toISOString(),
+          payload: JSON.stringify(webhook.payload),
+        })
+        .returning('id')
+
+      return {
+        id: id.id || id,
+        ...webhook,
+        received_at: new Date(),
+        expires_at: webhook.expires_at,
+      }
+    } catch (error) {
+      this.log.error('Error creating pending webhook:', error)
+      throw new Error('Failed to create pending webhook')
+    }
+  }
+
+  /**
+   * Gets all pending webhooks that haven't expired
+   *
+   * @param limit - Optional limit of results
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getPendingWebhooks(limit = 50): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where('expires_at', '>', new Date().toISOString())
+        .orderBy('received_at', 'asc')
+        .limit(limit)
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: JSON.parse(webhook.payload || '{}'),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error('Error getting pending webhooks:', error)
+      return []
+    }
+  }
+
+  /**
+   * Deletes a processed webhook
+   *
+   * @param id - The webhook ID to delete
+   * @returns Promise resolving to boolean indicating success
+   */
+  async deletePendingWebhook(id: number): Promise<boolean> {
+    try {
+      const deleted = await this.knex('pending_webhooks').where({ id }).delete()
+
+      return deleted > 0
+    } catch (error) {
+      this.log.error(`Error deleting pending webhook ${id}:`, error)
+      return false
+    }
+  }
+
+  /**
+   * Cleans up expired webhooks
+   *
+   * @returns Promise resolving to number of deleted webhooks
+   */
+  async cleanupExpiredWebhooks(): Promise<number> {
+    try {
+      const deleted = await this.knex('pending_webhooks')
+        .where('expires_at', '<', new Date().toISOString())
+        .delete()
+
+      return deleted
+    } catch (error) {
+      this.log.error('Error cleaning up expired webhooks:', error)
+      return 0
+    }
+  }
+
+  /**
+   * Gets webhooks by GUID and media type
+   *
+   * @param guid - The GUID to search for
+   * @param mediaType - The media type (movie or show)
+   * @returns Promise resolving to array of pending webhooks
+   */
+  async getWebhooksByGuid(
+    guid: string,
+    mediaType: 'movie' | 'show',
+  ): Promise<PendingWebhook[]> {
+    try {
+      const webhooks = await this.knex('pending_webhooks')
+        .where({ guid, media_type: mediaType })
+        .where('expires_at', '>', new Date().toISOString())
+
+      return webhooks.map((webhook) => ({
+        ...webhook,
+        payload: JSON.parse(webhook.payload || '{}'),
+        received_at: new Date(webhook.received_at),
+        expires_at: new Date(webhook.expires_at),
+      }))
+    } catch (error) {
+      this.log.error(`Error getting webhooks for ${guid}:`, error)
+      return []
+    }
+  }
path: src/services/database.service.ts
user: login: coderabbitai[bot]
body: <details>
<summary>🧩 Analysis chain</summary>

The script returned:

```
  async getWebhooksByGuid(
    guid: string,
    mediaType: 'movie' | 'show',
  ): Promise<PendingWebhook[]> {
    try {
      const webhooks = await this.knex('pending_webhooks')
        .where({ guid, media_type: mediaType })
        .where('expires_at', '>', new Date().toISOString())

      return webhooks.map((webhook) => ({
        ...webhook,
        payload: (() => {
          try {
            return JSON.parse(webhook.payload ?? '{}')
          } catch (e) {
            this.log.warn(
              { webhookId: webhook.id, guid, error: e },
              'Malformed webhook payload - using empty object',
            )
            return {}
          }
        })(),
        received_at: new Date(webhook.received_at),
        expires_at: new Date(webhook.expires_at),
      }))
    } catch (error) {
      this.log.error(`Error getting webhooks for ${guid}:`, error)
      return []
```



@jamcalli Great job! I've verified the implementation and the issue has been properly addressed. The changes look excellent:

1. You've wrapped the JSON parsing in a try-catch block
2. You're providing a fallback empty object when parsing fails
3. Your error logging is comprehensive, including the webhook ID, GUID, and error details

This implementation is robust and will prevent a single corrupt payload from causing the entire method to fail. Thanks for making this improvement!



---

🏁 Script executed:
```
#!/bin/bash
# Check the current implementation of getWebhooksByGuid in the database service
grep -A 25 "getWebhooksByGuid" src/services/database.service.ts
```

Length of output: 879

---



</details>

@jamcalli The implementation looks excellent! You've properly addressed the JSON parsing vulnerability by:

1. Adding a try-catch block around the JSON.parse call
2. Including detailed error logging with the webhook ID, GUID, and error information
3. Providing a fallback empty object when parsing fails

Your implementation is robust and will prevent a single corrupt payload from causing the entire method to fail. The error handling is comprehensive and follows best practices.

Great job on this fix!



---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

The entire content of the file `src/services/database.service.ts` is provided and is highly detailed. Below are the relevant code snippets from the possibly relevant code snippets that provide context or type definitions used in this file:

---

### From `src/types/config.types.ts` (lines 1-117)

```ts
export interface Config {
  // System Config
  baseUrl: string
  port: number
  dbPath: string
  cookieSecret: string
  cookieName: string
  cookieSecured: boolean
  logLevel: LogLevel
  closeGraceDelay: number
  authenticationMethod: 'required' | 'requiredExceptLocal' | 'disabled'
  rateLimitMax: number
  syncIntervalSeconds: number
  queueProcessDelaySeconds: number
  // Discord Config
  discordWebhookUrl: string
  discordBotToken: string
  discordClientId: string
  discordGuildId: string
  // Apprise Config
  enableApprise: boolean
  appriseUrl: string
  systemAppriseUrl: string
  // General Notifications
  queueWaitTime: number
  newEpisodeThreshold: number
  upgradeBufferTime: number
  // Pending Webhooks Config
  pendingWebhookRetryInterval: number
  pendingWebhookMaxAge: number
  pendingWebhookCleanupInterval: number
  // Sonarr Config
  sonarrBaseUrl: string
  sonarrApiKey: string
  sonarrQualityProfile: string
  sonarrRootFolder: string
  sonarrBypassIgnored: boolean
  sonarrSeasonMonitoring: string
  sonarrMonitorNewItems: 'all' | 'none'
  sonarrTags: string[]
  // Radarr Config
  radarrBaseUrl: string
  radarrApiKey: string
  radarrQualityProfile: string
  radarrRootFolder: string
  radarrBypassIgnored: boolean
  radarrTags: string[]
  // Plex Config
  plexTokens: string[]
  skipFriendSync: boolean
  // Delete Config
  deletionMode: DeletionMode
  deleteMovie: boolean
  deleteEndedShow: boolean
  deleteContinuingShow: boolean
  deleteFiles: boolean
  respectUserSyncSetting: boolean
  deleteSyncNotify: DeleteSyncNotifyOption
  maxDeletionPrevention: number
  enablePlexPlaylistProtection: boolean
  plexProtectionPlaylistName: string
  plexServerUrl?: string // Optional: Only set this if automatic discovery fails, URL is auto-detected in most cases
  // RSS Config
  selfRss?: string
  friendsRss?: string
  // Tagging Config
  tagUsersInSonarr: boolean
  tagUsersInRadarr: boolean
  cleanupOrphanedTags: boolean
  tagPrefix: string
  // Special tag for removed content
  removedTagMode: RemovedTagMode
  removedTagPrefix: string
  // Ready state
  _isReady: boolean
}

export interface User {
  id: number
  name: string
  apprise: string | null
  alias: string | null
  discord_id: string | null
  notify_apprise: boolean
  notify_discord: boolean
  can_sync: boolean
  is_primary_token?: boolean
  created_at?: string
  updated_at?: string
}
```

---

### From `src/types/sonarr.types.ts` (lines 134-153, 173-179, 181-207)

```ts
export interface SonarrInstance {
  id: number
  name: string
  baseUrl: string
  apiKey: string
  qualityProfile?: string | number | null
  rootFolder?: string | null | undefined
  bypassIgnored: boolean
  seasonMonitoring: string
  monitorNewItems: 'all' | 'none'
  tags: string[]
  isDefault: boolean
  syncedInstances?: number[]
  searchOnAdd?: boolean
  seriesType?: 'standard' | 'anime' | 'daily'
  data?: {
    qualityProfiles?: Array<{ id: number; name: string }>
    rootFolders?: Array<{ path: string }>
  }
}

export interface SonarrEpisodeSchema {
  episodeNumber: number
  seasonNumber: number
  title: string
  overview?: string
  airDateUtc: string // Format: "2025-02-06T08:00:00Z"
}

export interface NotificationResult {
  user: {
    apprise: string | null
    discord_id: string | null
    notify_apprise: boolean
    notify_discord: boolean
    name: string
    id: number
    alias: string | null
    can_sync: boolean
  }
  notification: MediaNotification
}

export interface MediaNotification {
  type: 'movie' | 'show'
  title: string
  username: string
  posterUrl?: string
  episodeDetails?: {
    title?: string
    overview?: string
    seasonNumber?: number
    episodeNumber?: number
    airDateUtc?: string
  }
}
```

---

### From `src/types/radarr.types.ts` (lines 27, 84-101)

```ts
export type MinimumAvailability = 'announced' | 'inCinemas' | 'released'

export interface RadarrInstance {
  id: number
  name: string
  baseUrl: string
  apiKey: string
  qualityProfile?: string | number | null
  rootFolder?: string | null | undefined
  bypassIgnored: boolean
  tags: string[]
  isDefault: boolean
  syncedInstances?: number[]
  searchOnAdd?: boolean
  minimumAvailability?: MinimumAvailability
  data?: {
    qualityProfiles?: Array<{ id: number; name: string }>
    rootFolders?: Array<{ path: string }>
  }
}
```

---

### From `src/types/watchlist-status.types.ts` (lines 20-24, 26-42, 44-54)

```ts
export interface WatchlistInstanceStatus {
  status: 'pending' | 'requested' | 'grabbed' | 'notified'
  last_notified_at: string | null
  is_primary: boolean
}

export interface MainTableField {
  added?: string | null
  status?: 'pending' | 'requested' | 'grabbed' | 'notified'
  series_status?: 'continuing' | 'ended' | null
  movie_status?: 'available' | 'unavailable' | null
  last_notified_at?: string | null
  title?: string
  thumb?: string | null
  guids?: string[] | string
  genres?: string[] | string
  [key: string]: string | number | boolean | string[] | null | undefined
}

export interface JunctionTableField {
  radarr_instance_id?: number | null
  sonarr_instance_id?: number | null
}

export type WatchlistItemUpdate = Partial<
  Omit<
    DatabaseWatchlistItem,
    'id' | 'user_id' | 'type' | 'key' | 'created_at' | 'updated_at'
  >
> & {
  radarr_instance_id?: number | null
  sonarr_instance_id?: number | null
  last_notified_at?: string | null
  syncing?: boolean | null
}
```

---

### From `src/types/pending-webhooks.types.ts` (lines 3-23)

```ts
export interface PendingWebhook {
  id?: number
  instance_type: 'radarr' | 'sonarr'
  instance_id: number | null
  guid: string
  title: string
  media_type: 'movie' | 'show'
  payload: WebhookPayload // Full webhook payload
  received_at: Date
  expires_at: Date
}

export interface PendingWebhookCreate {
  instance_type: 'radarr' | 'sonarr'
  instance_id: number | null
  guid: string
  title: string
  media_type: 'movie' | 'show'
  payload: WebhookPayload
  expires_at: Date
}
```

---

### From `src/types/router.types.ts` (lines 14-32, 76-90)

```ts
export interface RouterRule {
  id: number
  name: string
  type: string
  criteria: Record<string, unknown>
  target_type: 'sonarr' | 'radarr'
  target_instance_id: number
  root_folder?: string | null
  quality_profile?: number | null
  tags?: string[]
  order: number
  enabled: boolean
  metadata?: RadarrMovieLookupResponse | SonarrSeriesLookupResponse | null
  search_on_add?: boolean | null
  season_monitoring?: string | null
  series_type?: 'standard' | 'anime' | 'daily' | null
  created_at: string
  updated_at: string
}

export interface Condition {
  field: string
  operator: ComparisonOperator
  value: unknown
  negate?: boolean
  _cid?: string
}

export interface ConditionGroup {
  operator: LogicalOperator
  conditions: Array<Condition | ConditionGroup>
  negate?: boolean
  _cid?: string
}
```

---

### From `src/utils/guid-handler.ts` (lines 8-61)

```ts
export function parseGuids(guids: string[] | string | undefined): string[] {
  if (!guids) return []

  if (Array.isArray(guids))
    return [
      ...new Set(
        guids
          .map((g) => (typeof g === 'string' ? g.trim() : ''))
          .filter((g): g is string => typeof g === 'string' && g.length > 0),
      ),
    ]

  if (typeof guids === 'string') {
    // Try strict JSON array first
    try {
      const parsed = JSON.parse(guids)
      if (Array.isArray(parsed)) {
        return [
          ...new Set(
            parsed
              .map((p) => (typeof p === 'string' ? p.trim() : ''))
              .filter(
                (p): p is string => typeof p === 'string' && p.length > 0,
              ),
          ),
        ]
      }
    } catch {
      /* fall‑through */
    }

    // Handle string input
    const trimmed = guids.trim()
    if (trimmed === '') {
      return []
    }

    // Handle comma-separated list
    if (trimmed.includes(',')) {
      return [
        ...new Set(
          trimmed
            .split(',')
            .map((g) => g.trim())
            .filter((g) => !!g),
        ),
      ]
    }

    // Last resort: treat as single GUID
    return [trimmed]
  }
  return []
}
```

---

### From `src/types/errors.ts` (lines 9-17)

```ts
export class DefaultInstanceError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'DefaultInstanceError'

    // This is needed for correct instanceof checks in TypeScript
    Object.setPrototypeOf(this, DefaultInstanceError.prototype)
  }
}
```

---

### From `src/schemas/auth/auth.ts` (lines 3-9)

```ts
export interface AdminUser {
  id: number
  username: string
  email: string
  password: string
  role: string
}
```

---

### From `src/types/plex.types.ts` (lines 21-29, 66-68)

```ts
export interface WatchlistItem {
  title: string
  plexKey: string
  type: string
  thumb: string
  guids: string[]
  genres: string[]
  status: 'pending'
}

export interface TokenWatchlistItem extends Item {
  id: string
}
```

---

These snippets provide the type definitions and error classes used throughout the `DatabaseService` class in `src/services/database.service.ts`. They are relevant for understanding the data structures, method parameters, and return types used in the service.

No other snippets from the provided possibly relevant code snippets are directly related to the content or functionality of `database.service.ts`.

</details>

---

<!-- This is an auto-generated comment: tweet message by coderabbit.ai -->

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Protect%20your%20webhook%20processing%20from%20corrupt%20payloads!%20%40coderabbitai%20recommends%20wrapping%20JSON.parse%20in%20try-catch%20to%20skip%20bad%20data%20without%20breaking%20the%20flow.%20Reliable%20%26%20robust%20code!%20%F0%9F%9B%A1%EF%B8%8F%F0%9F%9A%80&url=https%3A%2F%2Fgithub.com%2Fjamcalli%2FPulsarr%2Fpull%2F195%23discussion_r2092254725)?

Thank you for using CodeRabbit!

<!-- end of auto-generated comment: tweet message by coderabbit.ai -->


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer><details>
<summary>🧩 Analysis chain</summary>

The script returned:

```
  async getWebhooksByGuid(
    guid: string,
    mediaType: 'movie' | 'show',
  ): Promise<PendingWebhook[]> {
    try {
      const webhooks = await this.knex('pending_webhooks')
        .where({ guid, media_type: mediaType })
        .where('expires_at', '>', new Date().toISOString())

      return webhooks.map((webhook) => ({
        ...webhook,
        payload: (() => {
          try {
            return JSON.parse(webhook.payload ?? '{}')
          } catch (e) {
            this.log.warn(
              { webhookId: webhook.id, guid, error: e },
              'Malformed webhook payload - using empty object',
            )
            return {}
          }
        })(),
        received_at: new Date(webhook.received_at),
        expires_at: new Date(webhook.expires_at),
      }))
    } catch (error) {
      this.log.error(`Error getting webhooks for ${guid}:`, error)
      return []
```



@jamcalli Great job! I've verified the implementation and the issue has been properly addressed. The changes look excellent:

1. You've wrapped the JSON parsing in a try-catch block
2. You're providing a fallback empty object when parsing fails
3. Your error logging is comprehensive, including the webhook ID, GUID, and error details

This implementation is robust and will prevent a single corrupt payload from causing the entire method to fail. Thanks for making this improvement!



---

🏁 Script executed:
```
#!/bin/bash
# Check the current implementation of getWebhooksByGuid in the database service
grep -A 25 "getWebhooksByGuid" src/services/database.service.ts
```

Length of output: 879

---



</details>

@jamcalli The implementation looks excellent! You've properly addressed the JSON parsing vulnerability by:

1. Adding a try-catch block around the JSON.parse call
2. Including detailed error logging with the webhook ID, GUID, and error information
3. Providing a fallback empty object when parsing fails

Your implementation is robust and will prevent a single corrupt payload from causing the entire method to fail. The error handling is comprehensive and follows best practices.

Great job on this fix!



---

<details>
<summary>🧬 Code Graph Analysis Results</summary>

The entire content of the file `src/services/database.service.ts` is provided and is highly detailed. Below are the relevant code snippets from the possibly relevant code snippets that provide context or type definitions used in this file:

---

### From `src/types/config.types.ts` (lines 1-117)

```ts
export interface Config {
  // System Config
  baseUrl: string
  port: number
  dbPath: string
  cookieSecret: string
  cookieName: string
  cookieSecured: boolean
  logLevel: LogLevel
  closeGraceDelay: number
  authenticationMethod: 'required' | 'requiredExceptLocal' | 'disabled'
  rateLimitMax: number
  syncIntervalSeconds: number
  queueProcessDelaySeconds: number
  // Discord Config
  discordWebhookUrl: string
  discordBotToken: string
  discordClientId: string
  discordGuildId: string
  // Apprise Config
  enableApprise: boolean
  appriseUrl: string
  systemAppriseUrl: string
  // General Notifications
  queueWaitTime: number
  newEpisodeThreshold: number
  upgradeBufferTime: number
  // Pending Webhooks Config
  pendingWebhookRetryInterval: number
  pendingWebhookMaxAge: number
  pendingWebhookCleanupInterval: number
  // Sonarr Config
  sonarrBaseUrl: string
  sonarrApiKey: string
  sonarrQualityProfile: string
  sonarrRootFolder: string
  sonarrBypassIgnored: boolean
  sonarrSeasonMonitoring: string
  sonarrMonitorNewItems: 'all' | 'none'
  sonarrTags: string[]
  // Radarr Config
  radarrBaseUrl: string
  radarrApiKey: string
  radarrQualityProfile: string
  radarrRootFolder: string
  radarrBypassIgnored: boolean
  radarrTags: string[]
  // Plex Config
  plexTokens: string[]
  skipFriendSync: boolean
  // Delete Config
  deletionMode: DeletionMode
  deleteMovie: boolean
  deleteEndedShow: boolean
  deleteContinuingShow: boolean
  deleteFiles: boolean
  respectUserSyncSetting: boolean
  deleteSyncNotify: DeleteSyncNotifyOption
  maxDeletionPrevention: number
  enablePlexPlaylistProtection: boolean
  plexProtectionPlaylistName: string
  plexServerUrl?: string // Optional: Only set this if automatic discovery fails, URL is auto-detected in most cases
  // RSS Config
  selfRss?: string
  friendsRss?: string
  // Tagging Config
  tagUsersInSonarr: boolean
  tagUsersInRadarr: boolean
  cleanupOrphanedTags: boolean
  tagPrefix: string
  // Special tag for removed content
  removedTagMode: RemovedTagMode
  removedTagPrefix: string
  // Ready state
  _isReady: boolean
}

export interface User {
  id: number
  name: string
  apprise: string | null
  alias: string | null
  discord_id: string | null
  notify_apprise: boolean
  notify_discord: boolean
  can_sync: boolean
  is_primary_token?: boolean
  created_at?: string
  updated_at?: string
}
```

---

### From `src/types/sonarr.types.ts` (lines 134-153, 173-179, 181-207)

```ts
export interface SonarrInstance {
  id: number
  name: string
  baseUrl: string
  apiKey: string
  qualityProfile?: string | number | null
  rootFolder?: string | null | undefined
  bypassIgnored: boolean
  seasonMonitoring: string
  monitorNewItems: 'all' | 'none'
  tags: string[]
  isDefault: boolean
  syncedInstances?: number[]
  searchOnAdd?: boolean
  seriesType?: 'standard' | 'anime' | 'daily'
  data?: {
    qualityProfiles?: Array<{ id: number; name: string }>
    rootFolders?: Array<{ path: string }>
  }
}

export interface SonarrEpisodeSchema {
  episodeNumber: number
  seasonNumber: number
  title: string
  overview?: string
  airDateUtc: string // Format: "2025-02-06T08:00:00Z"
}

export interface NotificationResult {
  user: {
    apprise: string | null
    discord_id: string | null
    notify_apprise: boolean
    notify_discord: boolean
    name: string
    id: number
    alias: string | null
    can_sync: boolean
  }
  notification: MediaNotification
}

export interface MediaNotification {
  type: 'movie' | 'show'
  title: string
  username: string
  posterUrl?: string
  episodeDetails?: {
    title?: string
    overview?: string
    seasonNumber?: number
    episodeNumber?: number
    airDateUtc?: string
  }
}
```

---

### From `src/types/radarr.types.ts` (lines 27, 84-101)

```ts
export type MinimumAvailability = 'announced' | 'inCinemas' | 'released'

export interface RadarrInstance {
  id: number
  name: string
  baseUrl: string
  apiKey: string
  qualityProfile?: string | number | null
  rootFolder?: string | null | undefined
  bypassIgnored: boolean
  tags: string[]
  isDefault: boolean
  syncedInstances?: number[]
  searchOnAdd?: boolean
  minimumAvailability?: MinimumAvailability
  data?: {
    qualityProfiles?: Array<{ id: number; name: string }>
    rootFolders?: Array<{ path: string }>
  }
}
```

---

### From `src/types/watchlist-status.types.ts` (lines 20-24, 26-42, 44-54)

```ts
export interface WatchlistInstanceStatus {
  status: 'pending' | 'requested' | 'grabbed' | 'notified'
  last_notified_at: string | null
  is_primary: boolean
}

export interface MainTableField {
  added?: string | null
  status?: 'pending' | 'requested' | 'grabbed' | 'notified'
  series_status?: 'continuing' | 'ended' | null
  movie_status?: 'available' | 'unavailable' | null
  last_notified_at?: string | null
  title?: string
  thumb?: string | null
  guids?: string[] | string
  genres?: string[] | string
  [key: string]: string | number | boolean | string[] | null | undefined
}

export interface JunctionTableField {
  radarr_instance_id?: number | null
  sonarr_instance_id?: number | null
}

export type WatchlistItemUpdate = Partial<
  Omit<
    DatabaseWatchlistItem,
    'id' | 'user_id' | 'type' | 'key' | 'created_at' | 'updated_at'
  >
> & {
  radarr_instance_id?: number | null
  sonarr_instance_id?: number | null
  last_notified_at?: string | null
  syncing?: boolean | null
}
```

---

### From `src/types/pending-webhooks.types.ts` (lines 3-23)

```ts
export interface PendingWebhook {
  id?: number
  instance_type: 'radarr' | 'sonarr'
  instance_id: number | null
  guid: string
  title: string
  media_type: 'movie' | 'show'
  payload: WebhookPayload // Full webhook payload
  received_at: Date
  expires_at: Date
}

export interface PendingWebhookCreate {
  instance_type: 'radarr' | 'sonarr'
  instance_id: number | null
  guid: string
  title: string
  media_type: 'movie' | 'show'
  payload: WebhookPayload
  expires_at: Date
}
```

---

### From `src/types/router.types.ts` (lines 14-32, 76-90)

```ts
export interface RouterRule {
  id: number
  name: string
  type: string
  criteria: Record<string, unknown>
  target_type: 'sonarr' | 'radarr'
  target_instance_id: number
  root_folder?: string | null
  quality_profile?: number | null
  tags?: string[]
  order: number
  enabled: boolean
  metadata?: RadarrMovieLookupResponse | SonarrSeriesLookupResponse | null
  search_on_add?: boolean | null
  season_monitoring?: string | null
  series_type?: 'standard' | 'anime' | 'daily' | null
  created_at: string
  updated_at: string
}

export interface Condition {
  field: string
  operator: ComparisonOperator
  value: unknown
  negate?: boolean
  _cid?: string
}

export interface ConditionGroup {
  operator: LogicalOperator
  conditions: Array<Condition | ConditionGroup>
  negate?: boolean
  _cid?: string
}
```

---

### From `src/utils/guid-handler.ts` (lines 8-61)

```ts
export function parseGuids(guids: string[] | string | undefined): string[] {
  if (!guids) return []

  if (Array.isArray(guids))
    return [
      ...new Set(
        guids
          .map((g) => (typeof g === 'string' ? g.trim() : ''))
          .filter((g): g is string => typeof g === 'string' && g.length > 0),
      ),
    ]

  if (typeof guids === 'string') {
    // Try strict JSON array first
    try {
      const parsed = JSON.parse(guids)
      if (Array.isArray(parsed)) {
        return [
          ...new Set(
            parsed
              .map((p) => (typeof p === 'string' ? p.trim() : ''))
              .filter(
                (p): p is string => typeof p === 'string' && p.length > 0,
              ),
          ),
        ]
      }
    } catch {
      /* fall‑through */
    }

    // Handle string input
    const trimmed = guids.trim()
    if (trimmed === '') {
      return []
    }

    // Handle comma-separated list
    if (trimmed.includes(',')) {
      return [
        ...new Set(
          trimmed
            .split(',')
            .map((g) => g.trim())
            .filter((g) => !!g),
        ),
      ]
    }

    // Last resort: treat as single GUID
    return [trimmed]
  }
  return []
}
```

---

### From `src/types/errors.ts` (lines 9-17)

```ts
export class DefaultInstanceError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'DefaultInstanceError'

    // This is needed for correct instanceof checks in TypeScript
    Object.setPrototypeOf(this, DefaultInstanceError.prototype)
  }
}
```

---

### From `src/schemas/auth/auth.ts` (lines 3-9)

```ts
export interface AdminUser {
  id: number
  username: string
  email: string
  password: string
  role: string
}
```

---

### From `src/types/plex.types.ts` (lines 21-29, 66-68)

```ts
export interface WatchlistItem {
  title: string
  plexKey: string
  type: string
  thumb: string
  guids: string[]
  genres: string[]
  status: 'pending'
}

export interface TokenWatchlistItem extends Item {
  id: string
}
```

---

These snippets provide the type definitions and error classes used throughout the `DatabaseService` class in `src/services/database.service.ts`. They are relevant for understanding the data structures, method parameters, and return types used in the service.

No other snippets from the provided possibly relevant code snippets are directly related to the content or functionality of `database.service.ts`.

</details>

---

&lt;!-- This is an auto-generated comment: tweet message by coderabbit.ai --&gt;

If you found this review helpful, would you consider giving us a shout-out on [X](https://twitter.com/intent/tweet?related=CodeRabbitAI&text=Protect%20your%20webhook%20processing%20from%20corrupt%20payloads!%20%40coderabbitai%20recommends%20wrapping%20JSON.parse%20in%20try-catch%20to%20skip%20bad%20data%20without%20breaking%20the%20flow.%20Reliable%20%26%20robust%20code!%20%F0%9F%9B%A1%EF%B8%8F%F0%9F%9A%80&url=https%3A%2F%2Fgithub.com%2Fjamcalli%2FPulsarr%2Fpull%2F195%23discussion_r2092254725)?

Thank you for using CodeRabbit!

&lt;!-- end of auto-generated comment: tweet message by coderabbit.ai --&gt;

</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-16T06:50:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-15T23:48:59Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This update introduces a comprehensive "pending webhooks" feature to address the race condition where webhook notifications arrive before the system has matched new media items via RSS feeds. It adds a new database table, service, plugin, configuration options, and utility functions for queuing, processing, and cleaning up pending webhooks. Notification and sync services are updated to integrate with this new workflow, and logging is refined in several services.

## Changes

| File(s)                                                                                                    | Change Summary |
|------------------------------------------------------------------------------------------------------------|---------------|
| migrations/migrations/026_20250515_add_pending_webhooks.ts                                                 | Migration script creating the `pending_webhooks` table with schema, indexes, and constraints; provides up/down methods. |
| src/types/pending-webhooks.types.ts                                                                        | Introduces interfaces: `PendingWebhook`, `PendingWebhookCreate`, and `PendingWebhooksConfig` for type safety. |
| src/types/config.types.ts<br>src/schemas/config/config.schema.ts<br>src/plugins/external/env.ts            | Adds three new numeric config properties: `pendingWebhookRetryInterval`, `pendingWebhookMaxAge`, `pendingWebhookCleanupInterval`. |
| src/services/database.service.ts                                                                           | Adds methods to create, retrieve, delete, and clean up pending webhook records; integrates new config properties. |
| src/services/pending-webhooks.service.ts                                                                   | Adds `PendingWebhooksService` class for scheduling, processing, and cleaning up pending webhooks, with concurrency and error handling. |
| src/plugins/custom/pending-webhooks.ts                                                                     | Introduces a Fastify plugin to initialize and attach `PendingWebhooksService` to the Fastify instance, with dependency declarations. |
| src/utils/webhookQueue.ts                                                                                  | Adds `queuePendingWebhook` function to queue webhooks into the database if no matching media is found; updates `checkForUpgrade` signature. |
| src/routes/v1/notifications/webhook.ts                                                                     | Updates webhook handlers to queue webhooks via `queuePendingWebhook` when no matching media is found, deferring notification processing; adds instance ID handling. |
| src/services/apprise-notifications.service.ts<br>src/services/discord-notifications.service.ts             | Adds info-level logging after successfully sending notifications to users. |
| src/services/scheduler.service.ts                                                                          | Changes job execution start/completion logs from `info` to `debug` level. |
| src/plugins/custom/delete-sync.ts<br>src/services/watchlist-workflow.service.ts                            | Adds conditional logic to only run sync/reconciliation if necessary, with selective logging. |
| src/client/features/notifications/components/general/general-settings-form.tsx                             | Refactors value conversion logic into a memoized helper function for clarity and reuse. |
| src/types/webhook.types.ts                                                                                  | Adds optional `instanceId` property to `SeasonQueue` interface. |

## Sequence Diagram(s)

### Pending Webhook Handling and Processing

```mermaid
sequenceDiagram
    participant WebhookSource as Radarr/Sonarr
    participant Fastify as Fastify Server
    participant WebhookHandler as Webhook Route Handler
    participant DB as DatabaseService
    participant PendingWebhooks as PendingWebhooksService
    participant Notification as Notification Services

    WebhookSource->>Fastify: Send webhook (movie/show)
    Fastify->>WebhookHandler: Route webhook request
    WebhookHandler->>DB: Query for matching media by GUID
    alt No matching media found
        WebhookHandler->>DB: Insert pending webhook
        WebhookHandler->>WebhookSource: Return success
    else Matching media found
        WebhookHandler->>Notification: Send notifications
        WebhookHandler->>WebhookSource: Return success
    end

    Note over PendingWebhooks: Scheduled job runs every retryInterval

    PendingWebhooks->>DB: Get pending webhooks
    loop For each pending webhook
        PendingWebhooks->>DB: Query for matching media by GUID
        alt Match found
            PendingWebhooks->>Notification: Send notifications
            PendingWebhooks->>DB: Delete processed webhook
        else No match
            PendingWebhooks-->>PendingWebhooks: Skip for now
        end
    end

    Note over PendingWebhooks: Cleanup job runs every cleanupInterval

    PendingWebhooks->>DB: Delete expired webhooks
```

### Configuration Flow for Pending Webhooks

```mermaid
sequenceDiagram
    participant Admin as Admin/Operator
    participant Config as Config Schema/Env
    participant Fastify as Fastify Server
    participant PendingWebhooks as PendingWebhooksService

    Admin->>Config: Set pendingWebhookRetryInterval, MaxAge, CleanupInterval
    Fastify->>Config: Load configuration
    Fastify->>PendingWebhooks: Initialize with config values
    PendingWebhooks->>PendingWebhooks: Use config for scheduling jobs
```

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: .coderabbit.yml**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 820843c7e61ec4543de0fc3a5bc7a5b4febaf240 and 7c01570f87fd60656648e448c30c1f69fe47e05b.

</details>

<details>
<summary>📒 Files selected for processing (7)</summary>

* `migrations/migrations/026_20250515_add_pending_webhooks.ts` (1 hunks)
* `src/routes/v1/notifications/webhook.ts` (8 hunks)
* `src/schemas/config/config.schema.ts` (1 hunks)
* `src/services/database.service.ts` (4 hunks)
* `src/services/pending-webhooks.service.ts` (1 hunks)
* `src/types/webhook.types.ts` (1 hunks)
* `src/utils/webhookQueue.ts` (4 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* src/types/webhook.types.ts

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (6)</summary>

* src/schemas/config/config.schema.ts
* src/routes/v1/notifications/webhook.ts
* migrations/migrations/026_20250515_add_pending_webhooks.ts
* src/utils/webhookQueue.ts
* src/services/pending-webhooks.service.ts
* src/services/database.service.ts

</details>

</details>
<!-- internal state start -->


<!-- DwQgtGAEAqAWCWBnSTIEMB26CuAXA9mAOYCmGJATmriQCaQDG+Ats2bgFyQAOFk+AIwBWJBrngA3EsgEBPRvlqU0AgfFwA6NPEgQAfACgjoCEYDEZyAAUASpETZWaCrKNxU3bABsvkCiQBHbGlcSHFcLzpIACIAQVp6bjJaeAwiSAB3EgFYfHwAa2RESgl4BhJ0DHpEBlg6bwrENAAzElx5WtF8yGb8PiVImjBEWQwGaJQMXApFbHLkXAz8SGZtLFbqbH8F5dw6628mij5efBExDSMAMXgKRFwAGhRQtATkNEhyDOtk1PSAdWyuQKyAAyiUyhUlIh4ERyPQCJBYJhaJE/Ghygoqup4PgMDI2lkyJMmMw/pkgXlCpV6DZQaCeiQoqd5jC0hoYAgihDMTRmNw+s54F55Pc+tIKTkqQtkS9jpIKgISL1/JA6Qz1CRmO9VatcJ0EctsMU7hyAJKhFnSYoy6QVJLYtKS4HUkhSFyQABMAAZ7KI8bRkBl1LAcAQ9WU/G0KPAJSiSV5sND0JAAIzesBkjB4CokAAe3Fu1Fx6z69gI/noWSlII58RS4jxaB8sieHwYkUw2G4kCEgj82HxkDdlHkADZfcUmFUdlHmPgpMOC7cotWXYgnmQHDGncrmmVY1Mo4h8FtMasMGhSGwppcDODp7QnnsKrZhxhkWMJTU6rQGvYWjaeRUjCfYBjaEhhlGBgeETIgQLkdAEnJTAl24LwD1CH96jRMgVEiehOgYfIOXcZB/CCFdbXsA1/0RJU0Iwhh1BFd98KiUoPg+ABVM0wnwIgiEiJ5UiUB0lCPfBmkgcDGywYpcHENJEFIrlGE/UgeH8KQpneBhmIk3BmxkkhBhLAchwyOosBfGjf3/VAUiaAQCMgAQ8BMsy8UZTZtnQVV7mFXxp33IgtjoO84FfOxUAYagSCIPp4AALyiNB3k+Ehvg2XBwppBMkwlJglHLWQ0VeOp/C/J5ii8ZowG02MMieYqKlJG9cA3GT8AYRx2GLbzu1oeKuvjLdwuQDBli+TJnAwP5kDLfwiG2GE8WQED8yQJT0maQcxBLZt1FkDkADlln8Lx4voJAHAlMsan8Ldck6ilVVOUolFoUiQnJJQjOFINKAqKasKSZj9wiox9GMcAoGSfhpLQPBCFIcgqBoQiWA6rheH4YRRHEKQZA6RRlFUdQtB0GGTCgMiUHeLBkYIYgyGUTGFFYdguCob4HCcD1ENaqgKc0bRdDAQxYdMAwyRWgb8QAejljGS0QRXvU9McAH0fU9ABWb19dTfXtdeWhtfEv5tbXaUNE6jgDGiZ2DAsSBYjNVn0eu+xHFWD0pPUzBSEQNw1JVhWaJjbhQlSaZZnmFMZuGoyBHSipU7RS82HoAADK20htykQVzkyYThKJETFVVUlJclbYKYcdM61SM/YuytQ+WvE2TVDmcIWv/A68leHgf35HyEh5Fz+BaFzttyx3dIZ/xIyv219oklLjCyQ5xF0yDqgxEoIMQ3QHx8CyegJGbYJkAAcioYbjnv/g+Hvk9LxftssHwGPDt8IOcu8JJg0FIHwFe9xMDlG1rPUuVkyihjilgBi2YfALymhgMAaCrouUaNMckucwpwMgAACm7PxL0+t9aHwxDQO4ABKDBeJsHeFwWie4S9IC53CJEUu5CeyIgNjQ2ozg6EnyYflD4nDCE53gGgDesgt6QB3pqQ0aZfSiKPvQ0+exz5eEvhxW+Ep77zlKCQV+ZYP65AyPfc0LwvAnixADIcHxMGsJ8O3AAUqCAA8qdBQiZmBYHzmgMq+BXilyrhWDO+w9o+GdFSHgYSDGvAXuINgUD+TcP8OUBUFtqClyUM0ZGXgdqUNsr1Y47AwhjxID/egbiWE4PbhkkIaBsm53zIWbYZtcC51bmEdu3dCobSqPme6Nl9ikjUJeOSiNuHELnvlXOciFGbxIKXeM3kunLl6YUyhf8MkpQqEEUcPBKAqgvOUDkABhOoxEsScLWK9MgKpeT7GbAYq+kAb6JglCqbhqQoHrw2Vsqo3C1mKOUYgcG8B9xxRbD0MsoIACKAAZTUnNuDFjUBhdogzc60EvhgUue0xjzO0ifCUtkI7zMQrQGY3BCxOlsvnX4hcG6FCicM6S6glzbRUkYcwlhYhlPZmrCpYFRBXVVutBZ3S+gczLJ4FykZ2A4mkNDd2CQoiKooBzdK0Eej7XmbnbspD8jkDzFwAA0tahhXArAzDJMUYAEh8Czz0KXECqzYRyqVnStWGsta601obY2ptzaWw5UQIuNZCj20QFE5YDB/DxVAhUdljo41cuTUMvBmQz6wtEPC2M1ROirBEuMvM0gGlPOmC8oVUB6x6oLEqtKIwxgmopeZIlJLLXWrtQ6p1LqkAkHdZ62g3rJiQv9QrdWQb1ohp1nrCNJszYJBjTm+N64k0ppkkyzN3CC65uLtygtaJ4XPAFfcIVztojQxlogCgDBFYdkPLgRWOUJqK1BmWuKcl1akgFOQXSis0bKC8BBtmVAvDDDaDtRAYArlJptU7F2bsPZe3ZlEfm48FmiLSFqgwUVIBDQzf4EpYg+jUWnO6NaWADHwRgkqRYTIsBx0oJeXwZIfDjsfOWQUml4zGkoChjE5JBzqGQKQrMOYuq5C2F1KcAZEAMLcvIfMjaDpOn5XHZYHw2DzhOfQOoXgkh8HJQdHZpBcAABEkDoTCQANWMfm40hCxO3K+WnYiAzOSoGs/Moyk86N4lClsSOghzgvAhf4XKFBXG/wJmIItej6OUA5k5Zz8g/l3zfp8NhKBpJTSxKFBmWkFyzwipyLNYmACizRWhiFLi6UC1A/SvXjNsNoyArm/Pc/wLAIVYRB2I5NS+jAvkylQOZyzhyBAuJkk5q6eX3M1THuheFshyS2T3ITKRHrZ7kn8EkH2mW7jmVahyUEY9hTOBFM+fYudeu4CuH0ZgZLTXmTE9RJobAkSmQW4iN7x6BvBgy3id0HN8vSEGURkOc4FwVErIOYaR5RKSFntgYyN8YztyUB2MRQHkV8Eu7Dob8ZpwngwinWJbVocn3MsxsoIl+QzFKE6VqUYxP5WJzGdo6WlMOPoXtuJP2my+CVMiUoZYyzTnjr4Zo3y7wivduKgNs5bJE9lQuhV7aDVRBVdgNVMENXiBI1Ac642keIn1cqk4pumLvnCPIXXJO1YAG5j0O6iMFvtABxWDzZwSKQWh9igX37CwjmXlIeaxkD7Q0lDB9T6wBGBfW+9C2B4JK16mKZgitZKQS7QwJNjs09Yc9pBjGeG/bOHkIHRHJGyMl/sMavsAgkQokiHwDI6V3yfnKOokZJVHw4il+pLoBIVSo8HPNVl0rBiNGNSaUoNzrCNVPIgJ7x71+QlmuRQc5GxgBknzx1iVliS2S72EGMglgbfUgOdZqx7PyokoD0W49xIBnJjDSmBNQCoOnJQu6DtiVsevfCXlBGMK/NhH+FesgHhHgrQD7teqDCJKEAllsEOCQI9vIJDiLhZIvukLZAfpvqdNplgdPsRBtEjBgO7qZBBA1KZBdhFrCFFvMirleLJkSswTQAALLVabJPD8Er4NbjK0Cgg2LzyFbiEQS3J4hKTYB/AyGXy5wabOC5iXioEci+IYCsTXovjFA9BXREBjIpCAZQgCHmSoAoGuTEoSj8pWiVwXQn7kE8gkB1jND0IkF/A0HabaLUQEBGS+BWiIDFCNIQol5YyDivTNAurHrbDeBxb0DMYZT4aN4rDWhXgVB4hGEMHAR8hAzvQzCsjG79ACG1Zkb7Tn5yTGQUHz5MwQq16RjMZ57pBZCqhDwo43TrBoAep8CBwmGM7YgNHBQPLUjxi1SEwKgqICSdF3hYaa766Ig64yqe7yqBx+70Am5m6u6aqhwGDW6pop6lEVCrAlTrHSoC7647GG6O48DO7qpTBHGzp7BBbCjeEYaPonHPqvqKw5557AbGjhhAmxpgB5oV6/Guyio14h4cxZEBzSQt7HGxCZTfBXDpTiDNDyDAkgTZxRDZopBpBQnnr5qoBxwzB/gj5PAIK1CgIJR14ZS5xWCxqAgJqIDggUAb6bKgK7D7DYmBR4l+i8lf7Apryb5kYElYDpqBjlacEBqDb/L9ZJG2TCm4mkwYAVZEE9C+YYjdDFKlIJFLTRjARTAlDGSkI+h+iPjqZPCrB5joCaSkIHzyY0AOn86djZg9hca8nWkTh2lqYML2KMDpqemVCTAgqYiBxskckUk8l8mlyeZL4VCakQEdFf6kJ6ntgIBeDpGLFf41B/xuHHqymSIak4kQGSnQL1L5SVIcFhQBqqSoBNHRlSkVCoAvhYBE6CjKpTCCnpnVmim1lfjoDIAkl/CcnrgBYylwQgTLTbQnxRm5x4g2D4G0CyBtZJKIhGpjCwAzBTTGhGHzTiBHSpTHrxk5oznShJmQhbK+Ff6eHil8CoDymtgLGCTkgOD6TWiFaUAzB8AYhMAUCklCQnR1awS54gQe5+RiTJBkDMSTL8Avh8BNGIBcBErAFpzFByG5wIENAUD4VOSgW0DYL4C4llAKwIa8kPn1q5xoDMoxjFAUVUWAYli0XJmDIZmjmrx1lhBKJdnvC54dRllj4M4noJlcmlxNFq5wka70JrFDkmR3Gk4PEChG57FO4HEW6xjHEtq6qNKQC8X4kLlYC7ETkmQlKpE9BJEEWAmymgmF4Qk5rklcn7odahB9ksnHqmUdkCW5mYlVaWZC5TlpC3klwLIbLcLsk3mJleEDInGQANZ5g0BSF+UjmyBmj8Xjn+nUYZzLASVXmnqRWFBOrSXrj3nlClynChUnSwn/EZ4GBZ5AnmXqzabcbNiKxkASAwlV7wk4Z17VAN4om26t6HlMjBXZhsAxgwSjbNmRx1VZb6VvQVDmxln7YYClBHkdQdyrBYWlXnobnTDZWWkBleDwJnwfDGm2WBw+hiFHUJqCFoB5ixCkBXV6I3XKgmkLLpgMVPUui3I+ndg5X0J/KfWhjfU2VlILITitymHmwX7IFpUIz7Z5jbTkgLVcHmTYSrDC6nii6UAoSMG3rlLLUGqrVlis7l7CoKVipKWk43E2FqWSoaUdraXPG6VvGW7HHnTkCXBp5NWZ6AkzAKaKwSCph/qUUAYLqKxcr9WYaDW14+zIlN6onnFhwVAUY0BIQKluJZTDgEFgBbShAf4YROj3AkA9iAo2CvDOB8BmKxj5SghNjHAwAuZimrVcpVasgjzUCwAqQmVlh23PyO0iGJIghPBKhz6+3Wjkj/oIoLrPaM4lRTTfD/6rU644WgGAoD76iwAYS/6ajagrDUC1Di6XEiEfwwCCH2bd6B68T2bmilbLB6idA6gVC9Do4p2R3dA4rhKvCVZnLBD0CArFKAXMjlHx1OicTHpfBGH8gc3cIj0kBxXgVlUpniD4qfnxi2Tm1f44FJZFBzCsgoBcwpDxQii3b3ayp76Aqu1fz94UkfGTVQiY0UpuRUAHnSCAAoBFjp9Ljr4LkjUtbUgGTN1BkIYREoGNHeXaGCA0eGAyeNCPWircA6IKA4WCgxKIndRXJL/Y2SVJZlchlAYgUBQnnfA0XbHCUZpvvsTdIK/NAC5vXZAI3WaM3ZAGaK3WXQXRKFtPcL3T7agKvWPdTfFCcNPZESTQiPsAfXwEfXgQQa2cgCPeSDTVpCOLpCagapVJ8DLUnfMhEWyF0dZMemspVqDJALIG0FGEwHCKZq/RUPnbUDQ3WMhBMXvmymOeUGaMsqgOnUJpWM433X/sEMEH6OlN5DFoTLOOlCeMxBmmI8ONg2TLoqGLZElHnsWHprlZvj5j4LOLnERPkJHtxNwPLEoOCnsWhT2jZkOHsJ1rwG6LiCefIFdMRJXPsL4xUJw8klQGwH4UE8VZTFBYI+Ungxxd5CYxowJJGAnsCqfi3vQO5LHMgEQD7DLqJOWb1uOYHO3QgE6K44XdtM8FqEHb4u6F8inaYWiaAjSXMOtdZYBcZGwERkgMwGTqfrkgJPNKlPQJYyBKHQ7S7W7c/Qmj3lUBbUQJuB+NAljcoTML4Cjv3uY1M/rtoXHZEVDMKisYzZKszapXrupdJJZfsS7npVbtw0vQanrXQFhavevdOeemSvZQAAIzCUWKx4CAzy3nqooRPeFCCUkhKtVi2ekS1S0YtAb8sJr7qNUQDNWtV43pTvpNnqs6mwgaCquK2PrV5DWq2jXq3jXHFwBPTBVHIAJFazWRjY3KmQwFnkRsF7zLCnp90XH0vqLo0f3pD2uRyquHWVVUgnUuBg1WmXWPXBsFAvVvUfUA3Rv5DA34G+nhsXVzl1CmGOv62qgrSnhJD0Do5f4G3fDtQ1JTjzK4LMH0DRDMtOhlXIBKFatEAaATA26UX6P3NEuquFaaPdGXGvA/F4uiqrFM0qVwX3FkuPGVFc2Us836XaqtpyMWtWtT4zXE0wQU281Sq5jTt5xNuhSgiVpoClyxNpa+oqvHvAYasLU6vHv7qOyQC6BSXxUJqhtnXg3NhcDJQaAzVKgUCkKhmrs8aAe5wGBPtQDhVEBlWxvvUkDfu/uOD/uAcaDAfNigfgfPtQdlXJtdjcBpt/IId/uUAododeAYdC1Ksi1voYWKxMWjysUytqw6teF6v00InexInGuEaa2kbTIp4rCKBlqAFZrFBVCCF0DyLnTsUKylxDO5D9FXmxDMXjrSey1yTVX8nE6REcjOqtM77eP7DyeKAra5IRDyBKPdOo7SB3XSRspie0BqdGMlilyIpeCDI61RA87VwiduR5A+nHi2fcIOdOf4MuezofAEWn3Wilz47yJ4Ip1MYCSMyTC9BgCRBSC8a5GaRY4cWW3ReRHxJ+g5rRX7BMfeREtiZ8DkKmPDghjPn7BVc11HSD5lhEkNlleGNhcVcsQkAabXpRd/mRE+oLAUDBAcg3CX6fn8pKPUSDesixfGJnTLAdtf6aNliAVljm3kixQp7fR034sSrypEsTuktoTL0UuvHhALvJU25dsqXAK+T3RTuaVPGqpzvXfSA+4FEWn0I8ZfmRhEEXvJASeX2hfTOkpH6CcpCQz7eUcAk0deHqwpwgHFAsd0U3IOywkGsq1ccCwmtolhyoCed60n3MpKrfMXhXjkjRDut5oTBA9TJZr2Y53FCaeudXQ6fcOxxTCPOJx7AWszT+vGNiJDMrmXQ+yIh0/npRinVMkXWOmvVjyOAun1nU4g1+nnV/LVpgLDUCm+4Y2BTc5Nk43eQJYAF/L84RnmTGeBgI7ij1MTF63I3+TPMKQLL7m1BHk745F7CKCzhpr4E0BPDm+xhSBPAl71odgpvkY9jS+Qu/NgVFAxKKclsvvgW7rShFKs9tx4II2XFtAKfIAH2QA+L+Je0XkKzF52gE4YTJTRbSQ+0D2pL60QptJZLcBdT7ia78AeTdIrj0DsAAGjQQofQ1ay8W/GSIQcP2b5SWMbIcgNbHBbe947cbT8iRBiVVhnwdGyP6k+B+bdCzeZCVTrXMV/wE40CDIzS29jIdiFSOzJUlMRlr2Ju5xcA5UmjdbBXx/taJ/b96ITQ8iOvsyBSQwMGydSDvl1CUaV1wyQfY3KllCB6kZMMkHPtw2bpP87MdbaDhSXf5qhowYfXBiwn74hNf+0oCPjX2AEpR/ag9VvvQHpzf5TIsDYXPwH/hT4UisNVRDtEuCQcS82AzelwHszVEMoZAxuIhE4Yh82guBFCH53wABdfyZ9XLtQD6A8DuE0fPDqlR6R0AG2eAoQSvneAJISBzIWNB60kGJZSCx6JgPEQWSxF7GfQO3pgLaANsAAQrIEDyqE54XAUNoQOQCiDqQ3fehFEGn5N05+knD4Bsk3B5h7+4FNCAP1dzD9Iombdap40lRYt4kp5XXj7AMwG9fWqA1OKAXbKpBEiOJMbmIHCjVpohczb8k6D3qQCjI2SKnteHYD1pOqbfaZCb2VI9sq43YV7mE1mZOgcUgzCCKaAO4jsCWx3cdpsS1wG5XuM7d7ldyOKLsjKipRasYyZQrUaUywXOAe1hApkmgOXEJCz3yFs9EqDaUoRWH5xNlfeRfR9lh0BohtzSBHL9ja3/ZgcIO6fFls9VepwcuAxHYiph0g73CCguHVNlr2eF/CwOyVJdj/xeIwRPeh5PED71v6UJc4Rw1HiQHZ6MBOemFAEdwk95wD4o/A89DmXPQVVX2QNF/o6i3wsBx0wAIkQmm9S4jGKZeSAFgMTaIBSEXAgAPy/CkOlAKkc6hpFup6RLoAANoABdRke8OZHGo+BibUhLPF5HMB/2AosdG6gED+cU2UorDviPUG+lNBA/BtoB1HRCiJ0fw7UZB3xF2YXBbgjwaQiWRcAZEaQR0mEOgBCUuApiaupAAAA+kAaxJfHviqjTRdIxNhKMZHw9lWgJWjqRXsFsV1OzHOSljwGruwOOuGEavjx47BwSMGJUxleh1KfYFYxkZjOWHih7UB87wZYS0D8IKDrQ6Q0UCYJuorg0smSfYSDC64Q9fk8iSAI5hLJgVbscKJOi2DbBPk+AtkDUaEA/TltkgGUFIGZxyKRE8ilCD4FV17rtkgmGRKMkUMIAZdTI841sZMCsK5MyCjXE0O1xGL7Bck8AQsDUj3r7BeE+ROzp1xk7zIxOmgF/Ctzqb3cAeDATcMvz4DbdnRhWRXMizMJTZ+2ZdJQPJUO7TCTuUwydudy0qFZ5h5uediRn5o/EIx1HRWLR1PTuU90iYnEcmOwy4968GY5vLxwxIzR2y2nSciKLvKnCqSvPBOGWUaEuMX6TTOUDGEXAx0He6oRkFEEOaIsN+EEUaAkFWinYMQYxBsJKjYxEgRsyhG8QwEogwh5k8YH2g7UkDNhBkhQ5AP60LR6lzev3CNl1CdLK8vm6k0gFHw17y8/kXUW6mUj2xutpGtXEcB6FtKqYZwZgtYPXBfqAp3SqQBTFZJTbkgjBVYF+q5PkAekh2ZGdsoqhtCrlUgOIC8vyVt5eUO4iBGlEsHsZbAuEXeZAHPSrIilRQtEPvFwDxCFVsWvgkwXmikSgx9GiIPUbH1iEhNypQdZwR217CCAyeLTSIl+SDDotlgSwCgN0EcjlT8oxYn7if2JA1SsWJjGdnqJqL7B+h6QO/GN1cShBGyzbcKPQEMk2Tmw9aSpNZNWlAIm45yKKYkMZxlEeoM9dIKlPzjOSdBWjZuLpLxBVIqooQCZL1FJw9YpBx9I2hQEKIMMMewlAxtgQXzkgywGBQaS/VmmxowyrQfhlVJK41TEiLAY9Cj1wrrUIUgKfAoyS5QtQpi9BPhhXSdB8STmNDc5qXQmb0MZ+LdYmZm1d7Ioe6N6OaZNmfGSoYerQd6axExl7EsA8/d0ZTxEJdR+UDnNmfGPlREFCaAzWrrZGb4wMfcgKRADYhFlYRAIrEQYfFNshN8wBQ9e3GlW0SpNwGJULcVHkjjxhWZoMiWa4n0j2C/g19d8ezPlRYtXxWIN6ewFYhz0exZFUzodhbF5FGY9AZTgxzbFOyhwvM4bORhNBaMuZSFeHJAFBAFdEAdYyqauBhmqhbBqMr5nLJMHuMUqf45AH+C4TLSDx2OP8MZBqllgsm1zBJCY0ZlZl9ufHNqNZLul6juwBoysFskcROTtGr0VFldGZQhTa0X01ITEWEHNS05XJOymjOzrHD6yu/XcAXMgKYAm8+kLYHeCuZQYvAq4rwmXUvBI4MILWWQNHxqglSAibsnKUhW1JK5fxQFKFqiHPnxhyuvZJzPA2+Z+DZw40HoqZHi5ogS5VYr/HxNaACT4GQk9CCJKgljCjujTSYazW2IvcLuOlD7osKhHLDLKNE2KuyMxEXsoxSPVyuBTwl2wCJkNW4VACbacI5gFwkCtbXKS5xmMXAUys4PTjooiyxFMQiUiKkMKsqn/TsnIW2RsD/ugw88sFCbKXB3hVgWEayMQxf4SmTZY0T8HJHSgdhRAJKuIskX4i7piU4RScnkWCjXUE6Y7NOlUXPtnUmk3WhosL4md7p10yIkaKDH6LgA5o4xVAFMU3xzFLI1uRrw7l0BdFaos0XyIoDepBaLsYWi1VwXAz1YhFPvOjz5JscceiJUiQRnIlZizW+wBeekB3HK4ywd+T6by28hQI6W1OFgOAvmT+ykcIENlIH3iheJBAcnSxbQGa4sijIiAboOWPGpj17KW4g9PwXcgqL+xpaQcYZwqDFiLaXZbEHl2PH4JnAoQQONIhKlRA78bSlZtPK+Y8IuQGgZjBoC6UojPiKkTZUoF6V8KIUMIPjAQV7ojLUgYyw8eUhrGFdvAnU7vCBi8hYA2lzYJxMsqzlAp8x3Sg5bngCzttPx5xHds4hAkq5L4t8lftC3PnU15mMEcCVcSHbq4Ga0C7XLcRJZs0EFiEy7ihM+581ypwSv4lRzCWI8Il8tahttChJ9B8gYKjIDEshBxLlaCS9MUko1opKtae8oBh3m7R343Oh/KHvOBh7lpKEE+LxviUuSfZj05Ms5r8zGDCh5E8ySaRgSZBfRdO2+dpr3TvzNgB8sgZAA3P3xGQDUTwBGQaFmgF0aGKmaCAiP+arhyVxdEovWj1VPK2gv2M/OMQAQOzX8NBfcHcAnGEzOxkXZWaeALJCCuZgov2mkFA5TS6mMq5iBhEjiBNMGC4lwHTJPA0ESGn2aiMaqhrHLjUpZaYTMT6wpgeCy8GNXKoVjskKAVyOgAemmDjcoKTqytgJEqxah1AA5QGaWrjWqSyhXyVee7OfxL875AE9IIsyHDJ4sxjc6CcpXRlwSzu5LJBQsN5pGB0JBK9PFhI2TXtm29sISipCTFK0UxhrPHiytNbsrthTZH1OdQKpQ9WhX0FgQLymozR12c1EKhsOdZXRXWFyZGTL1Yk4wPhEVY6o8LBGRtf1OAr4XG1EIrIgRSbDXk8Mur59nepOaksxJWGm9f4AiooAOOopIpAUwEuQTCwn7yBjkgE0ycwBV55EgpeHPaY4gWQfzluZNEeOsMpr3Q+AfbYGMjikCc1mwgQxuUitHaEtYF6K+BQhLe6SKqWBlHVDeq3ZZ0VKp65tuevoSXqcFb6ddZq1ChbqkgO65NKQuA1lV32MGpUa8KZGQbYOpAfTZQDeF3DE2II0GoBtM3/DMJxKxWMptwnQl11DK/dSROZXZFklE2IwJRMNpuikgR7aOKEAFX/hikiU1lNuqjBvqyyH8nvASGJCIbaStWC0EJplAWt8qkkzCpgsUUFB8KdE4ES/yOV5wCthQZRRmyzSla5NlySSVGB6nsAMoPtIfoQTPhkcbWG7IFHPEV5VBlBHoSBJ2WhT8ltgBCY+Ookfj20X4hWD+OC3vhPAWkhaR9ZGH611lYEXWxeIQiWQ1NuE94/ClCjBTHgRt76z0eYksTvxA1tiBeOkL6G6yEQQlRpJOTKpWAbtxW2pJknqGd9vm1+JnmEzaUgN8k+UL7TeiMFB0yM15DeueluRFb5eCmooLfWcBuQ++UQnuL5za0zxlkt4rNH9vY19IokdQjpNwEkH1a3iaZPIQbToBlkql5ij9TEIVpQUwdnw9cOVuh21bwt5AF6VtOVKDCOkww/rGWEaEjxqpFJCoT3EISGSYN/CDyYGEkSrJvhH1MhFFK9LxgSm0GwDRLv9AzhJEuM0MHqXsmhA4clhfBlEBAhltdIgyULVelpavQ2Uj257YJSSArKoynVJLI0WPbQ86IuwISpKpl7yyh6KPSBYpRRXArTuGKoTXMJE2oSxN0IzLZiHp1/qE0564LoCSc2QkXN26hVoZRvXR7KtlmqHYpsc3br8FfwQhbWFc2dQwOGeo3Retq2x6QNjOs9bOgcpKaC9zmikmpvhxl7FWCPHltvXVhcpBWJAcbruv1aMrOOiSrzayp80GA/N3weEd72NCO8+0TLN/lDw2rqJG93evln3qFaeVEQwKLLCmD8F2CwK+vWeeiJYGoQSBkcdvu9pdFGQUe9aH3VWAGn0zyQQLOhmkNPBVBVGC+7yBuNuWLQrM2gRMKqDWa+xmK1oARkvL3KGL78sIcBD5IT52gZwhKUpuU0qZPx+SAefJbHie5Q9POo+MYMjoSn5MSA/jWqqL2GH0kEAjJNsin1CbdgqmGcI+PkHJCr0SxV/bht9usWsh+9o9R6VgYwDDi/CJc5+SLN4aiG1qUi0fdEXoBlYpVdqi5vTIsZhD2GTdBeIftEZCtC2sssrobVzhL7ct+Qb7L2m8hBUacta0bQnPBbDB2gf8m7d/qRrzIv5gBTrIOEOaTz1wjM//v7gkbCHnJfwQZFtgp5xkbdtAqJJ7teXLDuh5POlrZBmgCGa6j+gZtzvoS0bVuVmSXP93ubwrB2/u5FTBP41bEhw7NLFfOpxUoKoAVwTI16ywr4iBD4TAfa/0MOkIOFWpLhUVJ4V1kI+wBLgAAG8AqX4ALfBz9FPwHar8X0dNqfr3x0CJB/xrZp9FFYfAPuB0etrSA+57xjoghGsZyKX0hjHop2hYgWP+jbEPuR/VwFCMt9IAAAX3sW0jDFM6PPby0cRysXQvB7wh3sqPVHHuiWbWtwHpy0BajLI+o6gb6AVNGDpCXABIFoACA5jqx2FlE0/inQAltmzcGkyUBInlRlAFEwzDBMYGuAGouQSmyNVZV2jWpTo1+FuPqjNRmAGdIACTCPEUCeqMgmKAuJwdhCahMwmATcJmqPgURPImXhlAVE8bJIAYn/22JpAKyaUD4nqTghgY34y5N/CFjOCYk5wpMrcKSDlJidASZ9IPGxWgJJ473oFbb6O99m1qspoVql7CJe64iUyt9hkSJ9IcXzahqd5btp4vTMg9FU925xFTvonBG1kHxKhiQq+4FbnHBDRMMAbx6rZWsknf7XTVjSinvJTh9B5A8YVYPIAYhFt9wICDbgWCYgsR6xG06aGwjyO8aJh06uBcUcxXCbuauKpYTera1xnltX4fxjyIFN8BfTbCGtS9jDOfxIzzOzEHnvNPno29GmsDvDyli0x3wexJGCjAPVedsY3MdEHzG45CwyYIsNQGLGpiSwDAk59qOoFW2IBtYjULKHQG1gFLQgMMHc3DEgAAAOH0DeYAAsAAZgYAAB2EgGOFTCiAHz+sZ80oG9DNAGAT5tAPrAEBvmQLAgB860DTjNBPQD530DTGvOvmGA3oE2K+YAs3nXzzQWgBODHD6wxwY4B8zeZIAPmiLQF70AwFTDNAxwAATlaAPn3zRsbvJed3PYx9zs8Q88eavjawEY+gIAA=== -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

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.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=jamcalli/Pulsarr&utm_content=195):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: coderabbitai[bot]
created_at: 2025-05-15T23:52:52Z
body: > [!NOTE]
> Generated docstrings for this pull request at https://github.com/jamcalli/Pulsarr/pull/196
author_association: CONTRIBUTOR
user: login: jamcalli
created_at: 2025-05-16T06:49:47Z
body: @coderabbitai review
author_association: OWNER
user: login: coderabbitai[bot]
created_at: 2025-05-16T06:49:52Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>✅ Actions performed</summary>

Review triggered.

> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

</details>
author_association: CONTRIBUTOR