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)] };