< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.TV.SeriesMetadataService
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/TV/SeriesMetadataService.cs
Line coverage
3%
Covered lines: 3
Uncovered lines: 76
Coverable lines: 79
Total lines: 322
Line coverage: 3.7%
Branch coverage
0%
Covered branches: 0
Total branches: 58
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 11/15/2025 - 12:10:12 AM Line coverage: 4.1% (3/73) Branch coverage: 0% (0/50) Total lines: 2892/16/2026 - 12:13:54 AM Line coverage: 3.7% (3/79) Branch coverage: 0% (0/58) Total lines: 322 11/15/2025 - 12:10:12 AM Line coverage: 4.1% (3/73) Branch coverage: 0% (0/50) Total lines: 2892/16/2026 - 12:13:54 AM Line coverage: 3.7% (3/79) Branch coverage: 0% (0/58) Total lines: 322

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
MergeData(...)0%210140%
RemoveObsoleteSeasons(...)0%272160%
RemoveObsoleteEpisodes(...)0%210140%
DeleteEpisode(...)100%210%
NeedsVirtualSeason(...)0%7280%
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)
 2151        : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, ite
 52    {
 2153        _localizationManager = localizationManager;
 2154    }
 55
 56    /// <inheritdoc />
 57    public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, Can
 58    {
 59        if (item is Series series)
 60        {
 61            var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
 62
 63            foreach (var season in seasons)
 64            {
 65                var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMeta
 66                if (hasUpdate)
 67                {
 68                    await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(
 69                }
 70            }
 71        }
 72
 73        return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 74    }
 75
 76    /// <inheritdoc />
 77    protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationT
 78    {
 79        await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 80
 81        RemoveObsoleteEpisodes(item);
 82        RemoveObsoleteSeasons(item);
 83        await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
 84    }
 85
 86    /// <inheritdoc />
 87    protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lock
 88    {
 089        base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
 90
 091        var sourceItem = source.Item;
 092        var targetItem = target.Item;
 93
 094        if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
 95        {
 096            targetItem.AirTime = sourceItem.AirTime;
 97        }
 98
 099        if (replaceData || !targetItem.Status.HasValue)
 100        {
 0101            targetItem.Status = sourceItem.Status;
 102        }
 103
 0104        if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
 105        {
 0106            targetItem.AirDays = sourceItem.AirDays;
 107        }
 0108    }
 109
 110    private void RemoveObsoleteSeasons(Series series)
 111    {
 112        // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in
 0113        var physicalSeasonNumbers = new HashSet<int>();
 0114        var virtualSeasons = new List<Season>();
 0115        foreach (var existingSeason in series.Children.OfType<Season>())
 116        {
 0117            if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
 118            {
 0119                physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
 120            }
 0121            else if (existingSeason.LocationType == LocationType.Virtual)
 122            {
 0123                virtualSeasons.Add(existingSeason);
 124            }
 125        }
 126
 0127        foreach (var virtualSeason in virtualSeasons)
 128        {
 0129            var seasonNumber = virtualSeason.IndexNumber;
 130            // If there's a physical season with the same number or no episodes in the season, delete it
 0131            if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
 0132                || virtualSeason.GetEpisodes().Count == 0)
 133            {
 0134                Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.Ind
 135
 0136                LibraryManager.DeleteItem(
 0137                    virtualSeason,
 0138                    new DeleteOptions
 0139                    {
 0140                        // Internal metadata paths are removed regardless of this.
 0141                        DeleteFileLocation = false
 0142                    },
 0143                    false);
 144            }
 145        }
 0146    }
 147
 148    private void RemoveObsoleteEpisodes(Series series)
 149    {
 0150        var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
 0151                        .OfType<Episode>()
 0152                        .GroupBy(e => e.ParentIndexNumber)
 0153                        .ToList();
 154
 0155        foreach (var seasonEpisodes in episodesBySeason)
 156        {
 0157            List<Episode> nonPhysicalEpisodes = [];
 0158            List<Episode> physicalEpisodes = [];
 0159            foreach (var episode in seasonEpisodes)
 160            {
 0161                if (episode.IsVirtualItem || episode.IsMissingEpisode)
 162                {
 0163                    nonPhysicalEpisodes.Add(episode);
 0164                    continue;
 165                }
 166
 0167                physicalEpisodes.Add(episode);
 168            }
 169
 170            // Only consider non-physical episodes
 0171            foreach (var episode in nonPhysicalEpisodes)
 172            {
 173                // Episodes without an episode number are practically orphaned and should be deleted
 174                // Episodes with a physical equivalent should be deleted (they are no longer missing)
 0175                var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(epis
 176
 0177                if (shouldKeep)
 178                {
 179                    continue;
 180                }
 181
 0182                DeleteEpisode(episode);
 183            }
 184        }
 0185    }
 186
 187    private void DeleteEpisode(Episode episode)
 188    {
 0189        Logger.LogInformation(
 0190            "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
 0191            episode.ParentIndexNumber,
 0192            episode.IndexNumber,
 0193            episode.SeriesName);
 194
 0195        LibraryManager.DeleteItem(
 0196            episode,
 0197            new DeleteOptions
 0198            {
 0199                // Internal metadata paths are removed regardless of this.
 0200                DeleteFileLocation = false
 0201            },
 0202            false);
 0203    }
 204
 205    private static bool NeedsVirtualSeason(Episode episode, HashSet<Guid> physicalSeasonIds, HashSet<string> physicalSea
 206    {
 207        // Episode has a known season number, needs a season
 0208        if (episode.ParentIndexNumber.HasValue)
 209        {
 0210            return true;
 211        }
 212
 213        // Not yet processed
 0214        if (episode.SeasonId.IsEmpty())
 215        {
 0216            return false;
 217        }
 218
 219        // Episode has been processed, only needs a virtual season if it isn't
 220        // already linked to a known physical season by ID or path
 0221        return !physicalSeasonIds.Contains(episode.SeasonId)
 0222            && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty);
 223    }
 224
 225    /// <summary>
 226    /// Creates seasons for all episodes if they don't exist.
 227    /// If no season number can be determined, a dummy season will be created.
 228    /// </summary>
 229    /// <param name="series">The series.</param>
 230    /// <param name="cancellationToken">The cancellation token.</param>
 231    /// <returns>The async task.</returns>
 232    private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
 233    {
 234        var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
 235        var seasons = seriesChildren.OfType<Season>().ToList();
 236
 237        var physicalSeasonIds = seasons
 238            .Where(e => e.LocationType != LocationType.Virtual)
 239            .Select(e => e.Id)
 240            .ToHashSet();
 241
 242        var physicalSeasonPathSet = seasons
 243            .Where(e => e.LocationType != LocationType.Virtual && !string.IsNullOrEmpty(e.Path))
 244            .Select(e => e.Path)
 245            .ToHashSet(StringComparer.OrdinalIgnoreCase);
 246
 247        var uniqueSeasonNumbers = seriesChildren
 248            .OfType<Episode>()
 249            .Where(e => NeedsVirtualSeason(e, physicalSeasonIds, physicalSeasonPathSet))
 250            .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
 251            .Distinct();
 252
 253        // Loop through the unique season numbers
 254        foreach (var seasonNumber in uniqueSeasonNumbers)
 255        {
 256            // Null season numbers will have a 'dummy' season created because seasons are always required.
 257            var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
 258            if (existingSeason is null)
 259            {
 260                var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
 261                await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
 262            }
 263            else if (existingSeason.IsVirtualItem)
 264            {
 265                var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber && !e
 266                if (episodeCount > 0)
 267                {
 268                    existingSeason.IsVirtualItem = false;
 269                    await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).Configu
 270                }
 271            }
 272        }
 273    }
 274
 275    /// <summary>
 276    /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
 277    /// </summary>
 278    /// <param name="series">The series.</param>
 279    /// <param name="seasonName">The season name.</param>
 280    /// <param name="seasonNumber">The season number.</param>
 281    /// <param name="cancellationToken">The cancellation token.</param>
 282    /// <returns>The newly created season.</returns>
 283    private async Task CreateSeasonAsync(
 284        Series series,
 285        string? seasonName,
 286        int? seasonNumber,
 287        CancellationToken cancellationToken)
 288    {
 289        Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
 290
 291        var season = new Season
 292        {
 293            Name = seasonName,
 294            IndexNumber = seasonNumber,
 295            Id = LibraryManager.GetNewItemId(
 296                series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
 297                typeof(Season)),
 298            IsVirtualItem = false,
 299            SeriesId = series.Id,
 300            SeriesName = series.Name,
 301            SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 302        };
 303
 304        series.AddChild(season);
 305        await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).Co
 306    }
 307
 308    private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
 309    {
 0310        if (string.IsNullOrEmpty(seasonName))
 311        {
 0312            seasonName = seasonNumber switch
 0313            {
 0314                null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
 0315                0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
 0316                _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumb
 0317            };
 318        }
 319
 0320        return seasonName;
 321    }
 322}