From 5a2809e33725631ed25c0361331060e1821b66de Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 24 Jul 2026 21:44:46 +0200 Subject: [PATCH] Fix TmdbMissingEpisodeProvider --- .../Tmdb/TV/TmdbMissingEpisodeProvider.cs | 190 +++++++++++++++++- .../Plugins/Tmdb/TmdbClientManager.cs | 10 + .../Tmdb/TmdbMissingEpisodeProviderTests.cs | 82 ++++++++ 3 files changed, 277 insertions(+), 5 deletions(-) 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,11 +414,34 @@ 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; } } @@ -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 };