Skip to content

feat(exclusions): add watchlist exclusion system#1178

Open
AhmedNSidd wants to merge 41 commits into
jamcalli:developfrom
AhmedNSidd:feature/watchlist-exclusions
Open

feat(exclusions): add watchlist exclusion system#1178
AhmedNSidd wants to merge 41 commits into
jamcalli:developfrom
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

Release Notes

  • New Features
    • Added Watchlist Exclusions feature allowing users to prevent specific Plex watchlist items from being routed to Sonarr/Radarr. Exclusions can be configured per-user or globally and managed through a new utilities page with bulk exclude/remove capabilities.

jamcalli and others added 4 commits May 14, 2026 15:40
chore: merge develop into master for v0.15.5
…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

Review Change Stack

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 allowing users to prevent specific Plex watchlist items from being routed to Sonarr/Radarr. The system spans database persistence (watchlist_exclusions table), API endpoints for CRUD operations, sync engine gating that skips excluded items, routing integration that prevents enriched item routing, cleanup flows for deleted watchlist items, and a full-featured client UI with single/bulk exclusion management. The PR also integrates exclusion-driven GUIDs into tag-based deletion workflows and refactors shared table selection UI logic across components.

Changes

Watchlist Exclusions Feature

Layer / File(s) Summary
Database schema, types, and methods
migrations/migrations/091_20260516_add_watchlist_exclusions.ts, src/types/watchlist-exclusion.types.ts, src/services/database/types/watchlist-exclusion-methods.ts, src/services/database/methods/watchlist-exclusion.ts
Migration creates watchlist_exclusions table with (user_id, key) uniqueness, timestamp, and guids JSON. WatchlistExclusion interface models the record. Module augmentation and implementation provide excludeWatchlistItem, clearExclusions, getExclusionMap (key→Set of user ids), findExcludedKeys, getExclusionsForUser, getAllExclusions (joined with username), removeExclusion, getExclusionDrivenDeletionGuids (for tag-based deletion), and cleanupExcludedWatchlistItems (deletes routed items for excluded keys).
Database service wiring
src/services/database.service.ts
DatabaseService type imports and dynamically binds watchlist exclusion methods via module augmentation and bindMethods(), making them available on fastify.db instance.
API schemas and routes
src/schemas/watchlist-exclusions/watchlist-exclusions.schema.ts, src/routes/v1/watchlist-exclusions/watchlist-exclusions.ts
Zod schemas validate create (POST), list-all (GET /), get-per-user (GET /user/:userId), and remove (DELETE /:id) payloads. Fastify plugin registers four endpoints with OpenAPI documentation, Zod validation, error handling, and appropriate HTTP status codes (201/200/204/404).
Watchlist sync engine exclusion gating
src/services/watchlist-workflow/orchestration/sync-engine.ts
Sync engine fetches exclusionMap after watchlist-cap gate, skips items when their key is excluded for the current user or global (SYSTEM_USER_ID=0), tracks skippedDueToExclusion counter, and reports it in sync summary and logs.
Item routing, delete-sync cleanup, and ARR utilities
src/services/watchlist-workflow/routing/item-router.ts, src/services/delete-sync.service.ts, src/services/database/methods/watchlist.ts, src/utils/arr-error.ts, src/services/approval.service.ts, src/services/content-router.service.ts
Item router gates enriched-item routing via exclusion map. Delete-sync pre-deletes routed watchlist items for excluded keys. Watchlist deletion clears related exclusion records. New isArrAlreadyAddedError utility detects Radarr/Sonarr duplicate-add errors. Content router handles auto_approved approvals and synced instance routing with "already added" error tolerance via the shared utility.
Tag-based deletion with exclusion-driven GUID support
src/services/delete-sync/orchestration/tag-based-deletion.ts, src/services/delete-sync/tag-operations/tag-counter.ts, src/services/delete-sync/validation/content-validator.ts
Delete-sync loads exclusion-driven GUIDs via getExclusionDrivenDeletionGuids(). Tag counter and validator treat exclusion-driven GUIDs as deletion candidates (alongside removal tags) while skipping required-tag regex for exclusion-driven items; they still apply protection and tracked-only filters.
Client data hooks
src/client/features/utilities/hooks/useWatchlistExclusions.ts, src/client/features/utilities/hooks/useWatchlistExclusionMutations.ts
useWatchlistExclusions fetches list via GET /v1/watchlist-exclusions with schema validation; useCreateWatchlistExclusion POSTs new exclusions; useRemoveWatchlistExclusion DELETEs by id. All hooks manage React Query caching and invalidation.
Shared table select column factory
src/client/components/table/data-table-select-column.tsx, src/client/features/approvals/components/approval-table-columns.tsx, src/client/features/plex/components/user/user-table.tsx, src/client/features/utilities/components/session-monitoring/manage-rolling-sheet.tsx
New createSelectColumn helper provides reusable select-all/indeterminate/per-row checkbox logic; replaces inline select column implementations in approvals, users, and rolling-sheet tables.
Watchlist exclusions table columns
src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-columns.tsx
Column factory exports WatchlistExclusionTableRow, WatchlistExclusionRowKind, and GLOBAL_USER_LABEL. Columns include selection, title (with movie/TV icon), user (with global badge), hidden filters (userId, type), status (with custom ordering), added/excluded dates (localized, null-safe), and actions (exclude/remove buttons with loading/disabled state from mutations).
Watchlist exclusions table, toolbar, and skeleton
src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx, src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table.tsx, src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-skeleton.tsx
Table component uses TanStack useReactTable with controlled state (sorting, filters, visibility, selection), pagination, and imperative ref for clearSelection(). Toolbar renders title filter, bulk action buttons (exclude/remove), faceted filters (user/type/status), refresh/reset controls, and column visibility dropdown. Skeleton provides loading placeholder with table structure.
Confirmation modals for exclusion actions
src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-modal.tsx, src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-remove-modal.tsx, src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-delete-confirmation-modal.tsx, src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-exclude-confirmation-modal.tsx
Modal components for bulk exclude (per-user vs global scope, item filtering), bulk remove (count-based pluralization), single delete (username), and single exclude (status detection). All support async actionStatus transitions (idle/loading/success/error) with loading spinners, success checkmarks, and disabled button states.
Watchlist exclusions page
src/client/features/utilities/pages/watchlist-exclusions.tsx
Main page orchestrates watchlist fetching, combines with exclusions data, derives table rows with exclusion metadata, and implements single-row and bulk flows: exclude/unexclude via confirmation modals with per-action mutations; bulk exclude groups rows by key and builds scoped userIds (global = userId 0); bulk remove collects exclusionIds. All flows trigger concurrent mutations with Promise.allSettled, aggregate failures, refetch, clear selection, reset modals, and emit toasts.
Navigation, routing, and API documentation
src/client/components/AppSidebar.tsx, src/client/router/router.tsx, src/plugins/external/swagger.ts
Sidebar adds "Watchlist Exclusions" menu item under Utilities. Router registers lazy-loaded WatchlistExclusionsPage at /utilities/watchlist-exclusions within authenticated utilities route with Suspense. Swagger adds "Watchlist Exclusions" tag for API docs.
Version bump, documentation, and utility refactoring
package.json, README.md, src/services/approval.service.ts, src/services/plex-watchlist/orchestration/unified-processor.ts, src/client/components/ui/alert.tsx
Version bumped to 0.15.5. README documents "Watchlist Exclusions" feature. ApprovalService refactored to use shared isArrAlreadyAddedError utility instead of private method. Unified processor fixes debug log field from linkedCount variable to linkedItems.length. Alert component adds warn variant for UI styling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The PR introduces a substantial multi-layered feature spanning database schema, backend orchestration, API contracts, client data management, and a full-featured UI with complex state transitions. The integration points across sync engines, routing, and delete-sync workflows demand careful review. Tag-based deletion integration adds conditional logic around exclusion-driven GUIDs. The client page component manages multiple concurrent mutations with aggregated error handling and modal state coordination. Moderate scope and moderate cohesion across tightly-coupled systems increase review complexity.

Possibly related PRs

  • jamcalli/Pulsarr#1134: Both PRs touch the watchlist-item deletion cleanup path; the main PR updates deleteWatchlistItems to also clear related watchlist_exclusions records.
  • jamcalli/Pulsarr#689: Both PRs extend the delete-sync tag-based counting/validation flow in tag-counter.ts and content-validator.ts by adding exclusion-driven candidate handling alongside existing tag/regex logic.
  • jamcalli/Pulsarr#1043: Both PRs modify the watchlist sync engine to add new skip counters and gate watchlist processing—main PR for watchlist exclusions and the retrieved PR for watchlist cap skips.

Suggested labels

feature

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

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/watchlist-exclusions.tsx
Comment thread src/client/features/utilities/pages/exclusions.tsx Outdated
Comment thread src/schemas/exclusions/exclusions.schema.ts Outdated
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.

The real-time path interpreted RSS feed activity as a user "re-add"
signal and used it to clear exclusions and force-route items past
the normal dedup. That interpretation isn't safe: the RSS feed is a
shared resource across all friends, and bulk operations on one user's
watchlist (e.g. mass deletes via the Plex.tv API) can evict other
users' items from the feed window, then surface them again as
"new" once the feed settles. Those items then trip the re-add path
even though no user touched them, producing spurious requests to
Sonarr/Radarr for content that was deliberately excluded.

Exclusions are designed as a sticky administrative flag - they should
only be cleared by explicit user/admin action, not by indirect
real-time signals. Clean-up of orphan exclusions (items no longer on
any user's watchlist) should happen against authoritative state in
the periodic full reconciliation, not in the real-time path.

Removes both the exclusion-clearing block (formerly Step 1b) and the
force-route-past-dedup block (formerly Step 6b) from
processItemsForUser. The brand-new and existing-link paths
continue to route legitimate adds normally.
…exclusions

Adds two complementary cleanup paths so the exclusions table stays coherent
with the rest of the system.

1. Orphan exclusion cleanup (handleRemovedItems): when a watchlist_items row
   is deleted because the item is no longer on the user's Plex watchlist,
   also clear any matching (user_id, key) exclusion. The exclusion is dead
   weight once the item is gone — keeping it serves no behavioral purpose
   and would silently kick back in if the user later re-adds. Wires the
   existing clearExclusions method, which was defined for this purpose but
   previously unused.

2. Excluded-content cleanup (delete-sync step 4.5): drop watchlist_items
   rows where a matching exclusion exists and the row has been routed
   (status != 'pending'). Removed rows fall out of the protected GUID set
   built immediately after, so the standard *arr deletion pass prunes the
   content naturally without any new *arr-side logic. Skipped on dry-run.

The exclusion records themselves are preserved in path 2 — they are the
user's persistent rejection signal and must keep blocking future routes.
If the item is still on the user's Plex watchlist, RSS/ETag will recreate
a fresh pending row that the routing layer's exclusion gate vetoes; this
gives the dashboard accurate visibility ("on watchlist, excluded, not
routed") without producing *arr churn.

The status != 'pending' gate avoids churn on pre-excluded items: rows that
never got past the routing veto are already in the correct state, and
deleting them would only get them recreated by the next RSS poll.
Allows exclusions to apply system-wide rather than only to the user who
created them. An exclusion row with user_id = 0 (the existing System user)
vetoes routing for the matching key for every user, alongside any per-user
exclusions that may also exist.

Motivated by an upcoming maintainerr integration: when maintainerr decides
content should no longer exist in the library, its webhook will write an
exclusion at SYSTEM_USER_ID, preventing any user's watchlist from
re-requesting that content. Per-user exclusions are unchanged and continue
to serve the case where one user's noisy watchlist shouldn't dictate what
other users can request.

Touch points:
  - SYSTEM_USER_ID constant exported from the exclusion module
  - sync-engine routing gate vetoes on either user_id match or
    SYSTEM_USER_ID match
  - delete-sync cleanup query (cleanupExcludedWatchlistItems) extended so
    rows are pruned when matched by either a per-user or a global exclusion

No schema change. clearExclusions and excludeWatchlistItem are unchanged;
maintainerr (or any caller) writes globals by passing [SYSTEM_USER_ID] to
excludeWatchlistItem, and per-user removals naturally leave globals
untouched.
UI button state (exclusions page + delete-confirmation modal):
  - Drop the activeExcludeRowId state in favor of deriving per-row
    pending state from createExclusionMutation.variables, mirroring
    the unexclude path. Aligns with the pattern jamcalli pointed at.
  - Remove the setActiveExcludeRowId(null) clearing in the exclude
    handler's finally; useAppMutation's minimum-loading window now
    drives the visual state on its own.
  - Move the modal close out of the confirm button's finally (which
    fired the moment mutateAsync resolved and cut the 500ms min-load
    short) and into the parent's success path via setPendingUnexclude(null).
    Errors keep the modal open so the user can retry or cancel.

JSDoc cleanup (exclusion methods + type declarations):
  - Trim multi-paragraph descriptions on the exclusion DB methods to
    one-line summaries plus the relevant param/return, matching the
    style of other database/methods/ files (e.g. anime.ts).
Matches the page title and the multi-word convention used by every
other entry in the Utilities sidebar (API Keys, Delete Sync, Log
Viewer, Plex Labels, etc.). The bare "Exclusions" was ambiguous and
stood out as the only single-word item in the section.
Brings the page in line with the approval-table and user-table reference
implementations, which already handle narrow viewports cleanly.

  - Wrap the table region in overflow-x-auto so a wide table degrades to
    horizontal scroll inside its container instead of breaking the whole
    page layout (matches approval-table.tsx and user-table.tsx).
  - Restructure the toolbar into stacked rows with flex-wrap on the filter
    row so the search input, faceted filters, refresh button, and column
    selector wrap gracefully instead of overflowing on small screens
    (mirrors approval-table-toolbar.tsx).
  - Make the pagination info adaptive: "Showing X-Y of Z" on sm and up,
    "Page X of Y" on smaller screens, with the same "No results" fallback
    pattern the approvals table uses.
  - Replace remaining "Exclusions" references with "Watchlist Exclusions"
    so the doc matches the sidebar entry and page heading.
  - Add a brief info callout in How It Works noting that Delete Sync now
    treats excluded items as unwatchlisted — if you exclude something
    already in your library, the next Delete Sync run removes it. New
    behavior worth surfacing since it can lead to library content being
    removed.
@AhmedNSidd
Copy link
Copy Markdown
Author

@jamcalli Pushed updates.

On the per-user vs global question, I went with both rather than collapsing to global-only. There's a real per-user case I didn't want to lose: if one user has a noisy watchlist, an admin can quiet specific items for that user without blocking the same content for others who legitimately want it. Going fully global takes that away.

So per-user stays as the primary case, and I added a SYSTEM_USER_ID sentinel (user_id = 0, the existing System user) for global exclusions. An exclusion at user_id = 0 vetoes routing for everyone, which is the shape Maintainerr can write to without being user-aware. The routing gate and the delete-sync cleanup both check "matches this user OR is system", so the global behavior drops in without a schema change.

Other items from your comment:

  • Button handlers: dropped activeExcludeRowId. Both exclude and unexclude paths now derive their pending state from mutation.variables, no more finally clearing.
  • Modal: moved the close out of the button's finally into the parent's success path so it doesn't cut the 500ms min-loading short.
  • JSDocs in the exclusion methods trimmed to match the one-liner convention used in other database/methods/ files.

A few related things I also did while in there:

  • Wired up the orphan exclusion cleanup the doc had already been promising (when a user removes an item from their Plex watchlist, the matching exclusion goes too).
  • Taught delete-sync to drop routed watchlist_items rows the user has excluded. Pending rows are left alone since they're already vetoed at the routing gate and would just churn against RSS recreating them.
  • Renamed the sidebar entry "Exclusions" to "Watchlist Exclusions" to match the page heading and the multi-word convention in the Utilities section.
  • Tightened the page's responsiveness so it doesn't break on mobile (overflow-x-auto, wrapping toolbar, adaptive pagination text).

Let me know what you think.

@jamcalli jamcalli force-pushed the feature/watchlist-exclusions branch from c3f84b1 to 6a7d85b Compare May 27, 2026 07:28
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.

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

271-281: 💤 Low value

Inconsistent modal feedback compared to handleBulkExclude.

handleBulkExclude uses a 600ms delay before closing the modal, allowing users to see the success/error state. Here, the modal closes immediately after setting the status, so the success/error feedback is never visible.

🔧 Suggested fix to align behavior
     const failures = results.filter((r) => r.status === 'rejected').length
     setBulkRemoveStatus(failures === 0 ? 'success' : 'error')
     await refetchExclusions()
     activeTableRef.current?.clearSelection()
-    setPendingBulkRemove(null)
-    setBulkRemoveStatus('idle')
+    setTimeout(() => {
+      setPendingBulkRemove(null)
+      setBulkRemoveStatus('idle')
+    }, 600)
🤖 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/watchlist-exclusions.tsx` around lines
271 - 281, The modal is closing immediately so users never see the success/error
state; mirror handleBulkExclude by inserting a ~600ms delay after you
setBulkRemoveStatus(failures === 0 ? 'success' : 'error') and after awaiting
refetchExclusions(), then only clear selection, setPendingBulkRemove(null) and
setBulkRemoveStatus('idle') (or close the modal) after that delay. Target the
block using setBulkRemoveStatus, refetchExclusions,
activeTableRef.current?.clearSelection and setPendingBulkRemove to add an await
new Promise(res => setTimeout(res, 600)) (or equivalent) so the success/error
state is visible before resetting the modal.
🤖 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.

Nitpick comments:
In `@src/client/features/utilities/pages/watchlist-exclusions.tsx`:
- Around line 271-281: The modal is closing immediately so users never see the
success/error state; mirror handleBulkExclude by inserting a ~600ms delay after
you setBulkRemoveStatus(failures === 0 ? 'success' : 'error') and after awaiting
refetchExclusions(), then only clear selection, setPendingBulkRemove(null) and
setBulkRemoveStatus('idle') (or close the modal) after that delay. Target the
block using setBulkRemoveStatus, refetchExclusions,
activeTableRef.current?.clearSelection and setPendingBulkRemove to add an await
new Promise(res => setTimeout(res, 600)) (or equivalent) so the success/error
state is visible before resetting the modal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: b0c6ed31-9a81-4e04-b993-b7d87b07a1cb

📥 Commits

Reviewing files that changed from the base of the PR and between c3f84b1 and 6a7d85b.

📒 Files selected for processing (6)
  • src/client/components/table/data-table-select-column.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx
  • src/client/features/utilities/pages/watchlist-exclusions.tsx
  • src/services/approval.service.ts
  • src/services/content-router.service.ts
  • src/utils/arr-error.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/utils/arr-error.ts
  • src/client/components/table/data-table-select-column.tsx
  • src/services/approval.service.ts
  • src/services/content-router.service.ts
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx

@AhmedNSidd
Copy link
Copy Markdown
Author

@jamcalli Honest thoughts are, I'm not sure how I feel about the UX with two split tables. From a UX standpoint I think it was easier for a user to manage their watchlist exclusions all from a single table.

Now, if a user needs to manage their exclusions they do it from one table, but anything else they have to scroll down to a new table to delete any exclusions, and we may potentially add a different set of search filters for that second table, etc to make it easier for user to remove exclusions, and then the design and UX starts to feel worse with having to manage two different tables with their own filters / potential pagination, etc. This is just my opinion as a user

I still prefer the design of a single table use case, but I think there are other positives that we should keep in mind. The Excluded column I think is pretty helpful. If we did want to think about moving back to a single table use case, we may want to consider adding this Excluded column to the first table and have it sort descendingly by default as is the current implementation for the Active Exclusions table. I also think the excluded button looks nicer. I actually didn't personally like the Unexclude button we had before so this looks nicer, but if we did want to move to a single table use case, then we'll have to think about how to incorporate this but also give the user the ability to remove the exclusion straight from this table (maybe on hover, it changes the "Excluded" -> "Remove" option clickable button, or maybe just a small garbage button next to items on the table that are already excluded).
CleanShot 2026-05-27 at 05 57 48@2x

@jamcalli
Copy link
Copy Markdown
Owner

@AhmedNSidd

Makes sense. Reverted it to a single table. Also modified the migration so that the global exclusion can persist even when all other references were removed from watchlists.

bun run migrate:rollback - Will need to be run prior to running this as I amended the migration instead of adding a new one.

That being said, let me know how you think global exclusions should be handled. Currently they persist for forever, regardless of watchlist states. This might be desired, or perhaps they should prune when the last item is removed from a watchlist?

I will defer to you though, as this is your feature.

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.

♻️ Duplicate comments (1)
src/services/database/methods/watchlist-exclusion.ts (1)

125-139: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Global exclusions are still filtered out by the inner join

Line 126 uses an inner join, so user_id = 0 sentinel exclusions won’t appear in “all exclusions,” making them unmanageable from this path.

Suggested fix
-  const rows = await this.knex('watchlist_exclusions as we')
-    .join('users as u', 'we.user_id', 'u.id')
+  const rows = await this.knex('watchlist_exclusions as we')
+    .leftJoin('users as u', 'we.user_id', 'u.id')
     .select(
       'we.id',
       'we.user_id',
       'we.key',
       'we.title',
       'we.type',
       'we.guids',
       'we.excluded_at',
-      'u.name as username',
+      this.knex.raw("COALESCE(u.name, 'Global') as username"),
     )
🤖 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/services/database/methods/watchlist-exclusion.ts` around lines 125 - 139,
The query in the watchlist-exclusion retrieval uses an inner join on 'users as
u' which drops sentinel global exclusions where user_id = 0; change the join to
a left join (e.g., use this.knex(...).leftJoin('users as u', 'we.user_id',
'u.id')) so rows with no matching user are retained, and ensure downstream
handling of the selected username (u.name as username) tolerates nulls (e.g.,
leave username null or set a default) while keeping the parseGuids(row.guids)
mapping intact.
🤖 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.

Duplicate comments:
In `@src/services/database/methods/watchlist-exclusion.ts`:
- Around line 125-139: The query in the watchlist-exclusion retrieval uses an
inner join on 'users as u' which drops sentinel global exclusions where user_id
= 0; change the join to a left join (e.g., use this.knex(...).leftJoin('users as
u', 'we.user_id', 'u.id')) so rows with no matching user are retained, and
ensure downstream handling of the selected username (u.name as username)
tolerates nulls (e.g., leave username null or set a default) while keeping the
parseGuids(row.guids) mapping intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 618fb18b-701f-4bb6-894a-f748f089dc87

📥 Commits

Reviewing files that changed from the base of the PR and between 6a7d85b and a3c3f17.

📒 Files selected for processing (14)
  • migrations/migrations/091_20260516_add_watchlist_exclusions.ts
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-remove-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-delete-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-exclude-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-columns.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table.tsx
  • src/client/features/utilities/pages/watchlist-exclusions.tsx
  • src/routes/v1/watchlist-exclusions/watchlist-exclusions.ts
  • src/schemas/watchlist-exclusions/watchlist-exclusions.schema.ts
  • src/services/database/methods/watchlist-exclusion.ts
  • src/services/database/types/watchlist-exclusion-methods.ts
  • src/types/watchlist-exclusion.types.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/client/features/utilities/pages/watchlist-exclusions.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-exclude-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-modal.tsx
  • src/routes/v1/watchlist-exclusions/watchlist-exclusions.ts
  • src/services/database/types/watchlist-exclusion-methods.ts
  • src/schemas/watchlist-exclusions/watchlist-exclusions.schema.ts
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table.tsx

@AhmedNSidd
Copy link
Copy Markdown
Author

@jamcalli

I'll actually defer to your original intent on this because the global exclusions is part of your motivation: If one of the ideas behind global exclusions is to provide an integration point with Maintainerr, then it makes perfect sense to me that global exclusions should be independent of a user's watchlists. It should hold a stricter bar than user watchlist exclusions.

We wouldn't want to get into a situation where a global level exclusion that's added by maintainerr in Pulsarr is being overwritten by a user removing the item from their watchlist. It's a strict barrier for current and future users. Global exclusions feels like it should be stricter than simple user watchlist exclusions

@jamcalli
Copy link
Copy Markdown
Owner

@AhmedNSidd

I conjoined back into a single table with a global user. Check it out and let me know. Still provides the same functionality.

@AhmedNSidd
Copy link
Copy Markdown
Author

@jamcalli

Yep I checked it out. It looks really good to me imo. There's some fine tunings that we can do but the general layout looks pretty solid to me

@jamcalli
Copy link
Copy Markdown
Owner

@AhmedNSidd

What sort of fine tuning are you thinking?

@AhmedNSidd
Copy link
Copy Markdown
Author

AhmedNSidd commented May 29, 2026

@jamcalli

  1. Excluded_at -> more user friendly name
CleanShot 2026-05-28 at 21 49 43@2x
  1. Color scheming looks a bit off on this. I think it'd be better to make the warning sign yellow and the Bulk Remove button red
CleanShot 2026-05-28 at 22 05 28@2x
  1. Add another filter here for Status column
CleanShot 2026-05-28 at 22 05 58@2x
  1. I also hadn't played around with Global Exclusions yet, but I just played around with it right now and I think this could be fine tuned a bit more. Instead of showing the global exclusion as part of the user, we should consider showing a separate row for this specific movie with the user being "Global" or something.. Or make the user field appear as null and just show the status as "global" / "globally excluded". Right now even though the item is globally excluded, it still gives the option to "exclude" the item and that I think is because global exclusions and user exclusions are two separate things, so I think they should also be represented in the table as two separate rows too then
CleanShot 2026-05-28 at 22 06 23@2x

Some of these are low hanging fruits, some of these are more involved decisions, but yeah this is what I was thinking about for future PRs

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.

🧹 Nitpick comments (1)
src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx (1)

170-171: 💤 Low value

Consider defining a typed meta interface for columns.

The type assertion (column.columnDef.meta as { displayName?: string }) is safe but could benefit from a typed interface. This is an optional enhancement for better type safety across column definitions.

♻️ Optional typed meta interface

Define a column meta type in watchlist-exclusions-table-columns.tsx:

interface ColumnMeta {
  displayName?: string
}

// Then use it in column definitions:
const columns: ColumnDef<WatchlistExclusionTableRow, unknown>[] = [
  {
    id: 'example',
    meta: { displayName: 'Example Column' } satisfies ColumnMeta,
    // ...
  }
]

And reference it here:

- {(column.columnDef.meta as { displayName?: string })
-   ?.displayName || column.id}
+ {(column.columnDef.meta as ColumnMeta)?.displayName || column.id}
🤖 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/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx`
around lines 170 - 171, Define a typed meta interface (e.g., interface
ColumnMeta { displayName?: string }) and use it as the ColumnDef generic for
your table columns (e.g., ColumnDef<WatchlistExclusionTableRow, ColumnMeta> and
ensure each column.meta satisfies ColumnMeta when you build the columns array),
then replace the inline assertion (column.columnDef.meta as { displayName?:
string }) with safe typed access (column.columnDef.meta?.displayName) so
TypeScript knows meta has the displayName property; update the columns
definition file where the columns array is created and the toolbar component
that reads column.columnDef.meta.
🤖 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.

Nitpick comments:
In
`@src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx`:
- Around line 170-171: Define a typed meta interface (e.g., interface ColumnMeta
{ displayName?: string }) and use it as the ColumnDef generic for your table
columns (e.g., ColumnDef<WatchlistExclusionTableRow, ColumnMeta> and ensure each
column.meta satisfies ColumnMeta when you build the columns array), then replace
the inline assertion (column.columnDef.meta as { displayName?: string }) with
safe typed access (column.columnDef.meta?.displayName) so TypeScript knows meta
has the displayName property; update the columns definition file where the
columns array is created and the toolbar component that reads
column.columnDef.meta.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 1f1aca8d-7f34-4539-aa9b-1d4f2543415e

📥 Commits

Reviewing files that changed from the base of the PR and between a3c3f17 and 5930b19.

📒 Files selected for processing (7)
  • src/client/components/ui/alert.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-remove-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-delete-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-exclude-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-columns.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-delete-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-remove-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-exclude-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-columns.tsx

@jamcalli
Copy link
Copy Markdown
Owner

@AhmedNSidd

Fair points. I've addressed them all. Give that a once over.

I reused an existing yellow-ish color from the pallet. Let me know if that looks better.

The global user already was present in the users table as a separate row, but perhaps causes confusion when an item is globally excluded. Modified how the individual users exclusions and global exclusions interact. Give that a once over too and let me know your thoughts.

The Global synthetic entry was appended after the alphabetical user
list, which buried it below real users in the dropdown. Prepend it so
it stays anchored at the top regardless of user count or name.
…bulk warning

The 'X items are already in your library — next Delete Sync run will
remove them' notice previously rendered as a separate Alert beneath
the main warning. Two same-color same-icon panels stacked back-to-back
read as visually indecisive, and the second one's lack of title made
it feel like an orphaned continuation.

Fold the notice into the primary warning's AlertDescription as a
second paragraph with mt-2 spacing. Phrasing changes from
'X items are already in your library' to 'X of them are already in
your library' to tie the count to the exclusion count above. This
also aligns the bulk modal with the single-item exclude modal, which
already folds the same sentence into one Alert.
@AhmedNSidd
Copy link
Copy Markdown
Author

Oooh yes, this looks really nice, I like it:

CleanShot 2026-05-29 at 09 09 55@2x

One thing I'll also mention is, for the user experience, it may be better for Global to be pinned to to the top of the user filter list, so I created a commit to do this:
CleanShot 2026-05-29 at 09 17 47@2x


I also created a commit to collapse the two warnings for bulk exclude into a single warning panel. Before the first warning panel was yellow and the second was blue, so visually this looks a bit stronger:

CleanShot 2026-05-29 at 09 34 53@2x

Everything else genuinely looks really good to me. Let me know if you have any other thoughts on this / the changes made.

@jamcalli
Copy link
Copy Markdown
Owner

@AhmedNSidd

I think we are almost there. A couple small wrinkles to iron out.

Currently, the delete sync only removes excluded items in watchlist-based removal. It doesn't for tag-based. Is this intentional or an oversight?

@AhmedNSidd AhmedNSidd force-pushed the feature/watchlist-exclusions branch from 57daa9d to d219849 Compare May 30, 2026 21:15
Watchlist-mode delete sync already respects exclusions via
cleanupExcludedWatchlistItems, which drops per-user watchlist_items
rows so the standard prune naturally removes items whose last
watchlister has excluded them. Tag-based mode bypassed that mechanism
entirely — it walked watchlist_items only via the per-user
consensus-tag computation in user-tag.service, and delete-sync itself
just trusted the consensus tag. As a result, both per-user and global
exclusions had no effect on tag-based delete-sync's decisions.

Add a parallel deletion signal: getExclusionDrivenDeletionGuids()
returns the union of GUIDs across keys where exclusions effectively
cover every current watchlister. A key qualifies when, after
subtracting per-user excluders from the set of users with
watchlist_items rows for it (status != 'pending'), no non-excluded
watchlister remains. Global exclusions short-circuit this check (any
user is "covered" by a global), so any item with a global exclusion
qualifies regardless of watchlist state. This mirrors watchlist-mode's
semantic — last excluder wins; one user excluding while others still
want it is a no-op — but expressed as a single read so tag-based mode
doesn't need to mutate watchlist_items.

validateTagBasedDeletion and the safety-check counters short-circuit
their hasRemovalTag gate when the item's GUIDs intersect the
exclusion-driven set. Downstream filters (Plex playlist protection,
deleteSyncTrackedOnly) still apply, with one carve-out: the
deleteSyncRequiredTagRegex filter is bypassed for exclusion-driven
candidates since exclusions are explicit deletion decisions that
shouldn't be gated by tag-pattern filters.

The DB method uses raw SQL because the doubly-nested correlated
subquery doesn't fit knex's nested-this typing. Tested against both
postgres ({ rows: [...] }) and sqlite (array) result shapes.
@AhmedNSidd AhmedNSidd force-pushed the feature/watchlist-exclusions branch from e73ce9b to 601b64f Compare May 30, 2026 21:33
@AhmedNSidd
Copy link
Copy Markdown
Author

@jamcalli

Yeah, that was an oversight, thanks for catching. Just pushed a fix — tag-based mode now respects exclusions (both per-user and global) the same way watchlist mode does.

One small thing worth flagging: I let exclusion-driven candidates bypass the deleteSyncRequiredTagRegex filter. Felt like exclusions should be an explicit override that doesn't get gated by tag-pattern rules. Easy to revert that piece if you'd rather keep the filter universal.

Let me know what you think

@jamcalli
Copy link
Copy Markdown
Owner

@AhmedNSidd

Cheers. Almost there. I noticed that the item-router for etag and rss both don't avoid routing the excluded items. Was this intentional? If not, any removal / re-addition to the watchlist would re-route.

I pushed a small commit mirror that logic from the full-sync. If this omission is intentional we can roll that back.

@jamcalli
Copy link
Copy Markdown
Owner

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 31, 2026

✅ Actions performed

Full review triggered.

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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/services/delete-sync.service.ts (1)

286-307: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't let exclusions trip the empty-watchlist safety abort.

cleanupExcludedWatchlistItems() runs before the Line 306 empty-set guard. If exclusions remove the last routed watchlist_items row, allWatchlistItems.size becomes 0 and the run aborts as if refresh failed, so a fully excluded watchlist can never drive deletions. Dry runs also diverge because this cleanup is skipped there.

🤖 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/services/delete-sync.service.ts` around lines 286 - 307, The safety check
is happening after cleanupExcludedWatchlistItems so exclusions can zero out
allWatchlistItems and incorrectly trigger handleSafetyTriggered, and dry runs
diverge because cleanup is skipped; fix by calling getAllWatchlistItems(...) and
evaluating allWatchlistItems.size before invoking
cleanupExcludedWatchlistItems(), keeping the cleanup conditional on !dryRun so
dry runs retain the original set for the safety check; ensure references to
cleanupExcludedWatchlistItems, getAllWatchlistItems, allWatchlistItems.size,
dryRun and handleSafetyTriggered are updated accordingly.
♻️ Duplicate comments (1)
src/services/database/methods/watchlist-exclusion.ts (1)

125-140: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Global exclusions are silently dropped from the "all exclusions" query.

Line 126 uses an inner join to users, so sentinel global rows (user_id = 0) never appear in results. This makes global exclusions invisible/unmanageable via this endpoint.

Suggested fix
 export async function getAllExclusions(
   this: DatabaseService,
 ): Promise<Array<WatchlistExclusion & { username: string }>> {
   const rows = await this.knex('watchlist_exclusions as we')
-    .join('users as u', 'we.user_id', 'u.id')
+    .leftJoin('users as u', 'we.user_id', 'u.id')
     .select(
       'we.id',
       'we.user_id',
       'we.key',
       'we.title',
       'we.type',
       'we.guids',
       'we.excluded_at',
-      'u.name as username',
+      this.knex.raw("COALESCE(u.name, 'Global') as username"),
     )
     .orderBy('we.excluded_at', 'desc')
 }
🤖 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/services/database/methods/watchlist-exclusion.ts` around lines 125 - 140,
The query against 'watchlist_exclusions as we' currently uses an inner join to
'users as u', which drops sentinel global rows (user_id = 0); change the join to
a leftJoin so rows with no matching user are returned, and update the
projection/mapping in the result mapping (the rows.map that calls parseGuids) to
handle null/undefined u.name (e.g., set username to null or a canonical value
like 'global' or 'system') so global exclusions are visible and properly
represented.
🧹 Nitpick comments (2)
migrations/migrations/091_20260516_add_watchlist_exclusions.ts (1)

15-15: Verify JSON column default '[]' behavior across SQLite and PostgreSQL

'[]' is a valid JSON array literal, so it should parse/cast correctly as the DEFAULT for both json/jsonb in PostgreSQL and as plain text for SQLite; this line is consistent with that. Optional: if you want extra Postgres unambiguity, use an explicit cast (e.g., '[]'::jsonb) instead of relying on implicit casting.

🤖 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 `@migrations/migrations/091_20260516_add_watchlist_exclusions.ts` at line 15,
The JSON column default currently uses a plain string literal ('[]') which works
but to ensure Postgres unambiguity update the migration to set the default using
an explicit cast for Postgres and keep the plain string for SQLite: detect the
active client and use table.json('guids').defaultTo(knex.raw("'[]'::jsonb"))
when client is 'pg'/'postgresql' and table.json('guids').defaultTo('[]') for
sqlite; reference the migration symbol table.json('guids') in
091_20260516_add_watchlist_exclusions.ts and implement the client-conditional
default assignment using knex.client.config.client or a similar runtime client
check.
src/client/components/table/data-table-select-column.tsx (1)

36-36: 💤 Low value

Consider using a ternary for the conditional meta spread.

The current pattern ...(options.meta !== undefined && { meta: options.meta }) works (spreads false when meta is undefined, which is a no-op), but a ternary is more idiomatic and clearer:

-    ...(options.meta !== undefined && { meta: options.meta }),
+    ...(options.meta !== undefined ? { meta: options.meta } : {}),
🤖 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/components/table/data-table-select-column.tsx` at line 36, In the
object spread where options.meta is conditionally included (currently written as
...(options.meta !== undefined && { meta: options.meta })) replace that pattern
with an explicit ternary to make intent clearer: use ...(options.meta !==
undefined ? { meta: options.meta } : {}) so the meta property is only spread
when present; update the code in data-table-select-column.tsx at the object
construction that references options.meta.
🤖 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 `@migrations/migrations/091_20260516_add_watchlist_exclusions.ts`:
- Around line 6-11: The migration adds a NOT NULL foreign key on column user_id
referencing users.id but the codebase uses SYSTEM_USER_ID = 0 as a sentinel for
global exclusions, causing FK violations for id=0; change the migration to allow
global exclusions by making table.column user_id nullable and remove or alter
the direct FK constraint so NULL represents global entries (and update any
unique/index constraints accordingly), then update application logic in
src/services/database/methods/watchlist-exclusion.ts to treat user_id IS NULL as
the global exclusion instead of 0 (or alternatively insert a system user id=0
row if you prefer that approach).

In `@src/services/database/methods/watchlist-exclusion.ts`:
- Around line 24-44: The root problem is the migration FK on
watchlist_exclusions.user_id blocks inserts for SYSTEM_USER_ID (0); update the
migration that creates the watchlist_exclusions table to relax that constraint
(choose one: allow a system row by inserting a users.id = 0, remove/skip the FK
for system entries, or make user_id nullable and use NULL for system entries, or
add a conditional FK/check that exempts 0). After changing the migration, update
the insert path in watchlist-exclusion.ts (the code that builds rows using
userIds and calls this.knex('watchlist_exclusions').insert(...)) to match the
new schema (e.g., filter out or map SYSTEM_USER_ID to NULL if you made user_id
nullable), so inserts no longer violate the FK.

---

Outside diff comments:
In `@src/services/delete-sync.service.ts`:
- Around line 286-307: The safety check is happening after
cleanupExcludedWatchlistItems so exclusions can zero out allWatchlistItems and
incorrectly trigger handleSafetyTriggered, and dry runs diverge because cleanup
is skipped; fix by calling getAllWatchlistItems(...) and evaluating
allWatchlistItems.size before invoking cleanupExcludedWatchlistItems(), keeping
the cleanup conditional on !dryRun so dry runs retain the original set for the
safety check; ensure references to cleanupExcludedWatchlistItems,
getAllWatchlistItems, allWatchlistItems.size, dryRun and handleSafetyTriggered
are updated accordingly.

---

Duplicate comments:
In `@src/services/database/methods/watchlist-exclusion.ts`:
- Around line 125-140: The query against 'watchlist_exclusions as we' currently
uses an inner join to 'users as u', which drops sentinel global rows (user_id =
0); change the join to a leftJoin so rows with no matching user are returned,
and update the projection/mapping in the result mapping (the rows.map that calls
parseGuids) to handle null/undefined u.name (e.g., set username to null or a
canonical value like 'global' or 'system') so global exclusions are visible and
properly represented.

---

Nitpick comments:
In `@migrations/migrations/091_20260516_add_watchlist_exclusions.ts`:
- Line 15: The JSON column default currently uses a plain string literal ('[]')
which works but to ensure Postgres unambiguity update the migration to set the
default using an explicit cast for Postgres and keep the plain string for
SQLite: detect the active client and use
table.json('guids').defaultTo(knex.raw("'[]'::jsonb")) when client is
'pg'/'postgresql' and table.json('guids').defaultTo('[]') for sqlite; reference
the migration symbol table.json('guids') in
091_20260516_add_watchlist_exclusions.ts and implement the client-conditional
default assignment using knex.client.config.client or a similar runtime client
check.

In `@src/client/components/table/data-table-select-column.tsx`:
- Line 36: In the object spread where options.meta is conditionally included
(currently written as ...(options.meta !== undefined && { meta: options.meta }))
replace that pattern with an explicit ternary to make intent clearer: use
...(options.meta !== undefined ? { meta: options.meta } : {}) so the meta
property is only spread when present; update the code in
data-table-select-column.tsx at the object construction that references
options.meta.
🪄 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: 94c12a03-4526-473b-b009-55fb7ac46281

📥 Commits

Reviewing files that changed from the base of the PR and between 8b54e35 and 599e11d.

⛔ Files ignored due to path filters (31)
  • docs/docs/api-documentation.md is excluded by !docs/**
  • docs/docs/api/bulk-manage-rolling-monitored-shows.api.mdx is excluded by !docs/**
  • docs/docs/api/configure-plex-notifications.api.mdx is excluded by !docs/**
  • docs/docs/api/create-radarr-instance.api.mdx is excluded by !docs/**
  • docs/docs/api/create-radarr-tag.api.mdx is excluded by !docs/**
  • docs/docs/api/create-schedule.api.mdx is excluded by !docs/**
  • docs/docs/api/create-sonarr-instance.api.mdx is excluded by !docs/**
  • docs/docs/api/create-sonarr-tag.api.mdx is excluded by !docs/**
  • docs/docs/api/get-all-dashboard-stats.api.mdx is excluded by !docs/**
  • docs/docs/api/get-all-schedules.api.mdx is excluded by !docs/**
  • docs/docs/api/get-most-watched-movies.api.mdx is excluded by !docs/**
  • docs/docs/api/get-most-watched-shows.api.mdx is excluded by !docs/**
  • docs/docs/api/get-radarr-quality-profiles.api.mdx is excluded by !docs/**
  • docs/docs/api/get-radarr-root-folders.api.mdx is excluded by !docs/**
  • docs/docs/api/get-radarr-tags.api.mdx is excluded by !docs/**
  • docs/docs/api/get-schedule-by-name.api.mdx is excluded by !docs/**
  • docs/docs/api/get-sonarr-quality-profiles.api.mdx is excluded by !docs/**
  • docs/docs/api/get-sonarr-root-folders.api.mdx is excluded by !docs/**
  • docs/docs/api/get-sonarr-shows.api.mdx is excluded by !docs/**
  • docs/docs/api/get-sonarr-tags.api.mdx is excluded by !docs/**
  • docs/docs/api/get-top-genres.api.mdx is excluded by !docs/**
  • docs/docs/api/get-top-users.api.mdx is excluded by !docs/**
  • docs/docs/api/get-user-by-id.api.mdx is excluded by !docs/**
  • docs/docs/api/sync-instance.api.mdx is excluded by !docs/**
  • docs/docs/api/update-schedule.api.mdx is excluded by !docs/**
  • docs/docs/api/update-user.api.mdx 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/**
  • docs/static/img/Watchlist-Exclusions.png is excluded by !**/*.png, !docs/**, !**/*.{png,jpg,jpeg,gif,svg,webp,ico,woff,woff2}
  • docs/static/openapi.json is excluded by !docs/**
📒 Files selected for processing (42)
  • README.md
  • migrations/migrations/091_20260516_add_watchlist_exclusions.ts
  • package.json
  • src/client/components/AppSidebar.tsx
  • src/client/components/table/data-table-select-column.tsx
  • src/client/components/ui/alert.tsx
  • src/client/features/approvals/components/approval-table-columns.tsx
  • src/client/features/plex/components/user/user-table.tsx
  • src/client/features/utilities/components/session-monitoring/manage-rolling-sheet.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-bulk-remove-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-delete-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-exclude-confirmation-modal.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-skeleton.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-columns.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table-toolbar.tsx
  • src/client/features/utilities/components/watchlist-exclusions/watchlist-exclusions-table.tsx
  • src/client/features/utilities/hooks/useWatchlistExclusionMutations.ts
  • src/client/features/utilities/hooks/useWatchlistExclusions.ts
  • src/client/features/utilities/pages/watchlist-exclusions.tsx
  • src/client/router/router.tsx
  • src/plugins/external/swagger.ts
  • src/routes/v1/watchlist-exclusions/watchlist-exclusions.ts
  • src/schemas/watchlist-exclusions/watchlist-exclusions.schema.ts
  • src/services/approval.service.ts
  • src/services/content-router.service.ts
  • src/services/database.service.ts
  • src/services/database/methods/watchlist-exclusion.ts
  • src/services/database/methods/watchlist.ts
  • src/services/database/types/watchlist-exclusion-methods.ts
  • src/services/delete-sync.service.ts
  • src/services/delete-sync/orchestration/tag-based-deletion.ts
  • src/services/delete-sync/tag-operations/tag-counter.ts
  • src/services/delete-sync/validation/content-validator.ts
  • src/services/plex-watchlist/orchestration/unified-processor.ts
  • src/services/watchlist-workflow/orchestration/sync-engine.ts
  • src/services/watchlist-workflow/routing/item-router.ts
  • src/types/watchlist-exclusion.types.ts
  • src/utils/arr-error.ts
  • test/unit/services/delete-sync/tag-operations/tag-counter.test.ts
  • test/unit/services/delete-sync/validation/content-validator.test.ts
  • test/unit/services/watchlist-workflow/routing/item-router.test.ts

Comment thread migrations/migrations/091_20260516_add_watchlist_exclusions.ts
Comment thread src/services/database/methods/watchlist-exclusion.ts
@jamcalli jamcalli force-pushed the feature/watchlist-exclusions branch from 7fe4129 to 04dd69b Compare May 31, 2026 08:09
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.

3 participants