From 2fcf4084f8bd58108bdef65c62b5722bb46d38e5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 21 Jun 2026 09:40:06 +0200 Subject: Add TMDb missing episode provider --- .../Tmdb/TmdbMissingEpisodeProviderTests.cs | 193 +++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs (limited to 'tests') diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs new file mode 100644 index 0000000000..f4b7bb5b75 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs @@ -0,0 +1,193 @@ +using System; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb.TV; +using TMDbLib.Objects.Search; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb; + +public class TmdbMissingEpisodeProviderTests +{ + private static readonly DateTime _today = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc); + + [Theory] + // No air date -> never imported, regardless of options. + [InlineData(null, true, true, false, false, false)] + [InlineData(null, false, false, false, false, false)] + // Future (unaired) episodes are gated by the unaired option. + [InlineData(5, true, false, false, false, true)] + [InlineData(5, false, false, false, false, false)] + [InlineData(5, false, true, false, false, false)] + // Today counts as unaired. + [InlineData(0, true, false, false, false, true)] + [InlineData(0, false, false, false, false, false)] + // Past (already aired) episodes are gated by the missing option. + [InlineData(-5, false, true, false, false, true)] + [InlineData(-5, false, false, false, false, false)] + [InlineData(-5, true, false, false, false, false)] + // Specials are never imported when the specials option is off, regardless of air date. + [InlineData(5, true, false, true, false, false)] + [InlineData(-5, false, true, true, false, false)] + // Specials follow the normal air-date gating when the specials option is on. + [InlineData(5, true, false, true, true, true)] + [InlineData(5, false, false, true, true, false)] + [InlineData(-5, false, true, true, true, true)] + [InlineData(-5, false, false, true, true, false)] + public void ShouldImportEpisode_RespectsAirDateAndOptions(int? dayOffset, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials, bool expected) + { + DateTime? premiere = dayOffset.HasValue ? _today.AddDays(dayOffset.Value) : null; + + Assert.Equal(expected, TmdbMissingEpisodeProvider.ShouldImportEpisode(premiere, _today, importUnaired, importMissing, isSpecial, importSpecials)); + } + + [Fact] + public void ShouldPrune_AgedOutVirtualTmdbEpisode_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_NotInPruningMode_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_StillUpcoming_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_VirtualEpisodeFromAnotherProvider_ReturnsFalse() + { + // No TMDb id -> not created by this provider (e.g. a TheTVDB plugin entry) -> left untouched. + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: false); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_PhysicalEpisode_ReturnsFalse() + { + var episode = new Episode { Path = "/media/show/Season 01/s01e01.mkv", PremiereDate = _today.AddDays(-1) }; + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredWithinGracePeriod_ReturnsFalse() + { + // Aired two days ago but the grace period keeps it around for the file to be added. + var episode = VirtualEpisode(_today.AddDays(-2), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredBeyondGracePeriod_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-10), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsDisabled_ReturnsTrue() + { + // Specials are removed entirely when the specials option is off, even when not in pruning mode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 7, importSpecials: false)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsEnabled_FollowsNormalRules() + { + // With specials enabled, an upcoming special is kept like any other upcoming episode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void GetPremiereDate_NullAirDate_ReturnsNull() + { + Assert.Null(TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = null })); + } + + [Fact] + public void GetPremiereDate_AirDate_ReturnsUtc() + { + var airDate = new DateTime(2026, 7, 28); + + var result = TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = airDate }); + + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result!.Value.Kind); + Assert.Equal(DateTime.SpecifyKind(airDate, DateTimeKind.Local).ToUniversalTime(), result.Value); + } + + [Fact] + public void UpdateVirtualEpisode_PlaceholderTitleReplaced_UpdatesAndReturnsTrue() + { + var episode = new Episode { Name = "Episode 14" }; + var tmdbEpisode = new TvSeasonEpisode { Name = "The Real Title" }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("The Real Title", episode.Name); + } + + [Fact] + public void UpdateVirtualEpisode_NoChanges_ReturnsFalse() + { + var date = _today; + var episode = new Episode { Name = "Same", Overview = "Description", PremiereDate = date }; + var tmdbEpisode = new TvSeasonEpisode { Name = "Same", Overview = "Description" }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, date)); + } + + [Fact] + public void UpdateVirtualEpisode_EmptyTmdbValues_DoNotOverwrite() + { + var episode = new Episode { Name = "Existing", Overview = "Existing overview" }; + var tmdbEpisode = new TvSeasonEpisode { Name = string.Empty, Overview = null }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("Existing", episode.Name); + Assert.Equal("Existing overview", episode.Overview); + } + + [Fact] + public void UpdateVirtualEpisode_RescheduledAirDate_UpdatesPremiereAndYear() + { + var episode = new Episode { Name = "X", PremiereDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }; + var newAirDate = new DateTime(2026, 8, 15); + var newPremiere = DateTime.SpecifyKind(newAirDate, DateTimeKind.Local).ToUniversalTime(); + var tmdbEpisode = new TvSeasonEpisode { Name = "X", AirDate = newAirDate }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, newPremiere)); + Assert.Equal(newPremiere, episode.PremiereDate); + Assert.Equal(2026, episode.ProductionYear); + } + + private static Episode VirtualEpisode(DateTime premiereDate, bool withTmdbId, int? seasonNumber = null) + { + var episode = new Episode { PremiereDate = premiereDate, IsVirtualItem = true, ParentIndexNumber = seasonNumber }; + if (withTmdbId) + { + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + } + + return episode; + } +} -- cgit v1.2.3 From 5a2809e33725631ed25c0361331060e1821b66de Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 24 Jul 2026 21:44:46 +0200 Subject: Fix TmdbMissingEpisodeProvider --- .../Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs | 190 ++++++++++++++++++++- .../Plugins/Tmdb/TmdbClientManager.cs | 10 ++ .../Tmdb/TmdbMissingEpisodeProviderTests.cs | 82 +++++++++ 3 files changed, 277 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs index 2b5229a0ab..a0a5e8fdf8 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs @@ -23,6 +23,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV private readonly TmdbClientManager _tmdbClientManager; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; private readonly ILogger _logger; /// @@ -31,16 +32,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// The . /// The . /// The . + /// The . /// The . public TmdbMissingEpisodeProvider( TmdbClientManager tmdbClientManager, ILibraryManager libraryManager, IFileSystem fileSystem, + IProviderManager providerManager, ILogger logger) { _tmdbClientManager = tmdbClientManager; _libraryManager = libraryManager; _fileSystem = fileSystem; + _providerManager = providerManager; _logger = logger; } @@ -179,6 +183,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV updatedEpisodes = true; } + // Backfill the still for placeholders created before images were fetched. + if (await EnsureEpisodeImageAsync(existingEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false)) + { + updatedEpisodes = true; + } + continue; } @@ -188,12 +198,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); - AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); + var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); + await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false); addedEpisodes = true; } } - if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes) + var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).ConfigureAwait(false); + + if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons) { return ItemUpdateType.None; } @@ -238,6 +251,105 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return season; } + /// + /// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by + /// number instead of jumping ahead. See for the details. + /// + /// The series' seasons (physical and virtual). + /// The cancellation token. + /// true if any virtual season was updated; otherwise false. + private async Task AlignVirtualSeasonSortNamesAsync(IEnumerable 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; + } + + /// + /// Builds a factory that maps a season number to a forced sort name mirroring a physical, + /// name-sorted sibling season, or null when no physical season sorts by name. + /// + /// The series' seasons (physical and virtual). + /// A season-number-to-sort-name factory, or null if there is nothing to mirror. + internal static Func? BuildSeasonSortNameTemplate(IEnumerable 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 disabledLibraries = Plugin.Instance?.Configuration.DisabledMissingEpisodeLibraries; @@ -262,6 +374,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { 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' @@ -300,14 +414,37 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value); keys.Add(key); - // Virtual episodes this provider created are candidates for metadata sync. + // 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) { - updatable[key] = episode; + 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); } @@ -443,7 +580,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return changed; } - private void AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + private Episode AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) { var seasonNumber = season.IndexNumber.GetValueOrDefault(); var episodeNumber = (int)tmdbEpisode.EpisodeNumber; @@ -484,6 +621,49 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.Name); season.AddChild(episode); + + return episode; + } + + /// + /// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back + /// to the season/series image. + /// + /// The virtual episode. + /// The matching TMDb episode. + /// The cancellation token. + /// true if a still was downloaded and saved; otherwise false. + private async Task 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/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 @@ -591,6 +591,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } + /// + /// Gets the absolute URL of an episode still. + /// + /// The relative URL of the still. + /// The absolute URL. + public string? GetStillUrl(string? stillPath) + { + return GetUrl(Plugin.Instance.Configuration.StillSize, stillPath); + } + /// /// Converts poster s into s. /// diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs index f4b7bb5b75..7813013c05 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs @@ -180,6 +180,88 @@ public class TmdbMissingEpisodeProviderTests 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 }; -- cgit v1.2.3