aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs5
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs47
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html75
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs663
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs3
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs207
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs10
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs9
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs275
10 files changed, 1293 insertions, 6 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index 80e16ca310..524a712776 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -390,7 +390,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);
}
@@ -510,7 +511,7 @@ public sealed partial class BaseItemRepository
var leafItems = context.BaseItems
.AsNoTracking()
- .Where(e => !e.IsFolder && !e.IsVirtualItem);
+ .Where(DescendantQueryHelper.IsCountableLeaf);
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
}
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index 4aa65769fd..fd683fb57e 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -296,7 +296,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);
@@ -357,7 +358,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..b3a67189bb 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 enabled. Whether episodes are
+ /// imported at all, and how, is still controlled by the global toggles above; those toggles only
+ /// apply to the libraries listed here. Libraries not listed, including newly added ones, are
+ /// never processed, so an empty list disables the feature entirely.
+ /// </summary>
+ public string[] EnabledMissingEpisodeLibraries { 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..582753759f 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. Libraries are opted in individually, so newly added libraries are not processed until they are enabled here.</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 (enabledLibraries) {
+ 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 = enabledLibraries.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.EnabledMissingEpisodeLibraries || []);
+
var maxCastMembers = document.querySelector('#maxCastMembers');
maxCastMembers.value = config.MaxCastMembers;
maxCastMembers.dispatchEvent(new Event('change', {
@@ -189,6 +253,17 @@
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);
+ var libraryCheckboxes = document.querySelectorAll('.missingEpisodeLibrary');
+ if (libraryCheckboxes.length > 0) {
+ config.EnabledMissingEpisodeLibraries = Array.prototype.filter
+ .call(libraryCheckboxes, function (checkbox) { return checkbox.checked; })
+ .map(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..b44361e4e7
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs
@@ -0,0 +1,663 @@
+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 IProviderManager _providerManager;
+ 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="providerManager">The <see cref="IProviderManager"/>.</param>
+ /// <param name="logger">The <see cref="ILogger{TmdbMissingEpisodeProvider}"/>.</param>
+ public TmdbMissingEpisodeProvider(
+ TmdbClientManager tmdbClientManager,
+ ILibraryManager libraryManager,
+ IFileSystem fileSystem,
+ IProviderManager providerManager,
+ ILogger<TmdbMissingEpisodeProvider> logger)
+ {
+ _tmdbClientManager = tmdbClientManager;
+ _libraryManager = libraryManager;
+ _fileSystem = fileSystem;
+ _providerManager = providerManager;
+ _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 not been opted in. In either case remove every virtual episode (unaired and
+ // missing alike) it previously created, so disabling the feature cleans up on the next 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;
+ }
+
+ // Backfill the still for placeholders created before images were fetched.
+ if (await EnsureEpisodeImageAsync(existingEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false))
+ {
+ updatedEpisodes = true;
+ }
+
+ continue;
+ }
+
+ if (!existingEpisodes.Add(key))
+ {
+ continue;
+ }
+
+ var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false);
+ var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate);
+ await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false);
+ addedEpisodes = true;
+ }
+ }
+
+ var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).ConfigureAwait(false);
+
+ if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons)
+ {
+ 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;
+ }
+
+ /// <summary>
+ /// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by
+ /// number instead of jumping ahead. See <see cref="BuildSeasonSortNameTemplate"/> for the details.
+ /// </summary>
+ /// <param name="seasons">The series' seasons (physical and virtual).</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns><c>true</c> if any virtual season was updated; otherwise <c>false</c>.</returns>
+ private async Task<bool> AlignVirtualSeasonSortNamesAsync(IEnumerable<Season> seasons, CancellationToken cancellationToken)
+ {
+ var seasonList = seasons.ToList();
+ var template = BuildSeasonSortNameTemplate(seasonList);
+ if (template is null)
+ {
+ // No physical season sorts by name: virtual seasons already share the bare-index key space.
+ return false;
+ }
+
+ var updated = false;
+ foreach (var season in seasonList)
+ {
+ if (!season.IsVirtualItem || !season.IndexNumber.HasValue)
+ {
+ continue;
+ }
+
+ var desired = template(season.IndexNumber.Value);
+ if (string.Equals(season.ForcedSortName, desired, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ _logger.LogInformation(
+ "Aligning sort name of virtual season {SeasonNumber} in series {SeriesName} to {SortName}",
+ season.IndexNumber,
+ season.SeriesName,
+ desired);
+
+ season.ForcedSortName = desired;
+ await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ updated = true;
+ }
+
+ return updated;
+ }
+
+ /// <summary>
+ /// Builds a factory that maps a season number to a forced sort name mirroring a physical,
+ /// name-sorted sibling season, or <c>null</c> when no physical season sorts by name.
+ /// </summary>
+ /// <param name="seasons">The series' seasons (physical and virtual).</param>
+ /// <returns>A season-number-to-sort-name factory, or <c>null</c> if there is nothing to mirror.</returns>
+ internal static Func<int, string>? BuildSeasonSortNameTemplate(IEnumerable<Season> seasons)
+ {
+ // Season.CreateSortName sorts by the bare padded index ("0003"), but season NFOs give physical
+ // seasons a name-based forced sort ("Season 01" -> "season 0000000001"). The digit-leading key
+ // sorts ahead of the letter-leading one, so mirror the sibling's token with each season number.
+ var reference = seasons.FirstOrDefault(s =>
+ !s.IsVirtualItem && s.IndexNumber.HasValue && !string.IsNullOrEmpty(s.ForcedSortName));
+ if (reference is null)
+ {
+ return null;
+ }
+
+ var forced = reference.ForcedSortName!;
+
+ // Locate the last run of digits (the season number) in the sibling's forced sort name.
+ var end = -1;
+ var start = -1;
+ for (var i = forced.Length - 1; i >= 0; i--)
+ {
+ if (char.IsDigit(forced[i]))
+ {
+ end = end < 0 ? i : end;
+ start = i;
+ }
+ else if (end >= 0)
+ {
+ break;
+ }
+ }
+
+ if (end < 0)
+ {
+ // Sibling has no numeric component to swap; leave virtual seasons on the bare-index key.
+ return null;
+ }
+
+ var prefix = forced[..start];
+ var suffix = forced[(end + 1)..];
+ var width = end - start + 1;
+
+ // The exact zero-padding is cosmetic: ModifySortChunks pads every digit run to 10 characters,
+ // so "Season 3" and "Season 03" collapse to the same sort key. Keeping the sibling's width just
+ // makes the stored value read naturally.
+ return number => prefix
+ + number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0')
+ + suffix;
+ }
+
+ private bool IsEnabledForLibrary(BaseItem item)
+ {
+ var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries;
+ if (enabledLibraries is null || enabledLibraries.Length == 0)
+ {
+ return false;
+ }
+
+ // A series can live under more than one collection folder; opting in any one of them is
+ // enough. An item that belongs to no collection folder cannot be opted in at all.
+ return _libraryManager.GetCollectionFolders(item).Any(folder =>
+ enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase));
+ }
+
+ 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>();
+ var physicalKeys = new HashSet<(int Season, int Episode)>();
+ var ourVirtuals = new List<((int Season, int Episode) Key, 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);
+
+ // Defer the ours/physical reconciliation: an episode's virtual counterpart and its
+ // physical file can appear in either order while walking the tree, so we can only
+ // decide which of our virtual episodes are superseded once every episode is seen.
+ if (isOurs)
+ {
+ ourVirtuals.Add((key, episode));
+ }
+ else if (!episode.IsVirtualItem)
+ {
+ physicalKeys.Add(key);
+ }
+ }
+ }
+
+ // A physical file now exists for one of our placeholders: delete the placeholder here rather
+ // than updating it (and then leaving RemoveObsoleteEpisodes to delete it moments later). The
+ // physical key already blocks re-creation via the dedupe set above.
+ foreach (var (key, episode) in ourVirtuals)
+ {
+ if (physicalKeys.Contains(key))
+ {
+ DeleteEpisode(episode, "a physical episode now exists for this slot");
+ pruned = true;
+ }
+ else
+ {
+ // Virtual episodes this provider created are candidates for metadata sync.
+ 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 Episode 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);
+
+ return episode;
+ }
+
+ /// <summary>
+ /// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back
+ /// to the season/series image.
+ /// </summary>
+ /// <param name="episode">The virtual episode.</param>
+ /// <param name="tmdbEpisode">The matching TMDb episode.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns><c>true</c> if a still was downloaded and saved; otherwise <c>false</c>.</returns>
+ private async Task<bool> EnsureEpisodeImageAsync(Episode episode, TvSeasonEpisode tmdbEpisode, CancellationToken cancellationToken)
+ {
+ // The still ships with the season episode list, so use it directly instead of a per-episode lookup.
+ if (episode.HasImage(ImageType.Primary, 0) || string.IsNullOrEmpty(tmdbEpisode.StillPath))
+ {
+ return false;
+ }
+
+ var stillUrl = _tmdbClientManager.GetStillUrl(tmdbEpisode.StillPath);
+ if (string.IsNullOrEmpty(stillUrl))
+ {
+ return false;
+ }
+
+ try
+ {
+ // SaveImage sets the image path on the item but does not persist it, so save afterwards.
+ await _providerManager.SaveImage(episode, stillUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false);
+ await episode.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "Error downloading still for virtual episode S{SeasonNumber}E{EpisodeNumber} of {SeriesName}",
+ episode.ParentIndexNumber,
+ episode.IndexNumber,
+ episode.SeriesName);
+ return false;
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
index 1eb522137d..9c41d64253 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
@@ -76,11 +76,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
result.Item.Name = seasonResult.Name;
}
+ result.Item.TrySetProviderId(MetadataProvider.Tmdb, seasonResult.Id?.ToString(CultureInfo.InvariantCulture));
result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId);
- // TODO why was this disabled?
var credits = seasonResult.Credits;
-
if (credits?.Cast is not null)
{
var castQuery = config.HideMissingCastMembers
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs
new file mode 100644
index 0000000000..e2846c74a3
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs
@@ -0,0 +1,207 @@
+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)
+ || configuration.EnabledMissingEpisodeLibraries.Length == 0)
+ {
+ RemoveAllVirtualItems(progress, cancellationToken);
+ return;
+ }
+
+ // Process non-ended series (they may have gained episodes) plus any series in a library that
+ // is not opted in (regardless of status) so the provider can prune the virtual episodes it
+ // previously created there. Ended series in enabled libraries cannot change, so they're skipped.
+ var series = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Series],
+ Recursive = true
+ })
+ .OfType<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 enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries;
+ if (enabledLibraries is null || enabledLibraries.Length == 0)
+ {
+ return false;
+ }
+
+ // A series can live under more than one collection folder; opting in any one of them is
+ // enough. An item that belongs to no collection folder cannot be opted in at all.
+ return _libraryManager.GetCollectionFolders(item).Any(folder =>
+ enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase));
+ }
+
+ /// <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).
+ var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Season],
+ IsVirtualItem = true,
+ HasTmdbId = 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/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index 174f1546a7..c8e3a7aa52 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -592,6 +592,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
+ /// Gets the absolute URL of an episode still.
+ /// </summary>
+ /// <param name="stillPath">The relative URL of the still.</param>
+ /// <returns>The absolute URL.</returns>
+ public string? GetStillUrl(string? stillPath)
+ {
+ return GetUrl(Plugin.Instance.Configuration.StillSize, stillPath);
+ }
+
+ /// <summary>
/// Converts poster <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s.
/// </summary>
/// <param name="images">The input images.</param>
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..7813013c05
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs
@@ -0,0 +1,275 @@
+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);
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_NoNameSortedPhysicalSeason_ReturnsNull()
+ {
+ // No physical season carries a forced (name-based) sort name -> virtual seasons keep their
+ // bare-index sort, so no template is produced.
+ var seasons = new[]
+ {
+ PhysicalSeason(1, forcedSortName: null),
+ VirtualSeason(3),
+ };
+
+ Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(seasons));
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_MirrorsSiblingConventionAndSwapsNumber()
+ {
+ var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: "Season 01"),
+ VirtualSeason(3),
+ });
+
+ Assert.NotNull(template);
+ // Keeps the sibling's text token and zero-padding width, swapping in the target number.
+ Assert.Equal("Season 03", template!(3));
+ Assert.Equal("Season 12", template(12));
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_PreservesNonEnglishToken()
+ {
+ var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: "Staffel 1"),
+ VirtualSeason(2),
+ });
+
+ Assert.NotNull(template);
+ Assert.Equal("Staffel 2", template!(2));
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_SiblingWithoutDigits_ReturnsNull()
+ {
+ var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: "Miniseries"),
+ VirtualSeason(2),
+ });
+
+ Assert.Null(template);
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_IgnoresVirtualSeasonsAsReference()
+ {
+ // A virtual season's own forced sort name must not be used as the convention source.
+ var virtualWithForced = VirtualSeason(3);
+ virtualWithForced.ForcedSortName = "Season 03";
+
+ Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: null),
+ virtualWithForced,
+ }));
+ }
+
+ private static Season PhysicalSeason(int indexNumber, string? forcedSortName)
+ {
+ var season = new Season { IndexNumber = indexNumber, Path = $"/media/show/Season {indexNumber:00}" };
+ if (!string.IsNullOrEmpty(forcedSortName))
+ {
+ season.ForcedSortName = forcedSortName;
+ }
+
+ return season;
+ }
+
+ private static Season VirtualSeason(int indexNumber)
+ => new Season { IndexNumber = indexNumber, IsVirtualItem = true };
+
+ 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;
+ }
+}