37 Commits

Author SHA1 Message Date
dbc7325e85 Bug fixes and version update 2026-03-10 07:44:29 -06:00
1de8e31468 Updated state to scan library first before looking for stale media to ensure data in table is accurate 2026-03-10 07:43:53 -06:00
a10769779a Fixed request handling to return empty arrays if request fails 2026-03-10 06:43:40 -06:00
c860141f5e Update version 2026-03-09 00:01:33 -06:00
ddb3433bef Merge pull request 'Radarr-Sonarr-Integration' (#15) from Radarr-Sonarr-Integration into main
Reviewed-on: #15
2026-03-08 23:58:32 -06:00
3f5b59b0bd Merge branch 'main' into Radarr-Sonarr-Integration 2026-03-08 23:58:26 -06:00
b6242de064 Added hasFile check to only delete files that exist. 2026-03-08 23:57:16 -06:00
2786d6c73d Updated endpoint naming in front end 2026-03-08 23:46:40 -06:00
16c8338ffe Refactor SonarrController to share methods betweeen endpoints 2026-03-08 23:44:43 -06:00
87bf40dab9 Refactored sonarr controller to be able to return anime or tv series based on the media found in the server. Also updated models 2026-03-08 23:39:17 -06:00
a676a8e8ec Merge branch 'Radarr-Sonarr-Integration' of https://gitea.silverhat.ca/T-Gander/jellyfin-plugin-mediacleaner into Radarr-Sonarr-Integration 2026-03-08 19:28:26 -06:00
21a9cc86d8 Refactor of http client and addition of sonarr anime table 2026-03-08 19:28:04 -06:00
d5b97e0bf3 Update README.md 2026-03-08 01:17:50 -07:00
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
29 changed files with 1279 additions and 269 deletions

View File

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

View File

@@ -3,30 +3,43 @@ using System.Collections.ObjectModel;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.MediaCleaner.Configuration;
namespace Jellyfin.Plugin.MediaCleaner;
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
public class Configuration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// Gets or sets the http and port address for your Radarr instance.
/// </summary>
public PluginConfiguration()
{
}
public string RadarrAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the api for your Radarr instance.
/// </summary>
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>
/// Gets or sets the api for your Sonarr instance.
/// </summary>
public string SonarrAPIKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the http and port address for your Sonarr instance.
/// </summary>
public string SonarrAnimeAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the api for your Sonarr instance.
/// </summary>
public string SonarrAnimeAPIKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the cut off days before deleting unwatched files.
/// </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,104 @@
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.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Jellyfin.Plugin.MediaCleaner.Enums;
using Microsoft.AspNetCore.Http;
using System.Linq;
namespace Jellyfin.Plugin.MediaCleaner.Controllers;
[Route("radarr")]
public class RadarrController : Controller
{
private async Task<ObjectResult> GetRadarrMovieInfo(MovieInfo movieInfo)
{
HttpHelper httpHelper = new(ServerType.Radarr);
var responseBody = await httpHelper.SendHttpRequestAsync(
HttpMethod.Get,
$"/api/v3/movie?tmdbId={Uri.EscapeDataString(movieInfo.TmdbId ?? string.Empty)}&excludeLocalCovers=false"
).ConfigureAwait(false);
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;
HttpHelper httpHelper = new(ServerType.Radarr);
var responseBody = await httpHelper.SendHttpRequestAsync(
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 testHttpClient = new HttpClient();
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, address);
httpRequest.Headers.Add("X-Api-Key", request.ApiKey);
var response = await testHttpClient.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,290 @@
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.Text.Json;
using System.Collections.Generic;
using System.Linq;
using System.Globalization;
using Jellyfin.Plugin.MediaCleaner.Enums;
using Jellyfin.Plugin.MediaCleaner.Helpers;
namespace Jellyfin.Plugin.MediaCleaner.Controllers;
[Route("sonarr")]
public class SonarrController : Controller
{
private readonly HttpClient _httpClient;
public SonarrController(HttpClient httpClient)
{
_httpClient = httpClient;
// Set the default request headers
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
private async Task<ObjectResult> GetSeriesInfo(SeriesInfo seriesInfo, ServerType serverType){
HttpHelper httpHelper = new(serverType);
var responseBody = await httpHelper.SendHttpRequestAsync(
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> GetEpisodeInfo(SonarrSeries sonarrSeries, ServerType serverType){
HttpHelper httpHelper = new(serverType);
var responseBody = await httpHelper.SendHttpRequestAsync(
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
.Select(s => s.SeasonNumber));
var staleEpisodesResponseObj = episodesResponseObj
.Where(episodeDeletionDetail => seasonNumbers.Contains(episodeDeletionDetail.SeasonNumber))
.ToList();
var episodeIds = staleEpisodesResponseObj
.Where(episodeDeletionDetail => episodeDeletionDetail.HasFile)
.Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeId)
.ToList();
var episodeFileIds = staleEpisodesResponseObj
.Where(episodeDeletionDetail => episodeDeletionDetail.HasFile)
.Select(episodeDeletionDetail => episodeDeletionDetail.EpisodeFileId)
.ToList();
return Ok(new EpisodeIdLists(episodeIds, episodeFileIds));
}
[HttpPost("deleteSeriesFromAnimeSonarr")]
public async Task<IActionResult> DeleteSeriesFromAnimeSonarr([FromBody] SeriesInfo seriesInfo){
if (seriesInfo == null || string.IsNullOrEmpty(seriesInfo.TvdbId))
{
return BadRequest("Invalid series information provided.");
}
try
{
var sonarrSeriesInfoResult = await GetSeriesInfo(seriesInfo, ServerType.SonarrAnime).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)))],
Ended: retrievedSeries.Ended,
TvdbId: retrievedSeries.TvdbId
);
var episodesToPurgeResult = await GetEpisodeInfo(staleSeries, ServerType.SonarrAnime).ConfigureAwait(false);
if (episodesToPurgeResult.StatusCode != StatusCodes.Status200OK || episodesToPurgeResult.Value is not EpisodeIdLists)
{
return sonarrSeriesInfoResult;
}
EpisodeIdLists episodesToPurge = (EpisodeIdLists)episodesToPurgeResult.Value;
await UnmonitorSeasons(staleSeries, ServerType.SonarrAnime).ConfigureAwait(false);
await UnmonitorEpisodeIds(episodesToPurge.EpisodeIds, ServerType.SonarrAnime).ConfigureAwait(false);
await DeleteEpisodeFiles(episodesToPurge.EpisodeFileIds, ServerType.SonarrAnime).ConfigureAwait(false);
return Ok();
}
catch (HttpRequestException e)
{
return StatusCode(StatusCodes.Status500InternalServerError, $"An unexpected error occurred. {e.Message}");
}
}
[HttpPost("deleteSeriesFromSonarr")]
public async Task<IActionResult> DeleteSeriesFromSonarr([FromBody] SeriesInfo seriesInfo){
if (seriesInfo == null || string.IsNullOrEmpty(seriesInfo.TvdbId))
{
return BadRequest("Invalid series information provided.");
}
try
{
var sonarrSeriesInfoResult = await GetSeriesInfo(seriesInfo, ServerType.Sonarr).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)))],
Ended: retrievedSeries.Ended,
TvdbId: retrievedSeries.TvdbId
);
var episodesToPurgeResult = await GetEpisodeInfo(staleSeries, ServerType.Sonarr).ConfigureAwait(false);
if (episodesToPurgeResult.StatusCode != StatusCodes.Status200OK || episodesToPurgeResult.Value is not EpisodeIdLists)
{
return sonarrSeriesInfoResult;
}
EpisodeIdLists episodesToPurge = (EpisodeIdLists)episodesToPurgeResult.Value;
await UnmonitorSeasons(staleSeries, ServerType.Sonarr).ConfigureAwait(false);
await UnmonitorEpisodeIds(episodesToPurge.EpisodeIds, ServerType.Sonarr).ConfigureAwait(false);
await DeleteEpisodeFiles(episodesToPurge.EpisodeFileIds, ServerType.Sonarr).ConfigureAwait(false);
return Ok();
}
catch (HttpRequestException e)
{
return StatusCode(StatusCodes.Status500InternalServerError, $"An unexpected error occurred. {e.Message}");
}
}
private async Task<ObjectResult> UnmonitorSeasons(SonarrSeries staleSeries, ServerType serverType){
if (staleSeries == null)
{
return BadRequest("No stale series provided.");
}
HttpHelper httpHelper = new(serverType);
var series = await httpHelper.SendHttpRequestAsync(
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(
HttpMethod.Put,
$"/api/v3/series/{staleSeries.Id}",
seriesDict
).ConfigureAwait(false);
return Ok(responseBody);
}
private async Task<ObjectResult> DeleteEpisodeFiles(IReadOnlyList<int> episodeFileIds, ServerType serverType)
{
if (episodeFileIds == null || episodeFileIds.Count == 0)
{
return BadRequest("No episode file IDs provided.");
}
HttpHelper httpHelper = new(serverType);
var responseBody = await httpHelper.SendHttpRequestAsync(
HttpMethod.Delete,
"/api/v3/episodefile/bulk",
new { episodeFileIds }
).ConfigureAwait(false);
return Ok(responseBody);
}
private async Task<ObjectResult> UnmonitorEpisodeIds(IReadOnlyList<int> episodeIds, ServerType serverType)
{
if (episodeIds == null || episodeIds.Count == 0)
{
return BadRequest("No episode IDs provided.");
}
HttpHelper httpHelper = new(serverType);
var responseBody = await httpHelper.SendHttpRequestAsync(
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,8 @@ using Jellyfin.Plugin.MediaCleaner.Data;
using Jellyfin.Plugin.MediaCleaner;
using Jellyfin.Plugin.MediaCleaner.Models;
using Microsoft.AspNetCore.Mvc;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using System.Threading.Tasks;
using System.Collections.Generic;
namespace Jellyfin.Plugin.MediaCleaner.Controllers;
@@ -10,23 +11,42 @@ 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")]
public IActionResult GetSeriesInfo() => Ok(_state.GetSeriesInfo());
[HttpGet("getTvSeriesInfo")]
public async Task<IActionResult> GetTvSeriesInfo()
{
var tvSeriesInfo = await _state.GetTvSeriesInfo().ConfigureAwait(false);
return Ok(tvSeriesInfo);
}
[HttpGet("getAnimeSeriesInfo")]
public async Task<IActionResult> GetAnimeSeriesInfo()
{
var animeSeriesInfo = await _state.GetAnimeSeriesInfo().ConfigureAwait(false);
return Ok(animeSeriesInfo);
}
[HttpGet("getMovieInfo")]
public IActionResult GetMovieInfo() => Ok(_state.GetMovieInfo());
[HttpGet("updateState")]
public IActionResult GetUpdateState() => Ok(_state.UpdateState());
public async Task<IActionResult> GetUpdateState()
{
await _state.UpdateState().ConfigureAwait(false);
return Ok();
}
[HttpGet("getMoviesTitle")]
public IActionResult GetMoviesTitle() =>
Ok($"Stale Movies (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)");
[HttpGet("getSeriesTitle")]
[HttpGet("getSeriesTitle")]
public IActionResult GetSeriesTitle() =>
Ok($"Stale Series (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)");
Ok($"Stale TV Series (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)");
[HttpGet("getAnimeSeriesTitle")]
public IActionResult GetAnimeSeriesTitle() =>
Ok($"Stale Anime Series (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)");
}

View File

@@ -1,38 +1,109 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.ModelConfiguration;
using Jellyfin.Plugin.MediaCleaner;
using Jellyfin.Plugin.MediaCleaner.Helpers;
using Jellyfin.Plugin.MediaCleaner.Models;
using Jellyfin.Plugin.MediaCleaner.ScheduledTasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.MediaCleaner.Enums;
using System.Net.Http;
using System;
using System.Text.Json;
using System.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.MediaCleaner.Data;
public class MediaCleanerState(ILogger<StaleMediaScanner> logger, ILibraryManager libraryManager)
public class MediaCleanerState(ILogger<StaleMediaScanner> logger, ILibraryManager libraryManager, ITaskManager taskManager)
{
private readonly Lock _lock = new();
private IEnumerable<MediaInfo> _mediaInfo = [];
private readonly StaleMediaScanner _staleMediaScanner = new(logger, libraryManager);
// private readonly ILibraryManager _libraryManager = libraryManager;
private readonly ITaskManager _taskManager = taskManager;
public async Task UpdateState()
{
// First re-scan library and then scan for stale media.
IScheduledTaskWorker? refreshLibraryWorker = _taskManager.ScheduledTasks.FirstOrDefault(task => task.ScheduledTask.Key == "RefreshLibrary");
if(refreshLibraryWorker != null)
{
await _taskManager.Execute(refreshLibraryWorker, new TaskOptions()).ConfigureAwait(false);
}
_mediaInfo = await _staleMediaScanner.ScanStaleMedia().ConfigureAwait(false);
return;
}
public IEnumerable<SeriesInfo> GetSeriesInfo()
public async Task<IEnumerable<SeriesInfo>> GetTvSeriesInfo()
{
// Filter only TV
// Get all series on tv sonarr server
HttpHelper httpHelper = new HttpHelper(ServerType.Sonarr);
JsonElement tvSeriesResponse = new JsonElement();
try
{
tvSeriesResponse = await httpHelper.SendHttpRequestAsync(HttpMethod.Get,"/api/v3/series").ConfigureAwait(false);
}
catch
{
return [];
}
var tvSeries = JsonSerializer.Deserialize<IEnumerable<SonarrSeries>>(tvSeriesResponse.GetRawText());
if(tvSeries == null)
{
return [];
}
lock (_lock)
{
return _mediaInfo.OfType<SeriesInfo>();
var allSeries = _mediaInfo.OfType<SeriesInfo>();
var tvSeriesInfo = allSeries
.Where(series => tvSeries.Any(tv => tv.TvdbId == int.Parse(series.TvdbId, CultureInfo.InvariantCulture)));
return [.. tvSeriesInfo];
}
}
public IEnumerable<MovieInfo> GetMovieInfo()
public async Task<IEnumerable<SeriesInfo>> GetAnimeSeriesInfo()
{
// Get all series on anime sonarr server
HttpHelper animeHttpHelper = new HttpHelper(ServerType.SonarrAnime);
JsonElement animeSeriesResponse = new JsonElement();
try
{
animeSeriesResponse = await animeHttpHelper.SendHttpRequestAsync(HttpMethod.Get,"/api/v3/series").ConfigureAwait(false);
}
catch
{
return [];
}
var animeSeries = JsonSerializer.Deserialize<List<SonarrSeries>>(animeSeriesResponse.GetRawText());
if(animeSeries == null)
{
return Enumerable.Empty<SeriesInfo>();
}
lock (_lock)
{
var allSeries = _mediaInfo.OfType<SeriesInfo>();
var animeSeriesInfo = allSeries
.Where(series => animeSeries.Any(anime => anime.TvdbId == int.Parse(series.TvdbId, CultureInfo.InvariantCulture)));
return animeSeriesInfo;
}
}
public IEnumerable<MovieInfo> GetMovieInfo()
{
lock (_lock)
{

View File

@@ -0,0 +1,8 @@
namespace Jellyfin.Plugin.MediaCleaner.Enums;
public enum ServerType
{
Radarr,
Sonarr,
SonarrAnime
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Plugin.MediaCleaner.Enums;
namespace Jellyfin.Plugin.MediaCleaner.Helpers;
public class HttpHelper
{
private string _baseAddress { get; }
private HttpClient _httpClient { get; }
private static Configuration Configuration =>
Plugin.Instance!.Configuration;
public HttpHelper(ServerType serverType)
{
_httpClient = new HttpClient();
// Set the default request headers
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_httpClient.DefaultRequestHeaders.Add("X-Api-Key", RetrieveApiKey(serverType));
_baseAddress = RetrieveBaseAddress(serverType);
}
/// <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 async Task<JsonElement> SendHttpRequestAsync(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);
}
private string RetrieveApiKey(ServerType serverType)
{
return serverType switch
{
ServerType.Sonarr => Configuration.SonarrAPIKey,
ServerType.SonarrAnime => Configuration.SonarrAnimeAPIKey,
ServerType.Radarr => Configuration.RadarrAPIKey,
_ => string.Empty,
};
}
private string RetrieveBaseAddress(ServerType serverType)
{
return serverType switch
{
ServerType.Sonarr => Configuration.SonarrAddress,
ServerType.SonarrAnime => Configuration.SonarrAnimeAddress,
ServerType.Radarr => Configuration.RadarrAddress,
_ => string.Empty,
};
}
}

View File

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

View File

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

View File

@@ -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<BaseItem> ProcessEpisodes(IReadOnlyCollection<BaseItem> episodes)

View File

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

View File

@@ -0,0 +1,3 @@
namespace Jellyfin.Plugin.MediaCleaner.Models;
public record ConnectionTestRequest(string Address, string ApiKey);

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MediaCleaner.Models;
public record EpisodeDeletionDetails(
[property: JsonPropertyName("id")] int EpisodeId,
[property: JsonPropertyName("episodeFileId")] int EpisodeFileId,
[property: JsonPropertyName("seasonNumber")] int SeasonNumber,
[property: JsonPropertyName("hasFile")] bool HasFile
);

View File

@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace Jellyfin.Plugin.MediaCleaner.Models;
public record EpisodeIdLists(IReadOnlyList<int> EpisodeIds, IReadOnlyList<int> EpisodeFileIds);

View File

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

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MediaCleaner.Models;
public record RadarrMovie(
[property: JsonPropertyName("id")] int? Id,
[property: JsonPropertyName("title")] string? Title
);

View File

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

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MediaCleaner.Models;
public record SonarrSeries(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("seasons")] IReadOnlyList<Season> Seasons,
[property: JsonPropertyName("ended")] bool Ended,
[property: JsonPropertyName("tvdbId")] int TvdbId
// [property: JsonPropertyName("tmdbId")] int TmdbId,
// [property: JsonPropertyName("imdbId")] int ImdbId
);
public record Season(
[property: JsonPropertyName("seasonNumber")] int SeasonNumber
);

View File

@@ -0,0 +1,94 @@
<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>
<div class="inlineContainer">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrAnimeAddress">Sonarr Anime Address (http:port)</label>
<input id="SonarrAnimeAddress" name="SonarrAnimeAddress" 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="SonarrAnimeAPIKey">Sonarr Anime API Key</label>
<input id="SonarrAnimeAPIKey" name="SonarrAnimeAPIKey" type="text" is="emby-input" />
<div class="fieldDescription">The api key used by your sonarr instance</div>
</div>
<div class="inputContainer">
<button id="SonarrAnimeTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button">
<span>Test</span>
</button>
<div class="validation" id="SonarrAnimeConnectionValidation" 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,180 @@
// 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 testConnectionSonarrAnime = async () => {
var apiKeyElement = document.getElementById('SonarrAnimeAPIKey');
var addressElement = document.getElementById('SonarrAnimeAddress');
var validationElement = document.getElementById('SonarrAnimeConnectionValidation');
await validateConnection(apiKeyElement, addressElement, validationElement, "sonarr");
}
const testConnectionSonarr = async () => {
var apiKeyElement = document.getElementById('SonarrAPIKey');
var addressElement = document.getElementById('SonarrAddress');
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('#SonarrAnimeTestConnectionButton')
.addEventListener('click', testConnectionSonarrAnime);
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('#SonarrAnimeAPIKey').value = config.SonarrAnimeAPIKey;
document.querySelector('#SonarrAnimeAddress').value = config.SonarrAnimeAddress;
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.SonarrAnimeAPIKey = document.querySelector('#SonarrAnimeAPIKey').value;
config.SonarrAnimeAddress = document.querySelector('#SonarrAnimeAddress').value;
config.StaleMediaCutoff = document.querySelector('#StaleMediaCutoff').value;
config.DebugMode = document.querySelector('#DebugMode').checked;
ApiClient.updatePluginConfiguration(MediaCleanerConfig.pluginUniqueId, config).then(function (result) {
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">
<div data-role="content">
<div class="content-primary">
<link rel="stylesheet" href="/web/configurationpage?name=home.css" />
<div id="loading">Loading...</div>
<link rel="stylesheet" href="/web/configurationpage?name=global.css" />
<div id="loading">Loading... This may take some time whilst we scan your library and retrieve data from Radarr and Sonarr to accurately fill tables.</div>
<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>
<h3 id="moviesTitle"></h3>
@@ -13,11 +13,12 @@
<thead>
<tr>
<th>Name</th>
<th class="actions-cell">Actions</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<button id="moviesDeleteButton" class="delete-button raised button-submit emby-button" style="visibility: hidden;">Delete</button>
<br>
<h3 id="seriesTitle"></h3>
@@ -26,11 +27,26 @@
<tr>
<th>Name</th>
<th>Seasons</th>
<th class="actions-cell">Actions</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<button id="seriesDeleteButton" class="delete-button raised button-submit emby-button" style="visibility: hidden;">Delete</button>
<br>
<h3 id="animeSeriesTitle"></h3>
<table id="animeSeriesTable">
<thead>
<tr>
<th>Name</th>
<th>Seasons</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<button id="animeSeriesDeleteButton" class="delete-button raised button-submit emby-button" style="visibility: hidden;">Delete</button>
</div>
</div>
</div>

View File

@@ -1,25 +1,43 @@
document.addEventListener('pageshow', async () => {
await refreshFrontEnd();
});
const refreshFrontEnd = async () => {
startLoading();
await updateMediaCleanerState();
var moviesTitle = document.getElementById("moviesTitle");
var seriesTitle = document.getElementById("seriesTitle");
var animeSeriesTitle = document.getElementById("animeSeriesTitle");
moviesTitle.innerHTML = await getMediaCleanerMoviesTitle();
seriesTitle.innerHTML = await getMediaCleanerSeriesTitle();
animeSeriesTitle.innerHTML = await getMediaCleanerAnimeSeriesTitle();
await populateTables();
addClickHandlersToLinks();
addClickHandlersToDeleteButtons();
finishLoading();
});
}
const getMediaCleanerSeriesInfo = async () => {
const response = await fetch("/mediacleaner/state/getSeriesInfo");
const getMediaCleanerTvSeriesInfo = async () => {
const response = await fetch("/mediacleaner/state/getTvSeriesInfo");
if(!response.ok){
throw new Error(`Response status: ${response.status}`)
}
return response.json();
return await response.json();
};
const getMediaCleanerAnimeSeriesInfo = async () => {
const response = await fetch("/mediacleaner/state/getAnimeSeriesInfo");
if(!response.ok){
throw new Error(`Response status: ${response.status}`)
}
return await response.json();
};
const getMediaCleanerMovieInfo = async () => {
@@ -29,7 +47,7 @@ const getMediaCleanerMovieInfo = async () => {
throw new Error(`Response status: ${response.status}`)
}
return response.json();
return await response.json();
};
const updateMediaCleanerState = async () => {
@@ -38,6 +56,14 @@ const updateMediaCleanerState = async () => {
if(!response.ok){
throw new Error(`Response status: ${response.status}`)
}
};
const getMediaCleanerAnimeSeriesTitle = async () => {
const response = await fetch("/mediacleaner/state/getAnimeSeriesTitle");
if(!response.ok){
throw new Error(`Response status: ${response.status}`);
}
return response.json();
};
@@ -62,15 +88,27 @@ const getMediaCleanerMoviesTitle = async () => {
return response.json();
};
const populateTables = async () => {
var moviesInfo = await getMediaCleanerMovieInfo();
var seriesInfo = await getMediaCleanerSeriesInfo();
var seriesInfo = await getMediaCleanerTvSeriesInfo();
var animeSeriesInfo = await getMediaCleanerAnimeSeriesInfo();
var seriesTable = document.getElementById("seriesTable");
var moviesTable = document.getElementById("moviesTable");
var animeSeriesTable = document.getElementById("animeSeriesTable");
var seriesTableBody = seriesTable.getElementsByTagName('tbody')[0];
seriesTableBody.replaceChildren();
var seriesDeleteButton = document.getElementById('seriesDeleteButton');
var moviesTableBody = moviesTable.getElementsByTagName('tbody')[0];
moviesTableBody.replaceChildren();
var moviesDeleteButton = document.getElementById('moviesDeleteButton');
var animeSeriesTableBody = animeSeriesTable.getElementsByTagName('tbody')[0];
animeSeriesTableBody.replaceChildren();
var animeSeriesDeleteButton = document.getElementById('animeSeriesDeleteButton');
if (moviesInfo.length > 0){
for(let i = 0; i < moviesInfo.length; i++){
@@ -78,10 +116,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 = "<input type=\"checkbox\" disabled />";
cell2.className = "actions-cell";
cell1.className = "table-text";
cell2.appendChild(createCheckbox(moviesInfo[i], moviesTable, moviesDeleteButton));
cell2.className = "table-checkbox"
}
}
else{
@@ -90,6 +127,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 +137,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 = "<input type=\"checkbox\" disabled />";
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{
@@ -111,13 +149,77 @@ const populateTables = async () => {
var row = seriesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
cell1.colSpan = columnCount;
cell1.innerHTML = "No stale series found.";
cell1.innerHTML = "No stale tv series found.";
cell1.className = "table-text";
}
if(animeSeriesInfo.length > 0){
for(let i = 0; i < animeSeriesInfo.length; i++){
var row = animeSeriesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
var cell2 = row.insertCell(1);
var cell3 = row.insertCell(2);
cell1.innerHTML = animeSeriesInfo[i].Name;
cell1.className = "table-text";
cell2.innerHTML = animeSeriesInfo[i].Seasons.map(season => season).join(", ");
cell2.className = "table-text";
cell3.appendChild(createCheckbox(animeSeriesInfo[i], animeSeriesTable, animeSeriesDeleteButton));
cell3.className = "table-checkbox"
}
}
else{
var columnCount = animeSeriesTable.tHead.rows[0].cells.length;
var row = animeSeriesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
cell1.colSpan = columnCount;
cell1.innerHTML = "No stale anime series found.";
cell1.className = "table-text";
}
};
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 +228,98 @@ const addClickHandlersToLinks = () => {
})
}
const addClickHandlersToDeleteButtons = () => {
const deleteMoviesButtonElement = document.getElementById("moviesDeleteButton");
const deleteSeriesButtonElement = document.getElementById("seriesDeleteButton");
const deleteAnimeSeriesButtonElement = document.getElementById("animeSeriesDeleteButton");
deleteMoviesButtonElement.addEventListener("click", deleteFromRadarr);
deleteSeriesButtonElement.addEventListener("click", deleteFromSonarr);
deleteAnimeSeriesButtonElement.addEventListener("click", deleteFromAnimeSonarr);
}
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 deleteSeriesFromAnimeSonarrApi = async (series) => {
const response = await fetch("/sonarr/deleteSeriesFromAnimeSonarr", {
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 deleteFromAnimeSonarr = () => {
const selectedSeries = getCheckedMedia(animeSeriesTable);
selectedSeries.forEach(async series => await deleteSeriesFromAnimeSonarrApi(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";
}

View File

@@ -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;
/// <summary>
/// The main plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
public class Plugin : BasePlugin<Configuration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
@@ -46,8 +42,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, 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<PluginConfiguration>, 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),
}
];
}

View File

@@ -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<IEnumerable<MediaInfo>> ScanStaleMedia()
public Task<IEnumerable<MediaInfo>> ScanStaleMedia()
{
_loggingHelper.LogDebugInformation("--DEBUG MODE ACTIVE--");
_loggingHelper.LogInformation("-------------------------------------------------");
@@ -97,7 +84,7 @@ public sealed class StaleMediaScanner
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
@@ -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 ?? string.Empty,
Name = movie.Name
};
});
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
@@ -134,7 +124,7 @@ public sealed class StaleMediaScanner
IEnumerable<MediaInfo> mediaInfo = staleSeriesInfo.Concat(staleMoviesInfo);
return mediaInfo;
return Task.FromResult(mediaInfo);
}
private List<BaseItem> GetStaleMovies(List<BaseItem> movies)
@@ -199,34 +189,6 @@ public sealed class StaleMediaScanner
List<BaseItem> staleSeasons = [.. GetStaleSeasonsWithShortCircuitOnNonStaleSeason(seasons)];
// [ ..seasons
// .Where(season => {
// var episodes = _libraryManager.GetItemList(new InternalItemsQuery
// {
// ParentId = season.Id,
// Recursive = false
// });
// _loggingHelper.LogDebugInformation("Season debug information for {SeasonNumber}:", season);
// bool isSeasonDataStale = false;
// try
// {
// isSeasonDataStale = _seriesHelper.IsSeasonDataStale(episodes);
// }
// catch (ArgumentNullException ex)
// {
// _loggingHelper.LogInformation("Arguement Null Exception in GetStaleSeasons!");
// _loggingHelper.LogInformation(ex.Message);
// }
// _loggingHelper.LogDebugInformation("End of season debug information for {SeasonNumber}.", season);
// return isSeasonDataStale;
// })];
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("End of scanning for series: {Series}", item);
@@ -244,11 +206,16 @@ public sealed class StaleMediaScanner
IEnumerable<SeriesInfo> 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 ?? string.Empty,
TvdbId = tvdbId ?? string.Empty,
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,8 +4,9 @@ At the time of writing, the plugin is only capable of logging movies and shows t
Planned features:
- 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.
- Integration with sonarr and radarr apis to delete your media.
- 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. ✅
- Whitelist for shows to ignore. (Seasonal shows)
Future features if I feel like it: