< 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
19%
Covered lines: 52
Uncovered lines: 221
Coverable lines: 273
Total lines: 663
Line coverage: 19%
Branch coverage
27%
Covered branches: 48
Total branches: 176
Branch coverage: 27.2%
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: 663 8/3/2026 - 12:16:46 AM Line coverage: 19% (52/273) Branch coverage: 27.2% (48/176) Total lines: 663

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%3660600%
GetOrCreateSeasonAsync()0%620%
AlignVirtualSeasonSortNamesAsync()0%110100%
BuildSeasonSortNameTemplate(...)100%1212100%
IsEnabledForLibrary(...)0%4260%
GetExistingEpisodes(...)0%600240%
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        {
 2244            _tmdbClientManager = tmdbClientManager;
 2245            _libraryManager = libraryManager;
 2246            _fileSystem = fileSystem;
 2247            _providerManager = providerManager;
 2248            _logger = logger;
 2249        }
 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) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays
 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
 0200                    var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber
 0201                    var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate);
 0202                    await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false);
 0203                    addedEpisodes = true;
 0204                }
 0205            }
 206
 0207            var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).Confi
 208
 0209            if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons)
 210            {
 0211                return ItemUpdateType.None;
 212            }
 213
 214            // Invalidate the cached children so that the season creation / cleanup that runs later in
 215            // SeriesMetadataService.AfterMetadataRefresh observes the newly created (and pruned) episodes.
 0216            item.Children = null;
 217
 0218            return ItemUpdateType.MetadataImport;
 0219        }
 220
 221        /// <summary>
 222        /// Returns the series' season with the given number, creating (and refreshing) a virtual season
 223        /// when the whole season is missing from the library.
 224        /// </summary>
 225        private async Task<Season> GetOrCreateSeasonAsync(Series series, int seasonNumber, string? seasonName, Dictionar
 226        {
 0227            if (seasonsByNumber.TryGetValue(seasonNumber, out var existingSeason))
 228            {
 0229                return existingSeason;
 230            }
 231
 0232            _logger.LogInformation("Creating virtual season {SeasonNumber} for series {SeriesName}", seasonNumber, serie
 233
 0234            var season = new Season
 0235            {
 0236                Name = seasonName,
 0237                IndexNumber = seasonNumber,
 0238                Id = _libraryManager.GetNewItemId(
 0239                    series.Id.ToString("N", CultureInfo.InvariantCulture) + "Season" + seasonNumber.ToString(CultureInfo
 0240                    typeof(Season)),
 0241                IsVirtualItem = true,
 0242                SeriesId = series.Id,
 0243                SeriesName = series.Name,
 0244                SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 0245            };
 246
 0247            series.AddChild(season);
 0248            await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToke
 249
 0250            seasonsByNumber[seasonNumber] = season;
 0251            return season;
 0252        }
 253
 254        /// <summary>
 255        /// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by
 256        /// number instead of jumping ahead. See <see cref="BuildSeasonSortNameTemplate"/> for the details.
 257        /// </summary>
 258        /// <param name="seasons">The series' seasons (physical and virtual).</param>
 259        /// <param name="cancellationToken">The cancellation token.</param>
 260        /// <returns><c>true</c> if any virtual season was updated; otherwise <c>false</c>.</returns>
 261        private async Task<bool> AlignVirtualSeasonSortNamesAsync(IEnumerable<Season> seasons, CancellationToken cancell
 262        {
 0263            var seasonList = seasons.ToList();
 0264            var template = BuildSeasonSortNameTemplate(seasonList);
 0265            if (template is null)
 266            {
 267                // No physical season sorts by name: virtual seasons already share the bare-index key space.
 0268                return false;
 269            }
 270
 0271            var updated = false;
 0272            foreach (var season in seasonList)
 273            {
 0274                if (!season.IsVirtualItem || !season.IndexNumber.HasValue)
 275                {
 276                    continue;
 277                }
 278
 0279                var desired = template(season.IndexNumber.Value);
 0280                if (string.Equals(season.ForcedSortName, desired, StringComparison.Ordinal))
 281                {
 282                    continue;
 283                }
 284
 0285                _logger.LogInformation(
 0286                    "Aligning sort name of virtual season {SeasonNumber} in series {SeriesName} to {SortName}",
 0287                    season.IndexNumber,
 0288                    season.SeriesName,
 0289                    desired);
 290
 0291                season.ForcedSortName = desired;
 0292                await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(fals
 0293                updated = true;
 294            }
 295
 0296            return updated;
 0297        }
 298
 299        /// <summary>
 300        /// Builds a factory that maps a season number to a forced sort name mirroring a physical,
 301        /// name-sorted sibling season, or <c>null</c> when no physical season sorts by name.
 302        /// </summary>
 303        /// <param name="seasons">The series' seasons (physical and virtual).</param>
 304        /// <returns>A season-number-to-sort-name factory, or <c>null</c> if there is nothing to mirror.</returns>
 305        internal static Func<int, string>? BuildSeasonSortNameTemplate(IEnumerable<Season> seasons)
 306        {
 307            // Season.CreateSortName sorts by the bare padded index ("0003"), but season NFOs give physical
 308            // seasons a name-based forced sort ("Season 01" -> "season 0000000001"). The digit-leading key
 309            // sorts ahead of the letter-leading one, so mirror the sibling's token with each season number.
 5310            var reference = seasons.FirstOrDefault(s =>
 5311                !s.IsVirtualItem && s.IndexNumber.HasValue && !string.IsNullOrEmpty(s.ForcedSortName));
 5312            if (reference is null)
 313            {
 2314                return null;
 315            }
 316
 3317            var forced = reference.ForcedSortName!;
 318
 319            // Locate the last run of digits (the season number) in the sibling's forced sort name.
 3320            var end = -1;
 3321            var start = -1;
 32322            for (var i = forced.Length - 1; i >= 0; i--)
 323            {
 15324                if (char.IsDigit(forced[i]))
 325                {
 3326                    end = end < 0 ? i : end;
 3327                    start = i;
 328                }
 12329                else if (end >= 0)
 330                {
 331                    break;
 332                }
 333            }
 334
 3335            if (end < 0)
 336            {
 337                // Sibling has no numeric component to swap; leave virtual seasons on the bare-index key.
 1338                return null;
 339            }
 340
 2341            var prefix = forced[..start];
 2342            var suffix = forced[(end + 1)..];
 2343            var width = end - start + 1;
 344
 345            // The exact zero-padding is cosmetic: ModifySortChunks pads every digit run to 10 characters,
 346            // so "Season 3" and "Season 03" collapse to the same sort key. Keeping the sibling's width just
 347            // makes the stored value read naturally.
 2348            return number => prefix
 2349                + number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0')
 2350                + suffix;
 351        }
 352
 353        private bool IsEnabledForLibrary(BaseItem item)
 354        {
 0355            var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries;
 0356            if (enabledLibraries is null || enabledLibraries.Length == 0)
 357            {
 0358                return false;
 359            }
 360
 361            // A series can live under more than one collection folder; opting in any one of them is
 362            // enough. An item that belongs to no collection folder cannot be opted in at all.
 0363            return _libraryManager.GetCollectionFolders(item).Any(folder =>
 0364                enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalI
 365        }
 366
 367        private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable) GetE
 368        {
 0369            var keys = new HashSet<(int Season, int Episode)>();
 0370            var updatable = new Dictionary<(int Season, int Episode), Episode>();
 0371            var physicalKeys = new HashSet<(int Season, int Episode)>();
 0372            var ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>();
 0373            pruned = false;
 374
 375            // Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes'
 376            // SeriesPresentationUniqueKey is not set yet, so the presentation-key based query would miss
 377            // them. GetRecursiveChildren walks the actual child tree and sees them regardless.
 0378            foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>())
 379            {
 380                // The series is refreshed before its episodes during an initial scan, so a freshly
 381                // resolved physical episode may not have its numbers populated yet. Resolve them from
 382                // the path (in memory, mirroring CreateSeasonsAsync) so we can dedupe against episodes
 383                // the user actually has files for instead of creating virtual duplicates.
 0384                if (episode.IsFileProtocol && (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue))
 385                {
 386                    try
 387                    {
 0388                        _libraryManager.FillMissingEpisodeNumbersFromPath(episode, false);
 0389                    }
 0390                    catch (Exception ex)
 391                    {
 0392                        _logger.LogError(ex, "Error resolving episode number from path for {Path}", episode.Path);
 0393                    }
 394                }
 395
 396                // Virtual episodes this provider created are candidates for metadata sync (and pruning).
 0397                var isOurs = episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb);
 398
 0399                if (ShouldPrune(episode, pruneAgedOut, today, gracePeriodDays, importSpecials))
 400                {
 0401                    DeleteEpisode(episode, "no longer upcoming and missing episodes are disabled");
 0402                    pruned = true;
 0403                    continue;
 404                }
 405
 0406                if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue)
 407                {
 0408                    var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value);
 0409                    keys.Add(key);
 410
 411                    // Defer the ours/physical reconciliation: an episode's virtual counterpart and its
 412                    // physical file can appear in either order while walking the tree, so we can only
 413                    // decide which of our virtual episodes are superseded once every episode is seen.
 0414                    if (isOurs)
 415                    {
 0416                        ourVirtuals.Add((key, episode));
 417                    }
 0418                    else if (!episode.IsVirtualItem)
 419                    {
 0420                        physicalKeys.Add(key);
 421                    }
 422                }
 423            }
 424
 425            // A physical file now exists for one of our placeholders: delete the placeholder here rather
 426            // than updating it (and then leaving RemoveObsoleteEpisodes to delete it moments later). The
 427            // physical key already blocks re-creation via the dedupe set above.
 0428            foreach (var (key, episode) in ourVirtuals)
 429            {
 0430                if (physicalKeys.Contains(key))
 431                {
 0432                    DeleteEpisode(episode, "a physical episode now exists for this slot");
 0433                    pruned = true;
 434                }
 435                else
 436                {
 437                    // Virtual episodes this provider created are candidates for metadata sync.
 0438                    updatable[key] = episode;
 439                }
 440            }
 441
 0442            return (keys, updatable);
 443        }
 444
 445        /// <summary>
 446        /// Removes every virtual episode this provider previously created in the series.
 447        /// </summary>
 448        /// <param name="series">The series to clean up.</param>
 449        /// <returns><c>true</c> if any episode was removed; otherwise <c>false</c>.</returns>
 450        private bool PruneAllVirtualEpisodes(Series series)
 451        {
 0452            var pruned = false;
 0453            foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>())
 454            {
 0455                if (episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb))
 456                {
 0457                    DeleteEpisode(episode, "the TMDb missing episode provider is disabled for this library");
 0458                    pruned = true;
 459                }
 460            }
 461
 0462            return pruned;
 463        }
 464
 465        private void DeleteEpisode(Episode episode, string reason)
 466        {
 0467            _logger.LogInformation(
 0468                "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}",
 0469                episode.ParentIndexNumber,
 0470                episode.IndexNumber,
 0471                episode.SeriesName,
 0472                reason);
 473
 0474            _libraryManager.DeleteItem(
 0475                episode,
 0476                new DeleteOptions { DeleteFileLocation = false },
 0477                false);
 0478        }
 479
 480        /// <summary>
 481        /// Determines whether a TMDb episode should be imported as a virtual item, based on its air date
 482        /// and the enabled options. Undated episodes are never imported; unaired (today or later) episodes
 483        /// require <paramref name="importUnaired"/>; already aired episodes require <paramref name="importMissing"/>.
 484        /// Specials (season 0) are only imported when <paramref name="importSpecials"/> is enabled.
 485        /// </summary>
 486        /// <param name="premiereDate">The episode air date (UTC), or null if unknown.</param>
 487        /// <param name="today">The current UTC date.</param>
 488        /// <param name="importUnaired">Whether unaired (upcoming) episodes should be imported.</param>
 489        /// <param name="importMissing">Whether already aired missing episodes should be imported.</param>
 490        /// <param name="isSpecial">Whether the episode belongs to the specials season (season 0).</param>
 491        /// <param name="importSpecials">Whether specials should be included.</param>
 492        /// <returns><c>true</c> if the episode should be imported; otherwise <c>false</c>.</returns>
 493        internal static bool ShouldImportEpisode(DateTime? premiereDate, DateTime today, bool importUnaired, bool import
 494        {
 16495            if (!premiereDate.HasValue)
 496            {
 2497                return false;
 498            }
 499
 500            // Specials are only imported when the user opts in.
 14501            if (isSpecial && !importSpecials)
 502            {
 2503                return false;
 504            }
 505
 12506            var isUnaired = premiereDate.Value.Date >= today;
 12507            return isUnaired ? importUnaired : importMissing;
 508        }
 509
 510        /// <summary>
 511        /// Determines whether an existing virtual episode created by this provider (carries a TMDb id)
 512        /// should be pruned. Specials are removed entirely unless <paramref name="importSpecials"/> is
 513        /// enabled. Otherwise, when missing episodes are not wanted, an entry is pruned once its air date
 514        /// is more than <paramref name="gracePeriodDays"/> in the past; the grace period keeps recently
 515        /// aired episodes in place to allow for the delay between an episode airing and its file being
 516        /// added to the library.
 517        /// </summary>
 518        /// <param name="episode">The episode to evaluate.</param>
 519        /// <param name="pruneAgedOut">Whether aged-out virtual episodes should be pruned (missing import disabled).</pa
 520        /// <param name="today">The current UTC date.</param>
 521        /// <param name="gracePeriodDays">The number of days an aired episode is retained before pruning.</param>
 522        /// <param name="importSpecials">Whether specials should be kept.</param>
 523        /// <returns><c>true</c> if the episode should be pruned; otherwise <c>false</c>.</returns>
 524        internal static bool ShouldPrune(Episode episode, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool i
 525        {
 9526            if (!episode.IsVirtualItem || !episode.HasProviderId(MetadataProvider.Tmdb))
 527            {
 2528                return false;
 529            }
 530
 531            // Specials are removed entirely unless the user opts in.
 7532            if (episode.ParentIndexNumber == 0 && !importSpecials)
 533            {
 1534                return true;
 535            }
 536
 537            // When missing episodes are not wanted, prune placeholders for episodes that aired more than
 538            // the grace period ago.
 6539            return pruneAgedOut
 6540                && episode.PremiereDate.HasValue
 6541                && episode.PremiereDate.Value.Date < today.AddDays(-gracePeriodDays);
 542        }
 543
 544        internal static DateTime? GetPremiereDate(TvSeasonEpisode tmdbEpisode)
 545        {
 2546            return tmdbEpisode.AirDate.HasValue
 2547                ? DateTime.SpecifyKind(tmdbEpisode.AirDate.Value, DateTimeKind.Local).ToUniversalTime()
 2548                : null;
 549        }
 550
 551        internal static bool UpdateVirtualEpisode(Episode episode, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate)
 552        {
 4553            var changed = false;
 554
 4555            if (!string.IsNullOrEmpty(tmdbEpisode.Name) && !string.Equals(episode.Name, tmdbEpisode.Name, StringComparis
 556            {
 1557                episode.Name = tmdbEpisode.Name;
 1558                changed = true;
 559            }
 560
 4561            if (!string.IsNullOrEmpty(tmdbEpisode.Overview) && !string.Equals(episode.Overview, tmdbEpisode.Overview, St
 562            {
 0563                episode.Overview = tmdbEpisode.Overview;
 0564                changed = true;
 565            }
 566
 4567            if (premiereDate.HasValue && episode.PremiereDate != premiereDate)
 568            {
 1569                episode.PremiereDate = premiereDate;
 1570                episode.ProductionYear = tmdbEpisode.AirDate?.Year;
 1571                changed = true;
 572            }
 573
 4574            return changed;
 575        }
 576
 577        private Episode AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereD
 578        {
 0579            var seasonNumber = season.IndexNumber.GetValueOrDefault();
 0580            var episodeNumber = (int)tmdbEpisode.EpisodeNumber;
 581
 582            // Leaving Path unset makes the item a virtual (metadata-only) episode.
 0583            var episode = new Episode
 0584            {
 0585                Name = tmdbEpisode.Name,
 0586                IndexNumber = episodeNumber,
 0587                ParentIndexNumber = seasonNumber,
 0588                Id = _libraryManager.GetNewItemId(
 0589                    series.Id.ToString("N", CultureInfo.InvariantCulture)
 0590                        + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture)
 0591                        + "Episode" + episodeNumber.ToString(CultureInfo.InvariantCulture),
 0592                    typeof(Episode)),
 0593                IsVirtualItem = true,
 0594                PremiereDate = premiereDate,
 0595                ProductionYear = tmdbEpisode.AirDate?.Year,
 0596                Overview = tmdbEpisode.Overview,
 0597                SeasonId = season.Id,
 0598                SeasonName = season.Name,
 0599                SeriesId = series.Id,
 0600                SeriesName = series.Name,
 0601                SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 0602            };
 603
 0604            episode.PresentationUniqueKey = episode.CreatePresentationUniqueKey();
 605
 0606            if (tmdbEpisode.Id > 0)
 607            {
 0608                episode.SetProviderId(MetadataProvider.Tmdb, tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture));
 609            }
 610
 0611            _logger.LogInformation(
 0612                "Creating virtual episode S{SeasonNumber}E{EpisodeNumber} for series {SeriesName}",
 0613                seasonNumber,
 0614                episodeNumber,
 0615                series.Name);
 616
 0617            season.AddChild(episode);
 618
 0619            return episode;
 620        }
 621
 622        /// <summary>
 623        /// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back
 624        /// to the season/series image.
 625        /// </summary>
 626        /// <param name="episode">The virtual episode.</param>
 627        /// <param name="tmdbEpisode">The matching TMDb episode.</param>
 628        /// <param name="cancellationToken">The cancellation token.</param>
 629        /// <returns><c>true</c> if a still was downloaded and saved; otherwise <c>false</c>.</returns>
 630        private async Task<bool> EnsureEpisodeImageAsync(Episode episode, TvSeasonEpisode tmdbEpisode, CancellationToken
 631        {
 632            // The still ships with the season episode list, so use it directly instead of a per-episode lookup.
 0633            if (episode.HasImage(ImageType.Primary, 0) || string.IsNullOrEmpty(tmdbEpisode.StillPath))
 634            {
 0635                return false;
 636            }
 637
 0638            var stillUrl = _tmdbClientManager.GetStillUrl(tmdbEpisode.StillPath);
 0639            if (string.IsNullOrEmpty(stillUrl))
 640            {
 0641                return false;
 642            }
 643
 644            try
 645            {
 646                // SaveImage sets the image path on the item but does not persist it, so save afterwards.
 0647                await _providerManager.SaveImage(episode, stillUrl, ImageType.Primary, null, cancellationToken).Configur
 0648                await episode.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(fals
 0649                return true;
 650            }
 0651            catch (Exception ex)
 652            {
 0653                _logger.LogError(
 0654                    ex,
 0655                    "Error downloading still for virtual episode S{SeasonNumber}E{EpisodeNumber} of {SeriesName}",
 0656                    episode.ParentIndexNumber,
 0657                    episode.IndexNumber,
 0658                    episode.SeriesName);
 0659                return false;
 660            }
 0661        }
 662    }
 663}

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()