From 21a9cc86d83219a723d52c4485db9ac775e7e034 Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sun, 8 Mar 2026 19:28:04 -0600 Subject: [PATCH] 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");