Skip to content

fix: preserve blocklist on media deletion & optimise watchlist-sync#2478

Open
fallenbagel wants to merge 3 commits intodevelopfrom
fallenbagel/fix/blocklist-watchlist-sync
Open

fix: preserve blocklist on media deletion & optimise watchlist-sync#2478
fallenbagel wants to merge 3 commits intodevelopfrom
fallenbagel/fix/blocklist-watchlist-sync

Conversation

@fallenbagel
Copy link
Collaborator

@fallenbagel fallenbagel commented Feb 17, 2026

Description

When a user blocklists media and then removes it from Sonarr/Radarr via the Seerr interface, the Media entity and its associated Blocklist record were both destroyed due to the cascade delete on the relationship. On the next watchlist sync cycle, no record existed to check against, so a fresh request was created and auto-approved, completely bypassing the blocklist.

This PR fixes the media delete endpoint to detect blocklisted status and preserve the entity by nulling out service-related fields rather than removing the row entirely. It also improves the watchlist sync filtering to exclude blocklisted media as a safety net and to skip items where the user already has an existing auto-request, eliminating the repeated unnecessary api calls and database lookups that were happening every sync cycle just to throw a duplicate request error.

How Has This Been Tested?

Screenshots / Logs (if applicable)

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Watchlist items you already auto-requested are now excluded from unavailable checks during sync, reducing duplicate requests.
    • Deleting a blocklisted media item now retains the record but clears external/service identifiers instead of full deletion.
    • Watchlist sync continues to respect user permissions and settings; existing error handling behavior is preserved.

…watchlist sync

Blocklisted media was being re-requested via watchlist sync because deleting media from
Sonarr/Radarr through the UI also deleted the Media entity and its associated Blocklist record via
cascade. The media delete endpoint now preserves blocklisted Media entities by clearing service
fields instead of removing the row. Additionally, the watchlist sync filter now excludes blocklisted
items and skips media that already has an existing auto-request for the user, avoiding unnecessary
API calls and database queries on every sync cycle.

fix #2475
@fallenbagel fallenbagel requested a review from Copilot February 17, 2026 10:01
@fallenbagel fallenbagel requested a review from a team as a code owner February 17, 2026 10:01
@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Adds filtering in watchlist sync to skip items the user has already auto-requested by querying existing MediaRequest records, and changes DELETE /:id to preserve BLOCKLISTED media by nulling service-related fields instead of deleting the record.

Changes

Cohort / File(s) Summary
Watchlist Sync Auto-Request Filtering
server/lib/watchlistsync.ts
Adds collection of watchlist TMDB IDs and queries MediaRequest for the current user's matching TMDB IDs to build a "mediaType:tmdbId" set; filters watchlist items to exclude those already auto-requested before attempting request creation.
Media Deletion with Blocklist Preservation
server/routes/media.ts
Modifies DELETE / :id to, when media.status is BLOCKLISTED, clear service-related fields (set serviceId, serviceId4k, externalServiceId*, externalServiceSlug*, ratingKey*, jellyfinMediaId* to null) and save the entity instead of removing the row; otherwise proceeds with removal.

Sequence Diagram(s)

sequenceDiagram
    participant Plex as Plex Watchlist API
    participant Job as Watchlist Sync Job
    participant DB as Database
    participant Creator as MediaRequest Creator

    Plex->>Job: return watchlist items (tmdbId, mediaType)
    Job->>DB: query MediaRequest where userId = X and tmdbId IN (watchlistTmdbIds)
    DB-->>Job: existing auto-request set (mediaType:tmdbId)
    Job->>Job: filter watchlist items excluding existing set and blocked/unavailable
    Job->>Creator: create media request for each remaining item
    Creator-->>DB: persist new MediaRequest (if created)
    Creator-->>Job: return result (success/failure)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I hopped through lists both new and old,
Skipped echoes of requests already told.
I left blocklisted bones all neat and null,
Quieted the sync — no duplicate pull.
🥕

🚥 Pre-merge checks | ✅ 4
✅ 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 accurately captures both main changes: preserving blocklisted media on deletion and optimizing watchlist-sync performance.
Linked Issues check ✅ Passed The PR implements both objectives from issue #2475: preserving blocklisted media by nulling fields instead of deleting, and optimizing watchlist-sync to skip already auto-requested items.
Out of Scope Changes check ✅ Passed All changes directly address the linked issue: media deletion logic preserves blocklisted records, and watchlist-sync filters blocklisted and previously auto-requested items.

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


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

Copy link

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/lib/watchlistsync.ts`:
- Around line 73-84: existingAutoRequests mapping assumes every MediaRequest has
a loaded media relation and accessing r.media.tmdbId can throw; update the
mapping that builds autoRequestedTmdbIds to guard against missing media by
filtering or null-checking each request first (e.g., use
existingAutoRequests.filter(r => r.media).map(r => r.media.tmdbId) or
conditional access) so only valid tmdbId values are added; modify the logic
around requestRepository, existingAutoRequests, and autoRequestedTmdbIds to
safely handle requests with null/undefined media.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a watchlist-sync + media deletion edge case where deleting a Media row could cascade-delete its Blocklist entry, allowing future watchlist sync cycles to recreate and auto-approve requests that should remain blocked.

Changes:

  • Updates the media delete endpoint to preserve blocklisted media by nulling service-related fields instead of deleting the row.
  • Hardens watchlist sync filtering to skip blocklisted items and avoid repeated duplicate auto-request attempts.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
server/routes/media.ts Preserves blocklisted media on deletion by clearing service identifiers instead of removing the entity.
server/lib/watchlistsync.ts Adds pre-filtering to skip blocklisted media and items with existing auto-requests before attempting request creation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@fallenbagel fallenbagel changed the title fix(watchlist-sync): preserve blocklist on media deletion & optimise … fix: preserve blocklist on media deletion & optimise watchlist-sync Feb 17, 2026
Comment on lines 178 to 187
media.serviceId = null;
media.serviceId4k = null;
media.externalServiceId = null;
media.externalServiceId4k = null;
media.externalServiceSlug = null;
media.externalServiceSlug4k = null;
media.ratingKey = null;
media.ratingKey4k = null;
media.jellyfinMediaId = null;
media.jellyfinMediaId4k = null;
Copy link
Member

Choose a reason for hiding this comment

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

This feels a bit hardcoded. Don't we have another way of resetting values of the entity? Doing this seems to be a good way of having issues later when we'll add other fields to the entity.

Copy link
Member

Choose a reason for hiding this comment

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

Or delete/recreate the entity with the blacklisted status instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Or delete/recreate the entity with the blacklisted status instead?

The delete/recreate approach won't work because, deleting the Media would kill the Blocklist row before you can recreate.

One thing i could try is dynamically handling non-nullable columns to survive, and nullable ones to get cleared. Wdyt?

  for (const column of mediaRepository.metadata.columns) {
    if (column.isNullable && !column.isPrimary) {
      updatePayload[column.propertyName] = null;
    }
  }

And then

  await mediaRepository.update

Copy link
Member

Choose a reason for hiding this comment

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

Yep, looks like more future-proof.

Copy link
Collaborator Author

@fallenbagel fallenbagel Feb 17, 2026

Choose a reason for hiding this comment

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

@gauthier-th Actually i just realised that approach wont work:
Nulling all nullable columns also wipes tvdbId, imdbId, and mediaAddedAt which aren't service data but they're media metadata required to identify them. If an admin later un-blocklists the media, those identifiers would be gone.

another approach would then be hardcoding these fields in the entity itself rather than the route. Like resetServiceData() method on the entity. Wdyt? Atleast then everything will be in one place and will be easier to remember to update

Copy link
Member

Choose a reason for hiding this comment

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

Good point. I do prefer the resetServiceData() option since it keeps all the entity logic at the same place.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

});

await mediaRepository.remove(media);
if (media.status === MediaStatus.BLOCKLISTED) {
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

The deletion guard only checks media.status but not media.status4k. According to the Blocklist.addToBlocklist method (server/entity/Blocklist.ts:99-100), both status and status4k are set to BLOCKLISTED when media is added to the blocklist. The check should be media.status === MediaStatus.BLOCKLISTED || media.status4k === MediaStatus.BLOCKLISTED to ensure the blocklist is preserved if either status indicates the media is blocklisted.

Suggested change
if (media.status === MediaStatus.BLOCKLISTED) {
if (
media.status === MediaStatus.BLOCKLISTED ||
media.status4k === MediaStatus.BLOCKLISTED
) {

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +186
const updatePayload: Record<string, null> = {};

for (const column of mediaRepository.metadata.columns) {
if (column.isNullable && !column.isPrimary) {
updatePayload[column.propertyName] = null;
}
}

await mediaRepository.update(media.id, updatePayload);
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

The current implementation nulls ALL nullable columns, which is overly broad and may have unintended consequences. This includes fields like tvdbId, imdbId, mediaAddedAt, and other metadata fields that should be preserved for blocklisted media. The description mentions "nulling out service-related fields," but the implementation goes beyond that. Consider explicitly specifying which fields to null (e.g., serviceId, serviceId4k, externalServiceId, externalServiceId4k, externalServiceSlug, externalServiceSlug4k, ratingKey, ratingKey4k, jellyfinMediaId, jellyfinMediaId4k) instead of nulling all nullable columns.

Copilot uses AI. Check for mistakes.
(m) =>
m.tmdbId === i.tmdbId &&
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
(m.status === MediaStatus.BLOCKLISTED ||
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

The blocklist filtering only checks m.status === MediaStatus.BLOCKLISTED but not m.status4k. According to Blocklist.addToBlocklist (server/entity/Blocklist.ts:99-100), both status and status4k are set to BLOCKLISTED. The check should be m.status === MediaStatus.BLOCKLISTED || m.status4k === MediaStatus.BLOCKLISTED to ensure blocklisted media is properly excluded regardless of which status field indicates it's blocklisted.

Suggested change
(m.status === MediaStatus.BLOCKLISTED ||
(m.status === MediaStatus.BLOCKLISTED ||
m.status4k === MediaStatus.BLOCKLISTED ||

Copilot uses AI. Check for mistakes.
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.

Media from watchlist with auto-approve isn't respected in blocklist

2 participants