diff --git a/Jellyfin.Plugin.MediaCleaner/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.MediaCleaner/Configuration.cs similarity index 67% rename from Jellyfin.Plugin.MediaCleaner/Configuration/PluginConfiguration.cs rename to Jellyfin.Plugin.MediaCleaner/Configuration.cs index 572175f..e32c0b4 100644 --- a/Jellyfin.Plugin.MediaCleaner/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.MediaCleaner/Configuration.cs @@ -3,25 +3,28 @@ using System.Collections.ObjectModel; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Plugins; -namespace Jellyfin.Plugin.MediaCleaner.Configuration; +namespace Jellyfin.Plugin.MediaCleaner; /// /// Plugin configuration. /// -public class PluginConfiguration : BasePluginConfiguration +public class Configuration : BasePluginConfiguration { /// - /// Initializes a new instance of the class. + /// Gets or sets the http and port address for your Radarr instance. /// - public PluginConfiguration() - { - } + public string RadarrAddress { get; set; } = string.Empty; /// /// Gets or sets the api for your Radarr instance. /// public string RadarrAPIKey { get; set; } = string.Empty; + /// + /// Gets or sets the http and port address for your Sonarr instance. + /// + public string SonarrAddress { get; set; } = string.Empty; + /// /// Gets or sets the api for your Sonarr instance. /// diff --git a/Jellyfin.Plugin.MediaCleaner/Configuration/settings.html b/Jellyfin.Plugin.MediaCleaner/Configuration/settings.html deleted file mode 100644 index aa5229d..0000000 --- a/Jellyfin.Plugin.MediaCleaner/Configuration/settings.html +++ /dev/null @@ -1,108 +0,0 @@ - - - - - Media Cleaner - - -
-
-
-
-
- - -
The api key used by your radarr instance
-
-
- - -
The api key used by your sonarr instance
-
-
- - -
How many days to wait before marking files as stale
-
-
- -
- -
- -
-
-
-
- -
- - diff --git a/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs new file mode 100644 index 0000000..9d5df7a --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/RadarrController.cs @@ -0,0 +1,127 @@ +using Jellyfin.Plugin.MediaCleaner.Helpers; +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); + } + + private async Task GetRadarrMovieInfo(MovieInfo movieInfo) + { + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.RadarrAddress, + HttpMethod.Get, + $"/api/v3/movie?tmdbId={Uri.EscapeDataString(movieInfo.TmdbId ?? string.Empty)}&excludeLocalCovers=false" + ).ConfigureAwait(false); + + var movies = JsonSerializer.Deserialize>(responseBody.GetRawText()); + var movie = movies?.FirstOrDefault(); + + if (movie == null) + { + return NotFound("Movie not found in Radarr library."); + } + + return Ok(movie); + } + + [HttpPost("deleteMovieFromRadarr")] + public async Task DeleteMovieFromRadarr([FromBody] MovieInfo movieInfo){ + + if (movieInfo == null || string.IsNullOrEmpty(movieInfo.TmdbId)) + { + return BadRequest("Invalid movie information provided."); + } + + try + { + var radarrMovieInfoResult = await GetRadarrMovieInfo(movieInfo).ConfigureAwait(false); + + if(radarrMovieInfoResult.StatusCode != StatusCodes.Status200OK || radarrMovieInfoResult.Value is not RadarrMovie){ + return radarrMovieInfoResult; + } + + RadarrMovie movie = (RadarrMovie)radarrMovieInfoResult.Value; + + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.RadarrAddress, + HttpMethod.Delete, + $"/api/v3/movie/{movie.Id}?deleteFiles=true&addImportExclusion=true" + ).ConfigureAwait(false); + + // Radarr typically returns an empty body on successful delete. + return Ok(responseBody); + } + 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..b3f98a3 --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/SonarrController.cs @@ -0,0 +1,277 @@ +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; +using System.Web; +using System.Text.Json; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using System.Globalization; +using System.Security.Cryptography.X509Certificates; +using Jellyfin.Plugin.MediaCleaner.Helpers; + +namespace Jellyfin.Plugin.MediaCleaner.Controllers; + +public record SonarrSeries( + [property: JsonPropertyName("id")] int? Id, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("seasons")] IReadOnlyList Seasons +); + +public record EpisodeDeletionDetails( + [property: JsonPropertyName("id")] int? EpisodeId, + [property: JsonPropertyName("episodeFileId")] int? EpisodeFileId, + [property: JsonPropertyName("seasonNumber")] int? SeasonNumber +); + +public record EpisodeIdLists(IReadOnlyList EpisodeIds, IReadOnlyList EpisodeFileIds); + +public record Season( + [property: JsonPropertyName("seasonNumber")] int? SeasonNumber +); + +[Route("sonarr")] +public class SonarrController : Controller +{ + 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); + } + + private async Task GetSonarrSeriesInfo(SeriesInfo seriesInfo){ + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Get, + $"/api/v3/series?tvdbId={Uri.EscapeDataString(seriesInfo.TvdbId ?? string.Empty)}" + ).ConfigureAwait(false); + + var seriesResponseObj = JsonSerializer.Deserialize>(responseBody.GetRawText()); + var series = seriesResponseObj?.FirstOrDefault(); + + if (series == null) + { + return NotFound("Series not found in Sonarr library."); + } + + return Ok(series); + } + + private async Task GetSonarrEpisodeInfo(SonarrSeries sonarrSeries){ + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Get, + $"/api/v3/episode?seriesId={sonarrSeries.Id?.ToString(CultureInfo.InvariantCulture)}" + ).ConfigureAwait(false); + + var episodesResponseObj = JsonSerializer.Deserialize>(responseBody.GetRawText()); + + if(episodesResponseObj == null){ + return NotFound("No episodes in response object."); + } + + var seasonNumbers = new HashSet(sonarrSeries.Seasons + .Where(s => s.SeasonNumber.HasValue) + .Select(s => s.SeasonNumber!.Value)); + + var staleEpisodesResponseObj = episodesResponseObj + .Where(episodeDeletionDetail => episodeDeletionDetail.SeasonNumber != null && + seasonNumbers.Contains(episodeDeletionDetail.SeasonNumber.Value)) + .ToList(); + + var episodeIds = staleEpisodesResponseObj + .Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeId) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + + var episodeFileIds = staleEpisodesResponseObj + .Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeFileId) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + + return Ok(new EpisodeIdLists(episodeIds, episodeFileIds)); + } + + [HttpPost("deleteSeriesFromSonarr")] + public async Task DeleteSeriesFromRadarr([FromBody] SeriesInfo seriesInfo){ + + if (seriesInfo == null || string.IsNullOrEmpty(seriesInfo.TvdbId)) + { + return BadRequest("Invalid series information provided."); + } + + try + { + var sonarrSeriesInfoResult = await GetSonarrSeriesInfo(seriesInfo).ConfigureAwait(false); + + if(sonarrSeriesInfoResult.StatusCode != StatusCodes.Status200OK || sonarrSeriesInfoResult.Value is not SonarrSeries){ + return sonarrSeriesInfoResult; + } + + SonarrSeries retrievedSeries = (SonarrSeries)sonarrSeriesInfoResult.Value; + SonarrSeries staleSeries = new( + Id: retrievedSeries.Id, + Title: retrievedSeries.Title, + Seasons: [.. seriesInfo.Seasons.Select(season => new Season(SeasonNumber: int.Parse(season, CultureInfo.InvariantCulture)))] + ); + + var episodesToPurgeResult = await GetSonarrEpisodeInfo(staleSeries).ConfigureAwait(false); + if (episodesToPurgeResult.StatusCode != StatusCodes.Status200OK || episodesToPurgeResult.Value is not EpisodeIdLists) + { + return sonarrSeriesInfoResult; + } + + EpisodeIdLists episodesToPurge = (EpisodeIdLists)episodesToPurgeResult.Value; + + await UnmonitorSeasons(staleSeries).ConfigureAwait(false); + await UnmonitorEpisodeIds(episodesToPurge.EpisodeIds).ConfigureAwait(false); + await DeleteEpisodeFiles(episodesToPurge.EpisodeFileIds).ConfigureAwait(false); + + return Ok(); + } + catch (HttpRequestException e) + { + return StatusCode(StatusCodes.Status500InternalServerError, $"An unexpected error occurred. {e.Message}"); + } + } + + private async Task UnmonitorSeasons(SonarrSeries staleSeries){ + if (staleSeries == null) + { + return BadRequest("No stale series provided."); + } + + var series = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Get, + $"/api/v3/series/{staleSeries.Id}" + ).ConfigureAwait(false); + + var seriesDict = JsonSerializer.Deserialize>(series.GetRawText()); + if (seriesDict == null) + { + throw new InvalidOperationException("Failed to deserialize season."); + } + var seasons = series.GetProperty("seasons").EnumerateArray().ToList(); + + var staleSeasonNumbers = staleSeries.Seasons + .Select(s => s.SeasonNumber) + .ToHashSet(); + + var updatedSeasons = seasons.Select(season => + { + var seasonNumber = season.GetProperty("seasonNumber").GetInt32(); + + if (staleSeasonNumbers.Contains(seasonNumber)) + { + var seasonDict = JsonSerializer.Deserialize>(season.GetRawText()); + if (seasonDict == null) + { + throw new InvalidOperationException("Failed to deserialize season."); + } + seasonDict["monitored"] = false; + return seasonDict; + } + + return JsonSerializer.Deserialize>(season.GetRawText()); + }).ToArray(); + + seriesDict["seasons"] = updatedSeasons; + + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Put, + $"/api/v3/series/{staleSeries.Id}", + seriesDict + ).ConfigureAwait(false); + + return Ok(responseBody); + } + + private async Task DeleteEpisodeFiles(IReadOnlyList episodeFileIds) + { + if (episodeFileIds == null || episodeFileIds.Count == 0) + { + return BadRequest("No episode file IDs provided."); + } + + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Delete, + "/api/v3/episodefile/bulk", + new { episodeFileIds } + ).ConfigureAwait(false); + + return Ok(responseBody); + } + + private async Task UnmonitorEpisodeIds(IReadOnlyList episodeIds) + { + if (episodeIds == null || episodeIds.Count == 0) + { + return BadRequest("No episode IDs provided."); + } + + var responseBody = await HttpHelper.SendHttpRequestAsync( + _httpClient, + Configuration.SonarrAddress, + HttpMethod.Put, + "/api/v3/episode/monitor", + new { episodeIds, monitored = false } + ).ConfigureAwait(false); + + return Ok(responseBody); + } + + [HttpPost("testConnection")] + public async Task TestConnection([FromBody] ConnectionTestRequest request) + { + 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/StateController.cs b/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs index 5e3833b..589fbed 100644 --- a/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs +++ b/Jellyfin.Plugin.MediaCleaner/Controllers/StateController.cs @@ -2,7 +2,6 @@ using Jellyfin.Plugin.MediaCleaner.Data; using Jellyfin.Plugin.MediaCleaner; using Jellyfin.Plugin.MediaCleaner.Models; using Microsoft.AspNetCore.Mvc; -using Jellyfin.Plugin.MediaCleaner.Configuration; namespace Jellyfin.Plugin.MediaCleaner.Controllers; @@ -10,7 +9,7 @@ namespace Jellyfin.Plugin.MediaCleaner.Controllers; public class StateController(MediaCleanerState state) : Controller { private readonly MediaCleanerState _state = state; - private static PluginConfiguration Configuration => + private static Configuration Configuration => Plugin.Instance!.Configuration; [HttpGet("getSeriesInfo")] diff --git a/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs b/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs new file mode 100644 index 0000000..1d2ec2c --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Helpers/HttpHelper.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MediaCleaner.Helpers; + +public static class HttpHelper +{ + /// + /// Sends a JSON request and returns the raw JSON element response. + /// + /// + /// Do NOT create a new HttpClient on every call; reuse one instance (DI or a singleton) to avoid socket exhaustion. + /// + public static async Task SendHttpRequestAsync(HttpClient httpClient, string baseAddress, HttpMethod method, string path, object? body = null) + { + var uri = new UriBuilder($"{baseAddress}{path}").Uri; + using var request = new HttpRequestMessage(method, uri); + + if (body != null) + { + var json = JsonSerializer.Serialize(body); + request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + } + + var response = await httpClient.SendAsync(request).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonSerializer.Deserialize(responseBody); + } +} diff --git a/Jellyfin.Plugin.MediaCleaner/Helpers/LoggingHelper.cs b/Jellyfin.Plugin.MediaCleaner/Helpers/LoggingHelper.cs index c78aadf..96787cc 100644 --- a/Jellyfin.Plugin.MediaCleaner/Helpers/LoggingHelper.cs +++ b/Jellyfin.Plugin.MediaCleaner/Helpers/LoggingHelper.cs @@ -1,12 +1,5 @@ using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using Jellyfin.Plugin.MediaCleaner.Configuration; -using Jellyfin.Plugin.MediaCleaner.Models; -using Jellyfin.Plugin.MediaCleaner.ScheduledTasks; -using MediaBrowser.Controller.Entities; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MediaCleaner.Helpers; @@ -30,6 +23,6 @@ public class LoggingHelper(ILogger logger) _logger.LogInformation(message, args); } - private static PluginConfiguration Configuration => + private static Configuration Configuration => Plugin.Instance!.Configuration; } diff --git a/Jellyfin.Plugin.MediaCleaner/Helpers/MovieHelper.cs b/Jellyfin.Plugin.MediaCleaner/Helpers/MovieHelper.cs index 86a9c06..9185ffb 100644 --- a/Jellyfin.Plugin.MediaCleaner/Helpers/MovieHelper.cs +++ b/Jellyfin.Plugin.MediaCleaner/Helpers/MovieHelper.cs @@ -1,8 +1,6 @@ using System; using System.Linq; -using System.Threading; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Plugin.MediaCleaner.Configuration; using MediaBrowser.Controller.Entities; using Microsoft.Extensions.Logging; @@ -12,7 +10,7 @@ public class MovieHelper(ILogger logger) { private readonly LoggingHelper _loggingHelper = new(logger); - private static PluginConfiguration Configuration => + private static Configuration Configuration => Plugin.Instance!.Configuration; public bool IsMovieStale(BaseItem movie) diff --git a/Jellyfin.Plugin.MediaCleaner/Helpers/SeriesHelper.cs b/Jellyfin.Plugin.MediaCleaner/Helpers/SeriesHelper.cs index cbd8c3d..07b7afa 100644 --- a/Jellyfin.Plugin.MediaCleaner/Helpers/SeriesHelper.cs +++ b/Jellyfin.Plugin.MediaCleaner/Helpers/SeriesHelper.cs @@ -1,10 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Entities.Libraries; -using Jellyfin.Plugin.MediaCleaner.Configuration; using MediaBrowser.Controller.Entities; using Microsoft.Extensions.Logging; @@ -15,7 +12,7 @@ public class SeriesHelper(ILogger logger) { private readonly LoggingHelper _loggingHelper = new(logger); - private static PluginConfiguration Configuration => + private static Configuration Configuration => Plugin.Instance!.Configuration; private List ProcessEpisodes(IReadOnlyCollection episodes) diff --git a/Jellyfin.Plugin.MediaCleaner/Jellyfin.Plugin.MediaCleaner.csproj b/Jellyfin.Plugin.MediaCleaner/Jellyfin.Plugin.MediaCleaner.csproj index 68b496e..81ec1ec 100644 --- a/Jellyfin.Plugin.MediaCleaner/Jellyfin.Plugin.MediaCleaner.csproj +++ b/Jellyfin.Plugin.MediaCleaner/Jellyfin.Plugin.MediaCleaner.csproj @@ -20,8 +20,6 @@ - - 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..47c8d11 100644 --- a/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs +++ b/Jellyfin.Plugin.MediaCleaner/Models/SeriesInfo.cs @@ -10,5 +10,7 @@ namespace Jellyfin.Plugin.MediaCleaner.Models; /// public class SeriesInfo : MediaInfo { + public Guid SeriesId { get; set; } public IEnumerable Seasons { get; set; } = []; + public required string? TvdbId { get; set; } } diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.html b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.html new file mode 100644 index 0000000..8285a76 --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.html @@ -0,0 +1,74 @@ +
+
+
+ +
+

Media Cleaner Configuration

+ +

Management Configuration

+ +
+
+ + +
The address and port of your radarr instance.
+
+
+ + +
The api key used by your radarr instance
+
+
+ + +
+
+ +
+
+ + +
The address and port of your sonarr instance.
+
+
+ + +
The api key used by your sonarr instance
+
+
+ + +
+
+ +

General Settings

+
+
+ + +
How many days to wait before marking files as stale
+
+ +
+ +
+
+ +
+ +
+
+
+
+
diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js new file mode 100644 index 0000000..a6f4bad --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Pages/configuration.js @@ -0,0 +1,165 @@ +// Variables +var MediaCleanerConfig = { + pluginUniqueId: 'fef007a8-3e8f-4aa8-a22e-486a387f4192' +}; + +// Fades +const startFadeOut = (element, interval = 100) => { + const timer = setInterval(() => { + let currentOpacity = parseFloat(getComputedStyle(element).opacity); + if (isNaN(currentOpacity)) currentOpacity = 1; + if (currentOpacity > 0) { + currentOpacity = Math.max(0, currentOpacity - 0.05); + element.style.opacity = currentOpacity.toString(); + } else { + clearInterval(timer); + element.setAttribute('hidden', ''); + } + }, interval); +}; + +const startFadeIn = (element, interval = 100) => { + const timer = setInterval(() => { + let currentOpacity = parseFloat(getComputedStyle(element).opacity); + if (isNaN(currentOpacity)) currentOpacity = 0; + if (currentOpacity < 1) { + currentOpacity = Math.max(0, currentOpacity + 0.05); + element.style.opacity = currentOpacity.toString(); + } else { + clearInterval(timer); + } + }, interval); +}; + +// Connection Methods +const testConnectionSonarr = async () => { + var apiKeyElement = document.getElementById('SonarrAPIKey'); + var addressElement = document.getElementById('SonarrAddress'); + var validationElement = document.getElementById('SonarrConnectionValidation'); + + await validateConnection(apiKeyElement, addressElement, validationElement, "sonarr"); +} + +const testConnectionRadarr = async () => { + var apiKeyElement = document.getElementById('RadarrAPIKey'); + var addressElement = document.getElementById('RadarrAddress'); + var validationElement = document.getElementById('RadarrConnectionValidation'); + + await validateConnection(apiKeyElement, addressElement, validationElement, "radarr"); +} + +// Validation and Normalization +const normalizeUrl = (url) => { + let normalizedUrl = url.trim(); + if (!/^https?:\/\//i.test(normalizedUrl)) { + normalizedUrl = 'http://' + normalizedUrl; + } + return normalizedUrl; +}; + +const validateConnection = async (apiKeyElement, addressElement, validationElement, controller) => { + var httpAddress = addressElement.value; + var apiKey = apiKeyElement.value; + + console.log("Address: ", httpAddress); + console.log("Api Key: ", apiKey); + + // Only valid with characters + const validHttp = httpAddress.trim().length > 0; + const validApiKey = apiKey.trim().length > 0; + + console.log("Valid Http: ", validHttp); + console.log("Valid Api: ", validApiKey); + + var success = false; + + if(validHttp && validApiKey){ + setAttemptingConnection(validationElement); + try{ + const url = normalizeUrl(httpAddress); + // Move endpoint to a constant? + const response = await fetch(`/${controller}/testConnection`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ address: url, apiKey }) + }); + + if (response.ok) { + const result = await response.json(); + success = result?.success === true; + } + } + catch (error){ + console.error(`Error: ${error}`); + } + } + + processValidationElement(validationElement, success); +} + +const setAttemptingConnection = (validationElement) => { + validationElement.removeAttribute('hidden'); + validationElement.style.opacity = '0'; + + validationElement.style.color = 'Yellow'; + validationElement.innerText = "Attempting Connection..." + setTimeout(startFadeIn(validationElement, 50)); +} + +const processValidationElement = (validationElement, success) => { + validationElement.removeAttribute('hidden'); + validationElement.style.opacity = '1'; + + if(success){ + validationElement.style.color = 'Green'; + validationElement.innerText = "Successful Connection!" + } + else { + validationElement.style.color = 'Red'; + validationElement.innerText = "Failed Connection!" + } + + setTimeout(() => startFadeOut(validationElement, 50), 2000); +} + +// Handlers +document.querySelector('#RadarrTestConnectionButton') + .addEventListener('click', testConnectionRadarr); + +document.querySelector('#SonarrTestConnectionButton') + .addEventListener('click', testConnectionSonarr); + +document.querySelector('#MediaCleanerConfigPage') + .addEventListener('pageshow', function() { + Dashboard.showLoadingMsg(); + ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) { + document.querySelector('#RadarrAPIKey').value = config.RadarrAPIKey; + document.querySelector('#RadarrAddress').value = config.RadarrAddress; + document.querySelector('#SonarrAPIKey').value = config.SonarrAPIKey; + document.querySelector('#SonarrAddress').value = config.SonarrAddress; + document.querySelector('#StaleMediaCutoff').value = config.StaleMediaCutoff; + document.querySelector('#DebugMode').checked = config.DebugMode; + Dashboard.hideLoadingMsg(); + }); + }); + +document.querySelector('#MediaCleanerConfigForm') + .addEventListener('submit', function(e) { + Dashboard.showLoadingMsg(); + ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) { + config.RadarrAPIKey = document.querySelector('#RadarrAPIKey').value; + config.RadarrAddress = document.querySelector('#RadarrAddress').value; + config.SonarrAPIKey = document.querySelector('#SonarrAPIKey').value; + config.SonarrAddress = document.querySelector('#SonarrAddress').value; + config.StaleMediaCutoff = document.querySelector('#StaleMediaCutoff').value; + config.DebugMode = document.querySelector('#DebugMode').checked; + ApiClient.updatePluginConfiguration(MediaCleanerConfig.pluginUniqueId, config).then(function (result) { + Dashboard.processPluginConfigurationUpdateResult(result); + }); + }); + + e.preventDefault(); + return false; +}); diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/global.css b/Jellyfin.Plugin.MediaCleaner/Pages/global.css new file mode 100644 index 0000000..455486a --- /dev/null +++ b/Jellyfin.Plugin.MediaCleaner/Pages/global.css @@ -0,0 +1,91 @@ +.content-primary > * { + box-sizing: border-box; +} + +.inlineContainer { + display: inline-flex; + flex-direction: column; + /* Would be nice to figure out why 50% doesn't work. */ + width: 49%; +} + +.inlineContainer > * { + padding: 0 0.5rem; +} + +table { + border: 1px solid; + border-collapse: collapse; +} + +td, th { + border: 1px solid; + padding: 0.5rem 0.75rem; + text-align: left; +} + +.table-text { + font-size: large; + text-align: center; +} + +/* Custom emby checkbox for table */ +.table-checkbox { + padding-left: 1.05rem; +} + +.table-checkbox .checkboxContainer .emby-checkbox-label .checkboxOutline { + height: 1.5em; + width: 1.5em; +} + +.table-checkbox .checkboxContainer .emby-checkbox-label { + height: 1.8em +} + +.links { + background-color: #0f0f0f; + border: 1px solid #00a4dc; + border-radius: 0.25rem; + padding: 0.8rem 1.8rem; + font-size: 1.2rem; + color: #ffffff; + text-decoration: none; + cursor: pointer; + line-height: inherit; + vertical-align: baseline; + transition: background-color 0.3s ease; +} + +.links:hover { + background-color: #2a2a2a; +} + +.actions-cell { + text-align: center; +} + +.validation { + border: 1px solid; + border-radius: 0.25rem; + margin-top: 1rem; + margin-bottom: 0; + padding: 1rem; + opacity: 1; +} + +.max-width{ + width: 100%; +} + +.medium-width { + width: 50%; +} + +.small-width { + width: 20%; +} + +.xsmall-width { + width: 10%; +} diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.css b/Jellyfin.Plugin.MediaCleaner/Pages/home.css deleted file mode 100644 index 023848d..0000000 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.css +++ /dev/null @@ -1,31 +0,0 @@ -table { - border: 1px solid; - border-collapse: collapse; -} - -td, th { - border: 1px solid; - padding: 0.5rem 0.75rem; - text-align: left; -} - -.links { - background-color: #0f0f0f; - border: 1px solid; - padding: 0.8rem 1.8rem; - font-size: 1.2rem; - color: #ffffff; - text-decoration: none; - cursor: pointer; - line-height: inherit; - vertical-align: baseline; - transition: background-color 0.3s ease; -} - -.links:hover { - background-color: #2a2a2a; -} - -.actions-cell { - text-align: center; -} diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.html b/Jellyfin.Plugin.MediaCleaner/Pages/home.html index e5a58a9..b6e2523 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.html +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.html @@ -2,10 +2,10 @@ data-controller="__plugin/home.js">
- +
Loading...
diff --git a/Jellyfin.Plugin.MediaCleaner/Pages/home.js b/Jellyfin.Plugin.MediaCleaner/Pages/home.js index d082522..15dcfa5 100644 --- a/Jellyfin.Plugin.MediaCleaner/Pages/home.js +++ b/Jellyfin.Plugin.MediaCleaner/Pages/home.js @@ -1,4 +1,9 @@ document.addEventListener('pageshow', async () => { + await refreshFrontEnd(); +}); + +const refreshFrontEnd = async () => { + startLoading(); await updateMediaCleanerState(); var moviesTitle = document.getElementById("moviesTitle"); @@ -9,8 +14,9 @@ document.addEventListener('pageshow', async () => { await populateTables(); addClickHandlersToLinks(); + addClickHandlersToDeleteButtons(); finishLoading(); -}); +} const getMediaCleanerSeriesInfo = async () => { const response = await fetch("/mediacleaner/state/getSeriesInfo"); @@ -62,15 +68,18 @@ const getMediaCleanerMoviesTitle = async () => { return response.json(); }; + const populateTables = async () => { var moviesInfo = await getMediaCleanerMovieInfo(); var seriesInfo = await getMediaCleanerSeriesInfo(); var seriesTableBody = seriesTable.getElementsByTagName('tbody')[0]; seriesTableBody.replaceChildren(); + var seriesDeleteButton = document.getElementById('seriesDeleteButton'); var moviesTableBody = moviesTable.getElementsByTagName('tbody')[0]; moviesTableBody.replaceChildren(); + var moviesDeleteButton = document.getElementById('moviesDeleteButton'); if (moviesInfo.length > 0){ for(let i = 0; i < moviesInfo.length; i++){ @@ -78,10 +87,9 @@ const populateTables = async () => { var cell1 = row.insertCell(0); var cell2 = row.insertCell(1); cell1.innerHTML = moviesInfo[i].Name; - // Will need to be enabled once radarr and sonarr integration is enabled. - // Maybe change this to an element to remove hard coding. - cell2.innerHTML = ""; - cell2.className = "actions-cell"; + cell1.className = "table-text"; + cell2.appendChild(createCheckbox(moviesInfo[i], moviesTable, moviesDeleteButton)); + cell2.className = "table-checkbox" } } else{ @@ -90,6 +98,7 @@ const populateTables = async () => { var cell1 = row.insertCell(0); cell1.colSpan = columnCount; cell1.innerHTML = "No stale movies found."; + cell1.className = "table-text"; } if(seriesInfo.length > 0){ @@ -99,11 +108,11 @@ const populateTables = async () => { var cell2 = row.insertCell(1); var cell3 = row.insertCell(2); cell1.innerHTML = seriesInfo[i].Name; - cell2.innerHTML = seriesInfo[i].Seasons.map(season => season.replace("Season ", "")).join(", "); - // Will need to be enabled once radarr and sonarr integration is enabled. - // Maybe change this to an element to remove hard coding. - cell3.innerHTML = ""; - cell3.className = "actions-cell"; + cell1.className = "table-text"; + cell2.innerHTML = seriesInfo[i].Seasons.map(season => season).join(", "); + cell2.className = "table-text"; + cell3.appendChild(createCheckbox(seriesInfo[i], seriesTable, seriesDeleteButton)); + cell3.className = "table-checkbox" } } else{ @@ -112,12 +121,53 @@ const populateTables = async () => { var cell1 = row.insertCell(0); cell1.colSpan = columnCount; cell1.innerHTML = "No stale series found."; + cell1.className = "table-text"; } }; +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 => { + const linkBtns = document.querySelectorAll("button.links"); + linkBtns.forEach(btn => { btn.addEventListener("click", () => { const target = btn.dataset.target; if (!target) return; @@ -126,13 +176,76 @@ const addClickHandlersToLinks = () => { }) } +const addClickHandlersToDeleteButtons = () => { + const deleteMoviesButtonElement = document.getElementById("moviesDeleteButton"); + const deleteSeriesButtonElement = document.getElementById("seriesDeleteButton"); + deleteMoviesButtonElement.addEventListener("click", deleteFromRadarr); + deleteSeriesButtonElement.addEventListener("click", deleteFromSonarr); +} + +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; +} + +const deleteMovieFromRadarrApi = async (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}`) + } +} + +const deleteSeriesFromSonarrApi = async (series) => { + const response = await fetch("/sonarr/deleteSeriesFromSonarr", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(series) + }); + + if(!response.ok){ + throw new Error(`Response status: ${response.status}`) + } +} + +const deleteFromRadarr = async () => { + const selectedMovies = getCheckedMedia(moviesTable); + selectedMovies.forEach(async movie => await deleteMovieFromRadarrApi(movie)); + refreshFrontEnd(); +} + +const deleteFromSonarr = () => { + const selectedSeries = getCheckedMedia(seriesTable); + selectedSeries.forEach(async series => await deleteSeriesFromSonarrApi(series)); + refreshFrontEnd(); +} + const finishLoading = () => { const loadingElement = document.getElementById("loading"); - const homepage = document.getElementById("homepage"); loadingElement.style.visibility = "hidden"; homepage.style.visibility = "visible"; - - console.log("Loading element: ", loadingElement); - console.log("Homepage element: ", homepage); +} + +const startLoading = () => { + const loadingElement = document.getElementById("loading"); + const homepage = document.getElementById("homepage"); + const moviesDeleteButton = document.getElementById('moviesDeleteButton'); + const seriesDeleteButton = document.getElementById('seriesDeleteButton'); + + loadingElement.style.visibility = "visible"; + homepage.style.visibility = "hidden"; + moviesDeleteButton.style.visibility = "hidden"; + seriesDeleteButton.style.visibility = "hidden"; } diff --git a/Jellyfin.Plugin.MediaCleaner/Plugin.cs b/Jellyfin.Plugin.MediaCleaner/Plugin.cs index 0c29674..cd4bc5c 100644 --- a/Jellyfin.Plugin.MediaCleaner/Plugin.cs +++ b/Jellyfin.Plugin.MediaCleaner/Plugin.cs @@ -1,21 +1,17 @@ using System; using System.Collections.Generic; using System.Globalization; -using Jellyfin.Plugin.MediaCleaner.Configuration; -using Jellyfin.Plugin.MediaCleaner.Data; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; -using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; -using Microsoft.Extensions.DependencyInjection; namespace Jellyfin.Plugin.MediaCleaner; /// /// The main plugin. /// -public class Plugin : BasePlugin, IHasWebPages +public class Plugin : BasePlugin, IHasWebPages { /// /// Initializes a new instance of the class. @@ -46,8 +42,13 @@ public class Plugin : BasePlugin, IHasWebPages [ new PluginPageInfo { - Name = "Settings", - EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.settings.html", GetType().Namespace), + Name = "configuration.js", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.configuration.js", GetType().Namespace), + }, + new PluginPageInfo + { + Name = "Configuration", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.configuration.html", GetType().Namespace), }, new PluginPageInfo { @@ -62,8 +63,8 @@ public class Plugin : BasePlugin, IHasWebPages }, new PluginPageInfo { - Name = "home.css", - EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.home.css", GetType().Namespace), + Name = "global.css", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.global.css", GetType().Namespace), } ]; } diff --git a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs index 5c94753..8207e26 100644 --- a/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs +++ b/Jellyfin.Plugin.MediaCleaner/StaleMediaScanner.cs @@ -1,25 +1,12 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Data.Common; -using System.Diagnostics; using System.Linq; -using System.Net; -using System.Reflection.Metadata.Ecma335; -using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Entities.Libraries; -using Jellyfin.Plugin.MediaCleaner.Configuration; using Jellyfin.Plugin.MediaCleaner.Helpers; -using Jellyfin.Plugin.MediaCleaner; using Jellyfin.Plugin.MediaCleaner.Models; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Tasks; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MediaCleaner; @@ -49,7 +36,7 @@ public sealed class StaleMediaScanner _seriesHelper = new SeriesHelper(_logger); } - public async Task> ScanStaleMedia() + public Task> ScanStaleMedia() { _loggingHelper.LogDebugInformation("--DEBUG MODE ACTIVE--"); _loggingHelper.LogInformation("-------------------------------------------------"); @@ -97,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 @@ -112,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 @@ -134,7 +124,7 @@ public sealed class StaleMediaScanner IEnumerable mediaInfo = staleSeriesInfo.Concat(staleMoviesInfo); - return mediaInfo; + return Task.FromResult(mediaInfo); } private List GetStaleMovies(List movies) @@ -244,11 +234,15 @@ public sealed class StaleMediaScanner IEnumerable seriesInfoList = series.Select(series => { + series.ProviderIds.TryGetValue("Tvdb", out string? tvdbId); + series.ProviderIds.TryGetValue("Tmdb", out string? tmdbId); return new SeriesInfo { - Id = series.Id, + SeriesId = series.Id, + TmdbId = tmdbId, + TvdbId = tvdbId, Name = series.Name, - Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name)] + Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name.Replace("Season ", "", StringComparison.OrdinalIgnoreCase))] }; });