21 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
16 changed files with 882 additions and 142 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

@@ -11,14 +11,9 @@ namespace Jellyfin.Plugin.MediaCleaner;
public class Configuration : BasePluginConfiguration public class Configuration : BasePluginConfiguration
{ {
/// <summary> /// <summary>
/// Gets or sets the http address for your Radarr instance. /// Gets or sets the http and port address for your Radarr instance.
/// </summary> /// </summary>
public string RadarrHTTPAddress { get; set; } = string.Empty; public string RadarrAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the port for your Radarr instance.
/// </summary>
public string RadarrPort { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the api for your Radarr instance. /// Gets or sets the api for your Radarr instance.
@@ -26,14 +21,9 @@ public class Configuration : BasePluginConfiguration
public string RadarrAPIKey { get; set; } = string.Empty; public string RadarrAPIKey { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the http address for your Sonarr instance. /// Gets or sets the http and port address for your Sonarr instance.
/// </summary> /// </summary>
public string SonarrHTTPAddress { get; set; } = string.Empty; public string SonarrAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the port for your Sonarr instance.
/// </summary>
public string SonarrPort { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the api for your Sonarr instance. /// Gets or sets the api for your Sonarr instance.

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

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

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

@@ -1,17 +1,17 @@
<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 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 data-role="content">
<div class="content-primary"> <div class="content-primary">
<link rel="stylesheet" href="/web/configurationpage?name=global.css" />
<form id="MediaCleanerConfigForm"> <form id="MediaCleanerConfigForm">
<h2>Media Cleaner Configuration</h2> <h2>Media Cleaner Configuration</h2>
<h3>Management Configuration</h3>
<div class="inlineContainer">
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RadarrHTTPAddress">Radarr Address (http)</label> <label class="inputLabel inputLabelUnfocused" for="RadarrAddress">Radarr Address (http:port)</label>
<input id="RadarrHTTPAddress" name="RadarrHTTPAddress" type="text" is="emby-input" /> <input id="RadarrAddress" name="RadarrAddress" type="text" is="emby-input" />
<div class="fieldDescription">The http address of your radarr instance.</div> <div class="fieldDescription">The address and port of your radarr instance.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RadarrPort">Radarr Port</label>
<input id="RadarrPort" name="RadarrPort" type="text" is="emby-input" />
<div class="fieldDescription">The port of your radarr instance.</div>
</div> </div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RadarrAPIKey">Radarr API Key</label> <label class="inputLabel inputLabelUnfocused" for="RadarrAPIKey">Radarr API Key</label>
@@ -19,31 +19,50 @@
<div class="fieldDescription">The api key used by your radarr instance</div> <div class="fieldDescription">The api key used by your radarr instance</div>
</div> </div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrHTTPAddress">Sonarr Address (http)</label> <button id="RadarrTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button">
<input id="SonarrHTTPAddress" name="SonarrHTTPAddress" type="text" is="emby-input" /> <span>Test</span>
<div class="fieldDescription">The http address of your sonarr instance.</div> </button>
<div class="validation" id="RadarrConnectionValidation" hidden>
</div> </div>
</div>
</div>
<div class="inlineContainer">
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrPort">Sonarr Port</label> <label class="inputLabel inputLabelUnfocused" for="SonarrAddress">Sonarr Address (http:port)</label>
<input id="SonarrPort" name="SonarrPort" type="text" is="emby-input" /> <input id="SonarrAddress" name="SonarrAddress" type="text" is="emby-input" />
<div class="fieldDescription">The port of your sonarr instance.</div> <div class="fieldDescription">The address and port of your sonarr instance.</div>
</div> </div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrAPIKey">Sonarr API Key</label> <label class="inputLabel inputLabelUnfocused" for="SonarrAPIKey">Sonarr API Key</label>
<input id="SonarrAPIKey" name="SonarrAPIKey" type="text" is="emby-input" /> <input id="SonarrAPIKey" name="SonarrAPIKey" type="text" is="emby-input" />
<div class="fieldDescription">The api key used by your sonarr instance</div> <div class="fieldDescription">The api key used by your sonarr instance</div>
</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"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="StaleMediaCutoff">Stale Media Cutoff</label> <label class="inputLabel inputLabelUnfocused" for="StaleMediaCutoff">Stale Media Cutoff</label>
<input id="StaleMediaCutoff" name="StaleMediaCutoff" type="number" is="emby-input" style="width: 20%;"/> <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 class="fieldDescription">How many days to wait before marking files as stale</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="DebugMode" name="DebugMode" type="checkbox" is="emby-checkbox" /> <input id="DebugMode" name="DebugMode" type="checkbox" is="emby-checkbox" />
<span>Debug Mode</span> <span>Debug Mode</span>
</label> </label>
</div> </div>
</div>
<div> <div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button"> <button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span> <span>Save</span>

View File

@@ -1,17 +1,144 @@
// Variables
var MediaCleanerConfig = { var MediaCleanerConfig = {
pluginUniqueId: 'fef007a8-3e8f-4aa8-a22e-486a387f4192' 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') document.querySelector('#MediaCleanerConfigPage')
.addEventListener('pageshow', function() { .addEventListener('pageshow', function() {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) {
document.querySelector('#RadarrHTTPAddress').value = config.RadarrHTTPAddress;
document.querySelector('#RadarrPort').value = config.RadarrPort;
document.querySelector('#RadarrAPIKey').value = config.RadarrAPIKey; document.querySelector('#RadarrAPIKey').value = config.RadarrAPIKey;
document.querySelector('#SonarrHTTPAddress').value = config.SonarrHTTPAddress; document.querySelector('#RadarrAddress').value = config.RadarrAddress;
document.querySelector('#SonarrPort').value = config.SonarrPort;
document.querySelector('#SonarrAPIKey').value = config.SonarrAPIKey; document.querySelector('#SonarrAPIKey').value = config.SonarrAPIKey;
document.querySelector('#SonarrAddress').value = config.SonarrAddress;
document.querySelector('#StaleMediaCutoff').value = config.StaleMediaCutoff; document.querySelector('#StaleMediaCutoff').value = config.StaleMediaCutoff;
document.querySelector('#DebugMode').checked = config.DebugMode; document.querySelector('#DebugMode').checked = config.DebugMode;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
@@ -22,12 +149,10 @@ document.querySelector('#MediaCleanerConfigForm')
.addEventListener('submit', function(e) { .addEventListener('submit', function(e) {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) {
config.RadarrHTTPAddress = document.querySelector('#RadarrHTTPAddress').value;
config.RadarrPort = document.querySelector('#RadarrPort').value;
config.RadarrAPIKey = document.querySelector('#RadarrAPIKey').value; config.RadarrAPIKey = document.querySelector('#RadarrAPIKey').value;
config.SonarrHTTPAddress = document.querySelector('#SonarrHTTPAddress').value; config.RadarrAddress = document.querySelector('#RadarrAddress').value;
config.SonarrPort = document.querySelector('#SonarrPort').value;
config.SonarrAPIKey = document.querySelector('#SonarrAPIKey').value; config.SonarrAPIKey = document.querySelector('#SonarrAPIKey').value;
config.SonarrAddress = document.querySelector('#SonarrAddress').value;
config.StaleMediaCutoff = document.querySelector('#StaleMediaCutoff').value; config.StaleMediaCutoff = document.querySelector('#StaleMediaCutoff').value;
config.DebugMode = document.querySelector('#DebugMode').checked; config.DebugMode = document.querySelector('#DebugMode').checked;
ApiClient.updatePluginConfiguration(MediaCleanerConfig.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(MediaCleanerConfig.pluginUniqueId, config).then(function (result) {

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,7 +2,7 @@
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=Configuration">Configuration</button> <button class="links" data-target="configurationpage?name=Configuration">Configuration</button>
@@ -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,27 +68,6 @@ const getMediaCleanerMoviesTitle = async () => {
return response.json(); return response.json();
}; };
const selectedMovies = new Set();
const selectedTvShows = new Set();
const createCheckbox = (mediaInfo = {}, state = []) => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.dataset.mediaInfo = JSON.stringify(mediaInfo) || '';
checkbox.addEventListener('change', (e) => {
const mediaInfo = checkbox.dataset.mediaInfo || '(no info)';
if (checkbox.checked) {
state.add(mediaInfo);
} else {
state.delete(mediaInfo);
}
// Update UI or state — use console.log for debugging
console.log('selected:', Array.from(state));
});
return checkbox;
};
const populateTables = async () => { const populateTables = async () => {
var moviesInfo = await getMediaCleanerMovieInfo(); var moviesInfo = await getMediaCleanerMovieInfo();
@@ -90,9 +75,11 @@ const populateTables = async () => {
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++){
@@ -100,8 +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;
cell2.appendChild(createCheckbox(moviesInfo[i], selectedMovies)); cell1.className = "table-text";
cell2.className = "actions-cell"; cell2.appendChild(createCheckbox(moviesInfo[i], moviesTable, moviesDeleteButton));
cell2.className = "table-checkbox"
} }
} }
else{ else{
@@ -110,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){
@@ -119,9 +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";
cell3.appendChild(createCheckbox(seriesInfo[i], selectedTvShows)); cell2.innerHTML = seriesInfo[i].Seasons.map(season => season).join(", ");
cell3.className = "actions-cell"; cell2.className = "table-text";
cell3.appendChild(createCheckbox(seriesInfo[i], seriesTable, seriesDeleteButton));
cell3.className = "table-checkbox"
} }
} }
else{ else{
@@ -130,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;
@@ -144,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

@@ -63,8 +63,8 @@ public class Plugin : BasePlugin<Configuration>, 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

@@ -36,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("-------------------------------------------------");
@@ -84,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
@@ -99,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
@@ -121,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)
@@ -231,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)