using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { /// /// Scheduled task that re-checks TMDb for newly announced unaired and missing episodes and creates /// the corresponding virtual items. This keeps the "Upcoming" view current for series whose local /// files have not changed, which an ordinary library scan would never re-examine. /// public class TmdbUpcomingEpisodesTask : IScheduledTask { private const int DefaultIntervalDays = 7; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The . /// The . /// The . public TmdbUpcomingEpisodesTask( ILibraryManager libraryManager, IFileSystem fileSystem, ILogger logger) { _libraryManager = libraryManager; _fileSystem = fileSystem; _logger = logger; } /// public string Name => "Refresh upcoming and missing episodes (TheMovieDb)"; /// public string Description => "Checks TheMovieDb for newly announced episodes and creates virtual entries for unaired and missing episodes, according to the TMDb plugin settings. When both options are disabled, removes any virtual entries previously created."; /// public string Category => "Library"; /// public string Key => "TmdbRefreshUpcomingEpisodes"; /// public IEnumerable GetDefaultTriggers() { var intervalDays = Plugin.Instance?.Configuration.MissingEpisodeRefreshIntervalDays ?? DefaultIntervalDays; if (intervalDays <= 0) { intervalDays = DefaultIntervalDays; } yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromDays(intervalDays).Ticks }; } /// public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { var configuration = Plugin.Instance?.Configuration; if (configuration is null) { progress.Report(100); return; } // The feature is fully disabled: remove every virtual episode (and now-empty virtual season) // this provider previously created, across all libraries, then stop. if ((!configuration.ImportUnairedEpisodes && !configuration.ImportMissingEpisodes) || configuration.EnabledMissingEpisodeLibraries.Length == 0) { RemoveAllVirtualItems(progress, cancellationToken); return; } // Process non-ended series (they may have gained episodes) plus any series in a library that // is not opted in (regardless of status) so the provider can prune the virtual episodes it // previously created there. Ended series in enabled libraries cannot change, so they're skipped. var series = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Series], Recursive = true }) .OfType() .Where(s => s.HasProviderId(MetadataProvider.Tmdb) && (s.Status != SeriesStatus.Ended || !IsEnabledForLibrary(s))) .ToList(); if (series.Count == 0) { progress.Report(100); return; } // ValidateChildren (rather than a bare RefreshMetadata) is required so the created episodes // are immediately visible. var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { MetadataRefreshMode = MetadataRefreshMode.Default, ImageRefreshMode = MetadataRefreshMode.ValidationOnly, IsAutomated = true }; for (var i = 0; i < series.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); try { await series[i].ValidateChildren(new Progress(), refreshOptions, cancellationToken: cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogError(ex, "Error refreshing upcoming episodes for series {SeriesName}", series[i].Name); } progress.Report(100.0 * (i + 1) / series.Count); } } private bool IsEnabledForLibrary(BaseItem item) { var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries; if (enabledLibraries is null || enabledLibraries.Length == 0) { return false; } // A series can live under more than one collection folder; opting in any one of them is // enough. An item that belongs to no collection folder cannot be opted in at all. return _libraryManager.GetCollectionFolders(item).Any(folder => enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); } /// /// Removes every virtual episode this provider created (identified by being virtual and carrying /// a TMDb id), plus any virtual season left without episodes as a result. Used when both import /// options are disabled so turning the feature off cleans up its placeholders. /// private void RemoveAllVirtualItems(IProgress progress, CancellationToken cancellationToken) { var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; var virtualEpisodes = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Episode], IsVirtualItem = true, HasTmdbId = true, Recursive = true }); for (var i = 0; i < virtualEpisodes.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); _logger.LogInformation("Removing virtual episode {Name}: the TMDb missing episode provider is disabled", virtualEpisodes[i].Name); _libraryManager.DeleteItem(virtualEpisodes[i], deleteOptions, false); progress.Report(95.0 * (i + 1) / virtualEpisodes.Count); } // Remove virtual seasons that are now empty (mirrors the cleanup an ordinary series refresh does). var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Season], IsVirtualItem = true, HasTmdbId = true, Recursive = true }); foreach (var season in virtualSeasons.OfType()) { cancellationToken.ThrowIfCancellationRequested(); if (season.GetEpisodes().Count == 0) { _libraryManager.DeleteItem(season, deleteOptions, false); } } progress.Report(100); } } }