14 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
4642a6c762 Update README.md 2026-02-16 18:44:13 -07:00
12 changed files with 724 additions and 129 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

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

@@ -8,16 +8,16 @@
<h3>Management Configuration</h3> <h3>Management Configuration</h3>
<div class="inlineContainer"> <div class="inlineContainer">
<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"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RadarrAddress">Radarr Address (http:port)</label> <label class="inputLabel inputLabelUnfocused" for="RadarrAddress">Radarr Address (http:port)</label>
<input id="RadarrAddress" name="RadarrAddress" type="text" is="emby-input" /> <input id="RadarrAddress" name="RadarrAddress" type="text" is="emby-input" />
<div class="fieldDescription">The address and port of your radarr instance.</div> <div class="fieldDescription">The address and port of your radarr instance.</div>
</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"> <div class="inputContainer">
<button id="RadarrTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button"> <button id="RadarrTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button">
<span>Test</span> <span>Test</span>
@@ -28,16 +28,16 @@
</div> </div>
<div class="inlineContainer"> <div class="inlineContainer">
<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"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SonarrAddress">Sonarr Address (http:port)</label> <label class="inputLabel inputLabelUnfocused" for="SonarrAddress">Sonarr Address (http:port)</label>
<input id="SonarrAddress" name="SonarrAddress" type="text" is="emby-input" /> <input id="SonarrAddress" name="SonarrAddress" type="text" is="emby-input" />
<div class="fieldDescription">The address and port of your sonarr instance.</div> <div class="fieldDescription">The address and port of your sonarr instance.</div>
</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"> <div class="inputContainer">
<button id="SonarrTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button"> <button id="SonarrTestConnectionButton" is="emby-button" type="button" class="raised button-submit block emby-button">
<span>Test</span> <span>Test</span>

View File

@@ -1,44 +1,9 @@
// Variables
var MediaCleanerConfig = { var MediaCleanerConfig = {
pluginUniqueId: 'fef007a8-3e8f-4aa8-a22e-486a387f4192' pluginUniqueId: 'fef007a8-3e8f-4aa8-a22e-486a387f4192'
}; };
const testConnectionSonarr = () => { // Fades
var apiKeyElement = document.getElementById('SonarrAPIKey');
var addressElement = document.getElementById('SonarrAddress');
var validationElement = document.getElementById('SonarrConnectionValidation');
validateConnection(apiKeyElement, addressElement, validationElement);
}
const testConnectionRadarr = () => {
var apiKeyElement = document.getElementById('RadarrAPIKey');
var addressElement = document.getElementById('RadarrAddress');
var validationElement = document.getElementById('RadarrConnectionValidation');
validateConnection(apiKeyElement, addressElement, validationElement);
}
const validateConnection = (apiKeyElement, addressElement, validationElement) => {
// Refactor this to a method called show element?
validationElement.removeAttribute('hidden');
validationElement.style.opacity = '1';
console.log("Api Key: ", apiKeyElement.value);
console.log("Address: ", addressElement.value);
var success = false;
if(success){
validationElement.style.color = 'Green';
validationElement.innerText = "Success!"
}
else {
validationElement.style.color = 'Red';
validationElement.innerText = "Failed!"
}
setTimeout(() => startFadeOut(validationElement, 50), 1000);
}
const startFadeOut = (element, interval = 100) => { const startFadeOut = (element, interval = 100) => {
const timer = setInterval(() => { const timer = setInterval(() => {
let currentOpacity = parseFloat(getComputedStyle(element).opacity); let currentOpacity = parseFloat(getComputedStyle(element).opacity);
@@ -53,6 +18,113 @@ const startFadeOut = (element, interval = 100) => {
}, interval); }, 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') document.querySelector('#RadarrTestConnectionButton')
.addEventListener('click', testConnectionRadarr); .addEventListener('click', testConnectionRadarr);

View File

@@ -18,6 +18,7 @@
</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>
@@ -31,6 +32,7 @@
</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,10 +68,64 @@ const getMediaCleanerMoviesTitle = async () => {
return response.json(); return response.json();
}; };
const selectedMovies = new Set();
const selectedTvShows = new Set();
const createCheckbox = (mediaInfo = {}, state = []) => { const populateTables = async () => {
var moviesInfo = await getMediaCleanerMovieInfo();
var seriesInfo = await getMediaCleanerSeriesInfo();
var seriesTableBody = seriesTable.getElementsByTagName('tbody')[0];
seriesTableBody.replaceChildren();
var seriesDeleteButton = document.getElementById('seriesDeleteButton');
var moviesTableBody = moviesTable.getElementsByTagName('tbody')[0];
moviesTableBody.replaceChildren();
var moviesDeleteButton = document.getElementById('moviesDeleteButton');
if (moviesInfo.length > 0){
for(let i = 0; i < moviesInfo.length; i++){
var row = moviesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
var cell2 = row.insertCell(1);
cell1.innerHTML = moviesInfo[i].Name;
cell1.className = "table-text";
cell2.appendChild(createCheckbox(moviesInfo[i], moviesTable, moviesDeleteButton));
cell2.className = "table-checkbox"
}
}
else{
var columnCount = moviesTable.tHead.rows[0].cells.length;
var row = moviesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
cell1.colSpan = columnCount;
cell1.innerHTML = "No stale movies found.";
cell1.className = "table-text";
}
if(seriesInfo.length > 0){
for(let i = 0; i < seriesInfo.length; i++){
var row = seriesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
var cell2 = row.insertCell(1);
var cell3 = row.insertCell(2);
cell1.innerHTML = seriesInfo[i].Name;
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{
var columnCount = seriesTable.tHead.rows[0].cells.length;
var row = seriesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
cell1.colSpan = columnCount;
cell1.innerHTML = "No stale series found.";
cell1.className = "table-text";
}
};
const createCheckbox = (mediaInfo = {}, table, deleteButton) => {
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'checkboxContainer'; container.className = 'checkboxContainer';
container.style.marginBottom = 0; container.style.marginBottom = 0;
@@ -87,78 +147,27 @@ const createCheckbox = (mediaInfo = {}, state = []) => {
label.appendChild(span); label.appendChild(span);
container.appendChild(label); container.appendChild(label);
// Remove dependency on local state. Move to scanning for all checked checkboxes and create the array at that point.
checkbox.addEventListener('change', (e) => { checkbox.addEventListener('change', (e) => {
const mediaInfo = checkbox.dataset.mediaInfo || '(no info)'; if(isDeleteButtonVisible(table)){
if (checkbox.checked) { deleteButton.style.visibility = 'visible';
state.add(mediaInfo); }
} else { else {
state.delete(mediaInfo); deleteButton.style.visibility = 'hidden';
} }
// Update UI or state — use console.log for debugging
console.log('selected:', Array.from(state));
}); });
return container; return container;
}; };
const populateTables = async () => { const isDeleteButtonVisible = (table) => {
var moviesInfo = await getMediaCleanerMovieInfo(); const checkboxes = table.getElementsByClassName('emby-checkbox');
var seriesInfo = await getMediaCleanerSeriesInfo(); const hasChecked = Array.from(checkboxes).some(checkbox => checkbox.checked);
return hasChecked;
var seriesTableBody = seriesTable.getElementsByTagName('tbody')[0]; }
seriesTableBody.replaceChildren();
var moviesTableBody = moviesTable.getElementsByTagName('tbody')[0];
moviesTableBody.replaceChildren();
if (moviesInfo.length > 0){
for(let i = 0; i < moviesInfo.length; i++){
var row = moviesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
var cell2 = row.insertCell(1);
cell1.innerHTML = moviesInfo[i].Name;
cell1.className = "table-text";
cell2.appendChild(createCheckbox(moviesInfo[i], selectedMovies));
cell2.className = "table-checkbox"
}
}
else{
var columnCount = moviesTable.tHead.rows[0].cells.length;
var row = moviesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
cell1.colSpan = columnCount;
cell1.innerHTML = "No stale movies found.";
cell1.className = "table-text";
}
if(seriesInfo.length > 0){
for(let i = 0; i < seriesInfo.length; i++){
var row = seriesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
var cell2 = row.insertCell(1);
var cell3 = row.insertCell(2);
cell1.innerHTML = seriesInfo[i].Name;
cell1.className = "table-text";
cell2.innerHTML = seriesInfo[i].Seasons.map(season => season.replace("Season ", "")).join(", ");
cell2.className = "table-text";
cell3.appendChild(createCheckbox(seriesInfo[i], selectedTvShows));
cell3.className = "table-checkbox"
}
}
else{
var columnCount = seriesTable.tHead.rows[0].cells.length;
var row = seriesTableBody.insertRow(-1);
var cell1 = row.insertCell(0);
cell1.colSpan = columnCount;
cell1.innerHTML = "No stale series found.";
cell1.className = "table-text";
}
};
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;
@@ -167,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

@@ -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);
Id = movie.Id, return new MovieInfo
Name = movie.Name {
TmdbId = tmdbId,
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
@@ -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)