< 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: 284
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.Library;
 12using MediaBrowser.Controller.Providers;
 13using MediaBrowser.Model.Entities;
 14using MediaBrowser.Model.Globalization;
 15using MediaBrowser.Model.IO;
 16using MediaBrowser.Providers.Manager;
 17using Microsoft.Extensions.Logging;
 18
 19namespace MediaBrowser.Providers.TV
 20{
 21    /// <summary>
 22    /// Service to manage series metadata.
 23    /// </summary>
 24    public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
 25    {
 26        private readonly ILocalizationManager _localizationManager;
 27
 28        /// <summary>
 29        /// Initializes a new instance of the <see cref="SeriesMetadataService"/> class.
 30        /// </summary>
 31        /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface
 32        /// <param name="logger">Instance of the <see cref="ILogger{SeasonMetadataService}"/> interface.</param>
 33        /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
 34        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
 35        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
 36        /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
 37        public SeriesMetadataService(
 38            IServerConfigurationManager serverConfigurationManager,
 39            ILogger<SeriesMetadataService> logger,
 40            IProviderManager providerManager,
 41            IFileSystem fileSystem,
 42            ILibraryManager libraryManager,
 43            ILocalizationManager localizationManager)
 2144            : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
 45        {
 2146            _localizationManager = localizationManager;
 2147        }
 48
 49        /// <inheritdoc />
 50        public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions,
 51        {
 52            if (item is Series series)
 53            {
 54                var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
 55
 56                foreach (var season in seasons)
 57                {
 58                    var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAll
 59                    if (hasUpdate)
 60                    {
 61                        await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAw
 62                    }
 63                }
 64            }
 65
 66            return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 67        }
 68
 69        /// <inheritdoc />
 70        protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, Cancellat
 71        {
 72            await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
 73
 74            RemoveObsoleteEpisodes(item);
 75            RemoveObsoleteSeasons(item);
 76            await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
 77        }
 78
 79        /// <inheritdoc />
 80        protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] 
 81        {
 082            base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
 83
 084            var sourceItem = source.Item;
 085            var targetItem = target.Item;
 86
 087            if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
 88            {
 089                targetItem.AirTime = sourceItem.AirTime;
 90            }
 91
 092            if (replaceData || !targetItem.Status.HasValue)
 93            {
 094                targetItem.Status = sourceItem.Status;
 95            }
 96
 097            if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
 98            {
 099                targetItem.AirDays = sourceItem.AirDays;
 100            }
 0101        }
 102
 103        private void RemoveObsoleteSeasons(Series series)
 104        {
 105            // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtua
 0106            var physicalSeasonNumbers = new HashSet<int>();
 0107            var virtualSeasons = new List<Season>();
 0108            foreach (var existingSeason in series.Children.OfType<Season>())
 109            {
 0110                if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
 111                {
 0112                    physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
 113                }
 0114                else if (existingSeason.LocationType == LocationType.Virtual)
 115                {
 0116                    virtualSeasons.Add(existingSeason);
 117                }
 118            }
 119
 0120            foreach (var virtualSeason in virtualSeasons)
 121            {
 0122                var seasonNumber = virtualSeason.IndexNumber;
 123                // If there's a physical season with the same number or no episodes in the season, delete it
 0124                if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
 0125                    || virtualSeason.GetEpisodes().Count == 0)
 126                {
 0127                    Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason
 128
 0129                    LibraryManager.DeleteItem(
 0130                        virtualSeason,
 0131                        new DeleteOptions
 0132                        {
 0133                            // Internal metadata paths are removed regardless of this.
 0134                            DeleteFileLocation = false
 0135                        },
 0136                        false);
 137                }
 138            }
 0139        }
 140
 141        private void RemoveObsoleteEpisodes(Series series)
 142        {
 0143            var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
 0144                            .OfType<Episode>()
 0145                            .GroupBy(e => e.ParentIndexNumber)
 0146                            .ToList();
 147
 0148            foreach (var seasonEpisodes in episodesBySeason)
 149            {
 0150                List<Episode> nonPhysicalEpisodes = [];
 0151                List<Episode> physicalEpisodes = [];
 0152                foreach (var episode in seasonEpisodes)
 153                {
 0154                    if (episode.IsVirtualItem || episode.IsMissingEpisode)
 155                    {
 0156                        nonPhysicalEpisodes.Add(episode);
 0157                        continue;
 158                    }
 159
 0160                    physicalEpisodes.Add(episode);
 161                }
 162
 163                // Only consider non-physical episodes
 0164                foreach (var episode in nonPhysicalEpisodes)
 165                {
 166                    // Episodes without an episode number are practically orphaned and should be deleted
 167                    // Episodes with a physical equivalent should be deleted (they are no longer missing)
 0168                    var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(
 169
 0170                    if (shouldKeep)
 171                    {
 172                        continue;
 173                    }
 174
 0175                    DeleteEpisode(episode);
 176                }
 177            }
 0178        }
 179
 180        private void DeleteEpisode(Episode episode)
 181        {
 0182            Logger.LogInformation(
 0183                "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
 0184                episode.ParentIndexNumber,
 0185                episode.IndexNumber,
 0186                episode.SeriesName);
 187
 0188            LibraryManager.DeleteItem(
 0189                episode,
 0190                new DeleteOptions
 0191                {
 0192                    // Internal metadata paths are removed regardless of this.
 0193                    DeleteFileLocation = false
 0194                },
 0195                false);
 0196        }
 197
 198        /// <summary>
 199        /// Creates seasons for all episodes if they don't exist.
 200        /// If no season number can be determined, a dummy season will be created.
 201        /// </summary>
 202        /// <param name="series">The series.</param>
 203        /// <param name="cancellationToken">The cancellation token.</param>
 204        /// <returns>The async task.</returns>
 205        private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
 206        {
 207            var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
 208            var seasons = seriesChildren.OfType<Season>().ToList();
 209            var uniqueSeasonNumbers = seriesChildren
 210                .OfType<Episode>()
 211                .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
 212                .Distinct();
 213
 214            // Loop through the unique season numbers
 215            foreach (var seasonNumber in uniqueSeasonNumbers)
 216            {
 217                // Null season numbers will have a 'dummy' season created because seasons are always required.
 218                var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
 219                if (existingSeason is null)
 220                {
 221                    var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
 222                    await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
 223                }
 224                else if (existingSeason.IsVirtualItem)
 225                {
 226                    var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber &
 227                    if (episodeCount > 0)
 228                    {
 229                        existingSeason.IsVirtualItem = false;
 230                        await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).Con
 231                    }
 232                }
 233            }
 234        }
 235
 236        /// <summary>
 237        /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
 238        /// </summary>
 239        /// <param name="series">The series.</param>
 240        /// <param name="seasonName">The season name.</param>
 241        /// <param name="seasonNumber">The season number.</param>
 242        /// <param name="cancellationToken">The cancellation token.</param>
 243        /// <returns>The newly created season.</returns>
 244        private async Task CreateSeasonAsync(
 245            Series series,
 246            string? seasonName,
 247            int? seasonNumber,
 248            CancellationToken cancellationToken)
 249        {
 250            Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
 251
 252            var season = new Season
 253            {
 254                Name = seasonName,
 255                IndexNumber = seasonNumber,
 256                Id = LibraryManager.GetNewItemId(
 257                    series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
 258                    typeof(Season)),
 259                IsVirtualItem = false,
 260                SeriesId = series.Id,
 261                SeriesName = series.Name,
 262                SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
 263            };
 264
 265            series.AddChild(season);
 266            await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken
 267        }
 268
 269        private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
 270        {
 0271            if (string.IsNullOrEmpty(seasonName))
 272            {
 0273                seasonName = seasonNumber switch
 0274                {
 0275                    null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
 0276                    0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
 0277                    _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeason
 0278                };
 279            }
 280
 0281            return seasonName;
 282        }
 283    }
 284}