Skip to content

feat(exclusions): add watchlist exclusion system#1178

Open
AhmedNSidd wants to merge 9 commits into
jamcalli:masterfrom
AhmedNSidd:feature/watchlist-exclusions
Open

feat(exclusions): add watchlist exclusion system#1178
AhmedNSidd wants to merge 9 commits into
jamcalli:masterfrom
AhmedNSidd:feature/watchlist-exclusions

Conversation

@AhmedNSidd
Copy link
Copy Markdown

@AhmedNSidd AhmedNSidd commented May 17, 2026

Hey, I've been running Pulsarr for my family's Plex server and ran into a re-request loop. Delete Sync removes content from Sonarr/Radarr, but the items stay on users' Plex watchlists. Next sync cycle, Pulsarr picks them up again and re-requests them. The only fix right now is asking everyone to manually remove items from their watchlists, which doesn't really work at scale.

This adds a watchlist exclusion system — you can mark items to skip during sync directly from a new Utilities page. Exclusions clear automatically when a user removes the item from their watchlist, so re-adding it later still works normally.

What's included

Backend

  • watchlist_exclusions table (migration for SQLite and PostgreSQL)
  • CRUD API routes at /v1/exclusions/exclusions, auto-registered via fastifyAutoload
  • Sync engine checks exclusions per-item with O(1) lookups, tracks skippedDueToExclusion stat
  • Exclusions clear atomically when watchlist items are deleted (same transaction)

Frontend

  • Utilities → Exclusions page showing all users' watchlist items in a table
  • Per-row Exclude/Unexclude actions with confirmation modal
  • User and Type faceted filters, title search, sortable columns (defaults to newest first)
  • Matched existing patterns — same table structure as user watchlists, same filter components as Approvals page

Docs

  • README, intro page, sidebar, API docs updated
  • New Docusaurus page under Utilities
  • OpenAPI tag added to swagger config

Testing

Been running this on my own instance. Happy to adjust anything that doesn't fit with the direction of the project.

Summary by CodeRabbit

  • New Features

    • Added watchlist exclusion management to prevent excluded items from being re-requested during sync.
    • New "Exclusions" utility page to view, filter, exclude/unexclude items with confirmation dialogs.
    • Exclusions are automatically cleaned up when watchlist items are removed.
    • Sync summaries now report exclusion skip counts.
  • UX

    • Loading skeletons, per-item loading states, confirmation modals, and toasts for clearer feedback.
  • Documentation

    • README key features updated to mention Watchlist Exclusions.

Review Change Stack

…t loops

When Delete Sync removes content but items remain on users' Plex watchlists,
the sync engine would re-request them on the next cycle. This adds an exclusion
system that lets admins mark items to skip during sync. Exclusions clear
automatically when users remove items from their watchlists.

Backend: migration, DB methods, API routes, sync engine integration
Frontend: table-based management UI with filters, sorting, per-row actions
Docs: utilities page, README, intro, API reference, OpenAPI tags
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR implements a complete watchlist exclusions feature: DB migration and methods, API schemas/routes, frontend store/hooks and UI, routing/sidebar entry, and integration into the watchlist sync engine to skip excluded items.

Changes

Watchlist Exclusions Feature

Layer / File(s) Summary
Type System and Database Foundation
src/types/exclusion.types.ts, src/services/database/types/exclusion-methods.ts, migrations/migrations/091_20260516_add_watchlist_exclusions.ts, src/services/database/methods/exclusion.ts, src/services/database.service.ts
Defines WatchlistExclusion and CreateExclusionData; adds migration to create watchlist_exclusions with composite unique (user_id, key) and indexes; augments DatabaseService with exclusion methods and binds them.
API Schemas and Endpoints
src/schemas/exclusions/exclusions.schema.ts, src/routes/v1/exclusions/exclusions.ts, src/plugins/external/swagger.ts
Adds Zod schemas and inferred types for create/get/remove operations; implements Fastify routes (POST, GET, GET by user, DELETE) with error handling; registers Exclusions OpenAPI tag.
Frontend State Management
src/client/features/utilities/store/exclusionsStore.ts, src/client/features/utilities/hooks/useExclusions.ts
Adds a Zustand store for exclusions with typed state, handleApiResponse helper, async fetch/create/remove workflows (with MIN_LOADING_DELAY and per-id loading), and a useExclusions hook that wraps store actions with toasts and initial fetch effect.
React Components and Page UI
src/client/features/utilities/components/exclusions/*, src/client/features/utilities/pages/exclusions.tsx
Adds ExclusionsPage with TanStack table (sorting/filtering/pagination), per-row exclude/unexclude actions, ExclusionsDeleteConfirmationModal, and ExclusionsSkeleton loading UI.
Routing and Sidebar Navigation
src/client/router/router.tsx, src/client/components/AppSidebar.tsx
Lazily loads ExclusionsPage, registers authenticated utilities/exclusions route with Suspense fallback, and adds "Exclusions" item to the Utilities sidebar.
Watchlist Sync Exclusion Gating
src/services/watchlist-workflow/orchestration/sync-engine.ts, src/services/database/methods/watchlist.ts
Sync engine fetches exclusion map, skips items for user+key exclusions, accumulates skippedDueToExclusion in SyncResult and logs it; deleteWatchlistItems clears matching exclusions when items are deleted.
Documentation
README.md
Adds Key Features entry describing Watchlist Exclusions preventing re-request loops and automatic cleanup.

Sequence Diagrams

sequenceDiagram
  participant User
  participant ExclusionsPage
  participant Store
  participant API
  participant Database
  User->>ExclusionsPage: Load page
  ExclusionsPage->>Store: useExclusions()
  Store->>API: GET /v1/exclusions
  API->>Database: getAllExclusions()
  Database-->>API: exclusions with usernames
  API-->>Store: response
  Store-->>ExclusionsPage: exclusions, loading=false
  ExclusionsPage->>ExclusionsPage: Fetch watchlist items
  ExclusionsPage->>ExclusionsPage: Join and render table
  User->>ExclusionsPage: Click Exclude button on row
  ExclusionsPage->>Store: createExclusion(key, userIds)
  Store->>API: POST /v1/exclusions
  API->>Database: excludeWatchlistItem()
  Database-->>API: created count
  API-->>Store: response
  Store->>Store: refresh exclusions
  Store-->>ExclusionsPage: update state, show toast
  User->>ExclusionsPage: Click Unexclude, confirm
  ExclusionsPage->>Store: removeExclusion(id)
  Store->>API: DELETE /v1/exclusions/:id
  API->>Database: removeExclusion()
  Database-->>API: success
  API-->>Store: response
  Store->>Store: remove from list, close modal
  Store-->>ExclusionsPage: update state, show toast
Loading
flowchart TD
  Start["Watchlist sync begins"] --> GetMap["Fetch exclusion map<br/>from database"]
  GetMap --> ProcessItem["For each watchlist item"]
  ProcessItem --> Check{"Is user excluded<br/>for this key?"}
  Check -->|Yes| Skip["Skip item<br/>increment skippedDueToExclusion"]
  Check -->|No| Process["Process item normally"]
  Skip --> Continue["Continue to next item"]
  Process --> Continue
  Continue --> Complete["Finalize sync summary<br/>with skip counts"]
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(exclusions): add watchlist exclusion system' clearly and concisely describes the main change in the PR: adding a complete watchlist exclusion feature for skipping Plex watchlist items during syncs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
src/client/features/utilities/pages/exclusions.tsx (1)

157-169: ⚡ Quick win

Use a precomputed exclusion lookup to avoid O(n×m) joins.

At Line 159, doing find per row scales poorly as data grows. A memoized map keyed by ${user_id}-${key} keeps lookup O(1).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/features/utilities/pages/exclusions.tsx` around lines 157 - 169,
The current tableData computation does an O(n×m) join by calling exclusions.find
for each watchlistItems row; replace this with a memoized lookup map (keyed by
`${user_id}-${key}`) computed with React.useMemo (e.g., build an exclusionsByKey
map from exclusions) and then map over watchlistItems to set id, isExcluded, and
exclusionId by doing a constant-time lookup into exclusionsByKey; update
references to exclusions, watchlistItems, and the produced
exclusionId/isExcluded in the tableData useMemo dependencies accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/features/utilities/hooks/useExclusions.ts`:
- Around line 50-54: The effect in useExclusions.ts calls fetchExclusions(false)
without handling rejections, which can cause unhandled promise rejections
because the store action rethrows; update the useEffect to invoke
fetchExclusions inside an async IIFE or attach a .catch() handler to handle
errors (e.g., call fetchExclusions(false).catch(err => {/* log or noop */})),
referencing the existing useEffect, fetchExclusions, and hasLoadedExclusions so
the initial fetch failure is caught and prevented from bubbling as an unhandled
rejection.

In `@src/client/features/utilities/pages/exclusions.tsx`:
- Around line 498-510: The icon-only refresh Button lacks an accessible name;
update the Button (the component rendering the refresh control that uses
onClick={handleRefresh}, disabled={isRefreshing}, and children
Loader2/RefreshCw) to include an aria-label (and optionally title) that clearly
describes the action (e.g., "Refresh exclusions" or switch to "Refreshing" when
isRefreshing is true) so screen readers can announce it; keep the existing props
and visuals but add the aria-label/title attributes to the Button element.
- Line 124: The early return when users is empty leaves the watchlist load flag
unset; update the branch in the component that currently does "if
(!users?.length) return" to mark the watchlists as loaded before returning by
calling the state updater (e.g., setHasLoadedWatchlists(true)) or otherwise
setting hasLoadedWatchlists to true, so the skeleton/unresolved initial-load
state is cleared when users length is zero; keep the existing return to avoid
running later logic after setting the flag.

In `@src/schemas/exclusions/exclusions.schema.ts`:
- Around line 19-21: The numeric exclusion IDs currently accept floats and
negatives; update the zod schemas to require positive integers by changing the
array element to .array(z.number().int().positive()) for userIds and use
.coerce.number().int().positive() for the individual userId and id parameters
(wherever the fields named userId and id are defined) so all incoming exclusion
IDs are coerced to integers and validated as positive before DB use.

In `@src/services/database/methods/exclusion.ts`:
- Around line 33-34: The insert-count logic in methods like the one setting
`inserted` uses dialect-specific shapes (`this.isPostgres` and `(result as
unknown as { rowCount: number }).rowCount` vs `(result as unknown as
number[])[0]`) which is unreliable; change the insert query that produced
`result` to include `.returning('id')` so it consistently returns inserted rows
and then compute `const inserted = Array.isArray(result) ? (result as
unknown[]).length : 0` (or simply `(result as any).length`) and return that
count (Number or 0) instead of using `rowCount`/indexing. Ensure the code that
constructs the Knex insert uses `.returning('id')` and the `inserted`
calculation uses `.length`.

---

Nitpick comments:
In `@src/client/features/utilities/pages/exclusions.tsx`:
- Around line 157-169: The current tableData computation does an O(n×m) join by
calling exclusions.find for each watchlistItems row; replace this with a
memoized lookup map (keyed by `${user_id}-${key}`) computed with React.useMemo
(e.g., build an exclusionsByKey map from exclusions) and then map over
watchlistItems to set id, isExcluded, and exclusionId by doing a constant-time
lookup into exclusionsByKey; update references to exclusions, watchlistItems,
and the produced exclusionId/isExcluded in the tableData useMemo dependencies
accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 6e3e2747-ce3e-4ef2-826d-80853f977d44

📥 Commits

Reviewing files that changed from the base of the PR and between a425a0e and 372d5f5.

⛔ Files ignored due to path filters (4)
  • docs/docs/api-documentation.md is excluded by !docs/**
  • docs/docs/intro.md is excluded by !docs/**
  • docs/docs/utilities/watchlist-exclusions.md is excluded by !docs/**
  • docs/sidebars.ts is excluded by !docs/**
📒 Files selected for processing (18)
  • README.md
  • migrations/migrations/091_20260516_add_watchlist_exclusions.ts
  • src/client/components/AppSidebar.tsx
  • src/client/features/utilities/components/exclusions/exclusions-delete-confirmation-modal.tsx
  • src/client/features/utilities/components/exclusions/exclusions-skeleton.tsx
  • src/client/features/utilities/hooks/useExclusions.ts
  • src/client/features/utilities/pages/exclusions.tsx
  • src/client/features/utilities/store/exclusionsStore.ts
  • src/client/router/router.tsx
  • src/plugins/external/swagger.ts
  • src/routes/v1/exclusions/exclusions.ts
  • src/schemas/exclusions/exclusions.schema.ts
  • src/services/database.service.ts
  • src/services/database/methods/exclusion.ts
  • src/services/database/methods/watchlist.ts
  • src/services/database/types/exclusion-methods.ts
  • src/services/watchlist-workflow/orchestration/sync-engine.ts
  • src/types/exclusion.types.ts

Comment thread src/client/features/utilities/hooks/useExclusions.ts Outdated
Comment thread src/client/features/utilities/pages/exclusions.tsx Outdated
Comment thread src/client/features/utilities/pages/exclusions.tsx
Comment thread src/client/features/utilities/pages/exclusions.tsx
Comment on lines +19 to +21
userIds: z
.array(z.number())
.min(1, { error: 'At least one user ID is required' }),
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For Zod 4 route/body validation, what is the recommended schema pattern to enforce IDs as positive integers (including for coerced path params)?

💡 Result:

To enforce IDs as positive integers in Zod, the recommended pattern is to use z.coerce.number combined with.int and.positive [1][2][3]. This approach is particularly effective for path parameters or query strings, where inputs are typically received as strings and require conversion [4][3]. Recommended Schema Pattern: const idSchema = z.coerce.number.int.positive; Explanation: 1. z.coerce.number: This attempts to convert the input (e.g., a string from a URL path parameter) into a number [4][3]. 2..int: This ensures the resulting number is a whole number (integer) [1][3]. 3..positive: This restricts the value to be greater than zero [1][3]. If you need to handle more complex scenarios—such as ensuring the input is strictly a string before coercion or applying custom error messages—you can use the.pipe method to chain the coercion to a more specific validation schema [5][6]: const strictIdSchema = z.coerce.string.pipe( z.coerce.number.int.positive({ message: "ID must be a positive integer" })); This pattern ensures that the data is coerced, validated as an integer, and confirmed to be positive, providing a robust way to handle route and body parameters [4][6].

Citations:


🏁 Script executed:

cat -n src/schemas/exclusions/exclusions.schema.ts

Repository: jamcalli/Pulsarr

Length of output: 2740


Constrain exclusion IDs to positive integers.

Lines 19-21, 39, and 50 accept broad numeric input (including floats and negatives). Add .int().positive() to each: .array(z.number().int().positive()) for userIds, and .coerce.number().int().positive() for userId and id parameters. This prevents invalid data from reaching database operations.

Suggested fix
 export const CreateExclusionSchema = z.object({
   key: z.string().trim().min(1, { error: 'Key is required' }),
   userIds: z
-    .array(z.number())
+    .array(z.number().int().positive())
     .min(1, { error: 'At least one user ID is required' }),
 })
@@
 export const GetUserExclusionsParamsSchema = z.object({
-  userId: z.coerce.number(),
+  userId: z.coerce.number().int().positive(),
 })
@@
 export const RemoveExclusionParamsSchema = z.object({
-  id: z.coerce.number(),
+  id: z.coerce.number().int().positive(),
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/schemas/exclusions/exclusions.schema.ts` around lines 19 - 21, The
numeric exclusion IDs currently accept floats and negatives; update the zod
schemas to require positive integers by changing the array element to
.array(z.number().int().positive()) for userIds and use
.coerce.number().int().positive() for the individual userId and id parameters
(wherever the fields named userId and id are defined) so all incoming exclusion
IDs are coerced to integers and validated as positive before DB use.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped — the existing api-keys.schema.ts uses bare z.number() and z.coerce.number() for IDs without .int().positive(). Kept the same convention to stay consistent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AhmedNSidd, understood — sticking to the existing convention is a valid call. I'll note this pattern for future reviews.


✏️ Learnings added
Learnt from: AhmedNSidd
Repo: jamcalli/Pulsarr PR: 1178
File: src/schemas/exclusions/exclusions.schema.ts:19-21
Timestamp: 2026-05-17T02:38:58.823Z
Learning: In jamcalli/Pulsarr, ID fields in Zod schemas (both body arrays and coerced path params) intentionally use bare `z.number()` and `z.coerce.number()` without `.int().positive()`, following the convention established in `src/schemas/api-keys/api-keys.schema.ts`. Do not flag the absence of `.int().positive()` on ID fields in this codebase.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: jamcalli
Repo: jamcalli/Pulsarr PR: 842
File: src/services/database/methods/analytics.ts:197-200
Timestamp: 2025-12-29T18:26:34.049Z
Learning: In SQLite usage within TypeScript code, ensure JSON_GROUP_ARRAY(DISTINCT col) is used for distinct aggregation of a single column. Do not use GROUP_CONCAT(DISTINCT col, delimiter) because the 2-argument form with DISTINCT and a delimiter is invalid. When reviewing analytics-related SQL strings in src/services/database/methods/analytics.ts, prefer JSON_GROUP_ARRAY(DISTINCT <column>) for distinct arrays and avoid the invalid GROUP_CONCAT(DISTINCT <column>, <delimiter>) pattern.

Learnt from: jamcalli
Repo: jamcalli/Pulsarr PR: 956
File: src/utils/logger.ts:234-235
Timestamp: 2026-02-02T21:38:02.949Z
Learning: In code that formats dates using pino-pretty's translateTime with the dateformat library, prefer the Z token for timezone output when you want human-readable US timezone abbreviations (EST, PST, MDT, etc.). For non-US timezones, Z falls back to GMT/UTC offsets like GMT-0500. Avoid relying on the o token, which outputs numeric offsets like -0500. This guidance applies to any TS file in the repo that uses translateTime with dateformat.

Learnt from: jamcalli
Repo: jamcalli/Pulsarr PR: 958
File: src/services/sonarr.service.ts:109-125
Timestamp: 2026-02-03T00:30:52.801Z
Learning: Ensure that URL inputs are validated to include http:// or https:// by parsing with new URL(url) and checking the protocol before use. Apply this validation in URL-related schemas (e.g., HttpUrlSchema, HttpUrlOptionalSchema) so that any non-empty baseUrl is validated prior to reaching service code, preventing malformed URLs from being stored or processed.

Learnt from: jamcalli
Repo: jamcalli/Pulsarr PR: 1060
File: src/server.ts:34-37
Timestamp: 2026-03-16T07:24:04.884Z
Learning: Do not rely on request.socket?.remoteAddress for authentication or access-control decisions. Use request.ip (as resolved by proxy-addr or equivalent) and ensure trustProxy is correctly configured. This guidance applies across TS codebases reviewing Node.js web servers behind proxies; verify that proxy trust settings are in place and that IP decisions use the framework-provided, proxy-aware value rather than raw socket addresses.

Comment thread src/services/database/methods/exclusion.ts Outdated
- Handle zero-users case in watchlist fetch (avoids permanent skeleton)
- Set hasLoadedExclusions on fetch failure (avoids stuck loading state)
- Add aria-label to refresh button for accessibility
- Use .returning('id') for insert count instead of dialect-specific logic
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/client/features/utilities/pages/exclusions.tsx (1)

161-173: ⚡ Quick win

Consider using a Map for O(1) exclusion lookups.

The current implementation is O(n×m) due to .find() inside .map(). Pre-building a keyed Map would reduce this to O(n+m).

♻️ Proposed refactor
 const tableData = React.useMemo<ExclusionTableRow[]>(() => {
+  const exclusionMap = new Map(
+    exclusions.map((e) => [`${e.user_id}-${e.key}`, e])
+  )
   return watchlistItems.map((item) => {
-    const exclusion = exclusions.find(
-      (e) => e.key === item.key && e.user_id === item.userId,
-    )
+    const exclusion = exclusionMap.get(`${item.userId}-${item.key}`)
     return {
       ...item,
       id: `${item.userId}-${item.key}`,
       isExcluded: !!exclusion,
       exclusionId: exclusion?.id ?? null,
     }
   })
 }, [watchlistItems, exclusions])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/features/utilities/pages/exclusions.tsx` around lines 161 - 173,
The tableData creation is currently O(n*m) because it calls exclusions.find()
for each watchlist item; instead, inside the same React.useMemo for tableData,
first build a Map keyed by the same identity used for row ids (e.g.
`${e.user_id}-${e.key}`) from the exclusions array, then replace the .find()
with a constant-time Map lookup to set isExcluded and exclusionId for each
watchlist item (keep the same row shape: id: `${item.userId}-${item.key}`,
isExcluded, exclusionId); ensure types (ExclusionTableRow) still align and keep
the useMemo dependency array as [watchlistItems, exclusions].
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/features/utilities/pages/exclusions.tsx`:
- Around line 155-159: The effect's guard uses users?.length which is falsy for
an empty array and prevents fetchAllWatchlistItems from running; update the
React.useEffect condition to check for users being defined (e.g., isInitialized
&& users !== undefined && !hasLoadedWatchlists) or simply remove the
users?.length check so fetchAllWatchlistItems is invoked when users is present
(even if empty), since fetchAllWatchlistItems already handles the empty-array
case; keep references to React.useEffect, users?.length, fetchAllWatchlistItems,
hasLoadedWatchlists, and isInitialized when making the change.

---

Nitpick comments:
In `@src/client/features/utilities/pages/exclusions.tsx`:
- Around line 161-173: The tableData creation is currently O(n*m) because it
calls exclusions.find() for each watchlist item; instead, inside the same
React.useMemo for tableData, first build a Map keyed by the same identity used
for row ids (e.g. `${e.user_id}-${e.key}`) from the exclusions array, then
replace the .find() with a constant-time Map lookup to set isExcluded and
exclusionId for each watchlist item (keep the same row shape: id:
`${item.userId}-${item.key}`, isExcluded, exclusionId); ensure types
(ExclusionTableRow) still align and keep the useMemo dependency array as
[watchlistItems, exclusions].
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 4b747b81-d08e-46a6-9c46-d8ba6b6f5823

📥 Commits

Reviewing files that changed from the base of the PR and between 372d5f5 and 0fc513e.

📒 Files selected for processing (3)
  • src/client/features/utilities/pages/exclusions.tsx
  • src/client/features/utilities/store/exclusionsStore.ts
  • src/services/database/methods/exclusion.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/services/database/methods/exclusion.ts
  • src/client/features/utilities/store/exclusionsStore.ts

Comment thread src/client/features/utilities/pages/exclusions.tsx Outdated
The useEffect guard checked users?.length which is falsy for an empty
array, preventing the early-return path from setting hasLoadedWatchlists.
@jamcalli
Copy link
Copy Markdown
Owner

Hi @AhmedNSidd

Thanks ill set some time aside to go over this.

I agree that a exclusion system is desirable and has something that's been on the docket for a while.

Can you explain the delete sync loop a bit though? I don't fully understand that loop as delete sync is supposed to check all watchlists prior to running, so it shouldnt actually remove anything still on users watchlists in the first place.

Regardless, this is a welcomed addition.

@AhmedNSidd
Copy link
Copy Markdown
Author

Hey @jamcalli

Appreciate it. The scenario I'm hitting isn't really about delete sync specifically — it's more about wanting to clean up stale content from my library manually.

My users request a lot of stuff, watch it briefly, then it sits untouched for months. Occasionally I want to clear out that stale media from Sonarr/Radarr, but since the items are still on their Plex watchlists, Pulsarr just picks them up again on the next sync cycle and re-adds them. I can't easily clean other users' watchlists for them, and unmonitoring in the arrs is a workaround but not ideal since it clutters things up.

Exclusions give me a way to say "stop syncing this item" without needing users to manage their own watchlists.

fastify-autoload derives the URL prefix from the directory structure,
so routes in routes/v1/exclusions/ already have the /v1/exclusions
prefix. The route handlers were redundantly specifying /exclusions
again, resulting in /v1/exclusions/exclusions instead of /v1/exclusions.
When a user removes an item from their Plex watchlist and re-adds it, the
exclusion for that item should be cleared so the watchlist sync can request
the item again. The existing logic clears the exclusion inside
deleteWatchlistItems, which only fires during the 2-hour full reconciliation
— too slow for an interactive re-request flow.

This routes the same clear through the real-time path. When RSS or ETag
polling surfaces an item that has a matching exclusion for the user, treat
that as the re-add signal: clear the exclusion and force-route the item
past the categorizer's already-linked dedup so the content router actually
fires.

Adds findExcludedKeys helper and updates clearExclusions to return the
delete count.
@jamcalli
Copy link
Copy Markdown
Owner

Makes sense.

Without having time to thoroughly sit down and comment, but just upon a first glance the most noticeable deviation:

The client is half migrated to using react query, and this appears to be using the old zustand fetch approach. Could you please mirror the query format used by session monitoring, the dashboard, and approvals etc?

Move exclusions data fetching off the zustand store and onto the same
react-query pattern used by approvals and session-monitoring:

- useExclusions: query hook backed by useAppQuery, with an exclusionKeys
  factory for targeted invalidation
- useExclusionMutations: useCreateExclusion / useRemoveExclusion using
  useAppMutation, both invalidating the list cache on success
- Drop exclusionsStore.ts entirely — server state lives in the cache,
  per-row UI state moves to component-local React state (activeExcludeRowId
  for the create spinner, mutation.variables comparison for the delete
  spinner)
- Clean up an unused itemKey prop on the delete confirmation modal that
  was tripping noUnusedParameters

Mirrors the pattern flagged on the PR review.
- Remove CreateExclusionData from exclusion.types.ts. It was never
  imported — excludeWatchlistItem takes (key, userIds) as separate
  params, so the wrapper type was dead.
- Group findExcludedKeys with the other read methods in
  exclusion.ts/exclusion-methods.ts. Was previously sandwiched between
  two write methods.
@AhmedNSidd
Copy link
Copy Markdown
Author

Hey @jamcalli — pushed a few updates.

The big one is the react-query migration you flagged. Both the fetch and the mutations now go through useAppQuery / useAppMutation with an exclusionKeys factory and proper cache invalidation, mirroring useApprovalMutations.ts and useSessionMonitoringQueries.ts. The old exclusionsStore.ts is gone — server state lives in the cache now, and the bit of per-row UI state that did need to stick around (which row is mid-exclude, which exclusion id is mid-delete) moved to component-local React state plus mutation.variables checks. That should be the last of the zustand surface area on this feature.

While I was in there I also went back through the whole PR diff against the codebase conventions to catch anything else I might have miscopied from an older pattern. Two small things came out of that:

  • Dropped an unused CreateExclusionData type that wasn't actually wired into anything — excludeWatchlistItem takes (key, userIds) directly.
  • Reordered the exclusion DB methods so reads sit together and writes sit together. findExcludedKeys had ended up sandwiched between two writes.

There's also one architectural decision I want to flag separately that came up while I was testing this end-to-end. I had the exclusion-clear inside deleteWatchlistItems, which only fires during the 2h reconciliation, so the "remove from watchlist → re-add → re-request" flow took up to 2 hours in practice. I moved the trigger to the real-time RSS/ETag path in processItemsForUser — when an excluded key comes through there it's treated as the user's re-add signal, exclusion gets cleared, and the item routes. The old delete-time clear is still in place as a backstop. No new Plex Discover calls. I considered shortening the reconciliation cadence instead but figured you'd want to keep that polite to Discover, so this seemed cleaner. Happy to walk through it or rework if you'd take it a different direction.

The dedup gate in checkExistingApprovalRequest was sending auto_approved
records to the default branch (silently skip routing) while approved
records called routeUsingApprovedDecision and re-routed. Both statuses
mean "this user is cleared for this content" — the only difference is
whether an admin had to click a button. Falling through to the same
handler makes re-requests work for users with bypass approval enabled.

Surfaced while testing the watchlist exclusion re-add flow: after the
exclusion cleared correctly, the content router still refused to send
the item to Sonarr because of a stale auto_approved record from the
original request.
@jamcalli
Copy link
Copy Markdown
Owner

@AhmedNSidd

This looks fairly good. Could I request you just clean up the jsdocs a bit? They are quite verbose.

@AhmedNSidd
Copy link
Copy Markdown
Author

@jamcalli

I'll try to carve some time out either this evening or in the coming days and I'll do that yeah, I had a feeling the docs were a bit too verbose as well.

Also let me know if there's any other preferences you have or things you want changed with respect to how the UI looks too. I was debating between either calling it Watchlist Exclusions or just Exclusions in the sidebar.

And I also added sorting ability on the "Exclude" column as well which should allow the admin user to group which watchlist items are excluded / not excluded faster.

I also had the thought that we should probably also add a button for "Exclude all" which would turn into a "Unexclude all", probably would be helpful for some admin users so they wouldn't have to individually try to exclude every item that's appearing in the table list, but we can always save this for a future feature PR as well

@jamcalli
Copy link
Copy Markdown
Owner

Looks pretty decent to me. It would be worthwhile to have a global setting and not per user (as the re-add clears and flow anyways).

Should it just be global per item and not per specific users?

This will lay the backbone for the maintainerr integration that I have been thinking through, where it will send webhooks when it removes items which will add to the exclusions list (which will not be user aware).

As for the button handlers, please bind the button to the mutation's isPending directly, the same way the dashboard refresh uses useAppQuery.isLoading. Drop activeExcludeRowId and figure out which row from mutation.variables.

Right now you clear it in finally as soon as mutateAsync resolves, which kills the 500ms min-loading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants