31 Commits

Author SHA1 Message Date
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
2c91846d81 Incremented version 2025-12-04 21:26:50 -07:00
c533eb4f90 Added cleaner for orphaned user data 2025-12-04 21:25:39 -07:00
2fdbae74e8 Updated userData search to filter out null last played dates from user data 2025-12-04 20:45:01 -07:00
08e26943c9 Added debugging 2025-12-04 20:24:24 -07:00
65dd638b09 Added logging 2025-12-03 23:30:58 -07:00
6b20bc0828 Fixed bug for nullable movie user data 2025-12-03 23:00:19 -07:00
db3a06cc67 Logic fix 2025-12-03 22:35:14 -07:00
1a4cefba40 Updated version 2025-12-03 22:03:54 -07:00
2387fecfb6 Added main page 2025-12-03 22:03:07 -07:00
1ddb840f4a Added checks for completely unwatched movies and series 2025-11-30 21:43:47 -07:00
c001a4f2ca Corrected csproj to use assembly version 2025-11-30 21:20:26 -07:00
08747baf51 Fixed versioning so that uninstall works 2025-11-30 13:56:56 -07:00
41c28f095f Updated logging to now show stale seasons for a show 2025-11-29 22:51:50 -07:00
17 changed files with 539 additions and 84 deletions

View File

@@ -1,7 +1,5 @@
<Project>
<PropertyGroup>
<Version>0.0.0.1</Version>
<AssemblyVersion>0.0.0.1</AssemblyVersion>
<FileVersion>0.0.0.1</FileVersion>
<AssemblyVersion>0.0.0.9</AssemblyVersion>
</PropertyGroup>
</Project>

View File

@@ -31,4 +31,9 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the cut off days before deleting unwatched files.
/// </summary>
public int StaleMediaCutoff { get; set; } = 90;
/// <summary>
/// Gets or sets debug mode.
/// </summary>
public bool DebugMode { get; set; }
}

View File

@@ -24,6 +24,12 @@
<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">
@@ -71,6 +77,7 @@
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();
});
});
@@ -86,6 +93,7 @@
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);
});

View File

@@ -0,0 +1,22 @@
using Jellyfin.Plugin.MediaCleaner.Data;
using Jellyfin.Plugin.MediaCleaner.Models;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.MediaCleaner.Controllers;
[Route("mediacleaner/state")]
public class StateController : Controller
{
private readonly PluginState _state;
public StateController(PluginState state) => _state = state;
[HttpGet]
public IActionResult Get() => Ok(_state.GetSeriesInfo());
[HttpPost("add")]
public IActionResult AddSeriesInfo([FromBody] SeriesInfo seriesInfo)
{
_state.AddSeriesInfo(seriesInfo);
return Ok();
}
}

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
using Jellyfin.Plugin.MediaCleaner.Models;
namespace Jellyfin.Plugin.MediaCleaner.Data;
public class PluginState
{
private readonly object _lock = new();
private List<SeriesInfo> _seriesInfo = new List<SeriesInfo>
{
new SeriesInfo { SeriesName = "TestName", Id = System.Guid.NewGuid() }
};
public void AddSeriesInfo(SeriesInfo seriesInfo)
{
lock (_lock)
{
_seriesInfo.Add(seriesInfo);
}
}
public IEnumerable<SeriesInfo> GetSeriesInfo()
{
lock (_lock)
{
return _seriesInfo;
}
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using Jellyfin.Plugin.MediaCleaner.Models;
using Jellyfin.Plugin.MediaCleaner.ScheduledTasks;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Helpers;
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 PluginConfiguration Configuration =>
Plugin.Instance!.Configuration;
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Helpers;
public class MovieHelper(ILogger logger)
{
private readonly LoggingHelper _loggingHelper = new(logger);
private static PluginConfiguration Configuration =>
Plugin.Instance!.Configuration;
public bool IsMovieStale(BaseItem movie)
{
_loggingHelper.LogDebugInformation("Start of scanning for 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;
}
}
else if (createdOutsideCutoff)
{
_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;
}
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("End of scanning for movie: {Movie}", movie);
return movieIsStale;
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Libraries;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.Helpers;
public class SeriesHelper(ILogger logger)
{
private readonly LoggingHelper _loggingHelper = new(logger);
private static PluginConfiguration 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)
{
if(episodes == null)
{
ArgumentNullException.ThrowIfNull(episodes);
}
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

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.MediaCleaner</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.3" >
<PackageReference Include="Jellyfin.Controller" Version="10.11.3">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.3">
@@ -20,14 +20,17 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
<None Remove="Configuration\settings.html" />
<EmbeddedResource Include="Configuration\settings.html" />
<None Remove="Pages\*" />
<EmbeddedResource Include="Pages\*" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<PropertyGroup>
<Timestamp>$([System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"))</Timestamp>
</PropertyGroup>
<Target Name="GeneratePluginJson" BeforeTargets="Publish">
<WriteLinesToFile File="$(PublishDir)\meta.json" Lines="{&#xA; &quot;guid&quot;: &quot;fef007a8-3e8f-4aa8-a22e-486a387f4192&quot;,&#xA; &quot;name&quot;: &quot;Media Cleaner&quot;,&#xA; &quot;category&quot;: &quot;Library&quot;,&#xA; &quot;overview&quot;: &quot;A cleaner for your stale media.&quot;,&#xA; &quot;description&quot;: &quot;Clean out the stale media from your library using scheduled tasks&quot;,&#xA; &quot;timestamp&quot;: &quot;$(Timestamp)&quot;,&#xA; &quot;targetAbi&quot;: &quot;10.11.0&quot;,&#xA; &quot;owner&quot;: &quot;T-Gander&quot;,&#xA; &quot;version&quot;: &quot;$(AssemblyVersion)&quot;&#xA; }" Overwrite="true" Encoding="utf-8" />
</Target>
</Project>

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Jellyfin.Plugin.MediaCleaner.Models;
/// <summary>
/// Contains series information.
/// </summary>
public class SeriesInfo
{
/// <summary>
/// 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>
public IEnumerable<string> Seasons { get; set; } = [];
}

View File

@@ -0,0 +1,21 @@
<div data-role="page" class="page type-interior pluginConfigurationPage withTabs"
data-controller="__plugin/media_cleaner_table.js">
<div data-role="content">
<div class="content-primary">
<div>
<a href="#configurationpage?name=Home">Home</a>
<a href="#configurationpage?name=Settings">Settings</a>
</div>
<h2>Media Cleaner</h2>
<table id="seriesTable">
<thead>
<tr>
<th>ID</th>
<th>Series Name</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
var table = document.getElementById("seriesTable");
const getMediaCleanerState = async () => {
const response = await fetch('/mediacleaner/state');
if(!response.ok){
throw new Error(`Response status: ${response.status}`)
}
return response.json();
}
var state = await getMediaCleanerState();
console.log("State: ", state);
for(let i = 0; i < state.length; i++){
var row = table.insertRow(-1);
var cell1 = row.insertCell(0);
var cell2 = row.insertCell(1);
var cell3 = row.insertCell(2);
cell1.innerHTML = state[i].Id;
cell2.innerHTML = state[i].SeriesName;
cell3.innerHTML = state[i].Seasons.length;
}

View File

@@ -2,11 +2,13 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using Jellyfin.Plugin.MediaCleaner.Data;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.MediaCleaner;
@@ -44,8 +46,19 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
Name = "Settings",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.settings.html", GetType().Namespace),
},
new PluginPageInfo
{
Name = "Home",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.home.html", GetType().Namespace),
EnableInMainMenu = true,
},
new PluginPageInfo
{
Name = "media_cleaner_table.js",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.media_cleaner_table.js", 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<PluginState>();
}
}

View File

@@ -0,0 +1,70 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MediaCleaner.ScheduledTasks;
/// <summary>
/// Task to clean up any detached userdata from the database.
/// </summary>
public class CleanupUserDataTask : IScheduledTask
{
private readonly ILocalizationManager _localization;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly ILogger<CleanupUserDataTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CleanupUserDataTask"/> class.
/// </summary>
/// <param name="localization">The localisation Provider.</param>
/// <param name="dbProvider">The DB context factory.</param>
/// <param name="logger">A logger.</param>
public CleanupUserDataTask(ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbProvider, ILogger<CleanupUserDataTask> logger)
{
_localization = localization;
_dbProvider = dbProvider;
_logger = logger;
}
/// <inheritdoc />
public string Name => _localization.GetLocalizedString("CleanupUserDataTask");
public Guid PlaceholderId => new("00000000-0000-0000-0000-000000000001");
/// <inheritdoc />
public string Description => _localization.GetLocalizedString("CleanupUserDataTaskDescription");
/// <inheritdoc />
public string Category => _localization.GetLocalizedString("Media");
/// <inheritdoc />
public string Key => nameof(CleanupUserDataTask);
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var detachedUserData = dbContext.UserData.Where(e => e.ItemId == PlaceholderId);
_logger.LogInformation("Deleting {DetachedUserDataCount} detached UserData entries.", detachedUserData.Count());
await detachedUserData.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield break;
}
}

View File

@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Data.Common;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Reflection.Metadata.Ecma335;
using System.Threading;
using System.Threading.Tasks;
@@ -10,6 +13,8 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Libraries;
using Jellyfin.Plugin.MediaCleaner.Configuration;
using Jellyfin.Plugin.MediaCleaner.Helpers;
using Jellyfin.Plugin.MediaCleaner.Models;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
@@ -23,20 +28,23 @@ namespace Jellyfin.Plugin.MediaCleaner.ScheduledTasks;
public sealed class StaleMediaTask : IScheduledTask
{
private readonly ILogger _logger;
private readonly IUserManager _userManager;
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.</param>
/// <param name="userManager">User manager.</param>
/// <param name="libraryManager">.</param>
public StaleMediaTask(ILogger<StaleMediaTask> logger, IUserManager userManager, ILibraryManager libraryManager)
/// <param name="logger">Logger for StaleMediaTask.</param>
/// <param name="libraryManager">Accesses jellyfin's library manager for media.</param>
public StaleMediaTask(ILogger<StaleMediaTask> logger, ILibraryManager libraryManager)
{
_logger = logger;
_userManager = userManager;
_libraryManager = libraryManager;
_loggingHelper = new LoggingHelper(_logger);
_movieHelper = new MovieHelper(_logger);
_seriesHelper = new SeriesHelper(_logger);
}
private static PluginConfiguration Configuration =>
@@ -52,34 +60,63 @@ public sealed class StaleMediaTask : IScheduledTask
Task IScheduledTask.ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_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];
_logger.LogInformation("Total items found: {AllItems}", allItems);
_loggingHelper.LogInformation("Total items: {ItemCount}", allItems.Count);
_loggingHelper.LogInformation("Stale items found: {AllItems}", allItems);
List<BaseItem> series = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Series)];
List<BaseItem> movies = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Movie && item.UserData.Count > 0)];
List<BaseItem> movies = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Movie)];
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Starting scan of series items.");
_loggingHelper.LogInformation("-------------------------------------------------");
List<BaseItem> staleSeasons = [.. series.SelectMany(GetStaleSeasons)];
_loggingHelper.LogInformation("Starting scan of movies items.");
_loggingHelper.LogInformation("-------------------------------------------------");
List<BaseItem> staleEpisodes = [.. series.SelectMany(GetStaleEpisodes)];
List<BaseItem> staleMovies = [.. GetStaleMovies(movies)];
_logger.LogInformation("Stale Movies found: {StaleMovies}", staleMovies.Count);
if (staleMovies.Count > 0)
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Stale Movies found: {StaleMovies}", staleMovies.Count);
if (staleMovies.Count > 0 && Configuration.DebugMode)
{
_logger.LogInformation("Movies: {Names}", string.Join(", ", staleMovies.Select(movie => movie.Name)));
foreach (var movieInfo in staleMovies)
{
_loggingHelper.LogDebugInformation("Movie Info: ID: {Id} | Movie Name: {MovieName}", [movieInfo.Id, movieInfo.Name]);
}
}
_logger.LogInformation("Stale Episodes found: {StaleEpisodes}", staleEpisodes.Count);
if (staleEpisodes.Count > 0)
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Stale seasons found: {StaleSeasons}", staleSeasons.Count);
if (staleSeasons.Count > 0 && Configuration.DebugMode)
{
// Firstly figure out the seasons, and then the Series to find the name.
List<string> seriesNames = FindDistinctSeriesNamesFromEpisodes(staleEpisodes);
_logger.LogInformation("Series: {Names}", string.Join(", ", seriesNames));
IEnumerable<SeriesInfo> staleSeriesInfo = FindSeriesInfo(staleSeasons);
foreach (var seriesInfo in staleSeriesInfo)
{
_loggingHelper.LogDebugInformation("Series Info: ID: {Id} | Series Name: {SeriesName} | Stale Seasons: {Seasons}", [seriesInfo.Id, seriesInfo.SeriesName, string.Join(", ", seriesInfo.Seasons)]);
}
}
_loggingHelper.LogInformation("-------------------------------------------------");
_loggingHelper.LogInformation("Ending stale media scan...");
_loggingHelper.LogInformation("-------------------------------------------------");
return Task.CompletedTask;
}
@@ -87,42 +124,17 @@ public sealed class StaleMediaTask : IScheduledTask
private List<BaseItem> GetStaleMovies(List<BaseItem> movies)
{
List<BaseItem> staleMovies = [];
foreach (var movie in movies)
{
var mostRecentUserData = movie.UserData.OrderByDescending(data => data.LastPlayedDate).First();
if (mostRecentUserData.LastPlayedDate < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff))
{
staleMovies.Add(movie);
}
}
staleMovies.AddRange(movies.Where(_movieHelper.IsMovieStale));
return staleMovies;
}
private List<string> FindDistinctSeriesNamesFromEpisodes(List<BaseItem> episodes)
private List<BaseItem> GetStaleSeasons(BaseItem item)
{
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
});
List<string> seriesNames = [.. series.Select(series => series.Name).Distinct()];
return seriesNames;
}
private List<BaseItem> GetStaleEpisodes(BaseItem item)
{
List<BaseItem> staleEpisodes = [];
_loggingHelper.LogDebugInformation("Debug data for series: {SeriesName}", item.Name);
_loggingHelper.LogDebugInformation("-------------------------------------------------");
// Gets each season in a show
var seasons = _libraryManager.GetItemList(new InternalItemsQuery
@@ -131,33 +143,51 @@ public sealed class StaleMediaTask : IScheduledTask
Recursive = false
});
foreach (var season in seasons)
{
// Gets each episode, to access user data.
List<BaseItem> staleSeasons = [ ..seasons
.Where(season => {
var episodes = _libraryManager.GetItemList(new InternalItemsQuery
{
ParentId = season.Id,
Recursive = false
});
bool seasonHasUserData = episodes.Any(episode => episode.UserData.Count > 0);
_loggingHelper.LogDebugInformation("Season debug information for {SeasonNumber}:", season);
if (seasonHasUserData)
{
var episodesWithUserData = episodes.Where(episode => episode.UserData.Count > 0).ToList();
foreach (var episode in episodesWithUserData)
{
var mostRecentUserData = episode.UserData.OrderByDescending(data => data.LastPlayedDate).First();
if (mostRecentUserData.LastPlayedDate < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff))
{
staleEpisodes.AddRange(episodes);
break;
}
}
}
bool isSeasonDataStale = _seriesHelper.IsSeasonDataStale(episodes);
_loggingHelper.LogDebugInformation("End of season debug information for {SeasonNumber}.", season);
return isSeasonDataStale;
})];
_loggingHelper.LogDebugInformation("-------------------------------------------------");
_loggingHelper.LogDebugInformation("End of scanning for series: {Series}", item);
_loggingHelper.LogDebugInformation("-------------------------------------------------");
return staleSeasons;
}
return staleEpisodes;
private IEnumerable<SeriesInfo> 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,
SeriesName = series.Name,
Seasons = [.. seasons.Where(season => season.ParentId == series.Id).Select(season => season.Name)]
};
});
return seriesInfoList;
}
IEnumerable<TaskTriggerInfo> IScheduledTask.GetDefaultTriggers()

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.
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:
- 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.
- Integration with sonarr and radarr apis to delete your media.
- Whitelist for shows to ignore. (Seasonal shows)