Skip to content

feat(servarr): add removal and tag/season cleanup methods (1/5 removal requests)#3175

Open
MrDWilson wants to merge 1 commit into
seerr-team:developfrom
MrDWilson:removal/1-servarr
Open

feat(servarr): add removal and tag/season cleanup methods (1/5 removal requests)#3175
MrDWilson wants to merge 1 commit into
seerr-team:developfrom
MrDWilson:removal/1-servarr

Conversation

@MrDWilson

@MrDWilson MrDWilson commented Jun 18, 2026

Copy link
Copy Markdown

Description

This is the first of a stacked series that splits the media removal request feature (originally #2828) into independently reviewable PRs, following the same approach as the Lidarr split (#3109).

This PR adds the Servarr connectivity layer only: new removal/cleanup helpers on the Radarr and Sonarr API clients. It is functionally stand-alone — it can be merged on its own with no behavioural change to the app, and is consumed by the later PRs in the series.

  • Radarr: removeMovie is now idempotent (no-op when the movie is no longer in the library, so retries are safe); adds removeTagFromMovie.
  • Sonarr: removeSeries is now idempotent; adds removeTagFromSeries and removeSeasonFiles (unmonitors the selected seasons/episodes and deletes their episode files).

Stacked series (each opened against develop as the previous merges)

  1. servarr removal APIthis PR
  2. data model + permissions (entity, migrations, permission bits)
  3. removal request API routes + tests + OpenAPI
  4. removal request UI building blocks (RemovalRequestBlock, SeasonRemovalModal, detail-page entry)
  5. wire-up into ManageSlideOver + RequestList

Tracking: #2828

How Has This Been Tested?

  • pnpm typecheck (server + client)
  • pnpm lint
  • Existing Radarr/Sonarr behaviour unchanged; new methods exercised as part of the removal stack (PR3 route tests).

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 — n/a (no UI strings)
  • Database migration (if required) — n/a

AI Assistance Notice

The mechanical splitting of the original branch into this stacked series, and drafting of this description, were done with assistance from Claude (Opus 4.8). All code is unchanged from the reviewed-by-author original feature branch.

Summary by CodeRabbit

  • New Features

    • Added ability to remove tags from movies
    • Added ability to remove tags from series
    • Added ability to remove season files from series
  • Bug Fixes

    • Movie and series removal operations are now idempotent, gracefully handling cases where items are already removed

Adds idempotent removal helpers to the Radarr and Sonarr API clients:
- Radarr: idempotent removeMovie, removeTagFromMovie
- Sonarr: idempotent removeSeries, removeTagFromSeries, removeSeasonFiles
  (unmonitor + delete episode files for selected seasons)

These are standalone API additions consumed by the upcoming media
removal request feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 18, 2026 14:26
@MrDWilson MrDWilson requested a review from a team as a code owner June 18, 2026 14:26
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

RadarrAPI.removeMovie and SonarrAPI.removeSeries gain idempotency guards that log and return early when the target is not found. New methods removeTagFromMovie (Radarr), removeTagFromSeries (Sonarr), and removeSeasonFiles (Sonarr) are added to support tag removal and multi-step season file deletion.

Changes

Servarr Removal Helpers

Layer / File(s) Summary
Idempotency guards on removeMovie and removeSeries
server/api/servarr/radarr.ts, server/api/servarr/sonarr.ts
Both removeMovie and removeSeries now check the resolved library id and return early with an info log when the item is not present, preventing failed delete calls on missing entries.
removeTagFromMovie and removeTagFromSeries
server/api/servarr/radarr.ts, server/api/servarr/sonarr.ts
New methods filter a tagId from the existing tags array of a movie (by TMDB ID) or series (by TVDB ID) and persist the change via PUT; failures are wrapped with cause.
removeSeasonFiles multi-step workflow
server/api/servarr/sonarr.ts
New removeSeasonFiles method resolves a series, fetches its episodes, unmonitors affected episodes, deletes each unique episode file, unmonitors the target seasons on the series object, and persists via PUT /series/:id; errors are wrapped and rethrown.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hoppy the rabbit tidied the shelves,
Removing old tags by themselves,
If a movie's not there, no panic, no fuss—
Just log and return without making a muss.
Seasons deleted, files swept away,
Idempotent magic hops through the day! 🎬

🚥 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 clearly and specifically describes the main changes: adding removal and tag/season cleanup methods to the Radarr and Sonarr API clients, with the context note indicating this is part 1 of a 5-part series.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds new Servarr (Radarr/Sonarr) client helpers to support upcoming “media removal request” functionality, including idempotent removals and tag/season cleanup operations.

Changes:

  • Make Radarr removeMovie and Sonarr removeSeries idempotent when the title is no longer present in the library.
  • Add Radarr removeTagFromMovie.
  • Add Sonarr removeTagFromSeries and removeSeasonFiles (unmonitor + delete episode files for selected seasons).

Reviewed changes

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

File Description
server/api/servarr/sonarr.ts Adds series tag removal and per-season file cleanup; updates series removal to be idempotent.
server/api/servarr/radarr.ts Adds movie tag removal; updates movie removal to be idempotent.

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

Comment on lines +444 to +452
const series = await this.getSeriesByTvdbId(tvdbId);
if (!series.id) {
throw new Error('Series not found in Sonarr');
}
const updatedTags = series.tags.filter((t) => t !== tagId);
await this.axios.put(`/series/${series.id}`, {
...series,
tags: updatedTags,
});
Comment on lines +500 to +506
// Unmonitor the seasons
series.seasons = series.seasons.map((s) => ({
...s,
monitored: seasonNumbers.includes(s.seasonNumber) ? false : s.monitored,
}));
await this.axios.put(`/series/${series.id}`, series);

Comment on lines +303 to +309
const movie = await this.getMovieByTmdbId(tmdbId);
const updatedTags = movie.tags.filter((t) => t !== tagId);
await this.axios.put(`/movie`, {
...movie,
tags: updatedTags,
});
logger.info(`[Radarr] Removed tag ${tagId} from movie ${movie.title}`);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (2)
server/api/servarr/sonarr.ts (1)

413-437: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Same idempotency issue: guard won't trigger when series is absent.

getSeriesByTvdbId throws 'Series not found' when !response.data[0] (line 176-178), so the !id guard at line 416 never executes for already-removed series. The exception propagates to the outer catch block.

Apply the same fix pattern as suggested for removeMovie:

🔧 Proposed fix
 public removeSeries = async (serieId: number): Promise<void> => {
   try {
-    const { id, title } = await this.getSeriesByTvdbId(serieId);
-    if (!id) {
-      // Series is not in the Sonarr library (e.g. already removed). Treat the
-      // desired end-state as reached so retries remain idempotent.
-      logger.info(
-        '[Sonarr] Series not present in library; nothing to remove',
-        { tvdbId: serieId }
-      );
-      return;
+    let series;
+    try {
+      series = await this.getSeriesByTvdbId(serieId);
+    } catch {
+      logger.info(
+        '[Sonarr] Series not present in library; nothing to remove',
+        { tvdbId: serieId }
+      );
+      return;
     }
+    if (!series.id) {
+      logger.info(
+        '[Sonarr] Series not present in library; nothing to remove',
+        { tvdbId: serieId }
+      );
+      return;
+    }
+    const { id, title } = series;
     await this.axios.delete(`/series/${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 `@server/api/servarr/sonarr.ts` around lines 413 - 437, The removeSeries method
attempts to guard against removing non-existent series with an `!id` check, but
getSeriesByTvdbId throws an exception when the series is not found instead of
returning a falsy value, so the guard never executes. To fix the idempotency
issue, wrap the getSeriesByTvdbId call in its own try-catch block that catches
the "Series not found" exception and returns early (treating it as a successful
no-op), rather than letting the exception propagate to the outer catch block.
Apply the same pattern that was used to fix removeMovie.
server/api/servarr/radarr.ts (1)

270-296: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Idempotency guard won't trigger when movie is not in library.

The getMovieByTmdbId method (lines 95-116) throws 'Movie not found' when !response.data[0], which means the catch block at line 291-295 will handle that case and rethrow the error. The !id guard at line 273 only executes if getMovieByTmdbId succeeds but the returned movie lacks an id.

For true idempotency (returning successfully when the movie was already removed), you need to catch the "Movie not found" scenario specifically:

🔧 Proposed fix for idempotent handling
 public removeMovie = async (movieId: number): Promise<void> => {
   try {
-    const { id, title } = await this.getMovieByTmdbId(movieId);
-    if (!id) {
-      // Movie is not in the Radarr library (e.g. already removed). Treat the
-      // desired end-state as reached so retries remain idempotent.
-      logger.info(
-        '[Radarr] Movie not present in library; nothing to remove',
-        {
-          tmdbId: movieId,
-        }
-      );
-      return;
-    }
+    let movie;
+    try {
+      movie = await this.getMovieByTmdbId(movieId);
+    } catch {
+      // Movie is not in the Radarr library (e.g. already removed). Treat the
+      // desired end-state as reached so retries remain idempotent.
+      logger.info(
+        '[Radarr] Movie not present in library; nothing to remove',
+        { tmdbId: movieId }
+      );
+      return;
+    }
+    if (!movie.id) {
+      logger.info(
+        '[Radarr] Movie not present in library; nothing to remove',
+        { tmdbId: movieId }
+      );
+      return;
+    }
+    const { id, title } = movie;
     await this.axios.delete(`/movie/${id}`, {
       params: {
         deleteFiles: true,
         addImportExclusion: false,
       },
     });
     logger.info(`[Radarr] Removed movie ${title}`);
   } catch (e) {
     throw new Error(`[Radarr] Failed to remove movie: ${e.message}`, {
       cause: e,
     });
   }
 };
🤖 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 `@server/api/servarr/radarr.ts` around lines 270 - 296, The idempotency guard
at the !id check in the removeMovie method won't trigger when getMovieByTmdbId
throws a "Movie not found" error, because that error gets caught by the outer
catch block instead. To fix this, wrap the getMovieByTmdbId call in a separate
try-catch block that specifically catches the "Movie not found" error scenario
and returns early with an info log (similar to the existing !id guard behavior),
rather than letting that error propagate to the outer catch block. This ensures
removeMovie remains idempotent whether the movie is already removed or was never
present in the Radarr library.
🧹 Nitpick comments (1)
server/api/servarr/sonarr.ts (1)

495-498: ⚖️ Poor tradeoff

Consider resilience for partial file deletion failures.

If one DELETE /episodefile/{fileId} call fails (e.g., network hiccup), the loop exits early. Episodes are already unmonitored, but remaining files and the series update (line 505) are skipped, leaving the system partially inconsistent.

A more resilient approach would use Promise.allSettled or continue-on-error with aggregated reporting. That said, a retry of the entire operation should be safe given the idempotent design.

🤖 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 `@server/api/servarr/sonarr.ts` around lines 495 - 498, The episode file
deletion loop using a for loop with await will exit early if any single delete
request fails, leaving remaining files undeleteted and skipping the series
update. Replace the for loop that iterates over episodeFileIds with
Promise.allSettled to send all delete requests concurrently, then handle the
results afterward to allow individual failures without stopping the entire
operation. This ensures the system remains more consistent by completing all
deletion attempts even if some individual requests fail, and you can aggregate
any errors for logging or further handling.
🤖 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 `@server/api/servarr/sonarr.ts`:
- Around line 462-476: The removeSeasonFiles method has an idempotency issue
where the guard check for series.id never executes because getSeriesByTvdbId
throws an error when the series is not found, rather than returning a series
object without an id. Wrap the call to getSeriesByTvdbId in a nested try-catch
block within the removeSeasonFiles method to catch the error when the series is
absent, log an appropriate message indicating the series is not present in the
library, and return early to treat this as an idempotent success case.

---

Outside diff comments:
In `@server/api/servarr/radarr.ts`:
- Around line 270-296: The idempotency guard at the !id check in the removeMovie
method won't trigger when getMovieByTmdbId throws a "Movie not found" error,
because that error gets caught by the outer catch block instead. To fix this,
wrap the getMovieByTmdbId call in a separate try-catch block that specifically
catches the "Movie not found" error scenario and returns early with an info log
(similar to the existing !id guard behavior), rather than letting that error
propagate to the outer catch block. This ensures removeMovie remains idempotent
whether the movie is already removed or was never present in the Radarr library.

In `@server/api/servarr/sonarr.ts`:
- Around line 413-437: The removeSeries method attempts to guard against
removing non-existent series with an `!id` check, but getSeriesByTvdbId throws
an exception when the series is not found instead of returning a falsy value, so
the guard never executes. To fix the idempotency issue, wrap the
getSeriesByTvdbId call in its own try-catch block that catches the "Series not
found" exception and returns early (treating it as a successful no-op), rather
than letting the exception propagate to the outer catch block. Apply the same
pattern that was used to fix removeMovie.

---

Nitpick comments:
In `@server/api/servarr/sonarr.ts`:
- Around line 495-498: The episode file deletion loop using a for loop with
await will exit early if any single delete request fails, leaving remaining
files undeleteted and skipping the series update. Replace the for loop that
iterates over episodeFileIds with Promise.allSettled to send all delete requests
concurrently, then handle the results afterward to allow individual failures
without stopping the entire operation. This ensures the system remains more
consistent by completing all deletion attempts even if some individual requests
fail, and you can aggregate any errors for logging or further handling.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c51f5e73-08b6-4069-9e3d-2444d6dab168

📥 Commits

Reviewing files that changed from the base of the PR and between 72536da and 462b7ca.

📒 Files selected for processing (2)
  • server/api/servarr/radarr.ts
  • server/api/servarr/sonarr.ts

Comment on lines +462 to +476
public removeSeasonFiles = async (
tvdbId: number,
seasonNumbers: number[]
): Promise<void> => {
try {
const series = await this.getSeriesByTvdbId(tvdbId);
if (!series.id) {
// Series is not in the Sonarr library (e.g. already removed). Treat the
// desired end-state as reached so retries remain idempotent.
logger.info(
'[Sonarr] Series not present in library; nothing to remove',
{ tvdbId }
);
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Same idempotency issue in removeSeasonFiles.

The !series.id guard at line 468 won't execute when the series is absent because getSeriesByTvdbId throws first. Apply the nested try-catch pattern here as well.

🔧 Proposed fix
 public removeSeasonFiles = async (
   tvdbId: number,
   seasonNumbers: number[]
 ): Promise<void> => {
   try {
-    const series = await this.getSeriesByTvdbId(tvdbId);
-    if (!series.id) {
-      // Series is not in the Sonarr library (e.g. already removed). Treat the
-      // desired end-state as reached so retries remain idempotent.
+    let series;
+    try {
+      series = await this.getSeriesByTvdbId(tvdbId);
+    } catch {
       logger.info(
         '[Sonarr] Series not present in library; nothing to remove',
         { tvdbId }
       );
       return;
     }
+    if (!series.id) {
+      logger.info(
+        '[Sonarr] Series not present in library; nothing to remove',
+        { tvdbId }
+      );
+      return;
+    }
📝 Committable suggestion

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

Suggested change
public removeSeasonFiles = async (
tvdbId: number,
seasonNumbers: number[]
): Promise<void> => {
try {
const series = await this.getSeriesByTvdbId(tvdbId);
if (!series.id) {
// Series is not in the Sonarr library (e.g. already removed). Treat the
// desired end-state as reached so retries remain idempotent.
logger.info(
'[Sonarr] Series not present in library; nothing to remove',
{ tvdbId }
);
return;
}
public removeSeasonFiles = async (
tvdbId: number,
seasonNumbers: number[]
): Promise<void> => {
try {
let series;
try {
series = await this.getSeriesByTvdbId(tvdbId);
} catch {
logger.info(
'[Sonarr] Series not present in library; nothing to remove',
{ tvdbId }
);
return;
}
if (!series.id) {
logger.info(
'[Sonarr] Series not present in library; nothing to remove',
{ tvdbId }
);
return;
}
🤖 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 `@server/api/servarr/sonarr.ts` around lines 462 - 476, The removeSeasonFiles
method has an idempotency issue where the guard check for series.id never
executes because getSeriesByTvdbId throws an error when the series is not found,
rather than returning a series object without an id. Wrap the call to
getSeriesByTvdbId in a nested try-catch block within the removeSeasonFiles
method to catch the error when the series is absent, log an appropriate message
indicating the series is not present in the library, and return early to treat
this as an idempotent success case.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants