From 3f5074aa3bd1a16bddf9fe36b8a8127f46c3487d Mon Sep 17 00:00:00 2001 From: Thomas Gander Date: Sat, 7 Mar 2026 18:02:45 -0700 Subject: [PATCH] Reworked Models to use tmdb to enable integration with Radarr api. Also reworked test connection to use api to validate as I ran into CORS errors. I also set up an endpoint to call radarr to delete movies. Currently only got to retrieving movie info. Should be able to use the id retrieved to then delete the movie. --- .../Controllers/RadarrController.cs | 110 +++++++++++++++ .../Controllers/SonarrController.cs | 60 ++++++++ .../Models/MediaInfo.cs | 2 +- .../Models/SeriesInfo.cs | 1 + .../Pages/configuration.js | 22 +-- Jellyfin.Plugin.MediaCleaner/Pages/home.js | 132 +++++++++++------- .../StaleMediaScanner.cs | 19 ++- 7 files changed, 276 insertions(+), 70 deletions(-) create mode 100644 Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs create mode 100644 Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs new file mode 100644 index 0000000..e6d538b --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs @@ -0,0 +1,110 @@ +using Jellyfin.Plugin.MediaCleaner.Models; +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 Microsoft.AspNetCore.Http; +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 +{ + 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); + } + + [HttpPost("deleteMovieFromRadarr")] + public async Task DeleteMovieFromRadarr([FromBody] MovieInfo movieInfo){ + + if (movieInfo == null || string.IsNullOrEmpty(movieInfo.TmdbId)) + { + return BadRequest("Invalid movie information provided."); + } + + try + { + var uriBuilder = new UriBuilder($"{Configuration.RadarrAddress}/api/v3/movie"); + var query = HttpUtility.ParseQueryString(uriBuilder.Query); + + 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 movie = movies?.FirstOrDefault(); + + if (movie == null) + { + return NotFound("Movie not found in Radarr library."); + } + + return Ok(movie); + } + catch (HttpRequestException e) + { + return StatusCode(StatusCodes.Status500InternalServerError, $"An unexpected error occurred. {e.Message}"); + } + } + + [HttpPost("testConnection")] + public async Task TestConnection([FromBody] ConnectionTestRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Address) || string.IsNullOrWhiteSpace(request.ApiKey)) + { + return BadRequest("Address and ApiKey are required."); + } + + var address = request.Address.Trim(); + if (!address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !address.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + address = "http://" + address; + } + + try + { + using var httpRequest = new HttpRequestMessage(HttpMethod.Get, address); + httpRequest.Headers.Add("X-Api-Key", request.ApiKey); + + var response = await _httpClient.SendAsync(httpRequest).ConfigureAwait(false); + return Ok(new { success = response.IsSuccessStatusCode }); + } + catch (HttpRequestException e) + { + return StatusCode(StatusCodes.Status502BadGateway, e.Message); + } + catch (Exception e) + { + return StatusCode(StatusCodes.Status500InternalServerError, e.Message); + } + } +} diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs new file mode 100644 index 0000000..67a1c50 --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs @@ -0,0 +1,60 @@ +using Jellyfin.Plugin.MediaCleaner.Models; +using Microsoft.AspNetCore.Mvc; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using System.Net.Http.Headers; +using System; + +namespace Jellyfin.Plugin.MediaCleaner.Controllers; + +[Route("sonarr")] +public class SonarrController : Controller +{ + private static Configuration Configuration => + Plugin.Instance!.Configuration; + + private readonly HttpClient _httpClient; + + public SonarrController(HttpClient httpClient) + { + _httpClient = httpClient; + + // Set the default request headers + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient.DefaultRequestHeaders.Add("X-Api-Key", Configuration.SonarrAPIKey); + } + + [HttpPost("testConnection")] + public async Task TestConnection([FromBody] ConnectionTestRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Address) || string.IsNullOrWhiteSpace(request.ApiKey)) + { + return BadRequest("Address and ApiKey are required."); + } + + var address = request.Address.Trim(); + if (!address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !address.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + address = "http://" + address; + } + + try + { + using var httpRequest = new HttpRequestMessage(HttpMethod.Get, address); + httpRequest.Headers.Add("X-Api-Key", request.ApiKey); + + var response = await _httpClient.SendAsync(httpRequest).ConfigureAwait(false); + return Ok(new { success = response.IsSuccessStatusCode }); + } + catch (HttpRequestException e) + { + return StatusCode(StatusCodes.Status502BadGateway, e.Message); + } + catch (Exception e) + { + return StatusCode(StatusCodes.Status500InternalServerError, e.Message); + } + } +} diff --git a/Jellyfin.Plugin.MediaCleaner/Models/MediaInfo.cs b/Jellyfin.Plugin.MediaCleaner/Models/MediaInfo.cs index 7ae7881..2de2c00 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 Guid Id { get; set; } + public required string? TmdbId { get; set; } public required string Name { get; set; } } diff --git a/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs b/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs index 1a49baa..123e410 100644 --- a/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs +++ b/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs @@ -10,5 +10,6 @@ namespace Jellyfin.Plugin.MediaCleaner.Models; /// public class SeriesInfo : MediaInfo { + public Guid SeriesId { get; set; } public IEnumerable Seasons { get; set; } = []; } diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js index 9da382b..a6f4bad 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js @@ -37,7 +37,7 @@ const testConnectionSonarr = async () => { var addressElement = document.getElementById('SonarrAddress'); var validationElement = document.getElementById('SonarrConnectionValidation'); - await validateConnection(apiKeyElement, addressElement, validationElement); + await validateConnection(apiKeyElement, addressElement, validationElement, "sonarr"); } const testConnectionRadarr = async () => { @@ -45,7 +45,7 @@ const testConnectionRadarr = async () => { var addressElement = document.getElementById('RadarrAddress'); var validationElement = document.getElementById('RadarrConnectionValidation'); - await validateConnection(apiKeyElement, addressElement, validationElement); + await validateConnection(apiKeyElement, addressElement, validationElement, "radarr"); } // Validation and Normalization @@ -57,7 +57,7 @@ const normalizeUrl = (url) => { return normalizedUrl; }; -const validateConnection = async (apiKeyElement, addressElement, validationElement) => { +const validateConnection = async (apiKeyElement, addressElement, validationElement, controller) => { var httpAddress = addressElement.value; var apiKey = apiKeyElement.value; @@ -77,13 +77,19 @@ const validateConnection = async (apiKeyElement, addressElement, validationEleme setAttemptingConnection(validationElement); try{ const url = normalizeUrl(httpAddress); - const response = await fetch(url, { - method: "GET", + // Move endpoint to a constant? + const response = await fetch(`/${controller}/testConnection`, { + method: "POST", headers: { - "X-Api-Key": apiKey, - } + "Content-Type": "application/json" + }, + body: JSON.stringify({ address: url, apiKey }) }); - success = response.ok; + + if (response.ok) { + const result = await response.json(); + success = result?.success === true; + } } catch (error){ console.error(`Error: ${error}`); diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.js b/Jellyfin.Plugin.MediaCleaner/Pages/home.js index 662777e..7d23683 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.js @@ -63,45 +63,6 @@ const getMediaCleanerMoviesTitle = async () => { return response.json(); }; -const createCheckbox = (mediaInfo = {}, table, deleteButton) => { - const container = document.createElement('div'); - container.className = 'checkboxContainer'; - container.style.marginBottom = 0; - - const label = document.createElement('label'); - label.className = 'emby-checkbox-label'; - label.style.textAlign = 'center'; - label.style.paddingLeft = '1.8em'; - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.setAttribute('is', 'emby-checkbox'); - checkbox.dataset.mediaInfo = JSON.stringify(mediaInfo) || ''; - - const span = document.createElement('span'); - span.textContent = ''; - - label.appendChild(checkbox); - label.appendChild(span); - container.appendChild(label); - - checkbox.addEventListener('change', (e) => { - if(isDeleteButtonVisible(table)){ - deleteButton.style.visibility = 'visible'; - } - else { - deleteButton.style.visibility = 'hidden'; - } - }); - - return container; -}; - -const isDeleteButtonVisible = (table) => { - const checkboxes = table.getElementsByClassName('emby-checkbox'); - const hasChecked = Array.from(checkboxes).some(checkbox => checkbox.checked); - return hasChecked; -} const populateTables = async () => { var moviesInfo = await getMediaCleanerMovieInfo(); @@ -159,6 +120,46 @@ const populateTables = async () => { } }; +const createCheckbox = (mediaInfo = {}, table, deleteButton) => { + const container = document.createElement('div'); + container.className = 'checkboxContainer'; + container.style.marginBottom = 0; + + const label = document.createElement('label'); + label.className = 'emby-checkbox-label'; + label.style.textAlign = 'center'; + label.style.paddingLeft = '1.8em'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.setAttribute('is', 'emby-checkbox'); + checkbox.dataset.mediaInfo = JSON.stringify(mediaInfo) || ''; + + const span = document.createElement('span'); + span.textContent = ''; + + label.appendChild(checkbox); + label.appendChild(span); + container.appendChild(label); + + checkbox.addEventListener('change', (e) => { + if(isDeleteButtonVisible(table)){ + deleteButton.style.visibility = 'visible'; + } + else { + deleteButton.style.visibility = 'hidden'; + } + }); + + return container; +}; + +const isDeleteButtonVisible = (table) => { + const checkboxes = table.getElementsByClassName('emby-checkbox'); + const hasChecked = Array.from(checkboxes).some(checkbox => checkbox.checked); + return hasChecked; +} + const addClickHandlersToLinks = () => { const linkBtns = document.querySelectorAll("button.links"); linkBtns.forEach(btn => { @@ -177,15 +178,41 @@ const addClickHandlersToDeleteButtons = () => { deleteSeriesButtonElement.addEventListener("click", deleteFromSonarr); } -const deleteFromRadarr = () => { - // Need to GET first for movieIds? +const getCheckedMedia = (table) => { + const checkboxes = table.getElementsByClassName('emby-checkbox'); + const selectedMediaCheckboxes = Array.from(checkboxes).filter(checkbox => checkbox.checked); + const selectedMedia = selectedMediaCheckboxes.map(selectedMediaCheckbox => JSON.parse(selectedMediaCheckbox.dataset.mediaInfo)); + console.log("Selected media: ", selectedMedia); + return selectedMedia; +} - // Likely need to use MovieEditor DELETE endpoint (/api/v3/movie/editor) +const deleteMovieFromRadarrApi = async (movie) => { + console.log("Movie to post: ", movie); + const response = await fetch("/radarr/deleteMovieFromRadarr", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(movie) + }); + + if(!response.ok){ + throw new Error(`Response status: ${response.status}`) + } + return console.log("Response: ", response.json()); +} + +const deleteFromRadarr = async () => { + // Get all movies with checked checkboxes + const selectedMovies = getCheckedMedia(moviesTable); + selectedMovies.forEach(async movie => await deleteMovieFromRadarrApi(movie)); + // Need to GET first for movieIds? + // /api/v3/movie?tmdbId=383275 + + // Likely need to use Movie DELETE endpoint (/api/v3/movie/{id}) // Payload: // { - // "movieIds": [ - // 0 - // ], + // "id": id // "deleteFiles": true // } console.log("Delete from Radarr!") @@ -193,16 +220,13 @@ const deleteFromRadarr = () => { const deleteFromSonarr = () => { // Need to GET first for seriesIds? + getCheckedMedia(seriesTable); + // Use tvdbId included in filenames. + // /api/v5/series?tvdbId=383275 + // Possibly use statistics from GET to show on front end? - // Likely need to use SeriesEditor DELETE endpoint - // Payload: - // { - // "seriesIds": [ - // 1 - // ], - // "seasonFolder": null, - // "deleteFiles": true, - // } + // Likely need to use EpisodeFile bulk DELETE endpoint + // /api/v5/episodefile/bulk​ console.log("Delete from Sonarr!") } diff --git a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs index 098060c..a051b7c 100644 --- a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs +++ b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs @@ -84,7 +84,7 @@ public sealed class StaleMediaScanner foreach (SeriesInfo seriesInfo in staleSeriesInfo.Cast()) { - _loggingHelper.LogInformation("Series Info: ID: {Id} | Series Name: {SeriesName} | Stale Seasons: {Seasons}", [seriesInfo.Id, seriesInfo.Name, string.Join(", ", seriesInfo.Seasons)]); + _loggingHelper.LogInformation("Series Info: TmbdID: {Id} | Series Name: {SeriesName} | Stale Seasons: {Seasons}", [seriesInfo.TmdbId, seriesInfo.Name, string.Join(", ", seriesInfo.Seasons)]); } } else @@ -99,15 +99,18 @@ public sealed class StaleMediaScanner if (staleMovies.Count > 0) { - staleMoviesInfo = staleMovies.Select(movie => new MovieInfo - { - Id = movie.Id, - Name = movie.Name + staleMoviesInfo = staleMovies.Select(movie => { + movie.ProviderIds.TryGetValue("Tmdb", out string? tmdbId); + return new MovieInfo + { + TmdbId = tmdbId, + Name = movie.Name + }; }); foreach (MovieInfo movieInfo in staleMoviesInfo.Cast()) { - _loggingHelper.LogInformation("Movie Info: ID: {Id} | Movie Name: {MovieName}", [movieInfo.Id, movieInfo.Name]); + _loggingHelper.LogInformation("Movie Info: TmdbID: {Id} | Movie Name: {MovieName}", [movieInfo.TmdbId, movieInfo.Name]); } } else @@ -231,9 +234,11 @@ public sealed class StaleMediaScanner IEnumerable seriesInfoList = series.Select(series => { + series.ProviderIds.TryGetValue("Tmdb", out string? tmdbId); return new SeriesInfo { - Id = series.Id, + SeriesId = series.Id, + TmdbId = tmdbId, Name = series.Name, Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name)] };