< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.TV.SeriesMetadataService
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/TV/SeriesMetadataService.cs
Line coverage
1%
Covered lines: 3
Uncovered lines: 170
Coverable lines: 173
Total lines: 428
Line coverage: 1.7%
Branch coverage
0%
Covered branches: 0
Total branches: 108
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/8/2026 - 12:11:47 AM Line coverage: 3.7% (3/79) Branch coverage: 0% (0/58) Total lines: 3224/19/2026 - 12:14:27 AM Line coverage: 2.2% (3/134) Branch coverage: 0% (0/74) Total lines: 3226/4/2026 - 12:15:59 AM Line coverage: 2.2% (3/135) Branch coverage: 0% (0/76) Total lines: 3256/14/2026 - 12:16:28 AM Line coverage: 2% (3/144) Branch coverage: 0% (0/82) Total lines: 3447/6/2026 - 12:16:28 AM Line coverage: 1.7% (3/173) Branch coverage: 0% (0/108) Total lines: 428 4/8/2026 - 12:11:47 AM Line coverage: 3.7% (3/79) Branch coverage: 0% (0/58) Total lines: 3224/19/2026 - 12:14:27 AM Line coverage: 2.2% (3/134) Branch coverage: 0% (0/74) Total lines: 3226/4/2026 - 12:15:59 AM Line coverage: 2.2% (3/135) Branch coverage: 0% (0/76) Total lines: 3256/14/2026 - 12:16:28 AM Line coverage: 2% (3/144) Branch coverage: 0% (0/82) Total lines: 3447/6/2026 - 12:16:28 AM Line coverage: 1.7% (3/173) Branch coverage: 0% (0/108) Total lines: 428

Coverage delta

Coverage delta 2 -2

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
RefreshMetadata()0%7280%
AfterMetadataRefresh()0%620%
UpdateSeriesChildrenInfoAsync()0%420200%
MergeData(...)0%210140%
RemoveObsoleteSeasons(...)0%272160%
RemoveObsoleteEpisodes(...)0%210140%
DeleteEpisode(...)100%210%
NeedsVirtualSeason(...)0%110100%
CreateSeasonsAsync()0%342180%
CreateSeasonAsync()100%210%
GetValidSeasonNameForSeries(...)0%4260%

File(s)

/srv/git/jellyfin/MediaBrowser.Providers/TV/SeriesMetadataService.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.Linq;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using Jellyfin.Extensions;
 8using MediaBrowser.Controller.Configuration;
 9using MediaBrowser.Controller.Dto;
 10using MediaBrowser.Controller.Entities;
 11using MediaBrowser.Controller.Entities.TV;
 12using MediaBrowser.Controller.IO;
 13using MediaBrowser.Controller.Library;
 14using MediaBrowser.Controller.Persistence;
 15using MediaBrowser.Controller.Providers;
 16using MediaBrowser.Model.Entities;
 17using MediaBrowser.Model.Globalization;
 18using MediaBrowser.Model.IO;
 19using MediaBrowser.Providers.Manager;
 20using Microsoft.Extensions.Logging;
 21
 22namespace MediaBrowser.Providers.TV;
 23
 24/// <summary>
 25/// Service to manage series metadata.
 26/// </summary>
 27public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
 28{
 29    private readonly ILocalizationManager _localizationManager;
 30
 31    /// <summary>
 32    /// Initializes a new instance of the <see cref="SeriesMetadataService"/> class.
 33    /// </summary>
 34    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
 35    /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
 36    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
 37    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 38    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 39    /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 40    /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
 41    /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
 42    public SeriesMetadataService(
 43        IServerConfigurationManager serverConfigurationManager,
 44        ILogger<SeriesMetadataService> logger,
 45        IProviderManager providerManager,
 46        IFileSystem fileSystem,
 47        ILibraryManager libraryManager,
 48        ILocalizationManager localizationManager,
 49        IExternalDataManager externalDataManager,
 50        IItemRepository itemRepository)
 2251        : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, ite
 52    {
 2253        _localizationManager = localizationManager;
 2254    }
 55
 56    /// <inheritdoc />
 57    public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, Can
 58    {
 059        if (item is Series series)
 60        {
 061            var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
 62
 063            foreach (var season in seasons)
 64            {
 065                var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMeta
 066                if (hasUpdate)
 67                {
 068                    await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(
 69                }
 70            }
 71        }
 72
 073        return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 074    }
 75
 76    /// <inheritdoc />
 77    protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationT
 78    {
 079        await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 80
 81        // Note that this only updates the children's SeriesPresentationUniqueKey and SeasonId, not the ParentIndexNumbe
 082        if (LibraryManager.GetLibraryOptions(item).EnableAutomaticSeriesGrouping)
 83        {
 084            await UpdateSeriesChildrenInfoAsync(item, cancellationToken).ConfigureAwait(false);
 85        }
 86
 087        RemoveObsoleteEpisodes(item);
 088        RemoveObsoleteSeasons(item);
 089        await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
 090    }
 91
 92    /// <summary>
 93    /// Reconciles seasons and episodes with the series' finalized state.
 94    /// </summary>
 95    /// <remarks>
 96    /// The series' presentation unique key can change during a refresh once provider ids become
 97    /// available - notably with <c>EnableAutomaticSeriesGrouping</c>, where the key is derived from
 98    /// the provider id and the owning libraries instead of the (immutable) item id. Seasons and
 99    /// episodes cache this value in <see cref="IHasSeries.SeriesPresentationUniqueKey"/> and are
 100    /// matched to (and displayed under) the series by it, so any child left with a stale key - or an
 101    /// episode not yet linked to a freshly created season - stays hidden until a later scan. Syncing
 102    /// them against the series here lets everything appear within a single scan.
 103    /// </remarks>
 104    /// <param name="series">The series.</param>
 105    /// <param name="cancellationToken">The cancellation token.</param>
 106    /// <returns>The async task.</returns>
 107    private async Task UpdateSeriesChildrenInfoAsync(Series series, CancellationToken cancellationToken)
 108    {
 109        // Reload children so episode numbers / seasons persisted earlier in the refresh are seen.
 0110        series.Children = null;
 0111        var seriesKey = series.GetPresentationUniqueKey();
 0112        var children = series.GetRecursiveChildren(i => i is Season || i is Episode);
 0113        var seasons = children.OfType<Season>().ToList();
 114
 0115        foreach (var child in children)
 116        {
 0117            var updateType = ItemUpdateType.None;
 118
 0119            if (child is IHasSeries hasSeries
 0120                && !string.Equals(hasSeries.SeriesPresentationUniqueKey, seriesKey, StringComparison.Ordinal))
 121            {
 0122                hasSeries.SeriesPresentationUniqueKey = seriesKey;
 0123                updateType |= ItemUpdateType.MetadataImport;
 124            }
 125
 0126            if (child is Episode episode)
 127            {
 0128                var seasonId = episode.FindSeasonId();
 0129                if (seasonId.IsEmpty() && episode.ParentIndexNumber.HasValue)
 130                {
 0131                    seasonId = seasons.Find(s => s.IndexNumber == episode.ParentIndexNumber)?.Id ?? Guid.Empty;
 132                }
 133
 0134                if (!seasonId.IsEmpty() && !episode.SeasonId.Equals(seasonId))
 135                {
 0136                    episode.SeasonId = seasonId;
 0137                    updateType |= ItemUpdateType.MetadataImport;
 138                }
 139            }
 140
 0141            if (updateType > ItemUpdateType.None)
 142            {
 0143                await child.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false);
 144            }
 145        }
 0146    }
 147
 148    /// <inheritdoc />
 149    protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lock
 150    {
 0151        base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
 152
 0153        var sourceItem = source.Item;
 0154        var targetItem = target.Item;
 155
 0156        if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
 157        {
 0158            targetItem.AirTime = sourceItem.AirTime;
 159        }
 160
 0161        if (replaceData || !targetItem.Status.HasValue)
 162        {
 0163            targetItem.Status = sourceItem.Status;
 164        }
 165
 0166        if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
 167        {
 0168            targetItem.AirDays = sourceItem.AirDays;
 169        }
 0170    }
 171
 172    private void RemoveObsoleteSeasons(Series series)
 173    {
 174        // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in
 0175        var physicalSeasonNumbers = new HashSet<int>();
 0176        var virtualSeasons = new List<Season>();
 0177        foreach (var existingSeason in series.Children.OfType<Season>())
 178        {
 0179            if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
 180            {
 0181                physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
 182            }
 0183            else if (existingSeason.LocationType == LocationType.Virtual)
 184            {
 0185                virtualSeasons.Add(existingSeason);
 186            }
 187        }
 188
 0189        foreach (var virtualSeason in virtualSeasons)
 190        {
 0191            var seasonNumber = virtualSeason.IndexNumber;
 192            // If there's a physical season with the same number or no episodes in the season, delete it
 0193            if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
 0194                || virtualSeason.GetEpisodes().Count == 0)
 195            {
 0196                Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.Ind
 197
 0198                LibraryManager.DeleteItem(
 0199                    virtualSeason,
 0200                    new DeleteOptions
 0201                    {
 0202                        // Internal metadata paths are removed regardless of this.
 0203                        DeleteFileLocation = false
 0204                    },
 0205                    false);
 206            }
 207        }
 0208    }
 209
 210    private void RemoveObsoleteEpisodes(Series series)
 211    {
 0212        var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
 0213                        .OfType<Episode>()
 0214                        .GroupBy(e => e.ParentIndexNumber)
 0215                        .ToList();
 216
 0217        foreach (var seasonEpisodes in episodesBySeason)
 218        {
 0219            List<Episode> nonPhysicalEpisodes = [];
 0220            List<Episode> physicalEpisodes = [];
 0221            foreach (var episode in seasonEpisodes)
 222            {
 0223                if (episode.IsVirtualItem || episode.IsMissingEpisode)
 224                {
 0225                    nonPhysicalEpisodes.Add(episode);
 0226                    continue;
 227                }
 228
 0229                physicalEpisodes.Add(episode);
 230            }
 231
 232            // Only consider non-physical episodes
 0233            foreach (var episode in nonPhysicalEpisodes)
 234            {
 235                // Episodes without an episode number are practically orphaned and should be deleted
 236                // Episodes with a physical equivalent should be deleted (they are no longer missing)
 0237                var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(epis
 238
 0239                if (shouldKeep)
 240                {
 241                    continue;
 242                }
 243
 0244                DeleteEpisode(episode);
 245            }
 246        }
 0247    }
 248
 249    private void DeleteEpisode(Episode episode)
 250    {
 0251        Logger.LogInformation(
 0252            "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
 0253            episode.ParentIndexNumber,
 0254            episode.IndexNumber,
 0255            episode.SeriesName);
 256
 0257        LibraryManager.DeleteItem(
 0258            episode,
 0259            new DeleteOptions
 0260            {
 0261                // Internal metadata paths are removed regardless of this.
 0262                DeleteFileLocation = false
 0263            },
 0264            false);
 0265    }
 266
 267    private static bool NeedsVirtualSeason(Episode episode, HashSet<Guid> physicalSeasonIds, HashSet<string> physicalSea
 268    {
 269        // Episode has a known season number, needs a season
 0270        if (episode.ParentIndexNumber.HasValue)
 271        {
 0272            return true;
 273        }
 274
 275        // Episode has been processed and linked to a season, only needs a virtual season
 276        // if it isn't already linked to a known physical season by ID or path
 0277        if (!episode.SeasonId.IsEmpty())
 278        {
 0279            return !physicalSeasonIds.Contains(episode.SeasonId)
 0280                && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty);
 281        }
 282
 283        // Episode not yet linked, check if it's in a physical season folder
 284        // If yes then skip it, processing not finished
 285        // If no then include it, needs Season Unknown
 0286        var episodeDirectory = System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty;
 0287        return !physicalSeasonPaths.Contains(episodeDirectory);
 288    }
 289
 290    /// <summary>
 291    /// Creates seasons for all episodes if they don't exist.
 292    /// If no season number can be determined, a dummy season will be created.
 293    /// </summary>
 294    /// <param name="series">The series.</param>
 295    /// <param name="cancellationToken">The cancellation token.</param>
 296    /// <returns>The async task.</returns>
 297    private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
 298    {
 0299        var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
 300
 301        // CreateSeasonsAsync can run before the episodes themselves have been refreshed during an
 302        // initial scan, so their ParentIndexNumber may still be unset. Resolve the season number
 303        // from the path first to avoid creating a premature "Season Unknown" instead of the real
 304        // season for episodes that live directly in a flat series folder.
 0305        foreach (var episode in seriesChildren.OfType<Episode>())
 306        {
 0307            if (episode.ParentIndexNumber.HasValue)
 308            {
 309                continue;
 310            }
 311
 312            try
 313            {
 0314                LibraryManager.FillMissingEpisodeNumbersFromPath(episode, false);
 0315            }
 0316            catch (Exception ex)
 317            {
 0318                Logger.LogError(ex, "Error resolving season number from path for {Path}", episode.Path);
 0319            }
 320        }
 321
 0322        var seasons = seriesChildren.OfType<Season>().ToList();
 0323        var episodes = seriesChildren.OfType<Episode>().ToList();
 324
 0325        var physicalSeasonIds = seasons
 0326            .Where(e => e.LocationType != LocationType.Virtual)
 0327            .Select(e => e.Id)
 0328            .ToHashSet();
 329
 0330        var physicalSeasonPathSet = seasons
 0331            .Where(e => e.LocationType != LocationType.Virtual && !string.IsNullOrEmpty(e.Path))
 0332            .Select(e => e.Path)
 0333            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 334
 0335        var uniqueSeasonNumbers = seriesChildren
 0336            .OfType<Episode>()
 0337            .Where(e => NeedsVirtualSeason(e, physicalSeasonIds, physicalSeasonPathSet))
 0338            .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
 0339            .Distinct();
 340
 341        // Loop through the unique season numbers
 0342        foreach (var seasonNumber in uniqueSeasonNumbers)
 343        {
 344            // Null season numbers will have a 'dummy' season created because seasons are always required.
 0345            var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
 0346            if (existingSeason is null)
 347            {
 0348                var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
 0349                var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait
 0350                seasons.Add(season);
 351            }
 0352            else if (existingSeason.IsVirtualItem)
 353            {
 0354                var episodeCount = episodes.Count(e => e.ParentIndexNumber == seasonNumber && !e.IsMissingEpisode);
 0355                if (episodeCount > 0)
 356                {
 0357                    existingSeason.IsVirtualItem = false;
 0358                    await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).Configu
 359                }
 360            }
 361        }
 362
 363        // Loop through episodes
 0364        foreach (var episode in episodes)
 365        {
 0366            var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber);
 0367            if (season is null || episode.SeasonId.Equals(season.Id))
 368            {
 369                continue;
 370            }
 371
 372            // Assign the correct season id and name to episode.
 0373            episode.SeasonId = season.Id;
 0374            episode.SeasonName = season.Name;
 0375            await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false
 376        }
 0377    }
 378
 379    /// <summary>
 380    /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
 381    /// </summary>
 382    /// <param name="series">The series.</param>
 383    /// <param name="seasonName">The season name.</param>
 384    /// <param name="seasonNumber">The season number.</param>
 385    /// <param name="cancellationToken">The cancellation token.</param>
 386    /// <returns>The newly created season.</returns>
 387    private async Task<Season> CreateSeasonAsync(
 388        Series series,
 389        string? seasonName,
 390        int? seasonNumber,
 391        CancellationToken cancellationToken)
 392    {
 0393        Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
 394
 0395        var season = new Season
 0396        {
 0397            Name = seasonName,
 0398            IndexNumber = seasonNumber,
 0399            Id = LibraryManager.GetNewItemId(
 0400                series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
 0401                typeof(Season)),
 0402            IsVirtualItem = false,
 0403            SeriesId = series.Id,
 0404            SeriesName = series.Name,
 0405            SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 0406        };
 407
 0408        series.AddChild(season);
 0409        await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).Co
 410
 0411        return season;
 0412    }
 413
 414    private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
 415    {
 0416        if (string.IsNullOrEmpty(seasonName))
 417        {
 0418            seasonName = seasonNumber switch
 0419            {
 0420                null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
 0421                0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
 0422                _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumb
 0423            };
 424        }
 425
 0426        return seasonName;
 427    }
 428}