diff options
8 files changed, 1029 insertions, 4 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index d6ddf8f5c8..1b02f2ae41 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -387,7 +387,8 @@ public sealed partial class BaseItemRepository var baseQuery = context.BaseItems .AsNoTracking() - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); return ApplyAccessFiltering(context, baseQuery, filter); } @@ -507,7 +508,7 @@ public sealed partial class BaseItemRepository var leafItems = context.BaseItems .AsNoTracking() - .Where(b => !b.IsFolder && !b.IsVirtualItem); + .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = ApplyAccessFiltering(context, leafItems, filter); var playedLeafItems = leafItems diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 604db9f839..3c7a96c78c 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -293,7 +293,8 @@ public class ItemCountService : IItemCountService var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId); var baseQuery = dbContext.BaseItems - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); @@ -354,7 +355,7 @@ public class ItemCountService : IItemCountService var userId = user.Id; var leafItems = dbContext.BaseItems - .Where(b => !b.IsFolder && !b.IsVirtualItem); + .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); var playedLeafItems = leafItems diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 78405c21fc..0ebeebf1e2 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1819 // Properties should not return arrays + using MediaBrowser.Model.Plugins; namespace MediaBrowser.Providers.Plugins.Tmdb @@ -34,6 +36,51 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public bool ImportSeasonName { get; set; } /// <summary> + /// Gets or sets a value indicating whether unaired (upcoming) episodes should be created as + /// virtual items from the episode list provided by TMDb. These populate the "Upcoming" view. + /// Enabling this will increase scan times. + /// </summary> + public bool ImportUnairedEpisodes { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether already aired episodes that are not present in the + /// library should be created as virtual items from the episode list provided by TMDb. These + /// surface as missing episodes. Enabling this will increase scan times. + /// </summary> + public bool ImportMissingEpisodes { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether specials (season 0) should be included when creating + /// virtual unaired or missing episodes. When disabled, specials are never added and any existing + /// virtual specials created by this provider are removed. + /// </summary> + public bool ImportSpecials { get; set; } + + /// <summary> + /// Gets or sets the ids (the "N" formatted GUIDs from <c>VirtualFolderInfo.ItemId</c>) of the + /// libraries for which the unaired/missing episode provider is disabled. Whether episodes are + /// imported at all, and how, is still controlled by the global toggles above; this list only + /// opts individual libraries out. Libraries not listed here are enabled, so the global toggles + /// apply to every library unless it is explicitly opted out. + /// </summary> + public string[] DisabledMissingEpisodeLibraries { get; set; } = []; + + /// <summary> + /// Gets or sets how often, in days, the scheduled task re-checks TMDb for newly announced + /// unaired or missing episodes. This is what keeps the "Upcoming" view current for series + /// whose local files have not changed. + /// </summary> + public int MissingEpisodeRefreshIntervalDays { get; set; } = 7; + + /// <summary> + /// Gets or sets the number of days a virtual episode is retained after it airs before it is + /// pruned (when missing episode import is disabled). This grace period leaves recently aired + /// episodes in place to allow for the delay between an episode airing and its file being added + /// to the library. + /// </summary> + public int UpcomingEpisodeGracePeriodDays { get; set; } = 7; + + /// <summary> /// Gets or sets a value indicating the maximum number of cast members to fetch for an item. /// </summary> public int MaxCastMembers { get; set; } = 15; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 4048fc1655..b010749936 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -25,6 +25,40 @@ <input is="emby-checkbox" type="checkbox" id="importSeasonName" /> <span>Import season name from metadata fetched for series.</span> </label> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importUnairedEpisodes" /> + <span>Create unaired (upcoming) episodes from metadata fetched for series.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have not aired yet. This populates the "Upcoming" view. Specials are never added to the "Upcoming" view.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importMissingEpisodes" /> + <span>Create missing episodes from metadata fetched for series.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have already aired but are not present in your library. Missing episodes are only shown when enabled in the user display preferences. Both options increase scan times.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="importSpecials" /> + <span>Include specials when creating unaired and missing episodes.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">When disabled, specials (season 0) are never added as virtual entries and any existing virtual specials are removed.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="number" id="missingEpisodeRefreshIntervalDays" pattern="[0-9]*" required min="1" max="365" label="Episode refresh interval (days)" /> + <div class="fieldDescription">How often the scheduled task re-checks TMDb for newly announced unaired or missing episodes. Keeps the "Upcoming" view current for series whose local files have not changed.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="number" id="upcomingEpisodeGracePeriodDays" pattern="[0-9]*" required min="0" max="365" label="Recently aired grace period (days)" /> + <div class="fieldDescription">When missing episodes are disabled, how many days a recently aired episode is kept in place before its placeholder is removed. This allows for the delay between an episode airing and its file being added to the library.</div> + </div> + <div class="verticalSection"> + <h2>Libraries</h2> + <div class="fieldDescription" style="margin-bottom:1em;">Choose which TV libraries the unaired/missing episode options above apply to. The settings above are global; this only controls which libraries they run for. New libraries are enabled by default.</div> + <div id="missingEpisodeLibraries"></div> + </div> <div class="verticalSection"> <h2>Cast & Crew Settings</h2> <div class="inputContainer"> @@ -85,6 +119,29 @@ Dashboard.showLoadingMsg(); var clientConfig, pluginConfig; + var populateMissingEpisodeLibraries = function (disabledLibraries) { + var container = document.querySelector('#missingEpisodeLibraries'); + ApiClient.getVirtualFolders().then(function (folders) { + // Series only live in TV libraries (and mixed-content libraries, which report + // no collection type), so only those are worth listing here. + var tvLibraries = folders.filter(function (folder) { + return !folder.CollectionType || folder.CollectionType === 'tvshows'; + }); + + if (tvLibraries.length === 0) { + container.innerHTML = '<div class="fieldDescription">No TV libraries found.</div>'; + return; + } + + container.innerHTML = tvLibraries.map(function (folder) { + var checked = disabledLibraries.indexOf(folder.ItemId) === -1 ? ' checked' : ''; + return '<label class="checkboxContainer">' + + '<input is="emby-checkbox" type="checkbox" class="missingEpisodeLibrary" data-library-id="' + folder.ItemId + '"' + checked + ' />' + + '<span>' + folder.Name + '</span>' + + '</label>'; + }).join(''); + }); + } var configureImageScaling = function() { if (clientConfig === undefined || pluginConfig === undefined) { return; @@ -151,9 +208,16 @@ document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries; document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies; document.querySelector('#importSeasonName').checked = config.ImportSeasonName; + document.querySelector('#importUnairedEpisodes').checked = config.ImportUnairedEpisodes; + document.querySelector('#importMissingEpisodes').checked = config.ImportMissingEpisodes; + document.querySelector('#importSpecials').checked = config.ImportSpecials; + document.querySelector('#missingEpisodeRefreshIntervalDays').value = config.MissingEpisodeRefreshIntervalDays; + document.querySelector('#upcomingEpisodeGracePeriodDays').value = config.UpcomingEpisodeGracePeriodDays; document.querySelector('#hideMissingCastMembers').checked = config.HideMissingCastMembers; document.querySelector('#hideMissingCrewMembers').checked = config.HideMissingCrewMembers; + populateMissingEpisodeLibraries(config.DisabledMissingEpisodeLibraries || []); + var maxCastMembers = document.querySelector('#maxCastMembers'); maxCastMembers.value = config.MaxCastMembers; maxCastMembers.dispatchEvent(new Event('change', { @@ -189,6 +253,14 @@ config.ExcludeTagsSeries = document.querySelector('#excludeTagsSeries').checked; config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked; config.ImportSeasonName = document.querySelector('#importSeasonName').checked; + config.ImportUnairedEpisodes = document.querySelector('#importUnairedEpisodes').checked; + config.ImportMissingEpisodes = document.querySelector('#importMissingEpisodes').checked; + config.ImportSpecials = document.querySelector('#importSpecials').checked; + config.MissingEpisodeRefreshIntervalDays = parseInt(document.querySelector('#missingEpisodeRefreshIntervalDays').value, 10); + config.UpcomingEpisodeGracePeriodDays = parseInt(document.querySelector('#upcomingEpisodeGracePeriodDays').value, 10); + config.DisabledMissingEpisodeLibraries = Array.prototype.map.call( + document.querySelectorAll('.missingEpisodeLibrary:not(:checked)'), + function (checkbox) { return checkbox.getAttribute('data-library-id'); }); config.MaxCastMembers = document.querySelector('#maxCastMembers').value; config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value; config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs new file mode 100644 index 0000000000..2b5229a0ab --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs @@ -0,0 +1,489 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +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 Microsoft.Extensions.Logging; +using TMDbLib.Objects.Search; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// <summary> + /// Creates virtual (metadata-only) entries for missing and unaired episodes. + /// </summary> + public class TmdbMissingEpisodeProvider : ICustomMetadataProvider<Series>, IHasItemChangeMonitor, IHasOrder + { + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger<TmdbMissingEpisodeProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbMissingEpisodeProvider"/> class. + /// </summary> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="logger">The <see cref="ILogger{TmdbMissingEpisodeProvider}"/>.</param> + public TmdbMissingEpisodeProvider( + TmdbClientManager tmdbClientManager, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger<TmdbMissingEpisodeProvider> logger) + { + _tmdbClientManager = tmdbClientManager; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc /> + // Run after the remote series provider so the TMDb id and other metadata are available. + public int Order => 100; + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + // Reporting a change makes this provider (and only this provider) run during an otherwise incremental refresh. + if (Plugin.Instance?.Configuration is null) + { + return false; + } + + return item is Series series && series.HasProviderId(MetadataProvider.Tmdb); + } + + /// <inheritdoc /> + public async Task<ItemUpdateType> FetchAsync(Series item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var configuration = Plugin.Instance?.Configuration; + var importUnaired = (configuration?.ImportUnairedEpisodes).GetValueOrDefault(); + var importMissing = (configuration?.ImportMissingEpisodes).GetValueOrDefault(); + + // The provider is inactive for this series when both global imports are off, or the series' + // library has been opted out. In either case remove every virtual episode (unaired and missing + // alike) it previously created, so disabling the feature cleans up on the next library scan. + if ((!importUnaired && !importMissing) || !IsEnabledForLibrary(item)) + { + if (!PruneAllVirtualEpisodes(item)) + { + return ItemUpdateType.None; + } + + item.Children = null; + return ItemUpdateType.MetadataImport; + } + + var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); + if (string.IsNullOrEmpty(tmdbId) + || !int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seriesTmdbId) + || seriesTmdbId <= 0) + { + return ItemUpdateType.None; + } + + var language = item.GetPreferredMetadataLanguage(); + var countryCode = item.GetPreferredMetadataCountryCode(); + var imageLanguages = TmdbUtils.GetImageLanguagesParam(language, countryCode); + + var tmdbSeries = await _tmdbClientManager + .GetSeriesAsync(seriesTmdbId, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeries?.Seasons is null) + { + return ItemUpdateType.None; + } + + var today = DateTime.UtcNow.Date; + + var importSpecials = (configuration?.ImportSpecials).GetValueOrDefault(); + var gracePeriodDays = Math.Max(0, (configuration?.UpcomingEpisodeGracePeriodDays).GetValueOrDefault()); + + // Track every (season, episode) number that already exists (physical or virtual) so we never + // create a duplicate. + // When missing episodes are disabled, this pass also prunes virtual episodes that aired more + // than the grace period ago, as well as any specials when specials are not wanted. + var (existingEpisodes, updatableEpisodes) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes); + + var seasonsByNumber = item.GetRecursiveChildren(i => i is Season) + .OfType<Season>() + .Where(s => s.IndexNumber.HasValue) + .GroupBy(s => s.IndexNumber!.Value) + .ToDictionary(g => g.Key, g => g.First()); + + var addedEpisodes = false; + var updatedEpisodes = false; + + foreach (var seasonInfo in tmdbSeries.Seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + var seasonNumber = seasonInfo.SeasonNumber; + var tmdbSeason = await _tmdbClientManager + .GetSeasonAsync(seriesTmdbId, seasonNumber, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeason?.Episodes is null) + { + continue; + } + + foreach (var tmdbEpisode in tmdbSeason.Episodes) + { + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + var premiereDate = GetPremiereDate(tmdbEpisode); + + // Skips undated episodes, unaired (upcoming) ones unless upcoming import is enabled, + // already aired ones unless missing import is enabled, and unaired specials entirely. + if (!ShouldImportEpisode(premiereDate, today, importUnaired, importMissing, seasonNumber == 0, importSpecials)) + { + continue; + } + + var key = (seasonNumber, episodeNumber); + + // Already have a virtual episode this provider created, keep metadata in sync with TMDb. + if (updatableEpisodes.TryGetValue(key, out var existingEpisode)) + { + var season = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); + var changed = UpdateVirtualEpisode(existingEpisode, tmdbEpisode, premiereDate); + + if (!existingEpisode.ParentId.Equals(season.Id)) + { + existingEpisode.SetParent(season); + existingEpisode.SeasonId = season.Id; + existingEpisode.SeasonName = season.Name; + changed = true; + } + + if (string.IsNullOrEmpty(existingEpisode.PresentationUniqueKey)) + { + existingEpisode.PresentationUniqueKey = existingEpisode.CreatePresentationUniqueKey(); + changed = true; + } + + if (changed) + { + await existingEpisode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + updatedEpisodes = true; + } + + continue; + } + + if (!existingEpisodes.Add(key)) + { + continue; + } + + var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); + AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); + addedEpisodes = true; + } + } + + if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes) + { + return ItemUpdateType.None; + } + + // Invalidate the cached children so that the season creation / cleanup that runs later in + // SeriesMetadataService.AfterMetadataRefresh observes the newly created (and pruned) episodes. + item.Children = null; + + return ItemUpdateType.MetadataImport; + } + + /// <summary> + /// Returns the series' season with the given number, creating (and refreshing) a virtual season + /// when the whole season is missing from the library. + /// </summary> + private async Task<Season> GetOrCreateSeasonAsync(Series series, int seasonNumber, string? seasonName, Dictionary<int, Season> seasonsByNumber, CancellationToken cancellationToken) + { + if (seasonsByNumber.TryGetValue(seasonNumber, out var existingSeason)) + { + return existingSeason; + } + + _logger.LogInformation("Creating virtual season {SeasonNumber} for series {SeriesName}", seasonNumber, series.Name); + + var season = new Season + { + Name = seasonName, + IndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture), + typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + series.AddChild(season); + await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false); + + seasonsByNumber[seasonNumber] = season; + return season; + } + + private bool IsEnabledForLibrary(BaseItem item) + { + var disabledLibraries = Plugin.Instance?.Configuration.DisabledMissingEpisodeLibraries; + if (disabledLibraries is null || disabledLibraries.Length == 0) + { + return true; + } + + // A series can live under more than one collection folder; treat it as disabled only when + // every containing library is opted out. + var collectionFolders = _libraryManager.GetCollectionFolders(item); + if (collectionFolders.Count == 0) + { + return true; + } + + return collectionFolders.Any(folder => + !disabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); + } + + private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned) + { + var keys = new HashSet<(int Season, int Episode)>(); + var updatable = new Dictionary<(int Season, int Episode), Episode>(); + pruned = false; + + // Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes' + // SeriesPresentationUniqueKey is not set yet, so the presentation-key based query would miss + // them. GetRecursiveChildren walks the actual child tree and sees them regardless. + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>()) + { + // The series is refreshed before its episodes during an initial scan, so a freshly + // resolved physical episode may not have its numbers populated yet. Resolve them from + // the path (in memory, mirroring CreateSeasonsAsync) so we can dedupe against episodes + // the user actually has files for instead of creating virtual duplicates. + if (episode.IsFileProtocol && (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue)) + { + try + { + _libraryManager.FillMissingEpisodeNumbersFromPath(episode, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resolving episode number from path for {Path}", episode.Path); + } + } + + // Virtual episodes this provider created are candidates for metadata sync (and pruning). + var isOurs = episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb); + + if (ShouldPrune(episode, pruneAgedOut, today, gracePeriodDays, importSpecials)) + { + DeleteEpisode(episode, "no longer upcoming and missing episodes are disabled"); + pruned = true; + continue; + } + + if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue) + { + var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value); + keys.Add(key); + + // Virtual episodes this provider created are candidates for metadata sync. + if (isOurs) + { + updatable[key] = episode; + } + } + } + + return (keys, updatable); + } + + /// <summary> + /// Removes every virtual episode this provider previously created in the series. + /// </summary> + /// <param name="series">The series to clean up.</param> + /// <returns><c>true</c> if any episode was removed; otherwise <c>false</c>.</returns> + private bool PruneAllVirtualEpisodes(Series series) + { + var pruned = false; + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>()) + { + if (episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb)) + { + DeleteEpisode(episode, "the TMDb missing episode provider is disabled for this library"); + pruned = true; + } + } + + return pruned; + } + + private void DeleteEpisode(Episode episode, string reason) + { + _logger.LogInformation( + "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}", + episode.ParentIndexNumber, + episode.IndexNumber, + episode.SeriesName, + reason); + + _libraryManager.DeleteItem( + episode, + new DeleteOptions { DeleteFileLocation = false }, + false); + } + + /// <summary> + /// Determines whether a TMDb episode should be imported as a virtual item, based on its air date + /// and the enabled options. Undated episodes are never imported; unaired (today or later) episodes + /// require <paramref name="importUnaired"/>; already aired episodes require <paramref name="importMissing"/>. + /// Specials (season 0) are only imported when <paramref name="importSpecials"/> is enabled. + /// </summary> + /// <param name="premiereDate">The episode air date (UTC), or null if unknown.</param> + /// <param name="today">The current UTC date.</param> + /// <param name="importUnaired">Whether unaired (upcoming) episodes should be imported.</param> + /// <param name="importMissing">Whether already aired missing episodes should be imported.</param> + /// <param name="isSpecial">Whether the episode belongs to the specials season (season 0).</param> + /// <param name="importSpecials">Whether specials should be included.</param> + /// <returns><c>true</c> if the episode should be imported; otherwise <c>false</c>.</returns> + internal static bool ShouldImportEpisode(DateTime? premiereDate, DateTime today, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials) + { + if (!premiereDate.HasValue) + { + return false; + } + + // Specials are only imported when the user opts in. + if (isSpecial && !importSpecials) + { + return false; + } + + var isUnaired = premiereDate.Value.Date >= today; + return isUnaired ? importUnaired : importMissing; + } + + /// <summary> + /// Determines whether an existing virtual episode created by this provider (carries a TMDb id) + /// should be pruned. Specials are removed entirely unless <paramref name="importSpecials"/> is + /// enabled. Otherwise, when missing episodes are not wanted, an entry is pruned once its air date + /// is more than <paramref name="gracePeriodDays"/> in the past; the grace period keeps recently + /// aired episodes in place to allow for the delay between an episode airing and its file being + /// added to the library. + /// </summary> + /// <param name="episode">The episode to evaluate.</param> + /// <param name="pruneAgedOut">Whether aged-out virtual episodes should be pruned (missing import disabled).</param> + /// <param name="today">The current UTC date.</param> + /// <param name="gracePeriodDays">The number of days an aired episode is retained before pruning.</param> + /// <param name="importSpecials">Whether specials should be kept.</param> + /// <returns><c>true</c> if the episode should be pruned; otherwise <c>false</c>.</returns> + internal static bool ShouldPrune(Episode episode, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials) + { + if (!episode.IsVirtualItem || !episode.HasProviderId(MetadataProvider.Tmdb)) + { + return false; + } + + // Specials are removed entirely unless the user opts in. + if (episode.ParentIndexNumber == 0 && !importSpecials) + { + return true; + } + + // When missing episodes are not wanted, prune placeholders for episodes that aired more than + // the grace period ago. + return pruneAgedOut + && episode.PremiereDate.HasValue + && episode.PremiereDate.Value.Date < today.AddDays(-gracePeriodDays); + } + + internal static DateTime? GetPremiereDate(TvSeasonEpisode tmdbEpisode) + { + return tmdbEpisode.AirDate.HasValue + ? DateTime.SpecifyKind(tmdbEpisode.AirDate.Value, DateTimeKind.Local).ToUniversalTime() + : null; + } + + internal static bool UpdateVirtualEpisode(Episode episode, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var changed = false; + + if (!string.IsNullOrEmpty(tmdbEpisode.Name) && !string.Equals(episode.Name, tmdbEpisode.Name, StringComparison.Ordinal)) + { + episode.Name = tmdbEpisode.Name; + changed = true; + } + + if (!string.IsNullOrEmpty(tmdbEpisode.Overview) && !string.Equals(episode.Overview, tmdbEpisode.Overview, StringComparison.Ordinal)) + { + episode.Overview = tmdbEpisode.Overview; + changed = true; + } + + if (premiereDate.HasValue && episode.PremiereDate != premiereDate) + { + episode.PremiereDate = premiereDate; + episode.ProductionYear = tmdbEpisode.AirDate?.Year; + changed = true; + } + + return changed; + } + + private void AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var seasonNumber = season.IndexNumber.GetValueOrDefault(); + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + + // Leaving Path unset makes the item a virtual (metadata-only) episode. + var episode = new Episode + { + Name = tmdbEpisode.Name, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture) + + "Episode" + episodeNumber.ToString(CultureInfo.InvariantCulture), + typeof(Episode)), + IsVirtualItem = true, + PremiereDate = premiereDate, + ProductionYear = tmdbEpisode.AirDate?.Year, + Overview = tmdbEpisode.Overview, + SeasonId = season.Id, + SeasonName = season.Name, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + episode.PresentationUniqueKey = episode.CreatePresentationUniqueKey(); + + if (tmdbEpisode.Id > 0) + { + episode.SetProviderId(MetadataProvider.Tmdb, tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture)); + } + + _logger.LogInformation( + "Creating virtual episode S{SeasonNumber}E{EpisodeNumber} for series {SeriesName}", + seasonNumber, + episodeNumber, + series.Name); + + season.AddChild(episode); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs new file mode 100644 index 0000000000..0fb7215f27 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs @@ -0,0 +1,213 @@ +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 +{ + /// <summary> + /// 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. + /// </summary> + public class TmdbUpcomingEpisodesTask : IScheduledTask + { + private const int DefaultIntervalDays = 7; + + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger<TmdbUpcomingEpisodesTask> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbUpcomingEpisodesTask"/> class. + /// </summary> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="logger">The <see cref="ILogger{TmdbUpcomingEpisodesTask}"/>.</param> + public TmdbUpcomingEpisodesTask( + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger<TmdbUpcomingEpisodesTask> logger) + { + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _logger = logger; + } + + /// <inheritdoc /> + public string Name => "Refresh upcoming and missing episodes (TheMovieDb)"; + + /// <inheritdoc /> + 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."; + + /// <inheritdoc /> + public string Category => "Library"; + + /// <inheritdoc /> + public string Key => "TmdbRefreshUpcomingEpisodes"; + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> 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 + }; + } + + /// <inheritdoc /> + public async Task ExecuteAsync(IProgress<double> 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) + { + RemoveAllVirtualItems(progress, cancellationToken); + return; + } + + // Process non-ended series (they may have gained episodes) plus any series in a library that + // has been opted out (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<Series>() + .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<double>(), 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 disabledLibraries = Plugin.Instance?.Configuration.DisabledMissingEpisodeLibraries; + if (disabledLibraries is null || disabledLibraries.Length == 0) + { + return true; + } + + // A series can live under more than one collection folder; treat it as disabled only when + // every containing library is opted out. + var collectionFolders = _libraryManager.GetCollectionFolders(item); + if (collectionFolders.Count == 0) + { + return true; + } + + return collectionFolders.Any(folder => + !disabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); + } + + /// <summary> + /// 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. + /// </summary> + private void RemoveAllVirtualItems(IProgress<double> 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). + // Seasons created by this provider carry a TVDB id (from TMDb's external ids), not a TMDb id, + // so they cannot be filtered by HasTmdbId; any virtual season left without episodes is obsolete. + var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season], + IsVirtualItem = true, + Recursive = true + }); + + foreach (var season in virtualSeasons.OfType<Season>()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (season.GetEpisodes().Count == 0) + { + _libraryManager.DeleteItem(season, deleteOptions, false); + } + } + + progress.Report(100); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 88a2c684ff..bfd0fac34a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.MatchCriteria; @@ -13,6 +14,14 @@ namespace Jellyfin.Database.Implementations; public static class DescendantQueryHelper { /// <summary> + /// Gets the predicate identifying items that count toward played/total aggregation: + /// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes). + /// Shared by the per-item and batched count paths so they cannot diverge. + /// </summary> + public static Expression<Func<BaseItemEntity, bool>> IsCountableLeaf { get; } = + b => !b.IsFolder && !b.IsVirtualItem; + + /// <summary> /// Gets a queryable of all descendant IDs for a parent item. /// Traverses AncestorIds and LinkedChildren to find all descendants. /// </summary> diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs new file mode 100644 index 0000000000..f4b7bb5b75 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs @@ -0,0 +1,193 @@ +using System; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb.TV; +using TMDbLib.Objects.Search; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb; + +public class TmdbMissingEpisodeProviderTests +{ + private static readonly DateTime _today = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc); + + [Theory] + // No air date -> never imported, regardless of options. + [InlineData(null, true, true, false, false, false)] + [InlineData(null, false, false, false, false, false)] + // Future (unaired) episodes are gated by the unaired option. + [InlineData(5, true, false, false, false, true)] + [InlineData(5, false, false, false, false, false)] + [InlineData(5, false, true, false, false, false)] + // Today counts as unaired. + [InlineData(0, true, false, false, false, true)] + [InlineData(0, false, false, false, false, false)] + // Past (already aired) episodes are gated by the missing option. + [InlineData(-5, false, true, false, false, true)] + [InlineData(-5, false, false, false, false, false)] + [InlineData(-5, true, false, false, false, false)] + // Specials are never imported when the specials option is off, regardless of air date. + [InlineData(5, true, false, true, false, false)] + [InlineData(-5, false, true, true, false, false)] + // Specials follow the normal air-date gating when the specials option is on. + [InlineData(5, true, false, true, true, true)] + [InlineData(5, false, false, true, true, false)] + [InlineData(-5, false, true, true, true, true)] + [InlineData(-5, false, false, true, true, false)] + public void ShouldImportEpisode_RespectsAirDateAndOptions(int? dayOffset, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials, bool expected) + { + DateTime? premiere = dayOffset.HasValue ? _today.AddDays(dayOffset.Value) : null; + + Assert.Equal(expected, TmdbMissingEpisodeProvider.ShouldImportEpisode(premiere, _today, importUnaired, importMissing, isSpecial, importSpecials)); + } + + [Fact] + public void ShouldPrune_AgedOutVirtualTmdbEpisode_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_NotInPruningMode_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_StillUpcoming_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_VirtualEpisodeFromAnotherProvider_ReturnsFalse() + { + // No TMDb id -> not created by this provider (e.g. a TheTVDB plugin entry) -> left untouched. + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: false); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_PhysicalEpisode_ReturnsFalse() + { + var episode = new Episode { Path = "/media/show/Season 01/s01e01.mkv", PremiereDate = _today.AddDays(-1) }; + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredWithinGracePeriod_ReturnsFalse() + { + // Aired two days ago but the grace period keeps it around for the file to be added. + var episode = VirtualEpisode(_today.AddDays(-2), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredBeyondGracePeriod_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-10), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsDisabled_ReturnsTrue() + { + // Specials are removed entirely when the specials option is off, even when not in pruning mode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 7, importSpecials: false)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsEnabled_FollowsNormalRules() + { + // With specials enabled, an upcoming special is kept like any other upcoming episode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void GetPremiereDate_NullAirDate_ReturnsNull() + { + Assert.Null(TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = null })); + } + + [Fact] + public void GetPremiereDate_AirDate_ReturnsUtc() + { + var airDate = new DateTime(2026, 7, 28); + + var result = TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = airDate }); + + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result!.Value.Kind); + Assert.Equal(DateTime.SpecifyKind(airDate, DateTimeKind.Local).ToUniversalTime(), result.Value); + } + + [Fact] + public void UpdateVirtualEpisode_PlaceholderTitleReplaced_UpdatesAndReturnsTrue() + { + var episode = new Episode { Name = "Episode 14" }; + var tmdbEpisode = new TvSeasonEpisode { Name = "The Real Title" }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("The Real Title", episode.Name); + } + + [Fact] + public void UpdateVirtualEpisode_NoChanges_ReturnsFalse() + { + var date = _today; + var episode = new Episode { Name = "Same", Overview = "Description", PremiereDate = date }; + var tmdbEpisode = new TvSeasonEpisode { Name = "Same", Overview = "Description" }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, date)); + } + + [Fact] + public void UpdateVirtualEpisode_EmptyTmdbValues_DoNotOverwrite() + { + var episode = new Episode { Name = "Existing", Overview = "Existing overview" }; + var tmdbEpisode = new TvSeasonEpisode { Name = string.Empty, Overview = null }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("Existing", episode.Name); + Assert.Equal("Existing overview", episode.Overview); + } + + [Fact] + public void UpdateVirtualEpisode_RescheduledAirDate_UpdatesPremiereAndYear() + { + var episode = new Episode { Name = "X", PremiereDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }; + var newAirDate = new DateTime(2026, 8, 15); + var newPremiere = DateTime.SpecifyKind(newAirDate, DateTimeKind.Local).ToUniversalTime(); + var tmdbEpisode = new TvSeasonEpisode { Name = "X", AirDate = newAirDate }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, newPremiere)); + Assert.Equal(newPremiere, episode.PremiereDate); + Assert.Equal(2026, episode.ProductionYear); + } + + private static Episode VirtualEpisode(DateTime premiereDate, bool withTmdbId, int? seasonNumber = null) + { + var episode = new Episode { PremiereDate = premiereDate, IsVirtualItem = true, ParentIndexNumber = seasonNumber }; + if (withTmdbId) + { + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + } + + return episode; + } +} |
