< Summary - Jellyfin

Information
Class: MediaBrowser.Providers.TV.SeriesMetadataService
Assembly: MediaBrowser.Providers
File(s): /srv/git/jellyfin/MediaBrowser.Providers/TV/SeriesMetadataService.cs
Line coverage
4%
Covered lines: 3
Uncovered lines: 70
Coverable lines: 73
Total lines: 289
Line coverage: 4.1%
Branch coverage
0%
Covered branches: 0
Total branches: 50
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
MergeData(...)0%210140%
RemoveObsoleteSeasons(...)0%272160%
RemoveObsoleteEpisodes(...)0%210140%
DeleteEpisode(...)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 MediaBrowser.Controller.Configuration;
 8using MediaBrowser.Controller.Dto;
 9using MediaBrowser.Controller.Entities;
 10using MediaBrowser.Controller.Entities.TV;
 11using MediaBrowser.Controller.IO;
 12using MediaBrowser.Controller.Library;
 13using MediaBrowser.Controller.Persistence;
 14using MediaBrowser.Controller.Providers;
 15using MediaBrowser.Model.Entities;
 16using MediaBrowser.Model.Globalization;
 17using MediaBrowser.Model.IO;
 18using MediaBrowser.Providers.Manager;
 19using Microsoft.Extensions.Logging;
 20
 21namespace MediaBrowser.Providers.TV;
 22
 23/// <summary>
 24/// Service to manage series metadata.
 25/// </summary>
 26public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
 27{
 28    private readonly ILocalizationManager _localizationManager;
 29
 30    /// <summary>
 31    /// Initializes a new instance of the <see cref="SeriesMetadataService"/> class.
 32    /// </summary>
 33    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
 34    /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
 35    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
 36    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 37    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 38    /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 39    /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
 40    /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
 41    public SeriesMetadataService(
 42        IServerConfigurationManager serverConfigurationManager,
 43        ILogger<SeriesMetadataService> logger,
 44        IProviderManager providerManager,
 45        IFileSystem fileSystem,
 46        ILibraryManager libraryManager,
 47        ILocalizationManager localizationManager,
 48        IExternalDataManager externalDataManager,
 49        IItemRepository itemRepository)
 2150        : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, ite
 51    {
 2152        _localizationManager = localizationManager;
 2153    }
 54
 55    /// <inheritdoc />
 56    public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, Can
 57    {
 58        if (item is Series series)
 59        {
 60            var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
 61
 62            foreach (var season in seasons)
 63            {
 64                var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMeta
 65                if (hasUpdate)
 66                {
 67                    await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(
 68                }
 69            }
 70        }
 71
 72        return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 73    }
 74
 75    /// <inheritdoc />
 76    protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationT
 77    {
 78        await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 79
 80        RemoveObsoleteEpisodes(item);
 81        RemoveObsoleteSeasons(item);
 82        await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
 83    }
 84
 85    /// <inheritdoc />
 86    protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lock
 87    {
 088        base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
 89
 090        var sourceItem = source.Item;
 091        var targetItem = target.Item;
 92
 093        if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
 94        {
 095            targetItem.AirTime = sourceItem.AirTime;
 96        }
 97
 098        if (replaceData || !targetItem.Status.HasValue)
 99        {
 0100            targetItem.Status = sourceItem.Status;
 101        }
 102
 0103        if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
 104        {
 0105            targetItem.AirDays = sourceItem.AirDays;
 106        }
 0107    }
 108
 109    private void RemoveObsoleteSeasons(Series series)
 110    {
 111        // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in
 0112        var physicalSeasonNumbers = new HashSet<int>();
 0113        var virtualSeasons = new List<Season>();
 0114        foreach (var existingSeason in series.Children.OfType<Season>())
 115        {
 0116            if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
 117            {
 0118                physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
 119            }
 0120            else if (existingSeason.LocationType == LocationType.Virtual)
 121            {
 0122                virtualSeasons.Add(existingSeason);
 123            }
 124        }
 125
 0126        foreach (var virtualSeason in virtualSeasons)
 127        {
 0128            var seasonNumber = virtualSeason.IndexNumber;
 129            // If there's a physical season with the same number or no episodes in the season, delete it
 0130            if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
 0131                || virtualSeason.GetEpisodes().Count == 0)
 132            {
 0133                Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.Ind
 134
 0135                LibraryManager.DeleteItem(
 0136                    virtualSeason,
 0137                    new DeleteOptions
 0138                    {
 0139                        // Internal metadata paths are removed regardless of this.
 0140                        DeleteFileLocation = false
 0141                    },
 0142                    false);
 143            }
 144        }
 0145    }
 146
 147    private void RemoveObsoleteEpisodes(Series series)
 148    {
 0149        var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
 0150                        .OfType<Episode>()
 0151                        .GroupBy(e => e.ParentIndexNumber)
 0152                        .ToList();
 153
 0154        foreach (var seasonEpisodes in episodesBySeason)
 155        {
 0156            List<Episode> nonPhysicalEpisodes = [];
 0157            List<Episode> physicalEpisodes = [];
 0158            foreach (var episode in seasonEpisodes)
 159            {
 0160                if (episode.IsVirtualItem || episode.IsMissingEpisode)
 161                {
 0162                    nonPhysicalEpisodes.Add(episode);
 0163                    continue;
 164                }
 165
 0166                physicalEpisodes.Add(episode);
 167            }
 168
 169            // Only consider non-physical episodes
 0170            foreach (var episode in nonPhysicalEpisodes)
 171            {
 172                // Episodes without an episode number are practically orphaned and should be deleted
 173                // Episodes with a physical equivalent should be deleted (they are no longer missing)
 0174                var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(epis
 175
 0176                if (shouldKeep)
 177                {
 178                    continue;
 179                }
 180
 0181                DeleteEpisode(episode);
 182            }
 183        }
 0184    }
 185
 186    private void DeleteEpisode(Episode episode)
 187    {
 0188        Logger.LogInformation(
 0189            "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
 0190            episode.ParentIndexNumber,
 0191            episode.IndexNumber,
 0192            episode.SeriesName);
 193
 0194        LibraryManager.DeleteItem(
 0195            episode,
 0196            new DeleteOptions
 0197            {
 0198                // Internal metadata paths are removed regardless of this.
 0199                DeleteFileLocation = false
 0200            },
 0201            false);
 0202    }
 203
 204    /// <summary>
 205    /// Creates seasons for all episodes if they don't exist.
 206    /// If no season number can be determined, a dummy season will be created.
 207    /// </summary>
 208    /// <param name="series">The series.</param>
 209    /// <param name="cancellationToken">The cancellation token.</param>
 210    /// <returns>The async task.</returns>
 211    private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
 212    {
 213        var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
 214        var seasons = seriesChildren.OfType<Season>().ToList();
 215        var uniqueSeasonNumbers = seriesChildren
 216            .OfType<Episode>()
 217            .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
 218            .Distinct();
 219
 220        // Loop through the unique season numbers
 221        foreach (var seasonNumber in uniqueSeasonNumbers)
 222        {
 223            // Null season numbers will have a 'dummy' season created because seasons are always required.
 224            var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
 225            if (existingSeason is null)
 226            {
 227                var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
 228                await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
 229            }
 230            else if (existingSeason.IsVirtualItem)
 231            {
 232                var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber && !e
 233                if (episodeCount > 0)
 234                {
 235                    existingSeason.IsVirtualItem = false;
 236                    await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).Configu
 237                }
 238            }
 239        }
 240    }
 241
 242    /// <summary>
 243    /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
 244    /// </summary>
 245    /// <param name="series">The series.</param>
 246    /// <param name="seasonName">The season name.</param>
 247    /// <param name="seasonNumber">The season number.</param>
 248    /// <param name="cancellationToken">The cancellation token.</param>
 249    /// <returns>The newly created season.</returns>
 250    private async Task CreateSeasonAsync(
 251        Series series,
 252        string? seasonName,
 253        int? seasonNumber,
 254        CancellationToken cancellationToken)
 255    {
 256        Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
 257
 258        var season = new Season
 259        {
 260            Name = seasonName,
 261            IndexNumber = seasonNumber,
 262            Id = LibraryManager.GetNewItemId(
 263                series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
 264                typeof(Season)),
 265            IsVirtualItem = false,
 266            SeriesId = series.Id,
 267            SeriesName = series.Name,
 268            SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 269        };
 270
 271        series.AddChild(season);
 272        await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).Co
 273    }
 274
 275    private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
 276    {
 0277        if (string.IsNullOrEmpty(seasonName))
 278        {
 0279            seasonName = seasonNumber switch
 0280            {
 0281                null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
 0282                0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
 0283                _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumb
 0284            };
 285        }
 286
 0287        return seasonName;
 288    }
 289}