From 21a9cc86d83219a723d52c4485db9ac775e7e034 Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sun, 8 Mar 2026 19:28:04 -0600 Subject: [PATCH 1/5] Refactor of http client and addition of sonarr anime table --- Jellyfin.Plugin.MediaCleaner/Configuration.cs | 10 +++ .../Controllers/RadarrController.cs | 30 ++------ .../Controllers/SonarrController.cs | 38 ++++------- .../Controllers/StateController.cs | 8 ++- .../Enums/ServerType.cs | 8 +++ .../Helpers/HttpHelper.cs | 50 ++++++++++++-- .../Pages/configuration.html | 20 ++++++ .../Pages/configuration.js | 15 ++++ Jellyfin.Plugin.MediaCleaner/Pages/home.html | 13 ++++ Jellyfin.Plugin.MediaCleaner/Pages/home.js | 68 ++++++++++++++++++- 10 files changed, 206 insertions(+), 54 deletions(-) create mode 100644 Jellyfin.Plugin.MediaCleaner/Enums/ServerType.cs diff --git a/Jellyfin.Plugin.MediaCleaner/Configuration.cs b/Jellyfin.Plugin.MediaCleaner/Configuration.cs index e32c0b4..17dec16 100644 --- a/Jellyfin.Plugin.MediaCleaner/Configuration.cs +++ b/Jellyfin.Plugin.MediaCleaner/Configuration.cs @@ -30,6 +30,16 @@ public class Configuration : BasePluginConfiguration /// public string SonarrAPIKey { get; set; } = string.Empty; + /// + /// Gets or sets the http and port address for your Sonarr instance. + /// + public string SonarrAnimeAddress { get; set; } = string.Empty; + + /// + /// Gets or sets the api for your Sonarr instance. + /// + public string SonarrAnimeAPIKey { get; set; } = string.Empty; + /// /// Gets or sets the cut off days before deleting unwatched files. /// diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs index 9d5df7a..bf78246 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs @@ -4,11 +4,10 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; using System.Net.Http; -using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using System.Web; +using Jellyfin.Plugin.MediaCleaner.Enums; using Microsoft.AspNetCore.Http; using System.Linq; @@ -24,25 +23,10 @@ public record RadarrMovie( [Route("radarr")] public class RadarrController : Controller { - private static Configuration Configuration => - Plugin.Instance!.Configuration; - - private readonly HttpClient _httpClient; - - public RadarrController(HttpClient httpClient) - { - _httpClient = httpClient; - - // Set the default request headers - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _httpClient.DefaultRequestHeaders.Add("X-Api-Key", Configuration.RadarrAPIKey); - } - private async Task GetRadarrMovieInfo(MovieInfo movieInfo) { - var responseBody = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.RadarrAddress, + HttpHelper httpHelper = new(ServerType.Radarr); + var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, $"/api/v3/movie?tmdbId={Uri.EscapeDataString(movieInfo.TmdbId ?? string.Empty)}&excludeLocalCovers=false" ).ConfigureAwait(false); @@ -76,9 +60,8 @@ public class RadarrController : Controller RadarrMovie movie = (RadarrMovie)radarrMovieInfoResult.Value; - var responseBody = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.RadarrAddress, + HttpHelper httpHelper = new(ServerType.Radarr); + var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Delete, $"/api/v3/movie/{movie.Id}?deleteFiles=true&addImportExclusion=true" ).ConfigureAwait(false); @@ -109,10 +92,11 @@ public class RadarrController : Controller try { + using var testHttpClient = new HttpClient(); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, address); httpRequest.Headers.Add("X-Api-Key", request.ApiKey); - var response = await _httpClient.SendAsync(httpRequest).ConfigureAwait(false); + var response = await testHttpClient.SendAsync(httpRequest).ConfigureAwait(false); return Ok(new { success = response.IsSuccessStatusCode }); } catch (HttpRequestException e) diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs index b3f98a3..ac57b75 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs @@ -11,7 +11,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; using System.Globalization; -using System.Security.Cryptography.X509Certificates; +using Jellyfin.Plugin.MediaCleaner.Enums; using Jellyfin.Plugin.MediaCleaner.Helpers; namespace Jellyfin.Plugin.MediaCleaner.Controllers; @@ -37,9 +37,6 @@ public record Season( [Route("sonarr")] public class SonarrController : Controller { - private static Configuration Configuration => - Plugin.Instance!.Configuration; - private readonly HttpClient _httpClient; public SonarrController(HttpClient httpClient) @@ -48,13 +45,11 @@ public class SonarrController : Controller // Set the default request headers _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _httpClient.DefaultRequestHeaders.Add("X-Api-Key", Configuration.SonarrAPIKey); } private async Task GetSonarrSeriesInfo(SeriesInfo seriesInfo){ - var responseBody = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.SonarrAddress, + HttpHelper httpHelper = new(ServerType.Sonarr); + var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, $"/api/v3/series?tvdbId={Uri.EscapeDataString(seriesInfo.TvdbId ?? string.Empty)}" ).ConfigureAwait(false); @@ -71,9 +66,8 @@ public class SonarrController : Controller } private async Task GetSonarrEpisodeInfo(SonarrSeries sonarrSeries){ - var responseBody = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.SonarrAddress, + HttpHelper httpHelper = new(ServerType.Sonarr); + var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, $"/api/v3/episode?seriesId={sonarrSeries.Id?.ToString(CultureInfo.InvariantCulture)}" ).ConfigureAwait(false); @@ -109,7 +103,7 @@ public class SonarrController : Controller } [HttpPost("deleteSeriesFromSonarr")] - public async Task DeleteSeriesFromRadarr([FromBody] SeriesInfo seriesInfo){ + public async Task DeleteSeriesFromSonarr([FromBody] SeriesInfo seriesInfo){ if (seriesInfo == null || string.IsNullOrEmpty(seriesInfo.TvdbId)) { @@ -157,9 +151,8 @@ public class SonarrController : Controller return BadRequest("No stale series provided."); } - var series = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.SonarrAddress, + HttpHelper httpHelper = new(ServerType.Sonarr); + var series = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, $"/api/v3/series/{staleSeries.Id}" ).ConfigureAwait(false); @@ -195,9 +188,7 @@ public class SonarrController : Controller seriesDict["seasons"] = updatedSeasons; - var responseBody = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.SonarrAddress, + var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Put, $"/api/v3/series/{staleSeries.Id}", seriesDict @@ -213,9 +204,8 @@ public class SonarrController : Controller return BadRequest("No episode file IDs provided."); } - var responseBody = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.SonarrAddress, + HttpHelper httpHelper = new(ServerType.Sonarr); + var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Delete, "/api/v3/episodefile/bulk", new { episodeFileIds } @@ -231,9 +221,9 @@ public class SonarrController : Controller return BadRequest("No episode IDs provided."); } - var responseBody = await HttpHelper.SendHttpRequestAsync( - _httpClient, - Configuration.SonarrAddress, + + HttpHelper httpHelper = new(ServerType.Sonarr); + var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Put, "/api/v3/episode/monitor", new { episodeIds, monitored = false } diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs index 589fbed..af8e2bc 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs @@ -25,7 +25,11 @@ public class StateController(MediaCleanerState state) : Controller public IActionResult GetMoviesTitle() => Ok($"Stale Movies (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)"); - [HttpGet("getSeriesTitle")] + [HttpGet("getSeriesTitle")] public IActionResult GetSeriesTitle() => - Ok($"Stale Series (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)"); + Ok($"Stale TV Series (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)"); + + [HttpGet("getAnimeSeriesTitle")] + public IActionResult GetAnimeSeriesTitle() => + Ok($"Stale Anime Series (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)"); } diff --git a/Jellyfin.Plugin.MediaCleaner/Enums/ServerType.cs b/Jellyfin.Plugin.MediaCleaner/Enums/ServerType.cs new file mode 100644 index 0000000..7107b27 --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Enums/ServerType.cs @@ -0,0 +1,8 @@ +namespace Jellyfin.Plugin.MediaCleaner.Enums; + +public enum ServerType +{ + Radarr, + Sonarr, + SonarrAnime +} diff --git a/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs b/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs index 1d2ec2c..e3eabbf 100644 --- a/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs +++ b/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs @@ -1,23 +1,43 @@ using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Net.Http; +using System.Net.Http.Headers; using System.Text.Json; using System.Threading.Tasks; +using Jellyfin.Plugin.MediaCleaner.Enums; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MediaCleaner.Helpers; -public static class HttpHelper +public class HttpHelper { + private string _baseAddress { get; } + private HttpClient _httpClient { get; } + + private static Configuration Configuration => + Plugin.Instance!.Configuration; + + public HttpHelper(ServerType serverType) + { + _httpClient = new HttpClient(); + + // Set the default request headers + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient.DefaultRequestHeaders.Add("X-Api-Key", RetrieveApiKey(serverType)); + + _baseAddress = RetrieveBaseAddress(serverType); + } + /// /// 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) + public async Task SendHttpRequestAsync(HttpMethod method, string path, object? body = null) { - var uri = new UriBuilder($"{baseAddress}{path}").Uri; + var uri = new UriBuilder($"{_baseAddress}{path}").Uri; using var request = new HttpRequestMessage(method, uri); if (body != null) @@ -26,10 +46,32 @@ public static class HttpHelper request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); } - var response = await httpClient.SendAsync(request).ConfigureAwait(false); + var response = await _httpClient.SendAsync(request).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); return JsonSerializer.Deserialize(responseBody); } + + private string RetrieveApiKey(ServerType serverType) + { + return serverType switch + { + ServerType.Sonarr => Configuration.SonarrAPIKey, + ServerType.SonarrAnime => Configuration.SonarrAnimeAPIKey, + ServerType.Radarr => Configuration.RadarrAPIKey, + _ => string.Empty, + }; + } + + private string RetrieveBaseAddress(ServerType serverType) + { + return serverType switch + { + ServerType.Sonarr => Configuration.SonarrAddress, + ServerType.SonarrAnime => Configuration.SonarrAnimeAddress, + ServerType.Radarr => Configuration.RadarrAddress, + _ => string.Empty, + }; + } } diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.html b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.html index 8285a76..0da1009 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.html +++ b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.html @@ -47,6 +47,26 @@ +
+
+ + +
The address and port of your sonarr instance.
+
+
+ + +
The api key used by your sonarr instance
+
+
+ + +
+
+

General Settings

diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js index a6f4bad..51891a7 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js @@ -32,6 +32,14 @@ const startFadeIn = (element, interval = 100) => { }; // Connection Methods +const testConnectionSonarrAnime = async () => { + var apiKeyElement = document.getElementById('SonarrAnimeAPIKey'); + var addressElement = document.getElementById('SonarrAnimeAddress'); + var validationElement = document.getElementById('SonarrAnimeConnectionValidation'); + + await validateConnection(apiKeyElement, addressElement, validationElement, "sonarr"); +} + const testConnectionSonarr = async () => { var apiKeyElement = document.getElementById('SonarrAPIKey'); var addressElement = document.getElementById('SonarrAddress'); @@ -131,6 +139,9 @@ document.querySelector('#RadarrTestConnectionButton') document.querySelector('#SonarrTestConnectionButton') .addEventListener('click', testConnectionSonarr); +document.querySelector('#SonarrAnimeTestConnectionButton') + .addEventListener('click', testConnectionSonarrAnime); + document.querySelector('#MediaCleanerConfigPage') .addEventListener('pageshow', function() { Dashboard.showLoadingMsg(); @@ -139,6 +150,8 @@ document.querySelector('#MediaCleanerConfigPage') document.querySelector('#RadarrAddress').value = config.RadarrAddress; document.querySelector('#SonarrAPIKey').value = config.SonarrAPIKey; document.querySelector('#SonarrAddress').value = config.SonarrAddress; + document.querySelector('#SonarrAnimeAPIKey').value = config.SonarrAnimeAPIKey; + document.querySelector('#SonarrAnimeAddress').value = config.SonarrAnimeAddress; document.querySelector('#StaleMediaCutoff').value = config.StaleMediaCutoff; document.querySelector('#DebugMode').checked = config.DebugMode; Dashboard.hideLoadingMsg(); @@ -153,6 +166,8 @@ document.querySelector('#MediaCleanerConfigForm') config.RadarrAddress = document.querySelector('#RadarrAddress').value; config.SonarrAPIKey = document.querySelector('#SonarrAPIKey').value; config.SonarrAddress = document.querySelector('#SonarrAddress').value; + config.SonarrAnimeAPIKey = document.querySelector('#SonarrAnimeAPIKey').value; + config.SonarrAnimeAddress = document.querySelector('#SonarrAnimeAddress').value; config.StaleMediaCutoff = document.querySelector('#StaleMediaCutoff').value; config.DebugMode = document.querySelector('#DebugMode').checked; ApiClient.updatePluginConfiguration(MediaCleanerConfig.pluginUniqueId, config).then(function (result) { diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.html b/Jellyfin.Plugin.MediaCleaner/Pages/home.html index b6e2523..dacf46a 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.html +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.html @@ -33,6 +33,19 @@ +
+

+ + + + + + + + + +
NameSeasons
+
diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.js b/Jellyfin.Plugin.MediaCleaner/Pages/home.js index 15dcfa5..578e99f 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.js @@ -8,9 +8,11 @@ const refreshFrontEnd = async () => { var moviesTitle = document.getElementById("moviesTitle"); var seriesTitle = document.getElementById("seriesTitle"); + var animeSeriesTitle = document.getElementById("animeSeriesTitle"); moviesTitle.innerHTML = await getMediaCleanerMoviesTitle(); seriesTitle.innerHTML = await getMediaCleanerSeriesTitle(); + animeSeriesTitle.innerHTML = await getMediaCleanerAnimeSeriesTitle(); await populateTables(); addClickHandlersToLinks(); @@ -48,6 +50,16 @@ const updateMediaCleanerState = async () => { return response.json(); }; +const getMediaCleanerAnimeSeriesTitle = async () => { + const response = await fetch("/mediacleaner/state/getAnimeSeriesTitle"); + + if(!response.ok){ + throw new Error(`Response status: ${response.status}`); + } + + return response.json(); +}; + const getMediaCleanerSeriesTitle = async () => { const response = await fetch("/mediacleaner/state/getSeriesTitle"); @@ -72,6 +84,11 @@ const getMediaCleanerMoviesTitle = async () => { const populateTables = async () => { var moviesInfo = await getMediaCleanerMovieInfo(); var seriesInfo = await getMediaCleanerSeriesInfo(); + var animeSeriesInfo = await getMediaCleanerAnimeSeriesInfo(); + + var seriesTable = document.getElementById("seriesTable"); + var moviesTable = document.getElementById("moviesTable"); + var animeSeriesTable = document.getElementById("animeSeriesTable"); var seriesTableBody = seriesTable.getElementsByTagName('tbody')[0]; seriesTableBody.replaceChildren(); @@ -81,6 +98,10 @@ const populateTables = async () => { moviesTableBody.replaceChildren(); var moviesDeleteButton = document.getElementById('moviesDeleteButton'); + var animeSeriesTableBody = animeSeriesTable.getElementsByTagName('tbody')[0]; + animeSeriesTableBody.replaceChildren(); + var animeSeriesDeleteButton = document.getElementById('animeSeriesDeleteButton'); + if (moviesInfo.length > 0){ for(let i = 0; i < moviesInfo.length; i++){ var row = moviesTableBody.insertRow(-1); @@ -120,7 +141,30 @@ const populateTables = async () => { var row = seriesTableBody.insertRow(-1); var cell1 = row.insertCell(0); cell1.colSpan = columnCount; - cell1.innerHTML = "No stale series found."; + cell1.innerHTML = "No stale tv series found."; + cell1.className = "table-text"; + } + + if(animeSeriesInfo.length > 0){ + for(let i = 0; i < animeSeriesInfo.length; i++){ + var row = animeSeriesTableBody.insertRow(-1); + var cell1 = row.insertCell(0); + var cell2 = row.insertCell(1); + var cell3 = row.insertCell(2); + cell1.innerHTML = animeSeriesInfo[i].Name; + cell1.className = "table-text"; + cell2.innerHTML = animeSeriesInfo[i].Seasons.map(season => season).join(", "); + cell2.className = "table-text"; + cell3.appendChild(createCheckbox(animeSeriesInfo[i], animeSeriesTable, animeSeriesDeleteButton)); + cell3.className = "table-checkbox" + } + } + else{ + var columnCount = animeSeriesTableBody.tHead.rows[0].cells.length; + var row = animeSeriesTableBody.insertRow(-1); + var cell1 = row.insertCell(0); + cell1.colSpan = columnCount; + cell1.innerHTML = "No stale anime series found."; cell1.className = "table-text"; } }; @@ -179,8 +223,10 @@ const addClickHandlersToLinks = () => { const addClickHandlersToDeleteButtons = () => { const deleteMoviesButtonElement = document.getElementById("moviesDeleteButton"); const deleteSeriesButtonElement = document.getElementById("seriesDeleteButton"); + const deleteAnimeSeriesButtonElement = document.getElementById("animeSeriesDeleteButton"); deleteMoviesButtonElement.addEventListener("click", deleteFromRadarr); deleteSeriesButtonElement.addEventListener("click", deleteFromSonarr); + deleteAnimeSeriesButtonElement.addEventListener("click", deleteFromSonarrAnime); } const getCheckedMedia = (table) => { @@ -219,6 +265,20 @@ const deleteSeriesFromSonarrApi = async (series) => { } } +const deleteSeriesFromSonarrAnimeApi = async (series) => { + const response = await fetch("/sonarr/deleteSeriesFromSonarrAnime", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(series) + }); + + if(!response.ok){ + throw new Error(`Response status: ${response.status}`) + } +} + const deleteFromRadarr = async () => { const selectedMovies = getCheckedMedia(moviesTable); selectedMovies.forEach(async movie => await deleteMovieFromRadarrApi(movie)); @@ -231,6 +291,12 @@ const deleteFromSonarr = () => { refreshFrontEnd(); } +const deleteFromAnimeSonarr = () => { + const selectedSeries = getCheckedMedia(animeSeriesTable); + selectedSeries.forEach(async series => await deleteSeriesFromSonarrApi(series)); + refreshFrontEnd(); +} + const finishLoading = () => { const loadingElement = document.getElementById("loading"); const homepage = document.getElementById("homepage"); -- 2.39.5 From 87bf40dab9c7b7f1896df33f3e49d2a36e7b41ed Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sun, 8 Mar 2026 23:39:17 -0600 Subject: [PATCH 2/5] Refactored sonarr controller to be able to return anime or tv series based on the media found in the server. Also updated models --- .../Controllers/RadarrController.cs | 7 --- .../Controllers/SonarrController.cs | 26 ++------- .../Controllers/StateController.cs | 17 +++++- .../Data/MediaCleanerState.cs | 56 ++++++++++++++++--- .../Models/ConnectionTestRequest.cs | 3 + .../Models/EpisodeDeletionDetails.cs | 9 +++ .../Models/EpisodeIdLists.cs | 5 ++ .../Models/MediaInfo.cs | 2 +- .../Models/RadarrMovie.cs | 8 +++ .../Models/SeriesInfo.cs | 2 +- .../Models/SonarrSeries.cs | 18 ++++++ Jellyfin.Plugin.MediaCleaner/Pages/home.html | 1 + Jellyfin.Plugin.MediaCleaner/Pages/home.js | 24 +++++--- .../StaleMediaScanner.cs | 35 ++---------- 14 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 Jellyfin.Plugin.MediaCleaner/Models/ConnectionTestRequest.cs create mode 100644 Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs create mode 100644 Jellyfin.Plugin.MediaCleaner/Models/EpisodeIdLists.cs create mode 100644 Jellyfin.Plugin.MediaCleaner/Models/RadarrMovie.cs create mode 100644 Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs index bf78246..8285d15 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs @@ -13,13 +13,6 @@ using System.Linq; namespace Jellyfin.Plugin.MediaCleaner.Controllers; -public record ConnectionTestRequest(string Address, string ApiKey); - -public record RadarrMovie( - [property: JsonPropertyName("id")] int? Id, - [property: JsonPropertyName("title")] string? Title -); - [Route("radarr")] public class RadarrController : Controller { diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs index ac57b75..d7b6fa9 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs @@ -5,35 +5,15 @@ 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 Jellyfin.Plugin.MediaCleaner.Enums; 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 { @@ -69,7 +49,7 @@ public class SonarrController : Controller HttpHelper httpHelper = new(ServerType.Sonarr); var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, - $"/api/v3/episode?seriesId={sonarrSeries.Id?.ToString(CultureInfo.InvariantCulture)}" + $"/api/v3/episode?seriesId={sonarrSeries.Id.ToString(CultureInfo.InvariantCulture)}" ).ConfigureAwait(false); var episodesResponseObj = JsonSerializer.Deserialize>(responseBody.GetRawText()); @@ -122,7 +102,9 @@ public class SonarrController : Controller SonarrSeries staleSeries = new( Id: retrievedSeries.Id, Title: retrievedSeries.Title, - Seasons: [.. seriesInfo.Seasons.Select(season => new Season(SeasonNumber: int.Parse(season, CultureInfo.InvariantCulture)))] + Seasons: [.. seriesInfo.Seasons.Select(season => new Season(SeasonNumber: int.Parse(season, CultureInfo.InvariantCulture)))], + Ended: retrievedSeries.Ended, + TvdbId: retrievedSeries.TvdbId ); var episodesToPurgeResult = await GetSonarrEpisodeInfo(staleSeries).ConfigureAwait(false); diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs index af8e2bc..28f9684 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs @@ -2,6 +2,8 @@ using Jellyfin.Plugin.MediaCleaner.Data; using Jellyfin.Plugin.MediaCleaner; using Jellyfin.Plugin.MediaCleaner.Models; using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using System.Collections.Generic; namespace Jellyfin.Plugin.MediaCleaner.Controllers; @@ -12,8 +14,19 @@ public class StateController(MediaCleanerState state) : Controller private static Configuration Configuration => Plugin.Instance!.Configuration; - [HttpGet("getSeriesInfo")] - public IActionResult GetSeriesInfo() => Ok(_state.GetSeriesInfo()); + [HttpGet("getTvSeriesInfo")] + public async Task GetTvSeriesInfo() + { + var tvSeriesInfo = await _state.GetTvSeriesInfo().ConfigureAwait(false); + return Ok(tvSeriesInfo); + } + + [HttpGet("getAnimeSeriesInfo")] + public async Task GetAnimeSeriesInfo() + { + var animeSeriesInfo = await _state.GetAnimeSeriesInfo().ConfigureAwait(false); + return Ok(animeSeriesInfo); + } [HttpGet("getMovieInfo")] public IActionResult GetMovieInfo() => Ok(_state.GetMovieInfo()); diff --git a/Jellyfin.Plugin.MediaCleaner/Data/MediaCleanerState.cs b/Jellyfin.Plugin.MediaCleaner/Data/MediaCleanerState.cs index 4c168df..3d12c1e 100644 --- a/Jellyfin.Plugin.MediaCleaner/Data/MediaCleanerState.cs +++ b/Jellyfin.Plugin.MediaCleaner/Data/MediaCleanerState.cs @@ -1,15 +1,16 @@ -using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Database.Implementations.ModelConfiguration; -using Jellyfin.Plugin.MediaCleaner; +using Jellyfin.Plugin.MediaCleaner.Helpers; using Jellyfin.Plugin.MediaCleaner.Models; -using Jellyfin.Plugin.MediaCleaner.ScheduledTasks; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging; +using Jellyfin.Plugin.MediaCleaner.Enums; +using System.Net.Http; +using System; +using System.Text.Json; +using System.Globalization; namespace Jellyfin.Plugin.MediaCleaner.Data; @@ -24,15 +25,54 @@ public class MediaCleanerState(ILogger logger, ILibraryManage _mediaInfo = await _staleMediaScanner.ScanStaleMedia().ConfigureAwait(false); } - public IEnumerable GetSeriesInfo() + public async Task> GetTvSeriesInfo() { + // Filter only TV + // Get all series on tv sonarr server + HttpHelper tvHttpHelper = new HttpHelper(ServerType.Sonarr); + var tvSeriesResponse = await tvHttpHelper.SendHttpRequestAsync(HttpMethod.Get,"/api/v3/series").ConfigureAwait(false); + var tvSeries = JsonSerializer.Deserialize>(tvSeriesResponse.GetRawText()); + + if(tvSeries == null) + { + return []; + } + lock (_lock) { - return _mediaInfo.OfType(); + var allSeries = _mediaInfo.OfType(); + + var tvSeriesInfo = allSeries + .Where(series => tvSeries.Any(tv => tv.TvdbId == int.Parse(series.TvdbId, CultureInfo.InvariantCulture))); + + return [.. tvSeriesInfo]; } } - public IEnumerable GetMovieInfo() + public async Task> GetAnimeSeriesInfo() + { + // Get all series on anime sonarr server + HttpHelper animeHttpHelper = new HttpHelper(ServerType.SonarrAnime); + var animeSeriesResponse = await animeHttpHelper.SendHttpRequestAsync(HttpMethod.Get,"/api/v3/series").ConfigureAwait(false); + var animeSeries = JsonSerializer.Deserialize>(animeSeriesResponse.GetRawText()); + + if(animeSeries == null) + { + return Enumerable.Empty(); + } + + lock (_lock) + { + var allSeries = _mediaInfo.OfType(); + + var animeSeriesInfo = allSeries + .Where(series => animeSeries.Any(anime => anime.TvdbId == int.Parse(series.TvdbId, CultureInfo.InvariantCulture))); + + return animeSeriesInfo; + } + } + + public IEnumerable GetMovieInfo() { lock (_lock) { diff --git a/Jellyfin.Plugin.MediaCleaner/Models/ConnectionTestRequest.cs b/Jellyfin.Plugin.MediaCleaner/Models/ConnectionTestRequest.cs new file mode 100644 index 0000000..05b261e --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Models/ConnectionTestRequest.cs @@ -0,0 +1,3 @@ +namespace Jellyfin.Plugin.MediaCleaner.Models; + +public record ConnectionTestRequest(string Address, string ApiKey); diff --git a/Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs b/Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs new file mode 100644 index 0000000..0d1feec --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MediaCleaner.Models; + +public record EpisodeDeletionDetails( + [property: JsonPropertyName("id")] int? EpisodeId, + [property: JsonPropertyName("episodeFileId")] int? EpisodeFileId, + [property: JsonPropertyName("seasonNumber")] int? SeasonNumber +); diff --git a/Jellyfin.Plugin.MediaCleaner/Models/EpisodeIdLists.cs b/Jellyfin.Plugin.MediaCleaner/Models/EpisodeIdLists.cs new file mode 100644 index 0000000..17036f1 --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Models/EpisodeIdLists.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace Jellyfin.Plugin.MediaCleaner.Models; + +public record EpisodeIdLists(IReadOnlyList EpisodeIds, IReadOnlyList EpisodeFileIds); diff --git a/Jellyfin.Plugin.MediaCleaner/Models/MediaInfo.cs b/Jellyfin.Plugin.MediaCleaner/Models/MediaInfo.cs index 2de2c00..ead3dec 100644 --- a/Jellyfin.Plugin.MediaCleaner/Models/MediaInfo.cs +++ b/Jellyfin.Plugin.MediaCleaner/Models/MediaInfo.cs @@ -4,6 +4,6 @@ namespace Jellyfin.Plugin.MediaCleaner.Models; public abstract class MediaInfo { - public required string? TmdbId { get; set; } + public required string TmdbId { get; set; } public required string Name { get; set; } } diff --git a/Jellyfin.Plugin.MediaCleaner/Models/RadarrMovie.cs b/Jellyfin.Plugin.MediaCleaner/Models/RadarrMovie.cs new file mode 100644 index 0000000..dac8049 --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Models/RadarrMovie.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MediaCleaner.Models; + +public record RadarrMovie( + [property: JsonPropertyName("id")] int? Id, + [property: JsonPropertyName("title")] string? Title +); diff --git a/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs b/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs index 47c8d11..b3d9705 100644 --- a/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs +++ b/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs @@ -12,5 +12,5 @@ public class SeriesInfo : MediaInfo { public Guid SeriesId { get; set; } public IEnumerable Seasons { get; set; } = []; - public required string? TvdbId { get; set; } + public required string TvdbId { get; set; } } diff --git a/Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs b/Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs new file mode 100644 index 0000000..fc4a330 --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MediaCleaner.Models; + +public record SonarrSeries( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("seasons")] IReadOnlyList Seasons, + [property: JsonPropertyName("ended")] bool Ended, + [property: JsonPropertyName("tvdbId")] int TvdbId + // [property: JsonPropertyName("tmdbId")] int TmdbId, + // [property: JsonPropertyName("imdbId")] int ImdbId +); + +public record Season( + [property: JsonPropertyName("seasonNumber")] int? SeasonNumber +); diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.html b/Jellyfin.Plugin.MediaCleaner/Pages/home.html index dacf46a..8ee7deb 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.html +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.html @@ -34,6 +34,7 @@
+

diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.js b/Jellyfin.Plugin.MediaCleaner/Pages/home.js index 578e99f..4c1dd34 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.js @@ -20,14 +20,24 @@ const refreshFrontEnd = async () => { finishLoading(); } -const getMediaCleanerSeriesInfo = async () => { - const response = await fetch("/mediacleaner/state/getSeriesInfo"); +const getMediaCleanerTvSeriesInfo = async () => { + const response = await fetch("/mediacleaner/state/getTvSeriesInfo"); if(!response.ok){ throw new Error(`Response status: ${response.status}`) } - return response.json(); + return await response.json(); +}; + +const getMediaCleanerAnimeSeriesInfo = async () => { + const response = await fetch("/mediacleaner/state/getAnimeSeriesInfo"); + + if(!response.ok){ + throw new Error(`Response status: ${response.status}`) + } + + return await response.json(); }; const getMediaCleanerMovieInfo = async () => { @@ -37,7 +47,7 @@ const getMediaCleanerMovieInfo = async () => { throw new Error(`Response status: ${response.status}`) } - return response.json(); + return await response.json(); }; const updateMediaCleanerState = async () => { @@ -83,7 +93,7 @@ const getMediaCleanerMoviesTitle = async () => { const populateTables = async () => { var moviesInfo = await getMediaCleanerMovieInfo(); - var seriesInfo = await getMediaCleanerSeriesInfo(); + var seriesInfo = await getMediaCleanerTvSeriesInfo(); var animeSeriesInfo = await getMediaCleanerAnimeSeriesInfo(); var seriesTable = document.getElementById("seriesTable"); @@ -160,7 +170,7 @@ const populateTables = async () => { } } else{ - var columnCount = animeSeriesTableBody.tHead.rows[0].cells.length; + var columnCount = animeSeriesTable.tHead.rows[0].cells.length; var row = animeSeriesTableBody.insertRow(-1); var cell1 = row.insertCell(0); cell1.colSpan = columnCount; @@ -226,7 +236,7 @@ const addClickHandlersToDeleteButtons = () => { const deleteAnimeSeriesButtonElement = document.getElementById("animeSeriesDeleteButton"); deleteMoviesButtonElement.addEventListener("click", deleteFromRadarr); deleteSeriesButtonElement.addEventListener("click", deleteFromSonarr); - deleteAnimeSeriesButtonElement.addEventListener("click", deleteFromSonarrAnime); + deleteAnimeSeriesButtonElement.addEventListener("click", deleteFromAnimeSonarr); } const getCheckedMedia = (table) => { diff --git a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs index 8207e26..0c45f0e 100644 --- a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs +++ b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs @@ -103,7 +103,7 @@ public sealed class StaleMediaScanner movie.ProviderIds.TryGetValue("Tmdb", out string? tmdbId); return new MovieInfo { - TmdbId = tmdbId, + TmdbId = tmdbId ?? string.Empty, Name = movie.Name }; }); @@ -189,34 +189,6 @@ public sealed class StaleMediaScanner List staleSeasons = [.. GetStaleSeasonsWithShortCircuitOnNonStaleSeason(seasons)]; - // [ ..seasons - // .Where(season => { - // var episodes = _libraryManager.GetItemList(new InternalItemsQuery - // { - // ParentId = season.Id, - // Recursive = false - // }); - - // _loggingHelper.LogDebugInformation("Season debug information for {SeasonNumber}:", season); - - // bool isSeasonDataStale = false; - - // try - // { - // isSeasonDataStale = _seriesHelper.IsSeasonDataStale(episodes); - // } - // catch (ArgumentNullException ex) - // { - // _loggingHelper.LogInformation("Arguement Null Exception in GetStaleSeasons!"); - // _loggingHelper.LogInformation(ex.Message); - // } - - // _loggingHelper.LogDebugInformation("End of season debug information for {SeasonNumber}.", season); - - // return isSeasonDataStale; - // })]; - - _loggingHelper.LogDebugInformation("-------------------------------------------------"); _loggingHelper.LogDebugInformation("End of scanning for series: {Series}", item); @@ -236,11 +208,12 @@ public sealed class StaleMediaScanner { series.ProviderIds.TryGetValue("Tvdb", out string? tvdbId); series.ProviderIds.TryGetValue("Tmdb", out string? tmdbId); + return new SeriesInfo { SeriesId = series.Id, - TmdbId = tmdbId, - TvdbId = tvdbId, + TmdbId = tmdbId ?? string.Empty, + TvdbId = tvdbId ?? string.Empty, Name = series.Name, Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name.Replace("Season ", "", StringComparison.OrdinalIgnoreCase))] }; -- 2.39.5 From 16c8338ffef5c1eb53458547d4de2bc38c4eebd2 Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sun, 8 Mar 2026 23:44:43 -0600 Subject: [PATCH 3/5] Refactor SonarrController to share methods betweeen endpoints --- .../Controllers/SonarrController.cs | 79 +++++++++++++++---- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs index d7b6fa9..e8ab208 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs @@ -27,8 +27,8 @@ public class SonarrController : Controller _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } - private async Task GetSonarrSeriesInfo(SeriesInfo seriesInfo){ - HttpHelper httpHelper = new(ServerType.Sonarr); + private async Task GetSeriesInfo(SeriesInfo seriesInfo, ServerType serverType){ + HttpHelper httpHelper = new(serverType); var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, $"/api/v3/series?tvdbId={Uri.EscapeDataString(seriesInfo.TvdbId ?? string.Empty)}" @@ -45,8 +45,8 @@ public class SonarrController : Controller return Ok(series); } - private async Task GetSonarrEpisodeInfo(SonarrSeries sonarrSeries){ - HttpHelper httpHelper = new(ServerType.Sonarr); + private async Task GetEpisodeInfo(SonarrSeries sonarrSeries, ServerType serverType){ + HttpHelper httpHelper = new(serverType); var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, $"/api/v3/episode?seriesId={sonarrSeries.Id.ToString(CultureInfo.InvariantCulture)}" @@ -82,8 +82,8 @@ public class SonarrController : Controller return Ok(new EpisodeIdLists(episodeIds, episodeFileIds)); } - [HttpPost("deleteSeriesFromSonarr")] - public async Task DeleteSeriesFromSonarr([FromBody] SeriesInfo seriesInfo){ + [HttpPost("deleteSeriesFromAnimeSonarr")] + public async Task DeleteSeriesFromAnimeSonarr([FromBody] SeriesInfo seriesInfo){ if (seriesInfo == null || string.IsNullOrEmpty(seriesInfo.TvdbId)) { @@ -92,7 +92,7 @@ public class SonarrController : Controller try { - var sonarrSeriesInfoResult = await GetSonarrSeriesInfo(seriesInfo).ConfigureAwait(false); + var sonarrSeriesInfoResult = await GetSeriesInfo(seriesInfo, ServerType.SonarrAnime).ConfigureAwait(false); if(sonarrSeriesInfoResult.StatusCode != StatusCodes.Status200OK || sonarrSeriesInfoResult.Value is not SonarrSeries){ return sonarrSeriesInfoResult; @@ -107,7 +107,7 @@ public class SonarrController : Controller TvdbId: retrievedSeries.TvdbId ); - var episodesToPurgeResult = await GetSonarrEpisodeInfo(staleSeries).ConfigureAwait(false); + var episodesToPurgeResult = await GetEpisodeInfo(staleSeries, ServerType.SonarrAnime).ConfigureAwait(false); if (episodesToPurgeResult.StatusCode != StatusCodes.Status200OK || episodesToPurgeResult.Value is not EpisodeIdLists) { return sonarrSeriesInfoResult; @@ -115,9 +115,9 @@ public class SonarrController : Controller EpisodeIdLists episodesToPurge = (EpisodeIdLists)episodesToPurgeResult.Value; - await UnmonitorSeasons(staleSeries).ConfigureAwait(false); - await UnmonitorEpisodeIds(episodesToPurge.EpisodeIds).ConfigureAwait(false); - await DeleteEpisodeFiles(episodesToPurge.EpisodeFileIds).ConfigureAwait(false); + await UnmonitorSeasons(staleSeries, ServerType.SonarrAnime).ConfigureAwait(false); + await UnmonitorEpisodeIds(episodesToPurge.EpisodeIds, ServerType.SonarrAnime).ConfigureAwait(false); + await DeleteEpisodeFiles(episodesToPurge.EpisodeFileIds, ServerType.SonarrAnime).ConfigureAwait(false); return Ok(); } @@ -127,13 +127,58 @@ public class SonarrController : Controller } } - private async Task UnmonitorSeasons(SonarrSeries staleSeries){ + [HttpPost("deleteSeriesFromSonarr")] + public async Task DeleteSeriesFromSonarr([FromBody] SeriesInfo seriesInfo){ + + if (seriesInfo == null || string.IsNullOrEmpty(seriesInfo.TvdbId)) + { + return BadRequest("Invalid series information provided."); + } + + try + { + var sonarrSeriesInfoResult = await GetSeriesInfo(seriesInfo, ServerType.Sonarr).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)))], + Ended: retrievedSeries.Ended, + TvdbId: retrievedSeries.TvdbId + ); + + var episodesToPurgeResult = await GetEpisodeInfo(staleSeries, ServerType.Sonarr).ConfigureAwait(false); + if (episodesToPurgeResult.StatusCode != StatusCodes.Status200OK || episodesToPurgeResult.Value is not EpisodeIdLists) + { + return sonarrSeriesInfoResult; + } + + EpisodeIdLists episodesToPurge = (EpisodeIdLists)episodesToPurgeResult.Value; + + await UnmonitorSeasons(staleSeries, ServerType.Sonarr).ConfigureAwait(false); + await UnmonitorEpisodeIds(episodesToPurge.EpisodeIds, ServerType.Sonarr).ConfigureAwait(false); + await DeleteEpisodeFiles(episodesToPurge.EpisodeFileIds, ServerType.Sonarr).ConfigureAwait(false); + + return Ok(); + } + catch (HttpRequestException e) + { + return StatusCode(StatusCodes.Status500InternalServerError, $"An unexpected error occurred. {e.Message}"); + } + } + + private async Task UnmonitorSeasons(SonarrSeries staleSeries, ServerType serverType){ if (staleSeries == null) { return BadRequest("No stale series provided."); } - HttpHelper httpHelper = new(ServerType.Sonarr); + HttpHelper httpHelper = new(serverType); var series = await httpHelper.SendHttpRequestAsync( HttpMethod.Get, $"/api/v3/series/{staleSeries.Id}" @@ -179,14 +224,14 @@ public class SonarrController : Controller return Ok(responseBody); } - private async Task DeleteEpisodeFiles(IReadOnlyList episodeFileIds) + private async Task DeleteEpisodeFiles(IReadOnlyList episodeFileIds, ServerType serverType) { if (episodeFileIds == null || episodeFileIds.Count == 0) { return BadRequest("No episode file IDs provided."); } - HttpHelper httpHelper = new(ServerType.Sonarr); + HttpHelper httpHelper = new(serverType); var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Delete, "/api/v3/episodefile/bulk", @@ -196,7 +241,7 @@ public class SonarrController : Controller return Ok(responseBody); } - private async Task UnmonitorEpisodeIds(IReadOnlyList episodeIds) + private async Task UnmonitorEpisodeIds(IReadOnlyList episodeIds, ServerType serverType) { if (episodeIds == null || episodeIds.Count == 0) { @@ -204,7 +249,7 @@ public class SonarrController : Controller } - HttpHelper httpHelper = new(ServerType.Sonarr); + HttpHelper httpHelper = new(serverType); var responseBody = await httpHelper.SendHttpRequestAsync( HttpMethod.Put, "/api/v3/episode/monitor", -- 2.39.5 From 2786d6c73d49675a7eec1f2701f3903ea5df5537 Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sun, 8 Mar 2026 23:46:40 -0600 Subject: [PATCH 4/5] Updated endpoint naming in front end --- Jellyfin.Plugin.MediaCleaner/Pages/home.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.js b/Jellyfin.Plugin.MediaCleaner/Pages/home.js index 4c1dd34..73432f5 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.js @@ -275,8 +275,8 @@ const deleteSeriesFromSonarrApi = async (series) => { } } -const deleteSeriesFromSonarrAnimeApi = async (series) => { - const response = await fetch("/sonarr/deleteSeriesFromSonarrAnime", { +const deleteSeriesFromAnimeSonarrApi = async (series) => { + const response = await fetch("/sonarr/deleteSeriesFromAnimeSonarr", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -303,7 +303,7 @@ const deleteFromSonarr = () => { const deleteFromAnimeSonarr = () => { const selectedSeries = getCheckedMedia(animeSeriesTable); - selectedSeries.forEach(async series => await deleteSeriesFromSonarrApi(series)); + selectedSeries.forEach(async series => await deleteSeriesFromAnimeSonarrApi(series)); refreshFrontEnd(); } -- 2.39.5 From b6242de064f76c12dceef7be017976552373517c Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sun, 8 Mar 2026 23:57:16 -0600 Subject: [PATCH 5/5] Added hasFile check to only delete files that exist. --- .../Controllers/SonarrController.cs | 12 ++++-------- .../Models/EpisodeDeletionDetails.cs | 7 ++++--- Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs index e8ab208..6a6bcd2 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs @@ -59,24 +59,20 @@ public class SonarrController : Controller } var seasonNumbers = new HashSet(sonarrSeries.Seasons - .Where(s => s.SeasonNumber.HasValue) - .Select(s => s.SeasonNumber!.Value)); + .Select(s => s.SeasonNumber)); var staleEpisodesResponseObj = episodesResponseObj - .Where(episodeDeletionDetail => episodeDeletionDetail.SeasonNumber != null && - seasonNumbers.Contains(episodeDeletionDetail.SeasonNumber.Value)) + .Where(episodeDeletionDetail => seasonNumbers.Contains(episodeDeletionDetail.SeasonNumber)) .ToList(); var episodeIds = staleEpisodesResponseObj + .Where(episodeDeletionDetail => episodeDeletionDetail.HasFile) .Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeId) - .Where(id => id.HasValue) - .Select(id => id!.Value) .ToList(); var episodeFileIds = staleEpisodesResponseObj + .Where(episodeDeletionDetail => episodeDeletionDetail.HasFile) .Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeFileId) - .Where(id => id.HasValue) - .Select(id => id!.Value) .ToList(); return Ok(new EpisodeIdLists(episodeIds, episodeFileIds)); diff --git a/Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs b/Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs index 0d1feec..71f74be 100644 --- a/Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs +++ b/Jellyfin.Plugin.MediaCleaner/Models/EpisodeDeletionDetails.cs @@ -3,7 +3,8 @@ using System.Text.Json.Serialization; namespace Jellyfin.Plugin.MediaCleaner.Models; public record EpisodeDeletionDetails( - [property: JsonPropertyName("id")] int? EpisodeId, - [property: JsonPropertyName("episodeFileId")] int? EpisodeFileId, - [property: JsonPropertyName("seasonNumber")] int? SeasonNumber + [property: JsonPropertyName("id")] int EpisodeId, + [property: JsonPropertyName("episodeFileId")] int EpisodeFileId, + [property: JsonPropertyName("seasonNumber")] int SeasonNumber, + [property: JsonPropertyName("hasFile")] bool HasFile ); diff --git a/Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs b/Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs index fc4a330..8ed2ba6 100644 --- a/Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs +++ b/Jellyfin.Plugin.MediaCleaner/Models/SonarrSeries.cs @@ -14,5 +14,5 @@ public record SonarrSeries( ); public record Season( - [property: JsonPropertyName("seasonNumber")] int? SeasonNumber + [property: JsonPropertyName("seasonNumber")] int SeasonNumber ); -- 2.39.5