< 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: 286
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

0255075100

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.Providers;
 14using MediaBrowser.Model.Entities;
 15using MediaBrowser.Model.Globalization;
 16using MediaBrowser.Model.IO;
 17using MediaBrowser.Providers.Manager;
 18using Microsoft.Extensions.Logging;
 19
 20namespace MediaBrowser.Providers.TV;
 21
 22/// <summary>
 23/// Service to manage series metadata.
 24/// </summary>
 25public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
 26{
 27    private readonly ILocalizationManager _localizationManager;
 28
 29    /// <summary>
 30    /// Initializes a new instance of the <see cref="SeriesMetadataService"/> class.
 31    /// </summary>
 32    /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
 33    /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
 34    /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
 35    /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 36    /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 37    /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 38    /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
 39    public SeriesMetadataService(
 40        IServerConfigurationManager serverConfigurationManager,
 41        ILogger<SeriesMetadataService> logger,
 42        IProviderManager providerManager,
 43        IFileSystem fileSystem,
 44        ILibraryManager libraryManager,
 45        ILocalizationManager localizationManager,
 46        IExternalDataManager externalDataManager)
 2147        : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager)
 48    {
 2149        _localizationManager = localizationManager;
 2150    }
 51
 52    /// <inheritdoc />
 53    public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, Can
 54    {
 55        if (item is Series series)
 56        {
 57            var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
 58
 59            foreach (var season in seasons)
 60            {
 61                var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMeta
 62                if (hasUpdate)
 63                {
 64                    await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(
 65                }
 66            }
 67        }
 68
 69        return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 70    }
 71
 72    /// <inheritdoc />
 73    protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationT
 74    {
 75        await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 76
 77        RemoveObsoleteEpisodes(item);
 78        RemoveObsoleteSeasons(item);
 79        await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
 80    }
 81
 82    /// <inheritdoc />
 83    protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lock
 84    {
 085        base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
 86
 087        var sourceItem = source.Item;
 088        var targetItem = target.Item;
 89
 090        if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
 91        {
 092            targetItem.AirTime = sourceItem.AirTime;
 93        }
 94
 095        if (replaceData || !targetItem.Status.HasValue)
 96        {
 097            targetItem.Status = sourceItem.Status;
 98        }
 99
 0100        if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
 101        {
 0102            targetItem.AirDays = sourceItem.AirDays;
 103        }
 0104    }
 105
 106    private void RemoveObsoleteSeasons(Series series)
 107    {
 108        // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in
 0109        var physicalSeasonNumbers = new HashSet<int>();
 0110        var virtualSeasons = new List<Season>();
 0111        foreach (var existingSeason in series.Children.OfType<Season>())
 112        {
 0113            if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
 114            {
 0115                physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
 116            }
 0117            else if (existingSeason.LocationType == LocationType.Virtual)
 118            {
 0119                virtualSeasons.Add(existingSeason);
 120            }
 121        }
 122
 0123        foreach (var virtualSeason in virtualSeasons)
 124        {
 0125            var seasonNumber = virtualSeason.IndexNumber;
 126            // If there's a physical season with the same number or no episodes in the season, delete it
 0127            if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
 0128                || virtualSeason.GetEpisodes().Count == 0)
 129            {
 0130                Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.Ind
 131
 0132                LibraryManager.DeleteItem(
 0133                    virtualSeason,
 0134                    new DeleteOptions
 0135                    {
 0136                        // Internal metadata paths are removed regardless of this.
 0137                        DeleteFileLocation = false
 0138                    },
 0139                    false);
 140            }
 141        }
 0142    }
 143
 144    private void RemoveObsoleteEpisodes(Series series)
 145    {
 0146        var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
 0147                        .OfType<Episode>()
 0148                        .GroupBy(e => e.ParentIndexNumber)
 0149                        .ToList();
 150
 0151        foreach (var seasonEpisodes in episodesBySeason)
 152        {
 0153            List<Episode> nonPhysicalEpisodes = [];
 0154            List<Episode> physicalEpisodes = [];
 0155            foreach (var episode in seasonEpisodes)
 156            {
 0157                if (episode.IsVirtualItem || episode.IsMissingEpisode)
 158                {
 0159                    nonPhysicalEpisodes.Add(episode);
 0160                    continue;
 161                }
 162
 0163                physicalEpisodes.Add(episode);
 164            }
 165
 166            // Only consider non-physical episodes
 0167            foreach (var episode in nonPhysicalEpisodes)
 168            {
 169                // Episodes without an episode number are practically orphaned and should be deleted
 170                // Episodes with a physical equivalent should be deleted (they are no longer missing)
 0171                var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(epis
 172
 0173                if (shouldKeep)
 174                {
 175                    continue;
 176                }
 177
 0178                DeleteEpisode(episode);
 179            }
 180        }
 0181    }
 182
 183    private void DeleteEpisode(Episode episode)
 184    {
 0185        Logger.LogInformation(
 0186            "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
 0187            episode.ParentIndexNumber,
 0188            episode.IndexNumber,
 0189            episode.SeriesName);
 190
 0191        LibraryManager.DeleteItem(
 0192            episode,
 0193            new DeleteOptions
 0194            {
 0195                // Internal metadata paths are removed regardless of this.
 0196                DeleteFileLocation = false
 0197            },
 0198            false);
 0199    }
 200
 201    /// <summary>
 202    /// Creates seasons for all episodes if they don't exist.
 203    /// If no season number can be determined, a dummy season will be created.
 204    /// </summary>
 205    /// <param name="series">The series.</param>
 206    /// <param name="cancellationToken">The cancellation token.</param>
 207    /// <returns>The async task.</returns>
 208    private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
 209    {
 210        var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
 211        var seasons = seriesChildren.OfType<Season>().ToList();
 212        var uniqueSeasonNumbers = seriesChildren
 213            .OfType<Episode>()
 214            .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
 215            .Distinct();
 216
 217        // Loop through the unique season numbers
 218        foreach (var seasonNumber in uniqueSeasonNumbers)
 219        {
 220            // Null season numbers will have a 'dummy' season created because seasons are always required.
 221            var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
 222            if (existingSeason is null)
 223            {
 224                var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
 225                await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
 226            }
 227            else if (existingSeason.IsVirtualItem)
 228            {
 229                var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber && !e
 230                if (episodeCount > 0)
 231                {
 232                    existingSeason.IsVirtualItem = false;
 233                    await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).Configu
 234                }
 235            }
 236        }
 237    }
 238
 239    /// <summary>
 240    /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
 241    /// </summary>
 242    /// <param name="series">The series.</param>
 243    /// <param name="seasonName">The season name.</param>
 244    /// <param name="seasonNumber">The season number.</param>
 245    /// <param name="cancellationToken">The cancellation token.</param>
 246    /// <returns>The newly created season.</returns>
 247    private async Task CreateSeasonAsync(
 248        Series series,
 249        string? seasonName,
 250        int? seasonNumber,
 251        CancellationToken cancellationToken)
 252    {
 253        Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
 254
 255        var season = new Season
 256        {
 257            Name = seasonName,
 258            IndexNumber = seasonNumber,
 259            Id = LibraryManager.GetNewItemId(
 260                series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
 261                typeof(Season)),
 262            IsVirtualItem = false,
 263            SeriesId = series.Id,
 264            SeriesName = series.Name,
 265            SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 266        };
 267
 268        series.AddChild(season);
 269        await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).Co
 270    }
 271
 272    private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
 273    {
 0274        if (string.IsNullOrEmpty(seasonName))
 275        {
 0276            seasonName = seasonNumber switch
 0277            {
 0278                null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
 0279                0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
 0280                _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumb
 0281            };
 282        }
 283
 0284        return seasonName;
 285    }
 286}