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; /// /// A task to scan media for stale files. /// public sealed class StaleMediaTask : IScheduledTask { private readonly ILogger _logger; private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; /// /// Initializes a new instance of the class. /// /// Logger. /// User manager. /// . public StaleMediaTask(ILogger 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 progress, CancellationToken cancellationToken) { var query = new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Movie, BaseItemKind.Series], Recursive = true }; List allItems = [.. _libraryManager.GetItemsResult(query).Items]; if (Configuration.DebugMode) { _logger.LogInformation("Total items found: {AllItems}", allItems); } List series = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Series)]; List movies = [.. allItems.Where(item => item.GetBaseItemKind() == BaseItemKind.Movie)]; List staleEpisodes = [.. series.SelectMany(GetStaleEpisodes)]; List 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 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 GetStaleMovies(List movies) { List staleMovies = []; foreach (var movie in movies) { bool movieIsStale = movie.DateCreated < DateTime.Now.AddDays(-Configuration.StaleMediaCutoff); bool movieHasUserData = movie.UserData.Where(data => data.LastPlayedDate != null).ToList().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 FindSeriesInfoFromEpisodes(List 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 seriesNames = [.. series.Select(series => series.Name).Distinct()]; List 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 GetStaleEpisodes(BaseItem item) { List 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.Where(data => data.LastPlayedDate != null).ToList().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 IScheduledTask.GetDefaultTriggers() { // Run this task every 24 hours yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks }; } }