48 Commits

Author SHA1 Message Date
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
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
1dfa4f9996 Merge pull request 'Media-Cleaner-Homepage-and-Api' (#9) from Media-Cleaner-Homepage-and-Api into main
Reviewed-on: #9
2026-02-12 19:30:36 -07:00
b3db9a86f6 Merge branch 'main' into Media-Cleaner-Homepage-and-Api 2026-02-12 19:30:18 -07:00
d6bdfb95c7 Updated table to use check boxes for future selections. Added short circuiting when parsing seasons 2026-02-12 19:27:41 -07:00
d73153ad37 Merge branch 'Media-Cleaner-Homepage-and-Api' of https://gitea.silverhat.ca/T-Gander/jellyfin-plugin-mediacleaner into Media-Cleaner-Homepage-and-Api 2026-01-28 08:05:06 -07:00
1a4d1145b3 Managed to move css injection to a link tag by moving the link tag inside the div. 2026-01-28 08:04:34 -07:00
629d5d1f2e Merge pull request 'Media-Cleaner-Homepage-and-Api' (#7) from Media-Cleaner-Homepage-and-Api into main
Reviewed-on: #7
2026-01-27 08:16:01 -07:00
1bd4935232 Merge branch 'main' into Media-Cleaner-Homepage-and-Api 2026-01-27 08:15:49 -07:00
984db9e089 Bumped build version 2026-01-27 08:10:20 -07:00
e77f7dcd5f Cleaned up media_cleaner_table.js and renamed to home.js. Updated table with no data to have a message. 2026-01-27 08:09:03 -07:00
669f59db15 Managed to move js styling to css file and inject it during load. 2026-01-26 08:15:53 -07:00
a69f9df4e1 Disabled buttons with no click handlers and added comments. Also updated Settings button styling 2026-01-25 23:02:05 -07:00
e99ce96563 Added styling for tables and also converted settings anchor to a button to enable styling 2026-01-25 22:55:00 -07:00
56403b5722 Updated plugin to refresh page and state whenever the page is shown 2026-01-25 15:34:59 -07:00
ebe24e2630 Added barebones UI for Media Cleaner 2026-01-25 15:07:55 -07:00
04ef815a9b Converted StaleMediaTask to StaleMediaScanner for use in plugin home page. Also added a generic Media Info class that can be filtered to return the data you desire. 2026-01-25 14:52:47 -07:00
66716a9bc9 Updated logging to be more concise when debug is not active 2026-01-25 09:15:26 -07:00
9bddc59fe2 Further improvement to logging formatting and readability 2026-01-25 09:11:45 -07:00
82441d5247 Improved logging futher and updated some methods to use LogDebugInformation and to handle exceptions 2026-01-25 08:58:46 -07:00
5ba270c9a0 Merge pull request 'Media cleaner logging improvements and refactors' (#6) from Media-Cleaner-Homepage-and-Api into main
Reviewed-on: #6
2026-01-24 23:16:39 -07:00
4fc8b4799d Continued to improve logging and fixed a few bugs introduced by refactor 2026-01-24 23:14:16 -07:00
9e324f14a7 Significantly refactored code to help with readability of logging and episode processing 2026-01-24 08:34:42 -07:00
d024035d07 Managed to figure out how to use javascript on the plugin page by utilizing data-controller as found in other repos. Unsure how this is used, but appears to be how you can attach a js file to a div. Also implemented a basic state api to build off of in future. 2026-01-20 20:32:37 -07:00
8d85194df5 Removed unused logging 2026-01-19 08:12:19 -07:00
d78d1069b1 Merge branch 'main' of https://gitea.silverhat.ca/T-Gander/jellyfin-plugin-mediacleaner 2026-01-19 08:11:10 -07:00
f7c463aba4 Simplified Stale Episodes logic 2026-01-19 08:10:54 -07:00
77f2873180 Update README.md 2026-01-19 08:03:56 -07:00
b2da7beb00 Renamed method to be clearer in intention 2026-01-19 08:01:49 -07:00
9696826406 Increment version 2026-01-18 20:37:58 -07:00
873b29985c Fixed stale episode logic. 2026-01-18 20:32:27 -07:00
30107010b1 Simplified some linq queries and fixed some debug logging getting through 2026-01-18 19:23:59 -07:00
5af49eb390 Increment version 2026-01-18 18:52:30 -07:00
ee44dd9ee6 Further enhanced logging and refactored StaleMediaTask to aid in maintainability 2026-01-06 23:06:29 -07:00
9604289684 Improved logging and correctly separated the debug logging. Logs should now make more sense and are easier to comprehend. 2026-01-05 22:00:53 -07:00
ddf84b5cb6 Fixed possible null LastPlayedDate if just marked as watched 2025-12-04 22:16:11 -07:00
ca1d1fcab1 V6 2025-12-04 21:48:45 -07:00
3b43d6807a Enabled deletion of detached user data 2025-12-04 21:34:15 -07:00
23 changed files with 1112 additions and 428 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
using Jellyfin.Plugin.MediaCleaner.Data;
using Jellyfin.Plugin.MediaCleaner;
using Jellyfin.Plugin.MediaCleaner.Models;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.MediaCleaner.Controllers;
[Route("mediacleaner/state")]
public class StateController(MediaCleanerState state) : Controller
{
private readonly MediaCleanerState _state = state;
private static Configuration Configuration =>
Plugin.Instance!.Configuration;
[HttpGet("getSeriesInfo")]
public IActionResult GetSeriesInfo() => Ok(_state.GetSeriesInfo());
[HttpGet("getMovieInfo")]
public IActionResult GetMovieInfo() => Ok(_state.GetMovieInfo());
[HttpGet("updateState")]
public IActionResult GetUpdateState() => Ok(_state.UpdateState());
[HttpGet("getMoviesTitle")]
public IActionResult GetMoviesTitle() =>
Ok($"Stale Movies (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)");
[HttpGet("getSeriesTitle")]
public IActionResult GetSeriesTitle() =>
Ok($"Stale Series (Unwatched for and created over {Configuration.StaleMediaCutoff} Days ago.)");
}

View File

@@ -0,0 +1,42 @@
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.Models;
using Jellyfin.Plugin.MediaCleaner.ScheduledTasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Data;
public class MediaCleanerState(ILogger<StaleMediaScanner> logger, ILibraryManager libraryManager)
{
private readonly Lock _lock = new();
private IEnumerable<MediaInfo> _mediaInfo = [];
private readonly StaleMediaScanner _staleMediaScanner = new(logger, libraryManager);
public async Task UpdateState()
{
_mediaInfo = await _staleMediaScanner.ScanStaleMedia().ConfigureAwait(false);
}
public IEnumerable<SeriesInfo> GetSeriesInfo()
{
lock (_lock)
{
return _mediaInfo.OfType<SeriesInfo>();
}
}
public IEnumerable<MovieInfo> GetMovieInfo()
{
lock (_lock)
{
return _mediaInfo.OfType<MovieInfo>();
}
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Helpers;
public class LoggingHelper(ILogger logger)
{
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
[SuppressMessage("Microsoft.Performance", "CA2254:TemplateShouldBeConstant", Justification = "Message parameter is intentionally variable for flexible debug logging")]
public void LogDebugInformation(string message, params object?[] args)
{
if (Configuration.DebugMode)
{
_logger.LogInformation(message, args);
}
}
[SuppressMessage("Microsoft.Performance", "CA2254:TemplateShouldBeConstant", Justification = "Message parameter is intentionally variable for flexible logging")]
public void LogInformation(string message, params object?[] args)
{
_logger.LogInformation(message, args);
}
private static Configuration Configuration =>
Plugin.Instance!.Configuration;
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Helpers;
public class MovieHelper(ILogger logger)
{
private readonly LoggingHelper _loggingHelper = new(logger);
private static Configuration Configuration =>
Plugin.Instance!.Configuration;
public bool IsMovieStale(BaseItem movie)
{
ArgumentNullException.ThrowIfNull(movie, "IsMovieStale process recieved a null movie. Logic failure. Exception thrown.");
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("Scanning movie: {Movie}", movie);
_loggingHelper.LogDebugInformation("-------------------------------------------------");
bool movieIsStale = false;
bool createdOutsideCutoff = movie.DateCreated < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff);
bool hasUserData = movie.UserData.Where(data => data.LastPlayedDate != null).ToList().Count > 0;
if (hasUserData)
{
var mostRecentUserData = movie.UserData.OrderByDescending(data => data.LastPlayedDate).First(data => data.LastPlayedDate != null);
_loggingHelper.LogDebugInformation("Most recent user data: {Movie}", movie);
foreach (var property in typeof(UserData).GetProperties())
{
_loggingHelper.LogDebugInformation("{PropertyName}: {PropertyValue}", property.Name, property.GetValue(mostRecentUserData));
}
_loggingHelper.LogDebugInformation("-------------------------------------------------");
if (mostRecentUserData.LastPlayedDate < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff))
{
_loggingHelper.LogDebugInformation("Most recent user data has last played date that is outside of cutoff.");
_loggingHelper.LogDebugInformation("Adding {Movie} to stale movies.", movie);
_loggingHelper.LogDebugInformation("With Last Played Date: {LastPlayedDate}", mostRecentUserData.LastPlayedDate);
movieIsStale = true;
}
}
if (createdOutsideCutoff && !hasUserData)
{
_loggingHelper.LogDebugInformation("Movie has no user data and was created outside of cutoff: {DateCreated}.", movie.DateCreated);
_loggingHelper.LogDebugInformation("Adding {Movie} to stale movies.", movie);
movieIsStale = true;
}
if (!createdOutsideCutoff && !hasUserData)
{
_loggingHelper.LogDebugInformation("Movie has no user data and was not created outside of cutoff: {DateCreated}.", movie.DateCreated);
_loggingHelper.LogDebugInformation("Movie is not stale.");
}
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("End of scanning for movie: {Movie}", movie);
return movieIsStale;
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Helpers;
public class SeriesHelper(ILogger logger)
{
private readonly LoggingHelper _loggingHelper = new(logger);
private static Configuration Configuration =>
Plugin.Instance!.Configuration;
private List<BaseItem> ProcessEpisodes(IReadOnlyCollection<BaseItem> episodes)
{
List<BaseItem> staleEpisodes = [.. episodes
.Where(episode =>
{
bool episodeIsStale = false;
var staleCreationDate = episode.DateCreated < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff);
var hasUserDataWithLastPlayedDate = episode.UserData.Any(data => data.LastPlayedDate != null);
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("Debug data for episode: {Episode}", episode);
_loggingHelper.LogDebugInformation("-------------------------------------------------");
if (staleCreationDate && !hasUserDataWithLastPlayedDate){
_loggingHelper.LogDebugInformation("Creation date is stale, and no user data for episode {Episode}.", episode);
_loggingHelper.LogDebugInformation("Date created: {DateCreated}", episode.DateCreated);
episodeIsStale = true;
}
if (hasUserDataWithLastPlayedDate){
UserData mostRecentUserData = episode.UserData
.OrderByDescending(data => data.LastPlayedDate)
.First();
foreach (var property in typeof(UserData).GetProperties())
{
_loggingHelper.LogDebugInformation("{PropertyName}: {PropertyValue}", property.Name, property.GetValue(mostRecentUserData));
}
_loggingHelper.LogDebugInformation("-------------------------------------------------");
bool staleLastPlayedDate = mostRecentUserData.LastPlayedDate < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff);
if (staleLastPlayedDate && staleCreationDate)
{
episodeIsStale = true;
_loggingHelper.LogDebugInformation("Most recent user data has a last played date of: {LastPlayedDate}.", [mostRecentUserData.LastPlayedDate]);
_loggingHelper.LogDebugInformation("Episode created {DateCreated}.", episode.DateCreated);
_loggingHelper.LogDebugInformation("Episode is marked as stale.");
}
}
return episodeIsStale;
})];
return staleEpisodes;
}
public bool IsSeasonDataStale(IReadOnlyList<BaseItem> episodes)
{
ArgumentNullException.ThrowIfNull(episodes, "IsSeasonDataStale process recieved null episodes. Logic failure. Exception thrown.");
bool seasonIsStale = false;
List<BaseItem> staleEpisodes = ProcessEpisodes(episodes);
if(staleEpisodes.Count == episodes.Count)
{
seasonIsStale = true;
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("Stale episodes count matches season episode count. Season is stale.");
_loggingHelper.LogDebugInformation("-------------------------------------------------");
}
return seasonIsStale;
}
}

View File

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

View File

@@ -0,0 +1,9 @@
using System;
namespace Jellyfin.Plugin.MediaCleaner.Models;
public abstract class MediaInfo
{
public required Guid Id { get; set; }
public required string Name { get; set; }
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Jellyfin.Plugin.MediaCleaner;
namespace Jellyfin.Plugin.MediaCleaner.Models;
/// <summary>
/// Contains Movie information.
/// </summary>
public class MovieInfo : MediaInfo
{
}

View File

@@ -1,30 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Jellyfin.Plugin.MediaCleaner;
namespace Jellyfin.Plugin.MediaCleaner.Models; namespace Jellyfin.Plugin.MediaCleaner.Models;
/// <summary> /// <summary>
/// Contains series information. /// Contains series information.
/// </summary> /// </summary>
public class SeriesInfo public class SeriesInfo : MediaInfo
{ {
/// <summary> public IEnumerable<string> Seasons { get; set; } = [];
/// Gets or sets series identifier.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets series name.
/// </summary>
public string SeriesName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets seasons.
/// </summary>
#pragma warning disable CA2227 // Collection properties should be read only
#pragma warning disable CA1002 // Do not expose generic lists
public List<string> Seasons { get; set; } = [];
#pragma warning restore CA1002 // Do not expose generic lists
#pragma warning restore CA2227 // Collection properties should be read only
} }

View File

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

View File

@@ -0,0 +1,161 @@
// Variables
var MediaCleanerConfig = {
pluginUniqueId: 'fef007a8-3e8f-4aa8-a22e-486a387f4192'
};
// Handlers
document.querySelector('#RadarrTestConnectionButton')
.addEventListener('click', testConnectionRadarr);
document.querySelector('#SonarrTestConnectionButton')
.addEventListener('click', testConnectionSonarr);
document.querySelector('#MediaCleanerConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) {
document.querySelector('#RadarrAPIKey').value = config.RadarrAPIKey;
document.querySelector('#RadarrAddress').value = config.RadarrAddress;
document.querySelector('#SonarrAPIKey').value = config.SonarrAPIKey;
document.querySelector('#SonarrAddress').value = config.SonarrAddress;
document.querySelector('#StaleMediaCutoff').value = config.StaleMediaCutoff;
document.querySelector('#DebugMode').checked = config.DebugMode;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#MediaCleanerConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(MediaCleanerConfig.pluginUniqueId).then(function (config) {
config.RadarrAPIKey = document.querySelector('#RadarrAPIKey').value;
config.RadarrAddress = document.querySelector('#RadarrAddress').value;
config.SonarrAPIKey = document.querySelector('#SonarrAPIKey').value;
config.SonarrAddress = document.querySelector('#SonarrAddress').value;
config.StaleMediaCutoff = document.querySelector('#StaleMediaCutoff').value;
config.DebugMode = document.querySelector('#DebugMode').checked;
ApiClient.updatePluginConfiguration(MediaCleanerConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
// 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);
}
const testConnectionRadarr = async () => {
var apiKeyElement = document.getElementById('RadarrAPIKey');
var addressElement = document.getElementById('RadarrAddress');
var validationElement = document.getElementById('RadarrConnectionValidation');
await validateConnection(apiKeyElement, addressElement, validationElement);
}
// 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) => {
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);
const response = await fetch(url, {
method: "GET",
headers: {
"X-Api-Key": apiKey,
}
});
success = response.ok;
}
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);
}

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,11 +1,37 @@
<div data-role="page" class="page type-interior pluginConfigurationPage withTabs"> <div data-role="page" class="page type-interior pluginConfigurationPage"
data-controller="__plugin/home.js">
<div data-role="content"> <div data-role="content">
<div class="content-primary"> <div class="content-primary">
<div> <link rel="stylesheet" href="/web/configurationpage?name=global.css" />
<a href="#configurationpage?name=Home">Home</a> <div id="loading">Loading...</div>
<a href="#configurationpage?name=Settings">Settings</a> <div id="homepage" style="visibility: hidden;">
</div> <button class="links" data-target="configurationpage?name=Configuration">Configuration</button>
<h2>Media Cleaner</h2> <h2>Media Cleaner</h2>
<h3 id="moviesTitle"></h3>
<table id="moviesTable">
<thead>
<tr>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<br>
<h3 id="seriesTitle"></h3>
<table id="seriesTable">
<thead>
<tr>
<th>Name</th>
<th>Seasons</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,179 @@
document.addEventListener('pageshow', async () => {
await updateMediaCleanerState();
var moviesTitle = document.getElementById("moviesTitle");
var seriesTitle = document.getElementById("seriesTitle");
moviesTitle.innerHTML = await getMediaCleanerMoviesTitle();
seriesTitle.innerHTML = await getMediaCleanerSeriesTitle();
await populateTables();
addClickHandlersToLinks();
finishLoading();
});
const getMediaCleanerSeriesInfo = async () => {
const response = await fetch("/mediacleaner/state/getSeriesInfo");
if(!response.ok){
throw new Error(`Response status: ${response.status}`)
}
return response.json();
};
const getMediaCleanerMovieInfo = async () => {
const response = await fetch("/mediacleaner/state/getMovieInfo");
if(!response.ok){
throw new Error(`Response status: ${response.status}`)
}
return response.json();
};
const updateMediaCleanerState = async () => {
const response = await fetch("/mediacleaner/state/updateState");
if(!response.ok){
throw new Error(`Response status: ${response.status}`)
}
return response.json();
};
const getMediaCleanerSeriesTitle = async () => {
const response = await fetch("/mediacleaner/state/getSeriesTitle");
if(!response.ok){
throw new Error(`Response status: ${response.status}`);
}
return response.json();
};
const getMediaCleanerMoviesTitle = async () => {
const response = await fetch("/mediacleaner/state/getMoviesTitle");
if(!response.ok){
throw new Error(`Response status: ${response.status}`);
}
return response.json();
};
const selectedMovies = new Set();
const selectedTvShows = new Set();
const createCheckbox = (mediaInfo = {}, state = []) => {
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);
// Remove dependency on local state. Move to scanning for all checked checkboxes and create the array at that point.
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 container;
};
const populateTables = async () => {
var moviesInfo = await getMediaCleanerMovieInfo();
var seriesInfo = await getMediaCleanerSeriesInfo();
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 linkbtns = document.querySelectorAll("button.links")
linkbtns.forEach(btn => {
btn.addEventListener("click", () => {
const target = btn.dataset.target;
if (!target) return;
window.location.hash = target;
})
})
}
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);
}

View File

@@ -1,10 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
@@ -13,7 +11,7 @@ namespace Jellyfin.Plugin.MediaCleaner;
/// <summary> /// <summary>
/// The main plugin. /// The main plugin.
/// </summary> /// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<Configuration>, IHasWebPages
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class. /// Initializes a new instance of the <see cref="Plugin"/> class.
@@ -44,8 +42,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
[ [
new PluginPageInfo new PluginPageInfo
{ {
Name = "Settings", Name = "configuration.js",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.settings.html", GetType().Namespace), EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.configuration.js", GetType().Namespace),
},
new PluginPageInfo
{
Name = "Configuration",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.configuration.html", GetType().Namespace),
}, },
new PluginPageInfo new PluginPageInfo
{ {
@@ -53,6 +56,16 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.home.html", GetType().Namespace), EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.home.html", GetType().Namespace),
EnableInMainMenu = true, EnableInMainMenu = true,
}, },
new PluginPageInfo
{
Name = "home.js",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.home.js", GetType().Namespace),
},
new PluginPageInfo
{
Name = "global.css",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.global.css", GetType().Namespace),
}
]; ];
} }
} }

View File

@@ -0,0 +1,13 @@
using Jellyfin.Plugin.MediaCleaner.Data;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.MediaCleaner;
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<MediaCleanerState>();
}
}

View File

@@ -55,22 +55,10 @@ public class CleanupUserDataTask : IScheduledTask
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
var detachedUserData = dbContext.UserData.Where(e => e.ItemId == PlaceholderId).ToList(); var detachedUserData = dbContext.UserData.Where(e => e.ItemId == PlaceholderId);
_logger.LogInformation("There are {NoDetached} detached UserData entries.", detachedUserData.Count); _logger.LogInformation("Deleting {DetachedUserDataCount} detached UserData entries.", detachedUserData.Count());
foreach(var userData in detachedUserData)
{
if (userData.Item != null)
{
_logger.LogInformation("Detached user data for: {UserDataName}", userData.Item.Name);
}
else
{
_logger.LogInformation("Detached user data for: {UserDataName}", userData);
}
}
_logger.LogInformation("Detached user data count: {Count}.", detachedUserData.Count);
// await detachedUserData.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await detachedUserData.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
} }
} }

View File

@@ -1,263 +0,0 @@
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.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.Models;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.ScheduledTasks;
/// <summary>
/// A task to scan media for stale files.
/// </summary>
public sealed class StaleMediaTask : IScheduledTask
{
private readonly ILogger _logger;
private readonly IUserManager _userManager;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="StaleMediaTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="userManager">User manager.</param>
/// <param name="libraryManager">.</param>
public StaleMediaTask(ILogger<StaleMediaTask> logger, IUserManager userManager, ILibraryManager libraryManager)
{
_logger = logger;
_userManager = userManager;
_libraryManager = libraryManager;
}
private static PluginConfiguration Configuration =>
Plugin.Instance!.Configuration;
string IScheduledTask.Name => "Scan Stale Media";
string IScheduledTask.Key => "Stale Media";
string IScheduledTask.Description => "Scan Stale Media";
string IScheduledTask.Category => "Media";
Task IScheduledTask.ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var query = new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Movie, BaseItemKind.Series],
Recursive = true
};
List<BaseItem> allItems = [.. _libraryManager.GetItemsResult(query).Items];
if (Configuration.DebugMode)
{
_logger.LogInformation("Total items found: {AllItems}", allItems);
}
List<BaseItem> series = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Series)];
List<BaseItem> movies = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Movie)];
List<BaseItem> staleEpisodes = [.. series.SelectMany(GetStaleEpisodes)];
List<BaseItem> staleMovies = [.. GetStaleMovies(movies)];
_logger.LogInformation("Stale Movies found: {StaleMovies}", staleMovies.Count);
if (staleMovies.Count > 0)
{
_logger.LogInformation("Movies: {Names}", string.Join(", ", staleMovies.Select(movie => movie.Name)));
}
_logger.LogInformation("Stale Episodes found: {StaleEpisodes}", staleEpisodes.Count);
if (staleEpisodes.Count > 0)
{
// Firstly figure out the seasons, and then the Series to find the name.
List<SeriesInfo> seriesInfoList = FindSeriesInfoFromEpisodes(staleEpisodes);
foreach (var seriesInfo in seriesInfoList)
{
if (Configuration.DebugMode)
{
_logger.LogInformation("Series Info: ID: {Id} | Series Name: {SeriesName} | Stale Seasons: {Seasons}", [seriesInfo.Id, seriesInfo.SeriesName, string.Join(", ", seriesInfo.Seasons)]);
}
}
}
return Task.CompletedTask;
}
private List<BaseItem> GetStaleMovies(List<BaseItem> movies)
{
List<BaseItem> staleMovies = [];
foreach (var movie in movies)
{
bool movieIsStale = movie.DateCreated < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff);
bool movieHasUserData = movie.UserData.Count > 0;
if (movieHasUserData)
{
if (Configuration.DebugMode){
_logger.LogInformation("Movie has user data: {Movie}", movie);
_logger.LogInformation("-------------------------------------------------");
}
var mostRecentUserData = movie.UserData.OrderByDescending(data => data.LastPlayedDate).Where(data => data.LastPlayedDate != null).First();
if (Configuration.DebugMode){
_logger.LogInformation("Most recent user data: {Movie}", movie);
foreach (var property in typeof(UserData).GetProperties())
{
_logger.LogInformation("{PropertyName}: {PropertyValue}", property.Name, property.GetValue(mostRecentUserData));
}
_logger.LogInformation("-------------------------------------------------");
}
if (mostRecentUserData.LastPlayedDate < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff))
{
_logger.LogInformation("Most recent user data last played date is outside of cutoff. Adding to stale movies.");
staleMovies.Add(movie);
}
}
else if (movieIsStale)
{
_logger.LogInformation("Movie has no user data and was created outside of cutoff: {DateCreated}", movie.DateCreated);
staleMovies.Add(movie);
}
}
return staleMovies;
}
private List<SeriesInfo> FindSeriesInfoFromEpisodes(List<BaseItem> episodes)
{
Guid[] seasonIds = [.. episodes.Select(episode => episode.ParentId).Distinct()];
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
{
ItemIds = seasonIds
});
Guid[] seriesIds = [.. seasons.Select(season => season.ParentId).Distinct()];
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
ItemIds = seriesIds
}).ToList();
// Series Id, Series Name and Stale Seasons
List<string> seriesNames = [.. series.Select(series => series.Name).Distinct()];
List<SeriesInfo> seriesInfoList = [];
series.ForEach(series =>
{
seriesInfoList.Add(new SeriesInfo
{
Id = series.Id,
SeriesName = series.Name,
Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name)]
});
});
return seriesInfoList;
}
private List<BaseItem> GetStaleEpisodes(BaseItem item)
{
List<BaseItem> staleEpisodes = [];
// Gets each season in a show
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
{
ParentId = item.Id,
Recursive = false
});
foreach (var season in seasons)
{
// Gets each episode, to access user data.
var episodes = _libraryManager.GetItemList(new InternalItemsQuery
{
ParentId = season.Id,
Recursive = false
});
bool seasonHasUserData = episodes.Any(episode => episode.UserData.Count > 0);
if (seasonHasUserData && Configuration.DebugMode)
{
_logger.LogInformation("Season has user data for episodes: {Episodes}", episodes);
_logger.LogInformation("-------------------------------------------------");
}
bool seasonIsStale = episodes.All(episode => episode.DateCreated < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff));
if (seasonIsStale && Configuration.DebugMode)
{
_logger.LogInformation("All episodes are outside media cutoff.");
_logger.LogInformation("-------------------------------------------------");
}
if (seasonHasUserData)
{
var episodesWithUserData = episodes.Where(episode => episode.UserData.Count > 0).ToList();
if(Configuration.DebugMode){
_logger.LogInformation("Episodes with user data: {EpisodesWithUserData}", episodesWithUserData);
_logger.LogInformation("-------------------------------------------------");
}
foreach (var episode in episodesWithUserData)
{
var mostRecentUserData = episode.UserData.OrderByDescending(data => data.LastPlayedDate).Where(data => data.LastPlayedDate != null).First();
if(Configuration.DebugMode){
foreach (var property in typeof(UserData).GetProperties())
{
_logger.LogInformation("{PropertyName}: {PropertyValue}", property.Name, property.GetValue(mostRecentUserData));
}
_logger.LogInformation("-------------------------------------------------");
}
if (mostRecentUserData.LastPlayedDate < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff))
{
if(Configuration.DebugMode){
_logger.LogInformation("Most Recent User Data Last Played Date is: {LastPlayedDate}. All Episodes are stale.", mostRecentUserData.LastPlayedDate);
_logger.LogInformation("-------------------------------------------------");
}
staleEpisodes.AddRange(episodes);
break;
}
}
}
// Check for episodes that have gone unwatched for stale media cutoff
else if (seasonIsStale)
{
if(Configuration.DebugMode){
_logger.LogInformation("No user data, adding all episodes as it is outside of cutoff.");
_logger.LogInformation("-------------------------------------------------");
}
staleEpisodes.AddRange(episodes);
}
}
return staleEpisodes;
}
IEnumerable<TaskTriggerInfo> IScheduledTask.GetDefaultTriggers()
{
// Run this task every 24 hours
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromHours(24).Ticks
};
}
}

View File

@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.MediaCleaner.Helpers;
using Jellyfin.Plugin.MediaCleaner.Models;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner;
/// <summary>
/// A task to scan media for stale files.
/// </summary>
public sealed class StaleMediaScanner
{
private readonly ILogger _logger;
private readonly ILibraryManager _libraryManager;
private readonly LoggingHelper _loggingHelper;
private readonly MovieHelper _movieHelper;
private readonly SeriesHelper _seriesHelper;
/// <summary>
/// Initializes a new instance of the <see cref="StaleMediaTask"/> class.
/// </summary>
/// <param name="logger">Logger for StaleMediaTask.</param>
/// <param name="libraryManager">Accesses jellyfin's library manager for media.</param>
public StaleMediaScanner(ILogger<StaleMediaScanner> logger, ILibraryManager libraryManager)
{
_logger = logger;
_libraryManager = libraryManager;
_loggingHelper = new LoggingHelper(_logger);
_movieHelper = new MovieHelper(_logger);
_seriesHelper = new SeriesHelper(_logger);
}
public Task<IEnumerable<MediaInfo>> ScanStaleMedia()
{
_loggingHelper.LogDebugInformation("--DEBUG MODE ACTIVE--");
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Starting stale media scan...");
_loggingHelper.LogInformation("-------------------------------------------------");
var query = new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Movie, BaseItemKind.Series],
Recursive = true
};
List<BaseItem> allItems = [.. _libraryManager.GetItemsResult(query).Items];
_loggingHelper.LogInformation("Total items to scan: {ItemCount}", allItems.Count);
_loggingHelper.LogDebugInformation("Items found: {AllItems}", allItems);
List<BaseItem> series = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Series)];
List<BaseItem> movies = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Movie)];
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("Starting scan of series items.");
List<BaseItem> staleSeasons = [.. series.SelectMany(GetStaleSeasons)];
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("End of scan for series items.");
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("Starting scan of movie items.");
List<BaseItem> staleMovies = [.. GetStaleMovies(movies)];
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("End of scan for movie items.");
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Stale seasons found: {StaleSeasons}", staleSeasons.Count);
IEnumerable<MediaInfo> staleSeriesInfo = [];
if (staleSeasons.Count > 0)
{
staleSeriesInfo = FindSeriesInfo(staleSeasons);
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)]);
}
}
else
{
_loggingHelper.LogInformation("No stale seasons found!");
}
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Stale Movies found: {StaleMovies}", staleMovies.Count);
IEnumerable<MediaInfo> staleMoviesInfo = [];
if (staleMovies.Count > 0)
{
staleMoviesInfo = staleMovies.Select(movie => new MovieInfo
{
Id = movie.Id,
Name = movie.Name
});
foreach (MovieInfo movieInfo in staleMoviesInfo.Cast<MovieInfo>())
{
_loggingHelper.LogInformation("Movie Info: ID: {Id} | Movie Name: {MovieName}", [movieInfo.Id, movieInfo.Name]);
}
}
else
{
_loggingHelper.LogInformation("No stale movies found!");
}
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Ending stale media scan...");
_loggingHelper.LogInformation("-------------------------------------------------");
IEnumerable<MediaInfo> mediaInfo = staleSeriesInfo.Concat(staleMoviesInfo);
return Task.FromResult(mediaInfo);
}
private List<BaseItem> GetStaleMovies(List<BaseItem> movies)
{
List<BaseItem> staleMovies = [];
try
{
staleMovies.AddRange(movies.Where(_movieHelper.IsMovieStale));
}
catch (ArgumentNullException ex)
{
_loggingHelper.LogInformation("Arguement Null Exception in GetStaleMovies!");
_loggingHelper.LogInformation(ex.Message);
}
return staleMovies;
}
private IEnumerable<BaseItem> GetStaleSeasonsWithShortCircuitOnNonStaleSeason(IEnumerable<BaseItem> seasons)
{
foreach (BaseItem season in seasons)
{
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("Argument Null Exception in GetStaleSeasons!");
_loggingHelper.LogInformation(ex.Message);
}
_loggingHelper.LogDebugInformation("End of season debug information for {SeasonNumber}.", season);
if (!isSeasonDataStale) yield break;
yield return season;
}
}
private List<BaseItem> GetStaleSeasons(BaseItem item)
{
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("Debug data for series: {SeriesName}", item.Name);
_loggingHelper.LogDebugInformation("-------------------------------------------------");
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
{
ParentId = item.Id,
Recursive = false
});
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);
return staleSeasons;
}
private IEnumerable<MediaInfo> FindSeriesInfo(IReadOnlyCollection<BaseItem> seasons)
{
Guid[] seriesIds = [.. seasons.Select(season => season.ParentId).Distinct()];
IReadOnlyCollection<BaseItem> series = _libraryManager.GetItemList(new InternalItemsQuery
{
ItemIds = seriesIds
});
IEnumerable<SeriesInfo> seriesInfoList = series.Select(series =>
{
return new SeriesInfo
{
Id = series.Id,
Name = series.Name,
Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name)]
};
});
return seriesInfoList;
}
}

View File

@@ -1,9 +1,9 @@
The idea behind this plugin is to have an easy way to run a task to find all movies and shows in your media collection that users haven't viewed in a number of cutoff days. The idea behind this plugin is to have an easy way to run a task to find all movies and shows in your media collection that users haven't viewed in a number of cutoff days.
At the time of writing, the plugin is only capable of logging movies and shows that are stale (Unwatched for 90 days) by running a scheduled task. You will need to view your logs to know the number of stale files. At the time of writing, the plugin is only capable of logging movies and shows that are stale (Unwatched for a user set number of days) by running a scheduled task. You will need to view your logs to know the number of stale files and the names of said files.
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. And a button to confirm removal.
- 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)