From 324d48e7cf44ccafda9cc6f7e163fa43e1b38826 Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sat, 7 Mar 2026 23:22:06 -0700 Subject: [PATCH] Finished off sonarr integration (Non anime) and refactored requests into a Http helper. --- .../Controllers/RadarrController.cs | 43 ++-- .../Controllers/SonarrController.cs | 217 ++++++++++++++++++ .../Helpers/HttpHelper.cs | 35 +++ .../Models/SeriesInfo.cs | 1 + Jellyfin.Plugin.MediaCleaner/Pages/home.js | 19 +- .../StaleMediaScanner.cs | 4 +- 6 files changed, 291 insertions(+), 28 deletions(-) create mode 100644 Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs index cb2f8c8..9d5df7a 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs @@ -1,3 +1,4 @@ +using Jellyfin.Plugin.MediaCleaner.Helpers; using Jellyfin.Plugin.MediaCleaner.Models; using Microsoft.AspNetCore.Mvc; using System; @@ -37,20 +38,16 @@ public class RadarrController : Controller _httpClient.DefaultRequestHeaders.Add("X-Api-Key", Configuration.RadarrAPIKey); } - private async Task GetRadarrMovieInfo(MovieInfo movieInfo){ - var uriBuilder = new UriBuilder($"{Configuration.RadarrAddress}/api/v3/movie"); - var query = HttpUtility.ParseQueryString(uriBuilder.Query); + private async Task GetRadarrMovieInfo(MovieInfo movieInfo) + { + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.RadarrAddress, + HttpMethod.Get, + $"/api/v3/movie?tmdbId={Uri.EscapeDataString(movieInfo.TmdbId ?? string.Empty)}&excludeLocalCovers=false" + ).ConfigureAwait(false); - query["tmdbId"] = movieInfo.TmdbId; - query["excludeLocalCovers"] = "false"; - - uriBuilder.Query = query.ToString(); - var response = await _httpClient.GetAsync(uriBuilder.Uri).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - var movies = JsonSerializer.Deserialize>(responseBody); + var movies = JsonSerializer.Deserialize>(responseBody.GetRawText()); var movie = movies?.FirstOrDefault(); if (movie == null) @@ -79,19 +76,15 @@ public class RadarrController : Controller RadarrMovie movie = (RadarrMovie)radarrMovieInfoResult.Value; - var uriBuilder = new UriBuilder($"{Configuration.RadarrAddress}/api/v3/movie/{movie.Id}"); - var query = HttpUtility.ParseQueryString(uriBuilder.Query); + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.RadarrAddress, + HttpMethod.Delete, + $"/api/v3/movie/{movie.Id}?deleteFiles=true&addImportExclusion=true" + ).ConfigureAwait(false); - query["deleteFiles"] = "true"; - query["addImportExclusion"] = "true"; - - uriBuilder.Query = query.ToString(); - - using var request = new HttpRequestMessage(HttpMethod.Delete, uriBuilder.Uri); - var response = await _httpClient.SendAsync(request).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - return Ok(); + // Radarr typically returns an empty body on successful delete. + return Ok(responseBody); } catch (HttpRequestException e) { diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs index 67a1c50..b3f98a3 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs @@ -5,9 +5,35 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using System.Net.Http.Headers; using System; +using System.Web; +using System.Text.Json; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using System.Globalization; +using System.Security.Cryptography.X509Certificates; +using Jellyfin.Plugin.MediaCleaner.Helpers; namespace Jellyfin.Plugin.MediaCleaner.Controllers; +public record SonarrSeries( + [property: JsonPropertyName("id")] int? Id, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("seasons")] IReadOnlyList Seasons +); + +public record EpisodeDeletionDetails( + [property: JsonPropertyName("id")] int? EpisodeId, + [property: JsonPropertyName("episodeFileId")] int? EpisodeFileId, + [property: JsonPropertyName("seasonNumber")] int? SeasonNumber +); + +public record EpisodeIdLists(IReadOnlyList EpisodeIds, IReadOnlyList EpisodeFileIds); + +public record Season( + [property: JsonPropertyName("seasonNumber")] int? SeasonNumber +); + [Route("sonarr")] public class SonarrController : Controller { @@ -25,6 +51,197 @@ public class SonarrController : Controller _httpClient.DefaultRequestHeaders.Add("X-Api-Key", Configuration.SonarrAPIKey); } + private async Task GetSonarrSeriesInfo(SeriesInfo seriesInfo){ + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Get, + $"/api/v3/series?tvdbId={Uri.EscapeDataString(seriesInfo.TvdbId ?? string.Empty)}" + ).ConfigureAwait(false); + + var seriesResponseObj = JsonSerializer.Deserialize>(responseBody.GetRawText()); + var series = seriesResponseObj?.FirstOrDefault(); + + if (series == null) + { + return NotFound("Series not found in Sonarr library."); + } + + return Ok(series); + } + + private async Task GetSonarrEpisodeInfo(SonarrSeries sonarrSeries){ + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Get, + $"/api/v3/episode?seriesId={sonarrSeries.Id?.ToString(CultureInfo.InvariantCulture)}" + ).ConfigureAwait(false); + + var episodesResponseObj = JsonSerializer.Deserialize>(responseBody.GetRawText()); + + if(episodesResponseObj == null){ + return NotFound("No episodes in response object."); + } + + var seasonNumbers = new HashSet(sonarrSeries.Seasons + .Where(s => s.SeasonNumber.HasValue) + .Select(s => s.SeasonNumber!.Value)); + + var staleEpisodesResponseObj = episodesResponseObj + .Where(episodeDeletionDetail => episodeDeletionDetail.SeasonNumber != null && + seasonNumbers.Contains(episodeDeletionDetail.SeasonNumber.Value)) + .ToList(); + + var episodeIds = staleEpisodesResponseObj + .Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeId) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + + var episodeFileIds = staleEpisodesResponseObj + .Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeFileId) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + + return Ok(new EpisodeIdLists(episodeIds, episodeFileIds)); + } + + [HttpPost("deleteSeriesFromSonarr")] + public async Task DeleteSeriesFromRadarr([FromBody] SeriesInfo seriesInfo){ + + if (seriesInfo == null || string.IsNullOrEmpty(seriesInfo.TvdbId)) + { + return BadRequest("Invalid series information provided."); + } + + try + { + var sonarrSeriesInfoResult = await GetSonarrSeriesInfo(seriesInfo).ConfigureAwait(false); + + if(sonarrSeriesInfoResult.StatusCode != StatusCodes.Status200OK || sonarrSeriesInfoResult.Value is not SonarrSeries){ + return sonarrSeriesInfoResult; + } + + SonarrSeries retrievedSeries = (SonarrSeries)sonarrSeriesInfoResult.Value; + SonarrSeries staleSeries = new( + Id: retrievedSeries.Id, + Title: retrievedSeries.Title, + Seasons: [.. seriesInfo.Seasons.Select(season => new Season(SeasonNumber: int.Parse(season, CultureInfo.InvariantCulture)))] + ); + + var episodesToPurgeResult = await GetSonarrEpisodeInfo(staleSeries).ConfigureAwait(false); + if (episodesToPurgeResult.StatusCode != StatusCodes.Status200OK || episodesToPurgeResult.Value is not EpisodeIdLists) + { + return sonarrSeriesInfoResult; + } + + EpisodeIdLists episodesToPurge = (EpisodeIdLists)episodesToPurgeResult.Value; + + await UnmonitorSeasons(staleSeries).ConfigureAwait(false); + await UnmonitorEpisodeIds(episodesToPurge.EpisodeIds).ConfigureAwait(false); + await DeleteEpisodeFiles(episodesToPurge.EpisodeFileIds).ConfigureAwait(false); + + return Ok(); + } + catch (HttpRequestException e) + { + return StatusCode(StatusCodes.Status500InternalServerError, $"An unexpected error occurred. {e.Message}"); + } + } + + private async Task UnmonitorSeasons(SonarrSeries staleSeries){ + if (staleSeries == null) + { + return BadRequest("No stale series provided."); + } + + var series = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Get, + $"/api/v3/series/{staleSeries.Id}" + ).ConfigureAwait(false); + + var seriesDict = JsonSerializer.Deserialize>(series.GetRawText()); + if (seriesDict == null) + { + throw new InvalidOperationException("Failed to deserialize season."); + } + var seasons = series.GetProperty("seasons").EnumerateArray().ToList(); + + var staleSeasonNumbers = staleSeries.Seasons + .Select(s => s.SeasonNumber) + .ToHashSet(); + + var updatedSeasons = seasons.Select(season => + { + var seasonNumber = season.GetProperty("seasonNumber").GetInt32(); + + if (staleSeasonNumbers.Contains(seasonNumber)) + { + var seasonDict = JsonSerializer.Deserialize>(season.GetRawText()); + if (seasonDict == null) + { + throw new InvalidOperationException("Failed to deserialize season."); + } + seasonDict["monitored"] = false; + return seasonDict; + } + + return JsonSerializer.Deserialize>(season.GetRawText()); + }).ToArray(); + + seriesDict["seasons"] = updatedSeasons; + + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Put, + $"/api/v3/series/{staleSeries.Id}", + seriesDict + ).ConfigureAwait(false); + + return Ok(responseBody); + } + + private async Task DeleteEpisodeFiles(IReadOnlyList episodeFileIds) + { + if (episodeFileIds == null || episodeFileIds.Count == 0) + { + return BadRequest("No episode file IDs provided."); + } + + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Delete, + "/api/v3/episodefile/bulk", + new { episodeFileIds } + ).ConfigureAwait(false); + + return Ok(responseBody); + } + + private async Task UnmonitorEpisodeIds(IReadOnlyList episodeIds) + { + if (episodeIds == null || episodeIds.Count == 0) + { + return BadRequest("No episode IDs provided."); + } + + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Put, + "/api/v3/episode/monitor", + new { episodeIds, monitored = false } + ).ConfigureAwait(false); + + return Ok(responseBody); + } + [HttpPost("testConnection")] public async Task TestConnection([FromBody] ConnectionTestRequest request) { diff --git a/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs b/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs new file mode 100644 index 0000000..1d2ec2c --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MediaCleaner.Helpers; + +public static class HttpHelper +{ + /// + /// Sends a JSON request and returns the raw JSON element response. + /// + /// + /// Do NOT create a new HttpClient on every call; reuse one instance (DI or a singleton) to avoid socket exhaustion. + /// + public static async Task SendHttpRequestAsync(HttpClient httpClient, string baseAddress, HttpMethod method, string path, object? body = null) + { + var uri = new UriBuilder($"{baseAddress}{path}").Uri; + using var request = new HttpRequestMessage(method, uri); + + if (body != null) + { + var json = JsonSerializer.Serialize(body); + request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + } + + var response = await httpClient.SendAsync(request).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonSerializer.Deserialize(responseBody); + } +} diff --git a/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs b/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs index 123e410..47c8d11 100644 --- a/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs +++ b/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs @@ -12,4 +12,5 @@ public class SeriesInfo : MediaInfo { public Guid SeriesId { get; set; } public IEnumerable Seasons { get; set; } = []; + public required string? TvdbId { get; set; } } diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.js b/Jellyfin.Plugin.MediaCleaner/Pages/home.js index 8bca92b..ecd62e5 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.js @@ -108,7 +108,7 @@ const populateTables = async () => { var cell3 = row.insertCell(2); cell1.innerHTML = seriesInfo[i].Name; cell1.className = "table-text"; - cell2.innerHTML = seriesInfo[i].Seasons.map(season => season.replace("Season ", "")).join(", "); + cell2.innerHTML = seriesInfo[i].Seasons.map(season => season).join(", "); cell2.className = "table-text"; cell3.appendChild(createCheckbox(seriesInfo[i], seriesTable, seriesDeleteButton)); cell3.className = "table-checkbox" @@ -204,6 +204,20 @@ const deleteMovieFromRadarrApi = async (movie) => { } } +const deleteSeriesFromSonarrApi = async (series) => { + const response = await fetch("/sonarr/deleteSeriesFromSonarr", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(series) + }); + + if(!response.ok){ + throw new Error(`Response status: ${response.status}`) + } +} + const deleteFromRadarr = async () => { // Get all movies with checked checkboxes const selectedMovies = getCheckedMedia(moviesTable); @@ -213,7 +227,8 @@ const deleteFromRadarr = async () => { const deleteFromSonarr = () => { // Need to GET first for seriesIds? - getCheckedMedia(seriesTable); + const selectedSeries = getCheckedMedia(seriesTable); + selectedSeries.forEach(async series => await deleteSeriesFromSonarrApi(series)); // Use tvdbId included in filenames. // /api/v5/series?tvdbId=383275 // Possibly use statistics from GET to show on front end? diff --git a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs index a051b7c..8207e26 100644 --- a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs +++ b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs @@ -234,13 +234,15 @@ public sealed class StaleMediaScanner IEnumerable seriesInfoList = series.Select(series => { + series.ProviderIds.TryGetValue("Tvdb", out string? tvdbId); series.ProviderIds.TryGetValue("Tmdb", out string? tmdbId); return new SeriesInfo { SeriesId = series.Id, TmdbId = tmdbId, + TvdbId = tvdbId, Name = series.Name, - Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name)] + Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name.Replace("Season ", "", StringComparison.OrdinalIgnoreCase))] }; });