24 Commits

Author SHA1 Message Date
de94fbd7ec Update version 2026-03-08 00:13:16 -07:00
98dfd51d3e Merge pull request 'Radarr-Sonarr-Integration' (#12) from Radarr-Sonarr-Integration into main
Reviewed-on: #12
2026-03-08 00:08:19 -07:00
b41979297a Merge branch 'main' into Radarr-Sonarr-Integration 2026-03-08 00:08:12 -07:00
8f049e6704 Added refresh for buttons so that they aren't visible after delete 2026-03-08 00:06:48 -07:00
324d48e7cf Finished off sonarr integration (Non anime) and refactored requests into a Http helper. 2026-03-07 23:22:06 -07:00
11c241b149 Finished Radarr integration 2026-03-07 19:21:58 -07:00
3f5074aa3b 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. 2026-03-07 18:02:45 -07:00
958c581280 Added comments to figure out which endpoint to use 2026-03-07 12:24:13 -07:00
c94a8b8391 Added click handlers for radarr and sonarr 2026-03-07 12:10:09 -07:00
fe3b7e412b Added delete buttons to home page 2026-03-07 11:56:26 -07:00
bf40d758fc Resolved initialization order 2026-02-17 21:31:32 -07:00
48d118c56a Swapped layout of Address and API key 2026-02-17 21:28:12 -07:00
4bcd89c1a9 Added logic to be able to test your Management configuration settings for radarr and sonarr 2026-02-17 21:27:05 -07:00
da94e130ac Fixed issue with async task 2026-02-17 20:40:44 -07:00
4642a6c762 Update README.md 2026-02-16 18:44:13 -07:00
1253693ebe Added comment 2026-02-16 18:41:12 -07:00
80a126fb30 Created custom styling for checkbox 2026-02-16 18:38:33 -07:00
be5a58dcf1 Added a border radius and color to configuration button 2026-02-16 17:54:29 -07:00
e6c4939191 Removed margins from some areas to improve spacing 2026-02-16 17:50:19 -07:00
9348b33ed6 Updated styling for the configuration page 2026-02-16 17:47:26 -07:00
462a2beea1 Moved styles to a global.css stylesheet. Also added basic validation with a transition 2026-02-16 16:08:12 -07:00
a720bba7a7 Refactor of plugin structure to make more sense compared to the default template 2026-02-16 14:08:15 -07:00
61e868bfa2 Added settings for radarr and sonarr addresses 2026-02-14 16:18:11 -07:00
bc7c95af08 Added basic state with selections 2026-02-14 16:01:06 -07:00
22 changed files with 950 additions and 219 deletions

View File

@@ -1,5 +1,5 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<AssemblyVersion>0.0.0.12</AssemblyVersion> <AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -3,25 +3,28 @@ using System.Collections.ObjectModel;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.MediaCleaner.Configuration; namespace Jellyfin.Plugin.MediaCleaner;
/// <summary> /// <summary>
/// Plugin configuration. /// Plugin configuration.
/// </summary> /// </summary>
public class PluginConfiguration : BasePluginConfiguration public class Configuration : BasePluginConfiguration
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class. /// Gets or sets the http and port address for your Radarr instance.
/// </summary> /// </summary>
public PluginConfiguration() public string RadarrAddress { get; set; } = string.Empty;
{
}
/// <summary> /// <summary>
/// Gets or sets the api for your Radarr instance. /// Gets or sets the api for your Radarr instance.
/// </summary> /// </summary>
public string RadarrAPIKey { get; set; } = string.Empty; public string RadarrAPIKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the http and port address for your Sonarr instance.
/// </summary>
public string SonarrAddress { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the api for your Sonarr instance. /// Gets or sets the api for your Sonarr instance.
/// </summary> /// </summary>

View File

@@ -1,108 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Media Cleaner</title>
</head>
<body>
<div id="MediaCleanerConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="MediaCleanerConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RadarrAPIKey">Radarr API Key</label>
<input id="RadarrAPIKey" name="RadarrAPIKey" type="text" is="emby-input" />
<div class="fieldDescription">The api key used by your radarr instance</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrAPIKey">Sonarr API Key</label>
<input id="SonarrAPIKey" name="SonarrAPIKey" type="text" is="emby-input" />
<div class="fieldDescription">The api key used by your sonarr instance</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="StaleMediaCutoff">Stale Media Cutoff</label>
<input id="StaleMediaCutoff" name="StaleMediaCutoff" type="number" is="emby-input" style="width: 20%;"/>
<div class="fieldDescription">How many days to wait before marking files as stale</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="DebugMode" name="DebugMode" type="checkbox" is="emby-checkbox" />
<span>Debug Mode</span>
</label>
</div>
<!-- <div class="selectContainer">
<label class="selectLabel" for="Options">Several Options</label>
<select is="emby-select" id="Options" name="Options" class="emby-select-withcolor emby-select">
<option id="optOneOption" value="OneOption">One Option</option>
<option id="optAnotherOption" value="AnotherOption">Another Option</option>
</select>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AnInteger">An Integer</label>
<input id="AnInteger" name="AnInteger" type="number" is="emby-input" min="0" />
<div class="fieldDescription">A Description</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="TrueFalseSetting" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox" />
<span>A Checkbox</span>
</label>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="AString">A String</label>
<input id="AString" name="AString" type="text" is="emby-input" />
<div class="fieldDescription">Another Description</div>
</div> -->
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var MediaCleanerConfig = {
pluginUniqueId: 'fef007a8-3e8f-4aa8-a22e-486a387f4192'
};
document.querySelector('#MediaCleanerConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) {
// document.querySelector('#Options').value = config.Options;
// document.querySelector('#AnInteger').value = config.AnInteger;
// document.querySelector('#TrueFalseSetting').checked = config.TrueFalseSetting;
// document.querySelector('#AString').value = config.AString;
document.querySelector('#RadarrAPIKey').value = config.RadarrAPIKey;
document.querySelector('#SonarrAPIKey').value = config.SonarrAPIKey;
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.Options = document.querySelector('#Options').value;
// config.AnInteger = document.querySelector('#AnInteger').value;
// config.TrueFalseSetting = document.querySelector('#TrueFalseSetting').checked;
// config.AString = document.querySelector('#AString').value;
config.RadarrAPIKey = document.querySelector('#RadarrAPIKey').value;
config.SonarrAPIKey = document.querySelector('#SonarrAPIKey').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;
});
</script>
</div>
</body>
</html>

View File

@@ -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<ObjectResult> 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<List<RadarrMovie>>(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<IActionResult> 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<IActionResult> 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);
}
}
}

View File

@@ -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<Season> Seasons
);
public record EpisodeDeletionDetails(
[property: JsonPropertyName("id")] int? EpisodeId,
[property: JsonPropertyName("episodeFileId")] int? EpisodeFileId,
[property: JsonPropertyName("seasonNumber")] int? SeasonNumber
);
public record EpisodeIdLists(IReadOnlyList<int> EpisodeIds, IReadOnlyList<int> 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<ObjectResult> 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<List<SonarrSeries>>(responseBody.GetRawText());
var series = seriesResponseObj?.FirstOrDefault();
if (series == null)
{
return NotFound("Series not found in Sonarr library.");
}
return Ok(series);
}
private async Task<ObjectResult> 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<List<EpisodeDeletionDetails>>(responseBody.GetRawText());
if(episodesResponseObj == null){
return NotFound("No episodes in response object.");
}
var seasonNumbers = new HashSet<int>(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<IActionResult> 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<ObjectResult> 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<Dictionary<string, object>>(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<Dictionary<string, object>>(season.GetRawText());
if (seasonDict == null)
{
throw new InvalidOperationException("Failed to deserialize season.");
}
seasonDict["monitored"] = false;
return seasonDict;
}
return JsonSerializer.Deserialize<Dictionary<string, object>>(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<ObjectResult> DeleteEpisodeFiles(IReadOnlyList<int> 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<ObjectResult> UnmonitorEpisodeIds(IReadOnlyList<int> 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<IActionResult> 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);
}
}
}

View File

@@ -2,7 +2,6 @@ using Jellyfin.Plugin.MediaCleaner.Data;
using Jellyfin.Plugin.MediaCleaner; using Jellyfin.Plugin.MediaCleaner;
using Jellyfin.Plugin.MediaCleaner.Models; using Jellyfin.Plugin.MediaCleaner.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Jellyfin.Plugin.MediaCleaner.Configuration;
namespace Jellyfin.Plugin.MediaCleaner.Controllers; namespace Jellyfin.Plugin.MediaCleaner.Controllers;
@@ -10,7 +9,7 @@ namespace Jellyfin.Plugin.MediaCleaner.Controllers;
public class StateController(MediaCleanerState state) : Controller public class StateController(MediaCleanerState state) : Controller
{ {
private readonly MediaCleanerState _state = state; private readonly MediaCleanerState _state = state;
private static PluginConfiguration Configuration => private static Configuration Configuration =>
Plugin.Instance!.Configuration; Plugin.Instance!.Configuration;
[HttpGet("getSeriesInfo")] [HttpGet("getSeriesInfo")]

View File

@@ -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
{
/// <summary>
/// Sends a JSON request and returns the raw JSON element response.
/// </summary>
/// <remarks>
/// Do NOT create a new HttpClient on every call; reuse one instance (DI or a singleton) to avoid socket exhaustion.
/// </remarks>
public static async Task<JsonElement> 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<JsonElement>(responseBody);
}
}

View File

@@ -1,12 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis; 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; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Helpers; namespace Jellyfin.Plugin.MediaCleaner.Helpers;
@@ -30,6 +23,6 @@ public class LoggingHelper(ILogger logger)
_logger.LogInformation(message, args); _logger.LogInformation(message, args);
} }
private static PluginConfiguration Configuration => private static Configuration Configuration =>
Plugin.Instance!.Configuration; Plugin.Instance!.Configuration;
} }

View File

@@ -1,8 +1,6 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -12,7 +10,7 @@ public class MovieHelper(ILogger logger)
{ {
private readonly LoggingHelper _loggingHelper = new(logger); private readonly LoggingHelper _loggingHelper = new(logger);
private static PluginConfiguration Configuration => private static Configuration Configuration =>
Plugin.Instance!.Configuration; Plugin.Instance!.Configuration;
public bool IsMovieStale(BaseItem movie) public bool IsMovieStale(BaseItem movie)

View File

@@ -1,10 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Libraries;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -15,7 +12,7 @@ public class SeriesHelper(ILogger logger)
{ {
private readonly LoggingHelper _loggingHelper = new(logger); private readonly LoggingHelper _loggingHelper = new(logger);
private static PluginConfiguration Configuration => private static Configuration Configuration =>
Plugin.Instance!.Configuration; Plugin.Instance!.Configuration;
private List<BaseItem> ProcessEpisodes(IReadOnlyCollection<BaseItem> episodes) private List<BaseItem> ProcessEpisodes(IReadOnlyCollection<BaseItem> episodes)

View File

@@ -20,8 +20,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Remove="Configuration\settings.html" />
<EmbeddedResource Include="Configuration\settings.html" />
<None Remove="Pages\*" /> <None Remove="Pages\*" />
<EmbeddedResource Include="Pages\*" /> <EmbeddedResource Include="Pages\*" />
</ItemGroup> </ItemGroup>

View File

@@ -4,6 +4,6 @@ namespace Jellyfin.Plugin.MediaCleaner.Models;
public abstract class MediaInfo public abstract class MediaInfo
{ {
public required Guid Id { get; set; } public required string? TmdbId { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
} }

View File

@@ -10,5 +10,7 @@ namespace Jellyfin.Plugin.MediaCleaner.Models;
/// </summary> /// </summary>
public class SeriesInfo : MediaInfo public class SeriesInfo : MediaInfo
{ {
public Guid SeriesId { get; set; }
public IEnumerable<string> Seasons { get; set; } = []; public IEnumerable<string> Seasons { get; set; } = [];
public required string? TvdbId { get; set; }
} }

View File

@@ -0,0 +1,74 @@
<div id="MediaCleanerConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/configuration.js">
<div data-role="content">
<div class="content-primary">
<link rel="stylesheet" href="/web/configurationpage?name=global.css" />
<form id="MediaCleanerConfigForm">
<h2>Media Cleaner Configuration</h2>
<h3>Management Configuration</h3>
<div class="inlineContainer">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RadarrAddress">Radarr Address (http:port)</label>
<input id="RadarrAddress" name="RadarrAddress" type="text" is="emby-input" />
<div class="fieldDescription">The address and port of your radarr instance.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RadarrAPIKey">Radarr API Key</label>
<input id="RadarrAPIKey" name="RadarrAPIKey" type="text" is="emby-input" />
<div class="fieldDescription">The api key used by your radarr instance</div>
</div>
<div class="inputContainer">
<button id="RadarrTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button">
<span>Test</span>
</button>
<div class="validation" id="RadarrConnectionValidation" hidden>
</div>
</div>
</div>
<div class="inlineContainer">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrAddress">Sonarr Address (http:port)</label>
<input id="SonarrAddress" name="SonarrAddress" type="text" is="emby-input" />
<div class="fieldDescription">The address and port of your sonarr instance.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrAPIKey">Sonarr API Key</label>
<input id="SonarrAPIKey" name="SonarrAPIKey" type="text" is="emby-input" />
<div class="fieldDescription">The api key used by your sonarr instance</div>
</div>
<div class="inputContainer">
<button id="SonarrTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button">
<span>Test</span>
</button>
<div class="validation" id="SonarrConnectionValidation" hidden>
</div>
</div>
</div>
<h3>General Settings</h3>
<div class="inlineContainer">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="StaleMediaCutoff">Stale Media Cutoff</label>
<input id="StaleMediaCutoff" class="medium-width" name="StaleMediaCutoff" type="number" is="emby-input"/>
<div class="fieldDescription">How many days to wait before marking files as stale</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="DebugMode" name="DebugMode" type="checkbox" is="emby-checkbox" />
<span>Debug Mode</span>
</label>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
</div>

View File

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

View File

@@ -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%;
}

View File

@@ -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;
}

View File

@@ -2,10 +2,10 @@
data-controller="__plugin/home.js"> data-controller="__plugin/home.js">
<div data-role="content"> <div data-role="content">
<div class="content-primary"> <div class="content-primary">
<link rel="stylesheet" href="/web/configurationpage?name=home.css" /> <link rel="stylesheet" href="/web/configurationpage?name=global.css" />
<div id="loading">Loading...</div> <div id="loading">Loading...</div>
<div id="homepage" style="visibility: hidden;"> <div id="homepage" style="visibility: hidden;">
<button class="links" data-target="configurationpage?name=Settings">Settings</button> <button class="links" data-target="configurationpage?name=Configuration">Configuration</button>
<h2>Media Cleaner</h2> <h2>Media Cleaner</h2>
<h3 id="moviesTitle"></h3> <h3 id="moviesTitle"></h3>
@@ -13,11 +13,12 @@
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th class="actions-cell">Actions</th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<button id="moviesDeleteButton" class="delete-button raised button-submit emby-button" style="visibility: hidden;">Delete</button>
<br> <br>
<h3 id="seriesTitle"></h3> <h3 id="seriesTitle"></h3>
@@ -26,11 +27,12 @@
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Seasons</th> <th>Seasons</th>
<th class="actions-cell">Actions</th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<button id="seriesDeleteButton" class="delete-button raised button-submit emby-button" style="visibility: hidden;">Delete</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,9 @@
document.addEventListener('pageshow', async () => { document.addEventListener('pageshow', async () => {
await refreshFrontEnd();
});
const refreshFrontEnd = async () => {
startLoading();
await updateMediaCleanerState(); await updateMediaCleanerState();
var moviesTitle = document.getElementById("moviesTitle"); var moviesTitle = document.getElementById("moviesTitle");
@@ -9,8 +14,9 @@ document.addEventListener('pageshow', async () => {
await populateTables(); await populateTables();
addClickHandlersToLinks(); addClickHandlersToLinks();
addClickHandlersToDeleteButtons();
finishLoading(); finishLoading();
}); }
const getMediaCleanerSeriesInfo = async () => { const getMediaCleanerSeriesInfo = async () => {
const response = await fetch("/mediacleaner/state/getSeriesInfo"); const response = await fetch("/mediacleaner/state/getSeriesInfo");
@@ -62,15 +68,18 @@ const getMediaCleanerMoviesTitle = async () => {
return response.json(); return response.json();
}; };
const populateTables = async () => { const populateTables = async () => {
var moviesInfo = await getMediaCleanerMovieInfo(); var moviesInfo = await getMediaCleanerMovieInfo();
var seriesInfo = await getMediaCleanerSeriesInfo(); var seriesInfo = await getMediaCleanerSeriesInfo();
var seriesTableBody = seriesTable.getElementsByTagName('tbody')[0]; var seriesTableBody = seriesTable.getElementsByTagName('tbody')[0];
seriesTableBody.replaceChildren(); seriesTableBody.replaceChildren();
var seriesDeleteButton = document.getElementById('seriesDeleteButton');
var moviesTableBody = moviesTable.getElementsByTagName('tbody')[0]; var moviesTableBody = moviesTable.getElementsByTagName('tbody')[0];
moviesTableBody.replaceChildren(); moviesTableBody.replaceChildren();
var moviesDeleteButton = document.getElementById('moviesDeleteButton');
if (moviesInfo.length > 0){ if (moviesInfo.length > 0){
for(let i = 0; i < moviesInfo.length; i++){ for(let i = 0; i < moviesInfo.length; i++){
@@ -78,10 +87,9 @@ const populateTables = async () => {
var cell1 = row.insertCell(0); var cell1 = row.insertCell(0);
var cell2 = row.insertCell(1); var cell2 = row.insertCell(1);
cell1.innerHTML = moviesInfo[i].Name; cell1.innerHTML = moviesInfo[i].Name;
// Will need to be enabled once radarr and sonarr integration is enabled. cell1.className = "table-text";
// Maybe change this to an element to remove hard coding. cell2.appendChild(createCheckbox(moviesInfo[i], moviesTable, moviesDeleteButton));
cell2.innerHTML = "<input type=\"checkbox\" disabled />"; cell2.className = "table-checkbox"
cell2.className = "actions-cell";
} }
} }
else{ else{
@@ -90,6 +98,7 @@ const populateTables = async () => {
var cell1 = row.insertCell(0); var cell1 = row.insertCell(0);
cell1.colSpan = columnCount; cell1.colSpan = columnCount;
cell1.innerHTML = "No stale movies found."; cell1.innerHTML = "No stale movies found.";
cell1.className = "table-text";
} }
if(seriesInfo.length > 0){ if(seriesInfo.length > 0){
@@ -99,11 +108,11 @@ const populateTables = async () => {
var cell2 = row.insertCell(1); var cell2 = row.insertCell(1);
var cell3 = row.insertCell(2); var cell3 = row.insertCell(2);
cell1.innerHTML = seriesInfo[i].Name; cell1.innerHTML = seriesInfo[i].Name;
cell2.innerHTML = seriesInfo[i].Seasons.map(season => season.replace("Season ", "")).join(", "); cell1.className = "table-text";
// Will need to be enabled once radarr and sonarr integration is enabled. cell2.innerHTML = seriesInfo[i].Seasons.map(season => season).join(", ");
// Maybe change this to an element to remove hard coding. cell2.className = "table-text";
cell3.innerHTML = "<input type=\"checkbox\" disabled />"; cell3.appendChild(createCheckbox(seriesInfo[i], seriesTable, seriesDeleteButton));
cell3.className = "actions-cell"; cell3.className = "table-checkbox"
} }
} }
else{ else{
@@ -112,12 +121,53 @@ const populateTables = async () => {
var cell1 = row.insertCell(0); var cell1 = row.insertCell(0);
cell1.colSpan = columnCount; cell1.colSpan = columnCount;
cell1.innerHTML = "No stale series found."; 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 addClickHandlersToLinks = () => {
const linkbtns = document.querySelectorAll("button.links") const linkBtns = document.querySelectorAll("button.links");
linkbtns.forEach(btn => { linkBtns.forEach(btn => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const target = btn.dataset.target; const target = btn.dataset.target;
if (!target) return; 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 finishLoading = () => {
const loadingElement = document.getElementById("loading"); const loadingElement = document.getElementById("loading");
const homepage = document.getElementById("homepage"); const homepage = document.getElementById("homepage");
loadingElement.style.visibility = "hidden"; loadingElement.style.visibility = "hidden";
homepage.style.visibility = "visible"; 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";
} }

View File

@@ -1,21 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using Jellyfin.Plugin.MediaCleaner.Data;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.MediaCleaner; namespace Jellyfin.Plugin.MediaCleaner;
/// <summary> /// <summary>
/// The main plugin. /// The main plugin.
/// </summary> /// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<Configuration>, IHasWebPages
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
@@ -46,8 +42,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
[ [
new PluginPageInfo new PluginPageInfo
{ {
Name = "Settings", Name = "configuration.js",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.settings.html", GetType().Namespace), 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 new PluginPageInfo
{ {
@@ -62,8 +63,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
}, },
new PluginPageInfo new PluginPageInfo
{ {
Name = "home.css", Name = "global.css",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.home.css", GetType().Namespace), EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.global.css", GetType().Namespace),
} }
]; ];
} }

View File

@@ -1,25 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Data.Common;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Net;
using System.Reflection.Metadata.Ecma335;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Data.Enums; 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.Helpers;
using Jellyfin.Plugin.MediaCleaner;
using Jellyfin.Plugin.MediaCleaner.Models; using Jellyfin.Plugin.MediaCleaner.Models;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner; namespace Jellyfin.Plugin.MediaCleaner;
@@ -49,7 +36,7 @@ public sealed class StaleMediaScanner
_seriesHelper = new SeriesHelper(_logger); _seriesHelper = new SeriesHelper(_logger);
} }
public async Task<IEnumerable<MediaInfo>> ScanStaleMedia() public Task<IEnumerable<MediaInfo>> ScanStaleMedia()
{ {
_loggingHelper.LogDebugInformation("--DEBUG MODE ACTIVE--"); _loggingHelper.LogDebugInformation("--DEBUG MODE ACTIVE--");
_loggingHelper.LogInformation("-------------------------------------------------"); _loggingHelper.LogInformation("-------------------------------------------------");
@@ -97,7 +84,7 @@ public sealed class StaleMediaScanner
foreach (SeriesInfo seriesInfo in staleSeriesInfo.Cast<SeriesInfo>()) foreach (SeriesInfo seriesInfo in staleSeriesInfo.Cast<SeriesInfo>())
{ {
_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 else
@@ -112,15 +99,18 @@ public sealed class StaleMediaScanner
if (staleMovies.Count > 0) if (staleMovies.Count > 0)
{ {
staleMoviesInfo = staleMovies.Select(movie => new MovieInfo staleMoviesInfo = staleMovies.Select(movie => {
movie.ProviderIds.TryGetValue("Tmdb", out string? tmdbId);
return new MovieInfo
{ {
Id = movie.Id, TmdbId = tmdbId,
Name = movie.Name Name = movie.Name
};
}); });
foreach (MovieInfo movieInfo in staleMoviesInfo.Cast<MovieInfo>()) foreach (MovieInfo movieInfo in staleMoviesInfo.Cast<MovieInfo>())
{ {
_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 else
@@ -134,7 +124,7 @@ public sealed class StaleMediaScanner
IEnumerable<MediaInfo> mediaInfo = staleSeriesInfo.Concat(staleMoviesInfo); IEnumerable<MediaInfo> mediaInfo = staleSeriesInfo.Concat(staleMoviesInfo);
return mediaInfo; return Task.FromResult(mediaInfo);
} }
private List<BaseItem> GetStaleMovies(List<BaseItem> movies) private List<BaseItem> GetStaleMovies(List<BaseItem> movies)
@@ -244,11 +234,15 @@ public sealed class StaleMediaScanner
IEnumerable<SeriesInfo> seriesInfoList = series.Select(series => IEnumerable<SeriesInfo> seriesInfoList = series.Select(series =>
{ {
series.ProviderIds.TryGetValue("Tvdb", out string? tvdbId);
series.ProviderIds.TryGetValue("Tmdb", out string? tmdbId);
return new SeriesInfo return new SeriesInfo
{ {
Id = series.Id, SeriesId = series.Id,
TmdbId = tmdbId,
TvdbId = tvdbId,
Name = series.Name, 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))]
}; };
}); });

View File

@@ -4,7 +4,8 @@ At the time of writing, the plugin is only capable of logging movies and shows t
Planned features: Planned features:
- Better logging to show more than just the count. ✅ - Better logging to show more than just the count. ✅
- A page that shows what media is currently flagged for removal. And a button to confirm removal. - A page that shows what media is currently flagged for removal.
- Checkboxes to select media for removal within Jellyfin. ✅
- Integration with sonarr and radarr apis to delete your media. - Integration with sonarr and radarr apis to delete your media.
- Whitelist for shows to ignore. (Seasonal shows) - Whitelist for shows to ignore. (Seasonal shows)