From a5706c2fa6b09019828b2a9c5c6822f5bfa3327f Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 12 Jun 2026 21:20:29 +0200 Subject: [PATCH] Reconcile Seasons and Episodes --- .../TV/SeriesMetadataService.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 078c396730..70e75be11b 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -78,11 +78,73 @@ public class SeriesMetadataService : MetadataService { await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false); + // Note that this only updates the children's SeriesPresentationUniqueKey and SeasonId, not the ParentIndexNumber + if (LibraryManager.GetLibraryOptions(item).EnableAutomaticSeriesGrouping) + { + await UpdateSeriesChildrenInfoAsync(item, cancellationToken).ConfigureAwait(false); + } + RemoveObsoleteEpisodes(item); RemoveObsoleteSeasons(item); await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false); } + /// + /// Reconciles seasons and episodes with the series' finalized state. + /// + /// + /// The series' presentation unique key can change during a refresh once provider ids become + /// available - notably with EnableAutomaticSeriesGrouping, where the key is derived from + /// the provider id and the owning libraries instead of the (immutable) item id. Seasons and + /// episodes cache this value in and are + /// matched to (and displayed under) the series by it, so any child left with a stale key - or an + /// episode not yet linked to a freshly created season - stays hidden until a later scan. Syncing + /// them against the series here lets everything appear within a single scan. + /// + /// The series. + /// The cancellation token. + /// The async task. + private async Task UpdateSeriesChildrenInfoAsync(Series series, CancellationToken cancellationToken) + { + // Reload children so episode numbers / seasons persisted earlier in the refresh are seen. + series.Children = null; + var seriesKey = series.GetPresentationUniqueKey(); + var children = series.GetRecursiveChildren(i => i is Season || i is Episode); + var seasons = children.OfType().ToList(); + + foreach (var child in children) + { + var updateType = ItemUpdateType.None; + + if (child is IHasSeries hasSeries + && !string.Equals(hasSeries.SeriesPresentationUniqueKey, seriesKey, StringComparison.Ordinal)) + { + hasSeries.SeriesPresentationUniqueKey = seriesKey; + updateType |= ItemUpdateType.MetadataImport; + } + + if (child is Episode episode) + { + var seasonId = episode.FindSeasonId(); + if (seasonId.IsEmpty() && episode.ParentIndexNumber.HasValue) + { + seasonId = seasons.Find(s => s.IndexNumber == episode.ParentIndexNumber)?.Id ?? Guid.Empty; + } + + if (!seasonId.IsEmpty() && !episode.SeasonId.Equals(seasonId)) + { + episode.SeasonId = seasonId; + updateType |= ItemUpdateType.MetadataImport; + } + } + + if (updateType > ItemUpdateType.None) + { + await child.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false); + } + } + } + /// protected override void MergeData(MetadataResult source, MetadataResult target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { @@ -235,6 +297,28 @@ public class SeriesMetadataService : MetadataService private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken) { var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); + + // CreateSeasonsAsync can run before the episodes themselves have been refreshed during an + // initial scan, so their ParentIndexNumber may still be unset. Resolve the season number + // from the path first to avoid creating a premature "Season Unknown" instead of the real + // season for episodes that live directly in a flat series folder. + foreach (var episode in seriesChildren.OfType()) + { + if (episode.ParentIndexNumber.HasValue) + { + continue; + } + + try + { + LibraryManager.FillMissingEpisodeNumbersFromPath(episode, false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error resolving season number from path for {Path}", episode.Path); + } + } + var seasons = seriesChildren.OfType().ToList(); var physicalSeasonIds = seasons