< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.Plugins.Tmdb.TV.TmdbMissingEpisodeProvider
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs
Line coverage
18%
Covered lines: 52
Uncovered lines: 230
Coverable lines: 282
Total lines: 687
Line coverage: 18.4%
Branch coverage
26%
Covered branches: 48
Total branches: 184
Branch coverage: 26%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 8/3/2026 - 12:16:46 AM Line coverage: 19% (52/273) Branch coverage: 27.2% (48/176) Total lines: 66310/29/2026 - 12:17:11 AM Line coverage: 18.4% (52/282) Branch coverage: 26% (48/184) Total lines: 687 8/3/2026 - 12:16:46 AM Line coverage: 19% (52/273) Branch coverage: 27.2% (48/176) Total lines: 66310/29/2026 - 12:17:11 AM Line coverage: 18.4% (52/282) Branch coverage: 26% (48/184) Total lines: 687

Coverage delta

Coverage delta 2 -2

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Name()100%210%
get_Order()100%210%
HasChanged(...)0%4260%
FetchAsync()0%3906620%
GetOrCreateSeasonAsync()0%620%
AlignVirtualSeasonSortNamesAsync()0%110100%
BuildSeasonSortNameTemplate(...)100%1212100%
IsEnabledForLibrary(...)0%4260%
GetExistingEpisodes(...)0%930300%
PruneAllVirtualEpisodes(...)0%4260%
DeleteEpisode(...)100%210%
ShouldImportEpisode(...)100%88100%
ShouldPrune(...)100%1212100%
GetPremiereDate(...)100%22100%
UpdateVirtualEpisode(...)77.77%201883.33%
AddVirtualEpisode(...)0%2040%
EnsureEpisodeImageAsync()0%4260%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using MediaBrowser.Controller.Entities;
 8using MediaBrowser.Controller.Entities.TV;
 9using MediaBrowser.Controller.Library;
 10using MediaBrowser.Controller.Providers;
 11using MediaBrowser.Model.Entities;
 12using MediaBrowser.Model.IO;
 13using Microsoft.Extensions.Logging;
 14using TMDbLib.Objects.Search;
 15
 16namespace MediaBrowser.Providers.Plugins.Tmdb.TV
 17{
 18    /// <summary>
 19    /// Creates virtual (metadata-only) entries for missing and unaired episodes.
 20    /// </summary>
 21    public class TmdbMissingEpisodeProvider : ICustomMetadataProvider<Series>, IHasItemChangeMonitor, IHasOrder
 22    {
 23        private readonly TmdbClientManager _tmdbClientManager;
 24        private readonly ILibraryManager _libraryManager;
 25        private readonly IFileSystem _fileSystem;
 26        private readonly IProviderManager _providerManager;
 27        private readonly ILogger<TmdbMissingEpisodeProvider> _logger;
 28
 29        /// <summary>
 30        /// Initializes a new instance of the <see cref="TmdbMissingEpisodeProvider"/> class.
 31        /// </summary>
 32        /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param>
 33        /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
 34        /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
 35        /// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
 36        /// <param name="logger">The <see cref="ILogger{TmdbMissingEpisodeProvider}"/>.</param>
 37        public TmdbMissingEpisodeProvider(
 38            TmdbClientManager tmdbClientManager,
 39            ILibraryManager libraryManager,
 40            IFileSystem fileSystem,
 41            IProviderManager providerManager,
 42            ILogger<TmdbMissingEpisodeProvider> logger)
 43        {
 2744            _tmdbClientManager = tmdbClientManager;
 2745            _libraryManager = libraryManager;
 2746            _fileSystem = fileSystem;
 2747            _providerManager = providerManager;
 2748            _logger = logger;
 2749        }
 50
 51        /// <inheritdoc />
 052        public string Name => TmdbUtils.ProviderName;
 53
 54        /// <inheritdoc />
 55        // Run after the remote series provider so the TMDb id and other metadata are available.
 056        public int Order => 100;
 57
 58        /// <inheritdoc />
 59        public bool HasChanged(BaseItem item, IDirectoryService directoryService)
 60        {
 61            // Reporting a change makes this provider (and only this provider) run during an otherwise incremental refre
 062            if (Plugin.Instance?.Configuration is null)
 63            {
 064                return false;
 65            }
 66
 067            return item is Series series && series.HasProviderId(MetadataProvider.Tmdb);
 68        }
 69
 70        /// <inheritdoc />
 71        public async Task<ItemUpdateType> FetchAsync(Series item, MetadataRefreshOptions options, CancellationToken canc
 72        {
 073            var configuration = Plugin.Instance?.Configuration;
 074            var importUnaired = (configuration?.ImportUnairedEpisodes).GetValueOrDefault();
 075            var importMissing = (configuration?.ImportMissingEpisodes).GetValueOrDefault();
 76
 77            // The provider is inactive for this series when both global imports are off, or the series'
 78            // library has not been opted in. In either case remove every virtual episode (unaired and
 79            // missing alike) it previously created, so disabling the feature cleans up on the next scan.
 080            if ((!importUnaired && !importMissing) || !IsEnabledForLibrary(item))
 81            {
 082                if (!PruneAllVirtualEpisodes(item))
 83                {
 084                    return ItemUpdateType.None;
 85                }
 86
 087                item.Children = null;
 088                return ItemUpdateType.MetadataImport;
 89            }
 90
 091            var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
 092            if (string.IsNullOrEmpty(tmdbId)
 093                || !int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seriesTmdbId)
 094                || seriesTmdbId <= 0)
 95            {
 096                return ItemUpdateType.None;
 97            }
 98
 099            var language = item.GetPreferredMetadataLanguage();
 0100            var countryCode = item.GetPreferredMetadataCountryCode();
 0101            var imageLanguages = TmdbUtils.GetImageLanguagesParam(language, countryCode);
 102
 0103            var tmdbSeries = await _tmdbClientManager
 0104                .GetSeriesAsync(seriesTmdbId, language, imageLanguages, countryCode, cancellationToken)
 0105                .ConfigureAwait(false);
 106
 0107            if (tmdbSeries?.Seasons is null)
 108            {
 0109                return ItemUpdateType.None;
 110            }
 111
 0112            var today = DateTime.UtcNow.Date;
 113
 0114            var importSpecials = (configuration?.ImportSpecials).GetValueOrDefault();
 0115            var gracePeriodDays = Math.Max(0, (configuration?.UpcomingEpisodeGracePeriodDays).GetValueOrDefault());
 116
 117            // Track every (season, episode) number that already exists (physical or virtual) so we never
 118            // create a duplicate.
 119            // When missing episodes are disabled, this pass also prunes virtual episodes that aired more
 120            // than the grace period ago, as well as any specials when specials are not wanted.
 0121            var (existingEpisodes, updatableEpisodes, physicalTmdbIds) = GetExistingEpisodes(item, !importMissing, today
 122
 0123            var seasonsByNumber = item.GetRecursiveChildren(i => i is Season)
 0124                .OfType<Season>()
 0125                .Where(s => s.IndexNumber.HasValue)
 0126                .GroupBy(s => s.IndexNumber!.Value)
 0127                .ToDictionary(g => g.Key, g => g.First());
 128
 0129            var addedEpisodes = false;
 0130            var updatedEpisodes = false;
 131
 0132            foreach (var seasonInfo in tmdbSeries.Seasons)
 133            {
 0134                cancellationToken.ThrowIfCancellationRequested();
 135
 0136                var seasonNumber = seasonInfo.SeasonNumber;
 0137                var tmdbSeason = await _tmdbClientManager
 0138                    .GetSeasonAsync(seriesTmdbId, seasonNumber, language, imageLanguages, countryCode, cancellationToken
 0139                    .ConfigureAwait(false);
 140
 0141                if (tmdbSeason?.Episodes is null)
 142                {
 143                    continue;
 144                }
 145
 0146                foreach (var tmdbEpisode in tmdbSeason.Episodes)
 147                {
 0148                    var episodeNumber = (int)tmdbEpisode.EpisodeNumber;
 0149                    var premiereDate = GetPremiereDate(tmdbEpisode);
 150
 151                    // Skips undated episodes, unaired (upcoming) ones unless upcoming import is enabled,
 152                    // already aired ones unless missing import is enabled, and unaired specials entirely.
 0153                    if (!ShouldImportEpisode(premiereDate, today, importUnaired, importMissing, seasonNumber == 0, impor
 154                    {
 155                        continue;
 156                    }
 157
 0158                    var key = (seasonNumber, episodeNumber);
 159
 160                    // Already have a virtual episode this provider created, keep metadata in sync with TMDb.
 0161                    if (updatableEpisodes.TryGetValue(key, out var existingEpisode))
 162                    {
 0163                        var season = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, 
 0164                        var changed = UpdateVirtualEpisode(existingEpisode, tmdbEpisode, premiereDate);
 165
 0166                        if (!existingEpisode.ParentId.Equals(season.Id))
 167                        {
 0168                            existingEpisode.SetParent(season);
 0169                            existingEpisode.SeasonId = season.Id;
 0170                            existingEpisode.SeasonName = season.Name;
 0171                            changed = true;
 172                        }
 173
 0174                        if (string.IsNullOrEmpty(existingEpisode.PresentationUniqueKey))
 175                        {
 0176                            existingEpisode.PresentationUniqueKey = existingEpisode.CreatePresentationUniqueKey();
 0177                            changed = true;
 178                        }
 179
 0180                        if (changed)
 181                        {
 0182                            await existingEpisode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationTok
 0183                            updatedEpisodes = true;
 184                        }
 185
 186                        // Backfill the still for placeholders created before images were fetched.
 0187                        if (await EnsureEpisodeImageAsync(existingEpisode, tmdbEpisode, cancellationToken).ConfigureAwai
 188                        {
 0189                            updatedEpisodes = true;
 190                        }
 191
 0192                        continue;
 193                    }
 194
 0195                    if (!existingEpisodes.Add(key))
 196                    {
 197                        continue;
 198                    }
 199
 200                    // The slot is free, but the episode itself may not be: when the files follow a different
 201                    // order, the same episode sits at another number and creating this one would duplicate it.
 0202                    if (physicalTmdbIds.Contains(tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture)))
 203                    {
 204                        continue;
 205                    }
 206
 0207                    var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber
 0208                    var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate);
 0209                    await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false);
 0210                    addedEpisodes = true;
 0211                }
 0212            }
 213
 0214            var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).Confi
 215
 0216            if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons)
 217            {
 0218                return ItemUpdateType.None;
 219            }
 220
 221            // Invalidate the cached children so that the season creation / cleanup that runs later in
 222            // SeriesMetadataService.AfterMetadataRefresh observes the newly created (and pruned) episodes.
 0223            item.Children = null;
 224
 0225            return ItemUpdateType.MetadataImport;
 0226        }
 227
 228        /// <summary>
 229        /// Returns the series' season with the given number, creating (and refreshing) a virtual season
 230        /// when the whole season is missing from the library.
 231        /// </summary>
 232        private async Task<Season> GetOrCreateSeasonAsync(Series series, int seasonNumber, string? seasonName, Dictionar
 233        {
 0234            if (seasonsByNumber.TryGetValue(seasonNumber, out var existingSeason))
 235            {
 0236                return existingSeason;
 237            }
 238
 0239            _logger.LogInformation("Creating virtual season {SeasonNumber} for series {SeriesName}", seasonNumber, serie
 240
 0241            var season = new Season
 0242            {
 0243                Name = seasonName,
 0244                IndexNumber = seasonNumber,
 0245                Id = _libraryManager.GetNewItemId(
 0246                    series.Id.ToString("N", CultureInfo.InvariantCulture) + "Season" + seasonNumber.ToString(CultureInfo
 0247                    typeof(Season)),
 0248                IsVirtualItem = true,
 0249                SeriesId = series.Id,
 0250                SeriesName = series.Name,
 0251                SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 0252            };
 253
 0254            series.AddChild(season);
 0255            await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToke
 256
 0257            seasonsByNumber[seasonNumber] = season;
 0258            return season;
 0259        }
 260
 261        /// <summary>
 262        /// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by
 263        /// number instead of jumping ahead. See <see cref="BuildSeasonSortNameTemplate"/> for the details.
 264        /// </summary>
 265        /// <param name="seasons">The series' seasons (physical and virtual).</param>
 266        /// <param name="cancellationToken">The cancellation token.</param>
 267        /// <returns><c>true</c> if any virtual season was updated; otherwise <c>false</c>.</returns>
 268        private async Task<bool> AlignVirtualSeasonSortNamesAsync(IEnumerable<Season> seasons, CancellationToken cancell
 269        {
 0270            var seasonList = seasons.ToList();
 0271            var template = BuildSeasonSortNameTemplate(seasonList);
 0272            if (template is null)
 273            {
 274                // No physical season sorts by name: virtual seasons already share the bare-index key space.
 0275                return false;
 276            }
 277
 0278            var updated = false;
 0279            foreach (var season in seasonList)
 280            {
 0281                if (!season.IsVirtualItem || !season.IndexNumber.HasValue)
 282                {
 283                    continue;
 284                }
 285
 0286                var desired = template(season.IndexNumber.Value);
 0287                if (string.Equals(season.ForcedSortName, desired, StringComparison.Ordinal))
 288                {
 289                    continue;
 290                }
 291
 0292                _logger.LogInformation(
 0293                    "Aligning sort name of virtual season {SeasonNumber} in series {SeriesName} to {SortName}",
 0294                    season.IndexNumber,
 0295                    season.SeriesName,
 0296                    desired);
 297
 0298                season.ForcedSortName = desired;
 0299                await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(fals
 0300                updated = true;
 301            }
 302
 0303            return updated;
 0304        }
 305
 306        /// <summary>
 307        /// Builds a factory that maps a season number to a forced sort name mirroring a physical,
 308        /// name-sorted sibling season, or <c>null</c> when no physical season sorts by name.
 309        /// </summary>
 310        /// <param name="seasons">The series' seasons (physical and virtual).</param>
 311        /// <returns>A season-number-to-sort-name factory, or <c>null</c> if there is nothing to mirror.</returns>
 312        internal static Func<int, string>? BuildSeasonSortNameTemplate(IEnumerable<Season> seasons)
 313        {
 314            // Season.CreateSortName sorts by the bare padded index ("0003"), but season NFOs give physical
 315            // seasons a name-based forced sort ("Season 01" -> "season 0000000001"). The digit-leading key
 316            // sorts ahead of the letter-leading one, so mirror the sibling's token with each season number.
 5317            var reference = seasons.FirstOrDefault(s =>
 5318                !s.IsVirtualItem && s.IndexNumber.HasValue && !string.IsNullOrEmpty(s.ForcedSortName));
 5319            if (reference is null)
 320            {
 2321                return null;
 322            }
 323
 3324            var forced = reference.ForcedSortName!;
 325
 326            // Locate the last run of digits (the season number) in the sibling's forced sort name.
 3327            var end = -1;
 3328            var start = -1;
 32329            for (var i = forced.Length - 1; i >= 0; i--)
 330            {
 15331                if (char.IsDigit(forced[i]))
 332                {
 3333                    end = end < 0 ? i : end;
 3334                    start = i;
 335                }
 12336                else if (end >= 0)
 337                {
 338                    break;
 339                }
 340            }
 341
 3342            if (end < 0)
 343            {
 344                // Sibling has no numeric component to swap; leave virtual seasons on the bare-index key.
 1345                return null;
 346            }
 347
 2348            var prefix = forced[..start];
 2349            var suffix = forced[(end + 1)..];
 2350            var width = end - start + 1;
 351
 352            // The exact zero-padding is cosmetic: ModifySortChunks pads every digit run to 10 characters,
 353            // so "Season 3" and "Season 03" collapse to the same sort key. Keeping the sibling's width just
 354            // makes the stored value read naturally.
 2355            return number => prefix
 2356                + number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0')
 2357                + suffix;
 358        }
 359
 360        private bool IsEnabledForLibrary(BaseItem item)
 361        {
 0362            var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries;
 0363            if (enabledLibraries is null || enabledLibraries.Length == 0)
 364            {
 0365                return false;
 366            }
 367
 368            // A series can live under more than one collection folder; opting in any one of them is
 369            // enough. An item that belongs to no collection folder cannot be opted in at all.
 0370            return _libraryManager.GetCollectionFolders(item).Any(folder =>
 0371                enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalI
 372        }
 373
 374        private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable, Hash
 375        {
 0376            var keys = new HashSet<(int Season, int Episode)>();
 0377            var updatable = new Dictionary<(int Season, int Episode), Episode>();
 0378            var physicalKeys = new HashSet<(int Season, int Episode)>();
 0379            var physicalTmdbIds = new HashSet<string>(StringComparer.Ordinal);
 0380            var ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>();
 0381            pruned = false;
 382
 383            // Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes'
 384            // SeriesPresentationUniqueKey is not set yet, so the presentation-key based query would miss
 385            // them. GetRecursiveChildren walks the actual child tree and sees them regardless.
 0386            foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>())
 387            {
 388                // The series is refreshed before its episodes during an initial scan, so a freshly
 389                // resolved physical episode may not have its numbers populated yet. Resolve them from
 390                // the path (in memory, mirroring CreateSeasonsAsync) so we can dedupe against episodes
 391                // the user actually has files for instead of creating virtual duplicates.
 0392                if (episode.IsFileProtocol && (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue))
 393                {
 394                    try
 395                    {
 0396                        _libraryManager.FillMissingEpisodeNumbersFromPath(episode, false);
 0397                    }
 0398                    catch (Exception ex)
 399                    {
 0400                        _logger.LogError(ex, "Error resolving episode number from path for {Path}", episode.Path);
 0401                    }
 402                }
 403
 404                // Virtual episodes this provider created are candidates for metadata sync (and pruning).
 0405                var isOurs = episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb);
 406
 0407                if (ShouldPrune(episode, pruneAgedOut, today, gracePeriodDays, importSpecials))
 408                {
 0409                    DeleteEpisode(episode, "no longer upcoming and missing episodes are disabled");
 0410                    pruned = true;
 0411                    continue;
 412                }
 413
 0414                if (!episode.IsVirtualItem)
 415                {
 416                    // Which episode a file holds is what TMDb's episode id says, not the number the file is
 417                    // stored under: aired, DVD and absolute orders number the same episodes differently.
 0418                    var physicalTmdbId = episode.GetProviderId(MetadataProvider.Tmdb);
 0419                    if (!string.IsNullOrEmpty(physicalTmdbId))
 420                    {
 0421                        physicalTmdbIds.Add(physicalTmdbId);
 422                    }
 423                }
 424
 0425                if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue)
 426                {
 0427                    var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value);
 0428                    keys.Add(key);
 429
 430                    // Defer the ours/physical reconciliation: an episode's virtual counterpart and its
 431                    // physical file can appear in either order while walking the tree, so we can only
 432                    // decide which of our virtual episodes are superseded once every episode is seen.
 0433                    if (isOurs)
 434                    {
 0435                        ourVirtuals.Add((key, episode));
 436                    }
 0437                    else if (!episode.IsVirtualItem)
 438                    {
 0439                        physicalKeys.Add(key);
 440                    }
 441                }
 442            }
 443
 444            // A physical file now exists for one of our placeholders: delete the placeholder here rather
 445            // than updating it (and then leaving RemoveObsoleteEpisodes to delete it moments later). The
 446            // physical key already blocks re-creation via the dedupe set above.
 0447            foreach (var (key, episode) in ourVirtuals)
 448            {
 0449                if (physicalKeys.Contains(key))
 450                {
 0451                    DeleteEpisode(episode, "a physical episode now exists for this slot");
 0452                    pruned = true;
 453                }
 0454                else if (physicalTmdbIds.Contains(episode.GetProviderId(MetadataProvider.Tmdb)!))
 455                {
 0456                    DeleteEpisode(episode, "the same episode is already present under another number");
 0457                    pruned = true;
 458                }
 459                else
 460                {
 461                    // Virtual episodes this provider created are candidates for metadata sync.
 0462                    updatable[key] = episode;
 463                }
 464            }
 465
 0466            return (keys, updatable, physicalTmdbIds);
 467        }
 468
 469        /// <summary>
 470        /// Removes every virtual episode this provider previously created in the series.
 471        /// </summary>
 472        /// <param name="series">The series to clean up.</param>
 473        /// <returns><c>true</c> if any episode was removed; otherwise <c>false</c>.</returns>
 474        private bool PruneAllVirtualEpisodes(Series series)
 475        {
 0476            var pruned = false;
 0477            foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>())
 478            {
 0479                if (episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb))
 480                {
 0481                    DeleteEpisode(episode, "the TMDb missing episode provider is disabled for this library");
 0482                    pruned = true;
 483                }
 484            }
 485
 0486            return pruned;
 487        }
 488
 489        private void DeleteEpisode(Episode episode, string reason)
 490        {
 0491            _logger.LogInformation(
 0492                "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}",
 0493                episode.ParentIndexNumber,
 0494                episode.IndexNumber,
 0495                episode.SeriesName,
 0496                reason);
 497
 0498            _libraryManager.DeleteItem(
 0499                episode,
 0500                new DeleteOptions { DeleteFileLocation = false },
 0501                false);
 0502        }
 503
 504        /// <summary>
 505        /// Determines whether a TMDb episode should be imported as a virtual item, based on its air date
 506        /// and the enabled options. Undated episodes are never imported; unaired (today or later) episodes
 507        /// require <paramref name="importUnaired"/>; already aired episodes require <paramref name="importMissing"/>.
 508        /// Specials (season 0) are only imported when <paramref name="importSpecials"/> is enabled.
 509        /// </summary>
 510        /// <param name="premiereDate">The episode air date (UTC), or null if unknown.</param>
 511        /// <param name="today">The current UTC date.</param>
 512        /// <param name="importUnaired">Whether unaired (upcoming) episodes should be imported.</param>
 513        /// <param name="importMissing">Whether already aired missing episodes should be imported.</param>
 514        /// <param name="isSpecial">Whether the episode belongs to the specials season (season 0).</param>
 515        /// <param name="importSpecials">Whether specials should be included.</param>
 516        /// <returns><c>true</c> if the episode should be imported; otherwise <c>false</c>.</returns>
 517        internal static bool ShouldImportEpisode(DateTime? premiereDate, DateTime today, bool importUnaired, bool import
 518        {
 16519            if (!premiereDate.HasValue)
 520            {
 2521                return false;
 522            }
 523
 524            // Specials are only imported when the user opts in.
 14525            if (isSpecial && !importSpecials)
 526            {
 2527                return false;
 528            }
 529
 12530            var isUnaired = premiereDate.Value.Date >= today;
 12531            return isUnaired ? importUnaired : importMissing;
 532        }
 533
 534        /// <summary>
 535        /// Determines whether an existing virtual episode created by this provider (carries a TMDb id)
 536        /// should be pruned. Specials are removed entirely unless <paramref name="importSpecials"/> is
 537        /// enabled. Otherwise, when missing episodes are not wanted, an entry is pruned once its air date
 538        /// is more than <paramref name="gracePeriodDays"/> in the past; the grace period keeps recently
 539        /// aired episodes in place to allow for the delay between an episode airing and its file being
 540        /// added to the library.
 541        /// </summary>
 542        /// <param name="episode">The episode to evaluate.</param>
 543        /// <param name="pruneAgedOut">Whether aged-out virtual episodes should be pruned (missing import disabled).</pa
 544        /// <param name="today">The current UTC date.</param>
 545        /// <param name="gracePeriodDays">The number of days an aired episode is retained before pruning.</param>
 546        /// <param name="importSpecials">Whether specials should be kept.</param>
 547        /// <returns><c>true</c> if the episode should be pruned; otherwise <c>false</c>.</returns>
 548        internal static bool ShouldPrune(Episode episode, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool i
 549        {
 9550            if (!episode.IsVirtualItem || !episode.HasProviderId(MetadataProvider.Tmdb))
 551            {
 2552                return false;
 553            }
 554
 555            // Specials are removed entirely unless the user opts in.
 7556            if (episode.ParentIndexNumber == 0 && !importSpecials)
 557            {
 1558                return true;
 559            }
 560
 561            // When missing episodes are not wanted, prune placeholders for episodes that aired more than
 562            // the grace period ago.
 6563            return pruneAgedOut
 6564                && episode.PremiereDate.HasValue
 6565                && episode.PremiereDate.Value.Date < today.AddDays(-gracePeriodDays);
 566        }
 567
 568        internal static DateTime? GetPremiereDate(TvSeasonEpisode tmdbEpisode)
 569        {
 2570            return tmdbEpisode.AirDate.HasValue
 2571                ? DateTime.SpecifyKind(tmdbEpisode.AirDate.Value, DateTimeKind.Local).ToUniversalTime()
 2572                : null;
 573        }
 574
 575        internal static bool UpdateVirtualEpisode(Episode episode, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate)
 576        {
 4577            var changed = false;
 578
 4579            if (!string.IsNullOrEmpty(tmdbEpisode.Name) && !string.Equals(episode.Name, tmdbEpisode.Name, StringComparis
 580            {
 1581                episode.Name = tmdbEpisode.Name;
 1582                changed = true;
 583            }
 584
 4585            if (!string.IsNullOrEmpty(tmdbEpisode.Overview) && !string.Equals(episode.Overview, tmdbEpisode.Overview, St
 586            {
 0587                episode.Overview = tmdbEpisode.Overview;
 0588                changed = true;
 589            }
 590
 4591            if (premiereDate.HasValue && episode.PremiereDate != premiereDate)
 592            {
 1593                episode.PremiereDate = premiereDate;
 1594                episode.ProductionYear = tmdbEpisode.AirDate?.Year;
 1595                changed = true;
 596            }
 597
 4598            return changed;
 599        }
 600
 601        private Episode AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereD
 602        {
 0603            var seasonNumber = season.IndexNumber.GetValueOrDefault();
 0604            var episodeNumber = (int)tmdbEpisode.EpisodeNumber;
 605
 606            // Leaving Path unset makes the item a virtual (metadata-only) episode.
 0607            var episode = new Episode
 0608            {
 0609                Name = tmdbEpisode.Name,
 0610                IndexNumber = episodeNumber,
 0611                ParentIndexNumber = seasonNumber,
 0612                Id = _libraryManager.GetNewItemId(
 0613                    series.Id.ToString("N", CultureInfo.InvariantCulture)
 0614                        + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture)
 0615                        + "Episode" + episodeNumber.ToString(CultureInfo.InvariantCulture),
 0616                    typeof(Episode)),
 0617                IsVirtualItem = true,
 0618                PremiereDate = premiereDate,
 0619                ProductionYear = tmdbEpisode.AirDate?.Year,
 0620                Overview = tmdbEpisode.Overview,
 0621                SeasonId = season.Id,
 0622                SeasonName = season.Name,
 0623                SeriesId = series.Id,
 0624                SeriesName = series.Name,
 0625                SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 0626            };
 627
 0628            episode.PresentationUniqueKey = episode.CreatePresentationUniqueKey();
 629
 0630            if (tmdbEpisode.Id > 0)
 631            {
 0632                episode.SetProviderId(MetadataProvider.Tmdb, tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture));
 633            }
 634
 0635            _logger.LogInformation(
 0636                "Creating virtual episode S{SeasonNumber}E{EpisodeNumber} for series {SeriesName}",
 0637                seasonNumber,
 0638                episodeNumber,
 0639                series.Name);
 640
 0641            season.AddChild(episode);
 642
 0643            return episode;
 644        }
 645
 646        /// <summary>
 647        /// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back
 648        /// to the season/series image.
 649        /// </summary>
 650        /// <param name="episode">The virtual episode.</param>
 651        /// <param name="tmdbEpisode">The matching TMDb episode.</param>
 652        /// <param name="cancellationToken">The cancellation token.</param>
 653        /// <returns><c>true</c> if a still was downloaded and saved; otherwise <c>false</c>.</returns>
 654        private async Task<bool> EnsureEpisodeImageAsync(Episode episode, TvSeasonEpisode tmdbEpisode, CancellationToken
 655        {
 656            // The still ships with the season episode list, so use it directly instead of a per-episode lookup.
 0657            if (episode.HasImage(ImageType.Primary, 0) || string.IsNullOrEmpty(tmdbEpisode.StillPath))
 658            {
 0659                return false;
 660            }
 661
 0662            var stillUrl = _tmdbClientManager.GetStillUrl(tmdbEpisode.StillPath);
 0663            if (string.IsNullOrEmpty(stillUrl))
 664            {
 0665                return false;
 666            }
 667
 668            try
 669            {
 670                // SaveImage sets the image path on the item but does not persist it, so save afterwards.
 0671                await _providerManager.SaveImage(episode, stillUrl, ImageType.Primary, null, cancellationToken).Configur
 0672                await episode.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(fals
 0673                return true;
 674            }
 0675            catch (Exception ex)
 676            {
 0677                _logger.LogError(
 0678                    ex,
 0679                    "Error downloading still for virtual episode S{SeasonNumber}E{EpisodeNumber} of {SeriesName}",
 0680                    episode.ParentIndexNumber,
 0681                    episode.IndexNumber,
 0682                    episode.SeriesName);
 0683                return false;
 684            }
 0685        }
 686    }
 687}

Methods/Properties

.ctor(MediaBrowser.Providers.Plugins.Tmdb.TmdbClientManager,MediaBrowser.Controller.Library.ILibraryManager,MediaBrowser.Model.IO.IFileSystem,MediaBrowser.Controller.Providers.IProviderManager,Microsoft.Extensions.Logging.ILogger`1<MediaBrowser.Providers.Plugins.Tmdb.TV.TmdbMissingEpisodeProvider>)
get_Name()
get_Order()
HasChanged(MediaBrowser.Controller.Entities.BaseItem,MediaBrowser.Controller.Providers.IDirectoryService)
FetchAsync()
GetOrCreateSeasonAsync()
AlignVirtualSeasonSortNamesAsync()
BuildSeasonSortNameTemplate(System.Collections.Generic.IEnumerable`1<MediaBrowser.Controller.Entities.TV.Season>)
IsEnabledForLibrary(MediaBrowser.Controller.Entities.BaseItem)
GetExistingEpisodes(MediaBrowser.Controller.Entities.TV.Series,System.Boolean,System.DateTime,System.Int32,System.Boolean,System.Boolean&)
PruneAllVirtualEpisodes(MediaBrowser.Controller.Entities.TV.Series)
DeleteEpisode(MediaBrowser.Controller.Entities.TV.Episode,System.String)
ShouldImportEpisode(System.Nullable`1<System.DateTime>,System.DateTime,System.Boolean,System.Boolean,System.Boolean,System.Boolean)
ShouldPrune(MediaBrowser.Controller.Entities.TV.Episode,System.Boolean,System.DateTime,System.Int32,System.Boolean)
GetPremiereDate(TMDbLib.Objects.Search.TvSeasonEpisode)
UpdateVirtualEpisode(MediaBrowser.Controller.Entities.TV.Episode,TMDbLib.Objects.Search.TvSeasonEpisode,System.Nullable`1<System.DateTime>)
AddVirtualEpisode(MediaBrowser.Controller.Entities.TV.Series,MediaBrowser.Controller.Entities.TV.Season,TMDbLib.Objects.Search.TvSeasonEpisode,System.Nullable`1<System.DateTime>)
EnsureEpisodeImageAsync()